Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb710fdb59 | ||
|
|
5b476e0dc3 | ||
|
|
1c6abe9b59 | ||
|
|
4169d085e3 | ||
|
|
37a26ea86c | ||
|
|
42f56f2cc4 | ||
|
|
7a2700d649 | ||
|
|
d1a6c874e8 | ||
|
|
76ce13830d | ||
|
|
57285740f2 | ||
|
|
73baa0005f | ||
|
|
c71f389409 | ||
|
|
47bef92162 | ||
|
|
e2157e118c | ||
|
|
1ac0cecb9a | ||
|
|
583a792515 | ||
|
|
33a1510cbb | ||
|
|
d1facd8bdc | ||
|
|
0529a4e7c4 | ||
|
|
efb530e0b2 | ||
|
|
a7108e8504 | ||
|
|
f09d42a862 | ||
|
|
c91cfd5166 | ||
|
|
8cb3895264 | ||
|
|
d631fd8d54 | ||
|
|
48e25ae46e | ||
|
|
800acd079a | ||
|
|
defee1f513 | ||
|
|
7811381d54 | ||
|
|
bdb128d904 | ||
|
|
9b85a95552 | ||
|
|
2213251756 | ||
|
|
a057cdf488 | ||
|
|
251b2729c2 | ||
|
|
06c1a3a1be | ||
|
|
74bf6eef04 | ||
|
|
28e8028a02 | ||
|
|
71e5198d4c | ||
|
|
ef38f3be4c | ||
|
|
db9643eb58 | ||
|
|
193336967b | ||
|
|
f6d31285ea | ||
|
|
d4b265ad45 | ||
|
|
53a695580f | ||
|
|
6163202a93 | ||
|
|
4912d96f58 | ||
|
|
87bfccd55b | ||
|
|
a89ddb30bd | ||
|
|
e412cfed14 | ||
|
|
b8f6fb8dad | ||
|
|
6b0dcdccef | ||
|
|
1902775812 | ||
|
|
03dfce20b4 | ||
|
|
b3bb529a6a | ||
|
|
9a61d237fa | ||
|
|
702e335cb8 | ||
|
|
73e9ededd4 | ||
|
|
bad08e9b03 | ||
|
|
851988abbc | ||
|
|
222a9c8382 | ||
|
|
a8b1ce6658 | ||
|
|
807924241f | ||
|
|
91f8cc6c07 | ||
|
|
989d19020d | ||
|
|
b4f66ce84d | ||
|
|
294d25cea3 | ||
|
|
9048666c65 | ||
|
|
25fc02d07a | ||
|
|
9dc2ecd604 | ||
|
|
13ae64cbae | ||
|
|
024cf5a104 | ||
|
|
be68d23d6a | ||
|
|
81658ce0ea | ||
|
|
de109846bf | ||
|
|
5744073c11 | ||
|
|
b791af2372 | ||
|
|
af12158cbc | ||
|
|
62d047cac1 | ||
|
|
5c82db6729 | ||
|
|
116c4e9d69 | ||
|
|
0b739ac525 | ||
|
|
364cefa5fd | ||
|
|
e9b86c3ebb | ||
|
|
e0811fcf93 | ||
|
|
002d83da8c | ||
|
|
24abb7bbcc | ||
|
|
f4c696b3dc | ||
|
|
c0d2d77428 | ||
|
|
b3d1a4b552 | ||
|
|
b206832209 | ||
|
|
90048069d0 | ||
|
|
766d697e08 | ||
|
|
d511576fc0 | ||
|
|
380eca813b | ||
|
|
ca096295a3 | ||
|
|
7c9309cf88 | ||
|
|
74a08df800 | ||
|
|
8a606c8c2b | ||
|
|
deffa0c912 | ||
|
|
c244b7bf3f | ||
|
|
84177485ce | ||
|
|
497ab98abc | ||
|
|
2defe9d615 | ||
|
|
7fb65f0255 | ||
|
|
80badb784a | ||
|
|
90296b80f0 | ||
|
|
de45e1cd2f | ||
|
|
f7e2584304 | ||
|
|
bac3528e43 | ||
|
|
329e8015d4 | ||
|
|
dbeb8c3122 | ||
|
|
a54d21033e | ||
|
|
22b4577ec1 | ||
|
|
f72515e7bc | ||
|
|
98d52fa944 | ||
|
|
fdb0086d6c | ||
|
|
2112f458fb | ||
|
|
0886c9013c | ||
|
|
b4e2370ee8 | ||
|
|
c8b7250995 | ||
|
|
d8aa300d6b | ||
|
|
d229ee5df5 | ||
|
|
39d979302c | ||
|
|
a4d00e2068 | ||
|
|
663c801c05 | ||
|
|
d872d7101f | ||
|
|
cac79b4b1a | ||
|
|
4404b60305 | ||
|
|
dfd6732f5b | ||
|
|
948d2ed535 | ||
|
|
f4d067a3cc | ||
|
|
9dab0ca3f4 | ||
|
|
b75a8d272a | ||
|
|
43afe14017 | ||
|
|
01fe4fe6e3 | ||
|
|
4d9e691e04 | ||
|
|
e302df6ec0 | ||
|
|
a3a476f59d | ||
|
|
c970c5761f | ||
|
|
cded2993f5 | ||
|
|
0878afbc81 | ||
|
|
739dada015 | ||
|
|
10a8f738a0 | ||
|
|
550839003e | ||
|
|
874c7fed2e | ||
|
|
ae8f4a843a | ||
|
|
6a757a21f1 | ||
|
|
f5554158f9 | ||
|
|
bc0d403da9 | ||
|
|
26a5f3a2ee | ||
|
|
d2d1adca0a | ||
|
|
1edea552bf | ||
|
|
9a06a2fa47 | ||
|
|
f0fc282630 | ||
|
|
cdbd1dc821 | ||
|
|
5fea5bbf44 | ||
|
|
d0a31fd862 | ||
|
|
08d87d95e9 | ||
|
|
195b9f081c | ||
|
|
a20d191f87 | ||
|
|
5fd8c6a015 | ||
|
|
47b5b2d871 | ||
|
|
f4f6943ed6 | ||
|
|
ce1a138cdf | ||
|
|
cccfe5fb3a | ||
|
|
6200e01522 | ||
|
|
18dea21726 | ||
|
|
2ac499d9a3 | ||
|
|
c8e1f07fdb | ||
|
|
378ac1f98c | ||
|
|
5503b42806 | ||
|
|
4877eb57d2 | ||
|
|
e1fd28970b | ||
|
|
49700670b9 | ||
|
|
e912bda85f | ||
|
|
3071ea8eac | ||
|
|
12e54e45f6 | ||
|
|
4105a7e8a7 | ||
|
|
ba817e03fb | ||
|
|
a108bc8a06 | ||
|
|
8630cd90a3 | ||
|
|
776e53044c | ||
|
|
bf9ef340b6 | ||
|
|
26893fb66a | ||
|
|
5b04c2125c | ||
|
|
f42a98b887 | ||
|
|
708ed38229 | ||
|
|
ea2f7a49a7 | ||
|
|
f83a003a73 |
@@ -6,5 +6,6 @@
|
||||
!README.md
|
||||
!PROMPT_STYLE.md
|
||||
!project_CLAUDE.md
|
||||
!project_AGENTS.md
|
||||
!project_hooks.json
|
||||
#Do not add other exceptions here without careful consideration. Developer permissions0ns needed.
|
||||
+50
-4
@@ -8,6 +8,11 @@
|
||||
"handler": "aipass.hooks.apps.handlers.security.presence_gate.handle",
|
||||
"matcher": ""
|
||||
},
|
||||
"persistent_alert": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.persistent_alert.handle",
|
||||
"matcher": ""
|
||||
},
|
||||
"identity_injector": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.identity.handle",
|
||||
@@ -33,11 +38,38 @@
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.navmap.handle",
|
||||
"matcher": ""
|
||||
},
|
||||
"compass_recall": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.compass_recall.handle",
|
||||
"matcher": "",
|
||||
"max_per_session": 10
|
||||
},
|
||||
"feedback_pulse": {
|
||||
"enabled": false,
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.feedback_pulse.handle",
|
||||
"matcher": ""
|
||||
},
|
||||
"auto_process": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle",
|
||||
"matcher": "",
|
||||
"timeout": 120
|
||||
},
|
||||
"context_gauge": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.context_gauge.handle",
|
||||
"matcher": "",
|
||||
"timeout": 30
|
||||
},
|
||||
"temporal": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.temporal.handle",
|
||||
"matcher": ""
|
||||
},
|
||||
"user_message_relay": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.skills.lib.telegram.apps.handlers.user_message_relay.handle",
|
||||
"matcher": ""
|
||||
}
|
||||
},
|
||||
|
||||
@@ -62,10 +94,10 @@
|
||||
"handler": "aipass.hooks.apps.handlers.security.rm_gate.handle",
|
||||
"matcher": "Bash"
|
||||
},
|
||||
"engine_test_sound": {
|
||||
"enabled": false,
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/engine_test_sound.py",
|
||||
"matcher": "WebSearch"
|
||||
"registry_gate": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.security.registry_gate.handle",
|
||||
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -137,6 +169,20 @@
|
||||
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle",
|
||||
"matcher": "",
|
||||
"timeout": 120
|
||||
},
|
||||
"pre_compact_prep": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.lifecycle.pre_compact_prep.handle",
|
||||
"matcher": "",
|
||||
"timeout": 30
|
||||
}
|
||||
},
|
||||
|
||||
"SessionStart": {
|
||||
"cadence_reset": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.lifecycle.session_start.handle",
|
||||
"matcher": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# {name}
|
||||
|
||||
Agent workspace powered by AIPass.
|
||||
|
||||
# Startup protocol
|
||||
|
||||
On any greeting, silently run this sequence — no narration, no announcing steps. Just do it and respond with the status.
|
||||
|
||||
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`
|
||||
|
||||
Use drone commands for all operations. Never raw git, gh, or file access when drone provides it.
|
||||
|
||||
# Memories
|
||||
|
||||
Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`.
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_comment": "TEMPLATE: base per-project hook config copied into new projects by `aipass init` (DPLAN-0190). Mirrors AIPass's own .aipass/hooks.json. All handlers run from $AIPASS_HOME — projects only flip enabled true/false. Use `drone @hooks enable/disable <hook>` or edit here.",
|
||||
"_comment": "TEMPLATE: base per-project hook config copied into new projects by `aipass init` (DPLAN-0190). Mirrors AIPass's own .aipass/hooks.json. All handlers run from $AIPASS_HOME — projects only flip enabled true/false. Use `drone @hooks enable/disable <hook>` or edit here. NOTE: git_gate is enabled by default — it enforces git via drone to prevent state conflicts. To disable for your project, set git_gate.enabled to false below (this won't break other hooks).",
|
||||
"hooks_enabled": true,
|
||||
|
||||
"UserPromptSubmit": {
|
||||
@@ -92,6 +92,14 @@
|
||||
}
|
||||
},
|
||||
|
||||
"SessionStart": {
|
||||
"cadence_reset": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.lifecycle.session_start.handle",
|
||||
"matcher": ""
|
||||
}
|
||||
},
|
||||
|
||||
"PreCompact": {
|
||||
"pre_compact": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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 -->
|
||||
<!-- .aipass/tier0_kernel.md — Tier 0, injected every 5 turns (cadence period 5) + on every fresh context (new chat / clear / after compact). 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`.
|
||||
|
||||
@@ -13,6 +13,8 @@ You are an AIPass agent — a citizen with identity, memory, and a mailbox. Your
|
||||
- `drone @agent` — bare → the agent's live self-map.
|
||||
- `drone systems` — list every agent.
|
||||
|
||||
`aipass` is the one exception — the user's own front-door CLI and concierge (onboarding, `doctor`, OS/system help). Run `aipass` / `aipass --help` directly, **never `drone @aipass`** (drone can't resolve it). Serves humans, not agents.
|
||||
|
||||
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
|
||||
|
||||
+36
-27
@@ -1,16 +1,16 @@
|
||||
# AIPass — Navigation map
|
||||
|
||||
<!-- .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 -->
|
||||
<!-- Tier 1 — injected on cadence 5, at session start, and post-compaction. Kernel = tier0_kernel.md, every turn. Cap: ~8,000 chars per fire (hook truncates near 10k). Format: PROMPT_STYLE.md -->
|
||||
|
||||
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 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 is open source** — public repo on GitHub. Strangers read, clone, and scan this code; treat external findings as contributions.
|
||||
|
||||
# Finding your way
|
||||
|
||||
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:
|
||||
You can't carry everything; you can find anything. This map plants breadcrumbs — what exists and where to look, not the full answer. Cheapest, highest-signal sources first:
|
||||
|
||||
- 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.
|
||||
- bare `drone @agent` — the agent's live self-map of modules and commands.
|
||||
- `drone @agent --help` — the full reference, source of truth for usage.
|
||||
- the agent's `README.md` — quick overview of its domain.
|
||||
|
||||
# Terminology
|
||||
|
||||
@@ -18,15 +18,15 @@ You can't carry everything; you can find anything — you're the librarian, not
|
||||
- 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`.
|
||||
- Settings — provider `~/.claude/settings.json` (personal, don't touch) · project `.claude/settings.json` (ships with clone) · local override `settings.local.json`.
|
||||
|
||||
# The framework
|
||||
|
||||
Every branch is built the same. All agents live at `src/aipass/<name>` · mail address `@<name>`.
|
||||
Every branch is built the same: `src/aipass/<name>` · mail `@<name>`.
|
||||
|
||||
```
|
||||
src/aipass/<name>/
|
||||
├── .trinity/ # identity & memory (passport, local, observations)
|
||||
├── .trinity/ # identity & memory
|
||||
├── .aipass/ # branch prompt
|
||||
├── .ai_mail.local/ # mailbox
|
||||
├── apps/
|
||||
@@ -39,23 +39,23 @@ src/aipass/<name>/
|
||||
|
||||
# The agents
|
||||
|
||||
- @drone — command router. Resolves `@agent`, routes commands, enforces tier-based access. Also the only git interface (`drone @git`).
|
||||
- @drone — command router. Routes commands, enforces tier-based access. Also the only git interface (`drone @git`).
|
||||
- @devpulse — orchestration hub, the user's primary collaborator. Coordinates the other agents, dispatches work, only agent with git write.
|
||||
- @aipass — the user-facing front door and a system-ops collaborator. Onboarding (`aipass init`), `doctor` diagnostics, help chat, handoff; also partners with the user on host-level health (disk, thermal, docker, config). Concierge to other branches: reads, never writes.
|
||||
- @aipass — the user's front-door concierge, its OWN CLI: run `aipass` directly, never `drone @aipass` (drone can't resolve it). Onboarding (`init`/`install`), `doctor` health, help chat, OS/system questions. Serves humans, not agents — reads, never writes.
|
||||
- @ai_mail — inter-agent email. `dispatch` = send + wake (default for handing work), `email` = no wake, plus inbox/view/reply/close.
|
||||
- @flow — plan lifecycle: create, list, close, templates, registry. Plan types in the Plans section — never create plan files by hand.
|
||||
- @seedgo — code standards and audits. The standard pack, `audit` and `checklist`, the quality gate before and after building.
|
||||
- @prax — logging and monitoring. The only logging system: `from aipass.prax import logger`. Real-time monitor, dashboards. Logs are the first diagnostic tool.
|
||||
- @memory — long-term memory. Archives overflowing `.trinity/` files into searchable vectors; `search` recalls past sessions. Nothing is lost — it moves deeper.
|
||||
- @flow — plan lifecycle: create, list, close, templates, registry. See the Plans section.
|
||||
- @seedgo — code standards and audits. `audit` and `checklist` — the quality gate before and after building.
|
||||
- @prax — logging and monitoring. The only logging system: `from aipass.prax import logger`. Real-time monitor, dashboards, runaway-log detection. Logs are the first diagnostic tool.
|
||||
- @memory — long-term memory. Archives overflowing `.trinity/` files into searchable vectors; `search` recalls past sessions.
|
||||
- @spawn — branch lifecycle. Creates, updates, syncs, retires agents — scaffolding, passports, registry, templates.
|
||||
- @hooks — Claude Code hook engine. Prompt injection and cadence, security gates (git/edit/rm), bridges, per-project config, sound.
|
||||
- @hooks — Claude Code hook engine. Prompt injection and cadence, security gates (git/edit/rm), bridges, persistent alerts, per-project config, sound.
|
||||
- @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. Snapshots + versioning + restore for any directory; optional Google Drive sync (planned). `.backup/` is a shared runtime namespace — @memory rollover and @flow (plan archive) also write there.
|
||||
- @skills — capability framework. Discoverable, self-contained skill units any agent can run (e.g. the Telegram skill).
|
||||
- @daemon — task scheduler. Each branch owns its `.daemon/schedule.json`; the daemon discovers and fires.
|
||||
- @commons — the social space. Branches post, comment, vote.
|
||||
- @backup — local-first backups. Snapshots, versioning, restore for any directory; optional Google Drive sync. `.backup/` is shared — @memory rollover and @flow archives write there too.
|
||||
|
||||
# Daily commands
|
||||
|
||||
@@ -65,10 +65,20 @@ drone @ai_mail inbox # check mail → view <id>
|
||||
drone @flow create . "Subject" [dplan] # new plan (default FPLAN)
|
||||
drone @seedgo audit aipass @branch # standards audit (drop @branch = all)
|
||||
drone @seedgo checklist <file|dir> # quick standards check
|
||||
drone @trigger medic mute @<self> # BEFORE build/edit work — auto-expires 24h
|
||||
drone @git status / diff / log # read-only git awareness
|
||||
drone @memory search "query" # recall archived context
|
||||
```
|
||||
|
||||
# Talking to other agents
|
||||
|
||||
Citizens dispatch each other directly — allowed and expected, no permission needed. Pick by one question: does the recipient need to ACT?
|
||||
|
||||
- Need an answer, input, or work from them → `dispatch` (send + wake). A sleeping agent never reads plain email — a question sent as `email` stalls unread.
|
||||
- FYI only (status, steering an agent already awake) → `email` (no wake).
|
||||
- Replies never wake — wake-back does: when an agent you dispatched completes, YOU are woken. Team mission: the lead dispatches each phase BEFORE sleeping; the worker replies normally; wake-back returns the lead to verify and hand off the next phase.
|
||||
- Exception — managers (`citizen_class: manager`, e.g. @devpulse) are never dispatched — the wake is blocked. `email` them; the mail lands and they see it live.
|
||||
|
||||
Always reply to dispatches — reply auto-closes. No silent completions.
|
||||
|
||||
# Plans — flow
|
||||
@@ -78,15 +88,15 @@ Plans carry context so you don't have to. Create only via `drone @flow create <p
|
||||
- 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.
|
||||
- More types exist and new ones register over time. Named a type you don't know? `drone @flow templates` lists them all, live.
|
||||
- More types register over time — `drone @flow templates` lists them all, live.
|
||||
|
||||
# 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.
|
||||
- Default to sub-agents for reading, searching, building, testing, research. Do it yourself only for tiny edits, your own memories/plans, one-liners.
|
||||
- One clear task per agent. Brief with full context — they know nothing of your conversation.
|
||||
- No git, no memory, no dispatch. They build and report; you decide and act.
|
||||
- Sub-agent = local disposable worker. Dispatch (`@ai_mail`) = wake a citizen with memory and identity. Branch-expert work → dispatch; else → sub-agent.
|
||||
- Models, good practice: opus for build and analysis, sonnet for routine investigation, haiku for trivial mechanical tasks. Never fable for sub-agents.
|
||||
- Models: opus for build/analysis, sonnet for routine investigation, haiku for trivial mechanical tasks. Never fable for sub-agents.
|
||||
|
||||
# Memory — .trinity/
|
||||
|
||||
@@ -95,12 +105,11 @@ Your continuity across sessions. Save proactively — after milestones, decision
|
||||
- `passport.json` — identity. Update only when identity genuinely evolves.
|
||||
- `local.json` — session log, key learnings, todos.
|
||||
- `observations.json` — what you learn about the user.
|
||||
- Overflow rolls to vectors automatically — never trim by hand. Two ChromaDB stores: your branch's `.chroma` (local) + a global one across all branches. `drone @memory search "query"` recalls them. Search before assuming you're cold.
|
||||
- Entry caps are hook-enforced (over-limit edit = rejected whole). The live cap is rendered in each file's `*_meta` line — read it before writing, draft to ~80% of it; if rejected, rewrite hard in one pass.
|
||||
- Overflow rolls to vectors automatically — never trim by hand. `drone @memory search "query"` recalls it — search before assuming you're cold.
|
||||
- Entry caps are hook-enforced (over-limit edit = rejected whole). The live cap is in each file's `*_meta` line — read it before writing, draft to ~80%; if rejected, rewrite hard in one pass.
|
||||
|
||||
# House rules
|
||||
|
||||
- Cross-platform, no hardcoded paths. Public repo — `pathlib`, never `/home/...`.
|
||||
- Public repo — write as if it ships, because it does. No secrets in the tree, no hardcoded paths (`pathlib`, never `/home/...`), cross-platform.
|
||||
- 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; memories record; registries catalog.
|
||||
|
||||
@@ -44,7 +44,7 @@ Quick checks beat assumptions: `ls`/`find` for files, `git ls-files`/`grep` for
|
||||
- Update their execution logs, status, decision logs with current state
|
||||
- If a plan was completed, note it (but don't close — the user does that)
|
||||
|
||||
## 3. Git State
|
||||
## 3. Git State (Devpulse only)
|
||||
|
||||
- Run `git status` — report uncommitted changes
|
||||
- If there's a logical commit waiting, suggest it (don't commit without asking)
|
||||
@@ -54,8 +54,14 @@ Quick checks beat assumptions: `ls`/`find` for files, `git ls-files`/`grep` for
|
||||
|
||||
- Run `drone @ai_mail inbox 2>/dev/null` — report any unread emails
|
||||
- Close any that were already processed but not formally closed
|
||||
- Run `drone @devpulse feedback 2>/dev/null` — report unread cross-project feedback; view/reply/clear anything NEW (S304 F46: this box sat unread for 3 months because nothing invoked it)
|
||||
|
||||
## 5. Loose Ends
|
||||
## 5. Compass Review (Devpulse only)
|
||||
|
||||
- Run ONE `drone @devpulse compass review` — it serves the oldest-unreviewed entry. Judge it: still true → confirm; superseded → archive it and note what replaced it; wrong → fix or archive.
|
||||
- One entry per prep, every prep. This is the curation cadence — review only works if it actually runs (DPLAN-0246: all 127 entries sat unreviewed because nothing invoked it).
|
||||
|
||||
## 6. Loose Ends
|
||||
|
||||
- Flag anything in-flight: running background agents, dispatched branches waiting for replies, pending decisions
|
||||
- If anything can't survive compaction (e.g., agent IDs needed for resume), write it to local.json todos[]
|
||||
@@ -71,5 +77,6 @@ Prep complete:
|
||||
- Plans: [which ones updated]
|
||||
- Git: [branch, uncommitted count, suggestion]
|
||||
- Inbox: [count, action taken]
|
||||
- Compass: [entry #N reviewed — verdict]
|
||||
- Loose ends: [any flagged]
|
||||
```
|
||||
|
||||
@@ -4,11 +4,19 @@
|
||||
"cli": {
|
||||
"claude": {
|
||||
"hooks": [
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:presence_gate", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:persistent_alert", "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"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:compass_recall", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:feedback_pulse", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:auto_process", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:context_gauge", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:temporal", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:user_message_relay", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse", "event": "PostToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py SubagentStop", "event": "SubagentStop"},
|
||||
@@ -17,7 +25,9 @@
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "manual", "timeout": 60},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "auto", "timeout": 60},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "manual", "timeout": 120},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "auto", "timeout": 120}
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "auto", "timeout": 120},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_prep", "event": "PreCompact", "matcher": "manual", "timeout": 30},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_prep", "event": "PreCompact", "matcher": "auto", "timeout": 30}
|
||||
],
|
||||
"env": {
|
||||
"AIPASS_HOME": "{{REPO_ROOT}}",
|
||||
|
||||
@@ -12,6 +12,31 @@ updates:
|
||||
prefix-development: "deps"
|
||||
include: "scope"
|
||||
|
||||
# Hash-pinned CI tool installs (ruff/build/pytest/pip-audit/pip). Pinning is
|
||||
# what Scorecard's Pinned-Dependencies wants, but a frozen pin rots: these
|
||||
# locks are what security.yml's pip-audit scans, so a new advisory against a
|
||||
# pinned dep reds the job until the pin moves. This entry is what keeps that
|
||||
# window short. Dependabot reads the `pip-compile ...` command out of each
|
||||
# .txt header and regenerates the lock (hashes included) from the .in.
|
||||
# Separate from the "/" pip entry above, which tracks pyproject.toml.
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/.github/requirements"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
- "ci"
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
include: "scope"
|
||||
# packaging/pygments are shared across build.txt, e2e.txt and audit.txt.
|
||||
# Ungrouped, one bump fans out into several PRs that each rewrite a subset
|
||||
# of the locks and conflict with each other. One PR per week moves them all.
|
||||
groups:
|
||||
ci-tooling:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
@@ -22,3 +47,11 @@ updates:
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
include: "scope"
|
||||
# codeql-action is a monorepo (init/analyze/upload-sarif share one release).
|
||||
# Bumping them in separate PRs leaves mismatched versions in security.yml and
|
||||
# CodeQL hard-fails "init and analyze must be the same version". Group them so
|
||||
# every codeql-action bump lands as a single PR that moves all paths together.
|
||||
groups:
|
||||
codeql-action:
|
||||
patterns:
|
||||
- "github/codeql-action*"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# pip-audit for the security.yml `dependency-scan` job, hash-pinned (Scorecard:
|
||||
# Pinned-Dependencies).
|
||||
#
|
||||
# Target env: ubuntu-latest, Python 3.13. pip-audit's own dependency tree is
|
||||
# resolved and hashed here; the project itself is still installed unpinned via
|
||||
# `pip install -e .` and scanned with `pip-audit --skip-editable`, so pinning
|
||||
# this file does not narrow what the audit covers.
|
||||
#
|
||||
# TRADE-OFF: pip-audit scans the whole environment, including its own deps. They
|
||||
# used to float to latest on every run (self-healing); pinned, a new advisory
|
||||
# against one of them reds this job until the pin moves. Dependabot's `pip`
|
||||
# entry for /.github/requirements is what keeps that window short.
|
||||
#
|
||||
# Regenerate:
|
||||
# pip-compile --allow-unsafe --generate-hashes --no-emit-index-url --output-file=audit.txt audit.in
|
||||
pip-audit==2.10.1
|
||||
@@ -0,0 +1,330 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.12
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --allow-unsafe --generate-hashes --no-emit-index-url --output-file=audit.txt audit.in
|
||||
#
|
||||
boolean-py==5.0 \
|
||||
--hash=sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95 \
|
||||
--hash=sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9
|
||||
# via license-expression
|
||||
cachecontrol[filecache]==0.14.4 \
|
||||
--hash=sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b \
|
||||
--hash=sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1
|
||||
# via
|
||||
# cachecontrol
|
||||
# pip-audit
|
||||
certifi==2026.6.17 \
|
||||
--hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \
|
||||
--hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db
|
||||
# via requests
|
||||
charset-normalizer==3.4.9 \
|
||||
--hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \
|
||||
--hash=sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62 \
|
||||
--hash=sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c \
|
||||
--hash=sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226 \
|
||||
--hash=sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5 \
|
||||
--hash=sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833 \
|
||||
--hash=sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b \
|
||||
--hash=sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99 \
|
||||
--hash=sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501 \
|
||||
--hash=sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec \
|
||||
--hash=sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698 \
|
||||
--hash=sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4 \
|
||||
--hash=sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a \
|
||||
--hash=sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3 \
|
||||
--hash=sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2 \
|
||||
--hash=sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a \
|
||||
--hash=sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e \
|
||||
--hash=sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4 \
|
||||
--hash=sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419 \
|
||||
--hash=sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84 \
|
||||
--hash=sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da \
|
||||
--hash=sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519 \
|
||||
--hash=sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe \
|
||||
--hash=sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381 \
|
||||
--hash=sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29 \
|
||||
--hash=sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614 \
|
||||
--hash=sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0 \
|
||||
--hash=sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe \
|
||||
--hash=sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29 \
|
||||
--hash=sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0 \
|
||||
--hash=sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917 \
|
||||
--hash=sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9 \
|
||||
--hash=sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32 \
|
||||
--hash=sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94 \
|
||||
--hash=sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63 \
|
||||
--hash=sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd \
|
||||
--hash=sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198 \
|
||||
--hash=sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde \
|
||||
--hash=sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012 \
|
||||
--hash=sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1 \
|
||||
--hash=sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15 \
|
||||
--hash=sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b \
|
||||
--hash=sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993 \
|
||||
--hash=sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4 \
|
||||
--hash=sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5 \
|
||||
--hash=sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8 \
|
||||
--hash=sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35 \
|
||||
--hash=sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642 \
|
||||
--hash=sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2 \
|
||||
--hash=sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d \
|
||||
--hash=sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9 \
|
||||
--hash=sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c \
|
||||
--hash=sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33 \
|
||||
--hash=sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db \
|
||||
--hash=sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf \
|
||||
--hash=sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9 \
|
||||
--hash=sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee \
|
||||
--hash=sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84 \
|
||||
--hash=sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44 \
|
||||
--hash=sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f \
|
||||
--hash=sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9 \
|
||||
--hash=sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9 \
|
||||
--hash=sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177 \
|
||||
--hash=sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8 \
|
||||
--hash=sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b \
|
||||
--hash=sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39 \
|
||||
--hash=sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41 \
|
||||
--hash=sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0 \
|
||||
--hash=sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616 \
|
||||
--hash=sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d \
|
||||
--hash=sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba \
|
||||
--hash=sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2 \
|
||||
--hash=sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b \
|
||||
--hash=sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9 \
|
||||
--hash=sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b \
|
||||
--hash=sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209 \
|
||||
--hash=sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48 \
|
||||
--hash=sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046 \
|
||||
--hash=sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632 \
|
||||
--hash=sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a \
|
||||
--hash=sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2 \
|
||||
--hash=sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1 \
|
||||
--hash=sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b \
|
||||
--hash=sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990 \
|
||||
--hash=sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5 \
|
||||
--hash=sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b \
|
||||
--hash=sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9 \
|
||||
--hash=sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534 \
|
||||
--hash=sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81 \
|
||||
--hash=sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a \
|
||||
--hash=sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d \
|
||||
--hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \
|
||||
--hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115
|
||||
# via requests
|
||||
cyclonedx-python-lib==11.11.0 \
|
||||
--hash=sha256:3049fc83e06a059b5c5907a527625a8ed5073caab10607ed4c9e5503b590fd44 \
|
||||
--hash=sha256:4b3194db72b613717f2912447e67ab618c75ff7dcac6c4af3c0e9e1ac617c102
|
||||
# via pip-audit
|
||||
defusedxml==0.7.1 \
|
||||
--hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \
|
||||
--hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
|
||||
# via py-serializable
|
||||
filelock==3.29.7 \
|
||||
--hash=sha256:5b481979797ae69e72f0b389d89a80bdd585c260c5b3f1fb9c0a5ba9bb3f195d \
|
||||
--hash=sha256:987db6f789a3a2a59f55081801b2b3697cb97e2a736b5f1a9e99b559285fbc51
|
||||
# via cachecontrol
|
||||
idna==3.18 \
|
||||
--hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
|
||||
--hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
|
||||
# via requests
|
||||
license-expression==30.4.4 \
|
||||
--hash=sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4 \
|
||||
--hash=sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd
|
||||
# via cyclonedx-python-lib
|
||||
markdown-it-py==4.2.0 \
|
||||
--hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \
|
||||
--hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a
|
||||
# via rich
|
||||
mdurl==0.1.2 \
|
||||
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
|
||||
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
|
||||
# via markdown-it-py
|
||||
msgpack==1.2.1 \
|
||||
--hash=sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833 \
|
||||
--hash=sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a \
|
||||
--hash=sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647 \
|
||||
--hash=sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d \
|
||||
--hash=sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064 \
|
||||
--hash=sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107 \
|
||||
--hash=sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac \
|
||||
--hash=sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720 \
|
||||
--hash=sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c \
|
||||
--hash=sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06 \
|
||||
--hash=sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895 \
|
||||
--hash=sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde \
|
||||
--hash=sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203 \
|
||||
--hash=sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc \
|
||||
--hash=sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74 \
|
||||
--hash=sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22 \
|
||||
--hash=sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8 \
|
||||
--hash=sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35 \
|
||||
--hash=sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb \
|
||||
--hash=sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9 \
|
||||
--hash=sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8 \
|
||||
--hash=sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6 \
|
||||
--hash=sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07 \
|
||||
--hash=sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056 \
|
||||
--hash=sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4 \
|
||||
--hash=sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7 \
|
||||
--hash=sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1 \
|
||||
--hash=sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24 \
|
||||
--hash=sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355 \
|
||||
--hash=sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0 \
|
||||
--hash=sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce \
|
||||
--hash=sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f \
|
||||
--hash=sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707 \
|
||||
--hash=sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66 \
|
||||
--hash=sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b \
|
||||
--hash=sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e \
|
||||
--hash=sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d \
|
||||
--hash=sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8 \
|
||||
--hash=sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7 \
|
||||
--hash=sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73 \
|
||||
--hash=sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402 \
|
||||
--hash=sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a \
|
||||
--hash=sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d \
|
||||
--hash=sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c \
|
||||
--hash=sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273 \
|
||||
--hash=sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b \
|
||||
--hash=sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d \
|
||||
--hash=sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb \
|
||||
--hash=sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4 \
|
||||
--hash=sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190 \
|
||||
--hash=sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5 \
|
||||
--hash=sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a \
|
||||
--hash=sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7 \
|
||||
--hash=sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1 \
|
||||
--hash=sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889 \
|
||||
--hash=sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c \
|
||||
--hash=sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155 \
|
||||
--hash=sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24 \
|
||||
--hash=sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6 \
|
||||
--hash=sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1 \
|
||||
--hash=sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d \
|
||||
--hash=sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7 \
|
||||
--hash=sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64 \
|
||||
--hash=sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2 \
|
||||
--hash=sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc \
|
||||
--hash=sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c
|
||||
# via cachecontrol
|
||||
packageurl-python==0.17.6 \
|
||||
--hash=sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25 \
|
||||
--hash=sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9
|
||||
# via cyclonedx-python-lib
|
||||
packaging==26.2 \
|
||||
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
|
||||
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
|
||||
# via
|
||||
# pip-audit
|
||||
# pip-requirements-parser
|
||||
pip-api==0.0.34 \
|
||||
--hash=sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb \
|
||||
--hash=sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625
|
||||
# via pip-audit
|
||||
pip-audit==2.10.1 \
|
||||
--hash=sha256:1eb4565d19ebe5d48996f4b770b4d2b32887e12cb12cfa637f1a064011b55ffc \
|
||||
--hash=sha256:99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a
|
||||
# via -r audit.in
|
||||
pip-requirements-parser==32.0.1 \
|
||||
--hash=sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526 \
|
||||
--hash=sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3
|
||||
# via pip-audit
|
||||
platformdirs==4.10.0 \
|
||||
--hash=sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7 \
|
||||
--hash=sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a
|
||||
# via pip-audit
|
||||
py-serializable==2.1.0 \
|
||||
--hash=sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103 \
|
||||
--hash=sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304
|
||||
# via cyclonedx-python-lib
|
||||
pygments==2.20.0 \
|
||||
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
|
||||
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
|
||||
# via rich
|
||||
pyparsing==3.3.2 \
|
||||
--hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \
|
||||
--hash=sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc
|
||||
# via pip-requirements-parser
|
||||
requests==2.34.2 \
|
||||
--hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
|
||||
--hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
|
||||
# via
|
||||
# cachecontrol
|
||||
# pip-audit
|
||||
rich==15.0.0 \
|
||||
--hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \
|
||||
--hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36
|
||||
# via pip-audit
|
||||
sortedcontainers==2.4.0 \
|
||||
--hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \
|
||||
--hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0
|
||||
# via cyclonedx-python-lib
|
||||
tomli==2.4.1 \
|
||||
--hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \
|
||||
--hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \
|
||||
--hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \
|
||||
--hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \
|
||||
--hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \
|
||||
--hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \
|
||||
--hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \
|
||||
--hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \
|
||||
--hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \
|
||||
--hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \
|
||||
--hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \
|
||||
--hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \
|
||||
--hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \
|
||||
--hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \
|
||||
--hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \
|
||||
--hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \
|
||||
--hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \
|
||||
--hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \
|
||||
--hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \
|
||||
--hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \
|
||||
--hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \
|
||||
--hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \
|
||||
--hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \
|
||||
--hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \
|
||||
--hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \
|
||||
--hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \
|
||||
--hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \
|
||||
--hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \
|
||||
--hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \
|
||||
--hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \
|
||||
--hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \
|
||||
--hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \
|
||||
--hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \
|
||||
--hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \
|
||||
--hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \
|
||||
--hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \
|
||||
--hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \
|
||||
--hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \
|
||||
--hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \
|
||||
--hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \
|
||||
--hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \
|
||||
--hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \
|
||||
--hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \
|
||||
--hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \
|
||||
--hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \
|
||||
--hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \
|
||||
--hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049
|
||||
# via pip-audit
|
||||
tomli-w==1.2.0 \
|
||||
--hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \
|
||||
--hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021
|
||||
# via pip-audit
|
||||
typing-extensions==4.16.0 \
|
||||
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
|
||||
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
|
||||
# via cyclonedx-python-lib
|
||||
urllib3==2.7.0 \
|
||||
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
|
||||
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
|
||||
# via requests
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
pip==26.1.2 \
|
||||
--hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \
|
||||
--hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605
|
||||
# via pip-api
|
||||
@@ -0,0 +1,17 @@
|
||||
# `build` for the publish.yml `build` job, hash-pinned (Scorecard:
|
||||
# Pinned-Dependencies).
|
||||
#
|
||||
# Target env: ubuntu-latest, Python 3.13 ONLY.
|
||||
# That narrowness is load-bearing — build's marker-gated deps are all excluded
|
||||
# at this target and therefore absent from build.txt:
|
||||
# colorama ; os_name == "nt" -> posix runner, not needed
|
||||
# tomli ; python_version < "3.11" -> 3.13, not needed
|
||||
# importlib-metadata; python_full_version < "3.10.2" -> 3.13, not needed
|
||||
# If publish.yml ever gains a Windows runner or a <3.11 Python, regenerate this
|
||||
# file on that target (or add the dep explicitly) or --require-hashes will fail
|
||||
# with "all requirements must have their versions pinned".
|
||||
# See e2e.in for the cross-OS variant that handles this.
|
||||
#
|
||||
# Regenerate:
|
||||
# pip-compile --allow-unsafe --generate-hashes --no-emit-index-url --output-file=build.txt build.in
|
||||
build==1.5.0
|
||||
@@ -0,0 +1,18 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.12
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --allow-unsafe --generate-hashes --no-emit-index-url --output-file=build.txt build.in
|
||||
#
|
||||
build==1.5.0 \
|
||||
--hash=sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f \
|
||||
--hash=sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647
|
||||
# via -r build.in
|
||||
packaging==26.2 \
|
||||
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
|
||||
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
|
||||
# via build
|
||||
pyproject-hooks==1.2.0 \
|
||||
--hash=sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8 \
|
||||
--hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913
|
||||
# via build
|
||||
@@ -0,0 +1,26 @@
|
||||
# Outer build tooling for e2e-wheel.yml, hash-pinned (Scorecard:
|
||||
# Pinned-Dependencies).
|
||||
#
|
||||
# Target env: ubuntu-latest + windows-latest + macos-latest, Python 3.12.
|
||||
# conftest.py builds the wheel + a clean venv internally; the outer env only
|
||||
# needs build + pytest.
|
||||
#
|
||||
# WHY colorama IS LISTED EXPLICITLY (do not "clean up"):
|
||||
# both build and pytest depend on colorama behind a platform marker
|
||||
# (`os_name == "nt"` / `sys_platform == "win32"`). pip-compile evaluates markers
|
||||
# against the machine it runs on, so compiling on Linux DROPS colorama from the
|
||||
# lock — and the windows-latest leg then dies under --require-hashes with
|
||||
# "In --require-hashes mode, all requirements must have their versions pinned".
|
||||
# Listing it as a direct requirement forces it into the lock with hashes.
|
||||
# colorama is a pure-python universal wheel (py2.py3-none-any, zero deps), so
|
||||
# installing it on the Linux/macOS legs is inert.
|
||||
#
|
||||
# Python 3.12 is likewise load-bearing: pytest's `exceptiongroup`/`tomli` and
|
||||
# build's `tomli` are gated on python_version < "3.11" and are absent here. If
|
||||
# the matrix ever drops below 3.11, regenerate on that target.
|
||||
#
|
||||
# Regenerate (on Linux, Python 3.12):
|
||||
# pip-compile --allow-unsafe --generate-hashes --no-emit-index-url --output-file=e2e.txt e2e.in
|
||||
build==1.5.0
|
||||
pytest==9.1.1
|
||||
colorama==0.4.6
|
||||
@@ -0,0 +1,40 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.12
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --allow-unsafe --generate-hashes --no-emit-index-url --output-file=e2e.txt e2e.in
|
||||
#
|
||||
build==1.5.0 \
|
||||
--hash=sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f \
|
||||
--hash=sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647
|
||||
# via -r e2e.in
|
||||
colorama==0.4.6 \
|
||||
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
|
||||
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
|
||||
# via -r e2e.in
|
||||
iniconfig==2.3.0 \
|
||||
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
|
||||
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
|
||||
# via pytest
|
||||
packaging==26.2 \
|
||||
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
|
||||
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
|
||||
# via
|
||||
# build
|
||||
# pytest
|
||||
pluggy==1.6.0 \
|
||||
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
|
||||
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
|
||||
# via pytest
|
||||
pygments==2.20.0 \
|
||||
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
|
||||
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
|
||||
# via pytest
|
||||
pyproject-hooks==1.2.0 \
|
||||
--hash=sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8 \
|
||||
--hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913
|
||||
# via build
|
||||
pytest==9.1.1 \
|
||||
--hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \
|
||||
--hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
|
||||
# via -r e2e.in
|
||||
@@ -0,0 +1,15 @@
|
||||
# ruff for the ci.yml `lint` job, hash-pinned (Scorecard: Pinned-Dependencies).
|
||||
#
|
||||
# Target env: ubuntu-latest, Python 3.13. ruff has no dependencies, and
|
||||
# --generate-hashes emits every PyPI file hash for the pinned version (all 18
|
||||
# platform wheels + sdist), so this lock stays valid if lint ever runs on
|
||||
# another OS/arch.
|
||||
#
|
||||
# 0.15.21 == what the previous unpinned `pip install ruff` resolved to on
|
||||
# 2026-07-15, so pinning is a no-op for lint results today. Bumping this file
|
||||
# can surface new lint rules — that is the intended, reviewable trade-off.
|
||||
# Keep compatible with pyproject.toml's dev extra (`ruff>=0.11`).
|
||||
#
|
||||
# Regenerate:
|
||||
# pip-compile --allow-unsafe --generate-hashes --no-emit-index-url --output-file=lint.txt lint.in
|
||||
ruff==0.15.21
|
||||
@@ -0,0 +1,26 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.12
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --allow-unsafe --generate-hashes --no-emit-index-url --output-file=lint.txt lint.in
|
||||
#
|
||||
ruff==0.15.21 \
|
||||
--hash=sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075 \
|
||||
--hash=sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be \
|
||||
--hash=sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd \
|
||||
--hash=sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd \
|
||||
--hash=sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86 \
|
||||
--hash=sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341 \
|
||||
--hash=sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd \
|
||||
--hash=sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67 \
|
||||
--hash=sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41 \
|
||||
--hash=sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d \
|
||||
--hash=sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d \
|
||||
--hash=sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8 \
|
||||
--hash=sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233 \
|
||||
--hash=sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8 \
|
||||
--hash=sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500 \
|
||||
--hash=sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3 \
|
||||
--hash=sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646 \
|
||||
--hash=sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f
|
||||
# via -r lint.in
|
||||
@@ -0,0 +1,13 @@
|
||||
# pip self-upgrade, hash-pinned (OpenSSF Scorecard: Pinned-Dependencies).
|
||||
#
|
||||
# Replaces `python -m pip install --upgrade pip` in ci.yml, security.yml and
|
||||
# e2e-wheel.yml. pip has no runtime dependencies, so this lock is inherently
|
||||
# portable across every OS and Python in the CI matrix (3.10-3.13).
|
||||
#
|
||||
# 26.1.2 is deliberate, not merely "latest": security.yml's pip-audit scans the
|
||||
# whole environment and the runner's bundled pip (26.1.1) carries PYSEC-2026-196
|
||||
# (fixed in 26.1.2). Do not pin below 26.1.2 — audit will red.
|
||||
#
|
||||
# Regenerate:
|
||||
# pip-compile --allow-unsafe --generate-hashes --no-emit-index-url --output-file=pip.txt pip.in
|
||||
pip==26.1.2
|
||||
@@ -0,0 +1,12 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.12
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --allow-unsafe --generate-hashes --no-emit-index-url --output-file=pip.txt pip.in
|
||||
#
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
pip==26.1.2 \
|
||||
--hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \
|
||||
--hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605
|
||||
# via -r pip.in
|
||||
@@ -20,7 +20,9 @@ jobs:
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: pip install ruff
|
||||
# Hash-pinned tool install (Scorecard: Pinned-Dependencies). Pins ruff to
|
||||
# the version this lint gate is known-green against; see .github/requirements/lint.in.
|
||||
- run: python -m pip install --require-hashes -r .github/requirements/lint.txt
|
||||
- run: ruff check src/ tests/
|
||||
- run: ruff format --check src/ tests/
|
||||
|
||||
@@ -36,7 +38,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --require-hashes -r .github/requirements/pip.txt
|
||||
pip install -e ".[dev]"
|
||||
# tests/e2e build a wheel + clean venv per the dedicated e2e-wheel.yml
|
||||
# workflow — they are not part of the fast unit lane.
|
||||
@@ -57,7 +59,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --require-hashes -r .github/requirements/pip.txt
|
||||
# Install the `memory` extra (numpy/chromadb/fastembed) alongside dev:
|
||||
# the diagnostics standard runs pyright over every branch, and memory's
|
||||
# handlers import chromadb/numpy. Without these deps installed, pyright
|
||||
@@ -79,7 +81,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --require-hashes -r .github/requirements/pip.txt
|
||||
pip install -e ".[dev]"
|
||||
- run: coverage run -m pytest --rootdir=. --ignore=tests/e2e
|
||||
- run: coverage xml
|
||||
|
||||
@@ -47,7 +47,14 @@ jobs:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install build tooling
|
||||
run: python -m pip install --upgrade pip build pytest
|
||||
# Hash-pinned (Scorecard: Pinned-Dependencies). e2e.txt carries colorama
|
||||
# explicitly — build/pytest need it only on Windows (os_name == "nt" /
|
||||
# sys_platform == "win32"), and a Linux-generated lock would otherwise
|
||||
# omit it and break the windows-latest leg under --require-hashes.
|
||||
# See .github/requirements/e2e.in.
|
||||
run: |
|
||||
python -m pip install --require-hashes -r .github/requirements/pip.txt
|
||||
python -m pip install --require-hashes -r .github/requirements/e2e.txt
|
||||
|
||||
- name: Run cross-OS e2e wiring harness
|
||||
# conftest.py builds the wheel + clean venv internally; the outer env
|
||||
|
||||
@@ -11,13 +11,34 @@ permissions:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
# Job-level permissions REPLACE the top-level block rather than merge with
|
||||
# it, so `contents: read` is restated here on purpose — dropping it would
|
||||
# break actions/checkout. id-token/attestations are what the provenance
|
||||
# attestation below needs (Scorecard: Signed-Releases).
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: pip install build
|
||||
# Hash-pinned tool install (Scorecard: Pinned-Dependencies).
|
||||
- run: python -m pip install --require-hashes -r .github/requirements/build.txt
|
||||
- run: python -m build
|
||||
- name: Attest build provenance
|
||||
# Signs a provenance statement binding these exact sdist/wheel digests to
|
||||
# this workflow run, via the same keyless Sigstore/OIDC path as the
|
||||
# release signing below. Runs after the artifacts exist and before they
|
||||
# leave the job, so the attested digests are the published ones.
|
||||
# NOTE: the bundle is deliberately NOT written into dist/ — the publish
|
||||
# job feeds dist/* to gh-action-pypi-publish, which rejects any file that
|
||||
# is not a distribution. See the report note on attaching provenance to
|
||||
# the GitHub Release.
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
with:
|
||||
subject-path: "dist/*"
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dist
|
||||
|
||||
@@ -41,6 +41,6 @@ jobs:
|
||||
retention-days: 5
|
||||
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -27,9 +27,11 @@ jobs:
|
||||
# 26.1.2). Upgrading removes the vulnerable version outright rather than
|
||||
# suppressing it — and 26.1.2 also resolves CVE-2026-3219 / CVE-2026-6357,
|
||||
# which is why those two stale --ignore-vuln entries are no longer needed.
|
||||
# Both installs are hash-pinned (Scorecard: Pinned-Dependencies); pip.txt
|
||||
# holds the >=26.1.2 floor the comment above requires.
|
||||
- run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pip-audit
|
||||
python -m pip install --require-hashes -r .github/requirements/pip.txt
|
||||
python -m pip install --require-hashes -r .github/requirements/audit.txt
|
||||
- run: pip install -e .
|
||||
- name: Pip audit
|
||||
run: pip-audit --skip-editable
|
||||
@@ -42,7 +44,7 @@ jobs:
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
- uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
with:
|
||||
languages: python
|
||||
- uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
- uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
with:
|
||||
stale-issue-message: "This issue has been inactive for 30 days. It will be closed in 7 days if no further activity occurs."
|
||||
days-before-stale: 30
|
||||
|
||||
+16
@@ -118,6 +118,18 @@ src/aipass/*/apps/integrations/**
|
||||
!src/aipass/commons/apps/handlers/artifacts/
|
||||
!src/aipass/commons/apps/handlers/artifacts/*.py
|
||||
|
||||
# hooks boot-shim installer — must ship so fresh clones can install the claude()
|
||||
# shell function. Collides with the blanket tools/ ignore (line 51).
|
||||
!src/aipass/hooks/tools/
|
||||
src/aipass/hooks/tools/*
|
||||
!src/aipass/hooks/tools/install_boot_shim.sh
|
||||
|
||||
# User/sample projects — each is its own git repo (git init on create).
|
||||
# Contents never belong to the AIPass repo; only the catalog README is tracked
|
||||
# so the public repo can point at the standalone project repos.
|
||||
projects/*
|
||||
!projects/README.md
|
||||
|
||||
# CI artifacts
|
||||
windows-pytest-results/
|
||||
|
||||
@@ -133,3 +145,7 @@ README_ORIGINAL_DISABLED.md
|
||||
*.bak
|
||||
test/
|
||||
sandbox_test/
|
||||
|
||||
# SQLite WAL sidecars (runtime artifacts — commons.db ro-feed etc.)
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
+1699
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,8 @@ These steps are sequential and dependent — run each ONCE, wait for the result,
|
||||
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`
|
||||
- Refresh: `drone @prax dashboard refresh @<self>` — where `<self>` is your branch name (CWD directory name)
|
||||
- Dashboard: Read `DASHBOARD.local.json` — act on what needs attention (new mail → check inbox, active plans → note them). This is your single status glance.
|
||||
- announce ur current (PID)
|
||||
|
||||
|
||||
Use drone commands for all operations. Never raw git, gh, file access, or python -m when drone provides it.
|
||||
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ PRs are welcome after an issue has been discussed. Keep changes focused -- one f
|
||||
- Python 3.10+
|
||||
- Tests: `pytest` (4,900+ tests across the project)
|
||||
- Quality: AIPass uses its own standards system ([seedgo](src/aipass/seedgo/README.md)) for automated audits
|
||||
- Run `./setup.sh` to bootstrap the full environment
|
||||
- Run `./aipass install --no-init` to bootstrap the full environment (contributors work in the engine repo itself — no first-project scaffold needed)
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
@@ -12,8 +12,15 @@
|
||||
</p>
|
||||
<p align="center"><strong>Persistent Agent Workspace</strong></p>
|
||||
<p align="center"><em>AI agents that remember, collaborate, and never start from zero.</em></p>
|
||||
<p align="center">
|
||||
<a href="https://aipass.ai">aipass.ai</a> ·
|
||||
<a href="https://pypi.org/project/aipass/">PyPI</a> ·
|
||||
<a href="https://reddit.com/r/AIPass">r/AIPass</a> ·
|
||||
<a href="https://github.com/AIOSAI/AIPass/discussions">Discussions</a>
|
||||
</p>
|
||||
|
||||

|
||||
<!-- GIF SLOT 1 — hero (~20s): clone → ./aipass install → live conversation with the concierge.
|
||||
 -->
|
||||
|
||||
---
|
||||
|
||||
@@ -27,53 +34,15 @@ That's not a team. That's a room full of people wearing headphones.
|
||||
|
||||
## What AIPass Does
|
||||
|
||||
AIPass is a CLI-native scaffold that adds **persistent memory, identity, and coordination** to your AI agents. You bring your project — AIPass adds the agent layer on top. No UI, no dashboard. You work in your terminal.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AIOSAI/AIPass.git
|
||||
cd AIPass && ./setup.sh # installs the `aipass` + `drone` commands on your PATH
|
||||
|
||||
cd ~ && mkdir my-project && cd my-project
|
||||
aipass init run
|
||||
```
|
||||
|
||||
A guided setup creates your project, your first agent, and opens a terminal where that agent is already running. Say "hi" — it knows who it is. Come back tomorrow — it remembers.
|
||||
|
||||
This is the base framework. It gives your agents the infrastructure to persist, communicate, and organize — everything else you build on top.
|
||||
|
||||
Here's what lands in your project:
|
||||
|
||||
```
|
||||
my-project/
|
||||
├── .aipass/ # Project config + prompts
|
||||
├── .claude/ # Hooks (injected automatically)
|
||||
├── src/my_project/
|
||||
│ └── my_agent/
|
||||
│ ├── .trinity/ # Identity + memory (3 JSON files)
|
||||
│ ├── .ai_mail.local/ # Local mailbox
|
||||
│ ├── apps/ # Your agent's code
|
||||
│ └── README.md # Domain knowledge
|
||||
├── CLAUDE.md # Project instructions
|
||||
└── MY-PROJECT_REGISTRY.json
|
||||
```
|
||||
|
||||
Everything is plain files. No daemon, no hidden state. Delete the directory and it's gone.
|
||||
|
||||
**Start with one agent.** Add more when you need them:
|
||||
|
||||
```bash
|
||||
aipass init agent my-agent # Full agent: apps, mail, memory, identity
|
||||
```
|
||||
|
||||
**What makes this different:**
|
||||
AIPass is a CLI-native scaffold that adds **persistent memory, identity, and coordination** to your AI agents. You bring your project — AIPass adds the agent layer on top. No UI, no dashboard, no cloud. Everything is plain files on your machine; delete the directory and it's gone.
|
||||
|
||||
- **Agents are persistent.** They remember across sessions. Expertise develops over time. Nobody starts from zero.
|
||||
- **Bring your own project.** AIPass adds agent infrastructure to whatever you're building. It's a scaffold, not a product — you shape it.
|
||||
- **Everything is local.** Memory is JSON files. Communication is local mailbox files. No cloud, no external APIs.
|
||||
- **Shared workspace.** All agents work on the same filesystem, same project, same time. No sandboxes.
|
||||
- **One command for everything.** AIPass ships with `drone`, a CLI router — `drone @agent command` reaches any agent. Learn it once, use it everywhere.
|
||||
- **One command for everything.** `drone @agent command` reaches any agent. Learn it once, use it everywhere.
|
||||
|
||||
**Runs on your existing CLI subscription.** Claude Pro/Max or Codex — AIPass uses the same CLI binary you already run. No extra API keys, no extra costs for core functionality.
|
||||
**Runs on your existing Claude subscription.** AIPass drives the same [Claude Code](https://code.claude.com/docs) binary you already run — Pro or Max. No extra API keys, no extra costs for core functionality.
|
||||
|
||||
---
|
||||
|
||||
@@ -83,29 +52,48 @@ aipass init agent my-agent # Full agent: apps, mail, memory, identity
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AIOSAI/AIPass.git
|
||||
cd AIPass && ./setup.sh # Creates venv, installs, puts `aipass` + `drone` on your PATH, bootstraps 17 agents
|
||||
cd AIPass
|
||||
./aipass install
|
||||
```
|
||||
|
||||
### 2. Your own project
|
||||
One command does it all: builds the environment, puts `aipass` + `drone` on your PATH, bootstraps the 17-agent reference fleet, then walks you through a guided init — and ends **in a conversation**. The AIPass concierge opens right in your terminal with your install report in hand: it welcomes you, asks your name once, shows you around, and checks what your machine still needs — every machine is different.
|
||||
|
||||
Come back tomorrow, say "hi", and it picks up exactly where you left off. That's the whole interface.
|
||||
|
||||
<!-- GIF SLOT 2 — memory payoff (~15s): close the terminal, reopen, "hi", the agent recalls yesterday.
|
||||
 -->
|
||||
|
||||
Options: `--no-init` skips the guided chain, `--project <dir>` picks where your project lands. Non-interactive shells (CI, pipes) complete with defaults and exit 0 — no prompts, no spawned sessions; the handoff prints as a next-step command instead. The installer wires Claude Code hooks automatically — merging with any hooks you've already configured, never overwriting them. `./aipass` is a thin repo-root launcher over `setup.sh`; after setup it forwards to the installed `aipass` binary.
|
||||
|
||||
### 2. Your own project (if you skipped the chain)
|
||||
|
||||
Two ways in. From anywhere inside your AIPass environment, `aipass new` builds a complete project around a resident manager agent:
|
||||
|
||||
```bash
|
||||
aipass new my-project --template python # Project + resident manager agent + git birth commit
|
||||
```
|
||||
|
||||
It mints the project registry, spawns a full citizen (identity, memory, mailbox, birth certificate) at `src/my_project/my_project`, makes the first commit — and drops you straight into a conversation with your new manager.
|
||||
|
||||
Or bring your own directory, anywhere on disk:
|
||||
|
||||
```bash
|
||||
cd ~ && mkdir my-project && cd my-project
|
||||
aipass init run # Guided setup — project, first agent, terminal handoff
|
||||
aipass init run # Guided setup — project, first agent, ends in the conversation
|
||||
```
|
||||
|
||||
That's it. Your agent has identity, memory, a mailbox, and access to every AIPass service — planning, quality audits, dispatch, real-time monitoring. All through `drone @branch command`.
|
||||
Either way your agent has identity, memory, a mailbox, and access to every AIPass service — planning, quality audits, dispatch, real-time monitoring.
|
||||
|
||||
```bash
|
||||
aipass init # Just the scaffold (no guided setup)
|
||||
aipass init agent my_agent # Add another agent
|
||||
aipass doctor # Check system health
|
||||
aipass feedback off # Silence the occasional how-are-we-doing ask
|
||||
```
|
||||
|
||||
> **Need help?** [Ask in Discussions](https://github.com/AIOSAI/AIPass/discussions) or [file feedback](https://github.com/AIOSAI/AIPass/issues/new?template=feedback.yml) — both take 30 seconds.
|
||||
### 3. Meet the fleet
|
||||
|
||||
### 3. Explore the full framework
|
||||
|
||||
The clone above already includes all 17 agents working together — the reference implementation:
|
||||
The clone already includes all 17 agents working together — the reference implementation that maintains AIPass itself:
|
||||
|
||||
```bash
|
||||
cd src/aipass/devpulse
|
||||
@@ -113,45 +101,33 @@ claude # Talk to the orchestrator
|
||||
```
|
||||
|
||||
```bash
|
||||
# Things you can do:
|
||||
aipass doctor # Check system health
|
||||
drone @seedgo audit aipass # Run automated quality checks across all agents
|
||||
drone @flow create . "Add user auth" # Create a work plan
|
||||
drone @ai_mail dispatch @agent "Sub" "Body" # Send task + wake an agent
|
||||
drone @seedgo audit aipass # Quality checks across all agents
|
||||
drone @flow create . "Add user auth" # Create a work plan
|
||||
drone @ai_mail dispatch @agent "Subject" "Body" # Send a task + wake an agent
|
||||
```
|
||||
|
||||
> **Need help?** [Ask in Discussions](https://github.com/AIOSAI/AIPass/discussions) or [file feedback](https://github.com/AIOSAI/AIPass/issues/new?template=feedback.yml) — both take 30 seconds.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
**One agent:** Run `aipass init run` and in 5 minutes you have a project with an agent that reads `.trinity/` on startup and picks up where it left off. Memory starts as plain JSON files — no setup required. When they fill up, older entries automatically archive into ChromaDB for long-term search. Nothing is lost.
|
||||
**Memory.** Every agent owns a `.trinity/` directory — identity, session history, learnings — read on startup, updated as it works. Memory starts as plain JSON, no setup required. When files fill up, older entries automatically archive into ChromaDB for long-term semantic search. Nothing is lost.
|
||||
|
||||
**A team:** When one agent isn't enough, every agent shares the same structure:
|
||||
**One structure.** Every agent — yours and the reference fleet — shares the same layout. If you know one agent, you know all of them:
|
||||
|
||||
```
|
||||
src/my-project/<agent>/
|
||||
src/my_project/<agent>/
|
||||
├── .trinity/ # Identity + memory (persists across sessions)
|
||||
├── .ai_mail.local/ # Mailbox (receives tasks, sends results)
|
||||
├── apps/ # Entry point → modules → handlers
|
||||
└── README.md # Domain knowledge (the agent reads this on startup)
|
||||
└── README.md # Domain knowledge (read on startup)
|
||||
```
|
||||
|
||||
Identical layout everywhere. If you know one agent, you know all of them. `drone` is the single command that routes to any agent:
|
||||
**One router.** `drone @branch command [args]` reaches any agent — routing, access tiers, and @agent resolution handled for you. Agents use the same commands to reach each other: they dispatch work, share findings, and wake whoever they're waiting on.
|
||||
|
||||
```bash
|
||||
drone @branch command [args] # Every agent, every task. Drone handles routing.
|
||||
```
|
||||
|
||||
```bash
|
||||
drone @seedgo audit my_project # Run quality checks on everything
|
||||
drone @flow create . "Refactor auth module" # Create a work plan
|
||||
drone @ai_mail dispatch @agent "Archive old sessions" "Find sessions older than 30 days"
|
||||
```
|
||||
|
||||
**Two ways to use AIPass:**
|
||||
|
||||
- **Your own project:** `aipass init run` sets up a new project with your first agent. Add more agents as you need them. Your first agent is the orchestrator — it coordinates the others.
|
||||
- **The full framework:** Clone the repo to work with all 17 core agents. Talk to `devpulse` (the orchestrator), dispatch work across specialists. Agents work in parallel and report back.
|
||||
<!-- GIF SLOT 3 — team (~20s): dispatch a task to an agent, watchdog wake-back, result lands.
|
||||
 -->
|
||||
|
||||
---
|
||||
|
||||
@@ -164,7 +140,7 @@ devpulse (orchestrator)
|
||||
├── aipass — concierge + onboarding (aipass init, doctor, profile)
|
||||
├── drone — command routing + @agent resolution
|
||||
├── seedgo — automated quality standards
|
||||
├── prax — real-time monitoring across all agents
|
||||
├── prax — real-time monitoring + runaway-log detection across all agents
|
||||
├── ai_mail — agent-to-agent communication + task dispatch
|
||||
├── flow — plan lifecycle, templates, auto-archival
|
||||
├── spawn — creates new agents anywhere on your filesystem
|
||||
@@ -179,8 +155,6 @@ devpulse (orchestrator)
|
||||
└── commons — the social space — post, comment, vote, gather
|
||||
```
|
||||
|
||||
These agents work on the **same filesystem, same project, same time** — no sandboxes, no worktrees. This is the pattern your projects inherit.
|
||||
|
||||
<details>
|
||||
<summary>Agent details</summary>
|
||||
|
||||
@@ -202,9 +176,9 @@ These agents work on the **same filesystem, same project, same time** — no san
|
||||
| Agent | Role |
|
||||
|-------|------|
|
||||
| [**seedgo**](src/aipass/seedgo/README.md) | Automated quality standards, enforced across all agents |
|
||||
| [**prax**](src/aipass/prax/README.md) | Real-time monitoring, logs, dashboards |
|
||||
| [**prax**](src/aipass/prax/README.md) | Real-time monitoring, logs, dashboards, runaway-log detection |
|
||||
| [**flow**](src/aipass/flow/README.md) | Plan lifecycle — multiple template types, auto-archival, vector verification |
|
||||
| [**hooks**](src/aipass/hooks/README.md) | Hook engine — per-project config, sound control, event dispatch |
|
||||
| [**hooks**](src/aipass/hooks/README.md) | Hook engine — per-project config, sound control, event dispatch, persistent alerts |
|
||||
| [**trigger**](src/aipass/trigger/README.md) | Event-driven automation + self-healing |
|
||||
| [**cli**](src/aipass/cli/README.md) | Terminal formatting and rich output |
|
||||
| [**backup**](src/aipass/backup/README.md) | Local-first backups — snapshots, versioning, restore (optional Google Drive sync) |
|
||||
@@ -221,19 +195,6 @@ These agents work on the **same filesystem, same project, same time** — no san
|
||||
|
||||
---
|
||||
|
||||
## CLI Support
|
||||
|
||||
AIPass is built and tested with **Claude Code** on Linux/WSL.
|
||||
|
||||
| CLI | Autonomous Mode | Status |
|
||||
|-----|----------------|--------|
|
||||
| [Claude Code](https://code.claude.com/docs) | `claude -p "prompt" --permission-mode bypassPermissions` | Fully tested |
|
||||
| [Codex](https://github.com/openai/codex) | `codex exec "prompt" --dangerously-bypass-approvals-and-sandbox` | Experimental |
|
||||
|
||||
setup.sh auto-detects which CLIs are installed and configures hooks for each.
|
||||
|
||||
---
|
||||
|
||||
## Project Status
|
||||
|
||||
**Beta.** Actively developed by a solo developer working with the AI agents themselves — every PR, every test, every fix is human-AI collaboration.
|
||||
@@ -241,32 +202,21 @@ setup.sh auto-detects which CLIs are installed and configures hooks for each.
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Version | See [git tags](https://github.com/AIOSAI/AIPass/tags) |
|
||||
| Agents | 13 core + user-created |
|
||||
| Agents | 17 core + user-created |
|
||||
| Quality | Automated standards enforced across every agent |
|
||||
| Coverage | [](https://codecov.io/gh/AIOSAI/AIPass) — 75% minimum, CI-gated |
|
||||
| Tests | Extensive — every agent ships its own suite |
|
||||
|
||||
Each agent documents its own operational status in its branch README — what works, what doesn't, and why.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- [Claude Code](https://code.claude.com/docs)
|
||||
- Linux, macOS, or WSL (all CI-tested)
|
||||
- Linux or WSL
|
||||
- `sudo` access optional (for `/usr/local/bin` symlinks — falls back to `~/.local/bin` without sudo)
|
||||
- API keys optional (OpenRouter/OpenAI — for optional add-on agents)
|
||||
|
||||
## Roadmap
|
||||
|
||||
These items have partial work done and are under ongoing testing:
|
||||
|
||||
- **macOS support** — CI green, full test suite passing ([#360](https://github.com/AIOSAI/AIPass/issues/360))
|
||||
- **Windows native** — CI green, full test suite passing
|
||||
- **Codex CLI** — hooks and AGENTS.md wired, needs end-to-end testing
|
||||
- **Fork contributor workflow** — improved error handling for fork-based PRs ([#329](https://github.com/AIOSAI/AIPass/issues/329))
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
@@ -304,9 +254,9 @@ This archives the agent's directory and removes it from the registry.
|
||||
|
||||
### Use your existing subscription
|
||||
|
||||
AIPass runs on your **existing CLI subscription** — Claude Pro/Max or Codex. No API keys required for core functionality. No extra costs beyond your existing subscription.
|
||||
AIPass runs on your **existing Claude subscription** — Pro or Max. No API keys required for core functionality. No extra costs beyond your existing subscription.
|
||||
|
||||
This works because AIPass runs each CLI as an **official subprocess** — the same binary you'd run yourself in a terminal. It doesn't extract credentials, proxy API calls, or intercept tokens. Your subscription stays within the provider's infrastructure at all times.
|
||||
This works because AIPass runs Claude Code as an **official subprocess** — the same binary you'd run yourself in a terminal. It doesn't extract credentials, proxy API calls, or intercept tokens. Your subscription stays within the provider's infrastructure at all times.
|
||||
|
||||
### What AIPass does NOT do
|
||||
|
||||
@@ -315,7 +265,7 @@ This works because AIPass runs each CLI as an **official subprocess** — the sa
|
||||
- Bypass rate limits or prompt caching
|
||||
- Impersonate official CLI clients
|
||||
|
||||
Claude Code is proprietary but officially supports hooks and subprocess usage. Codex CLI is open source (Apache 2.0).
|
||||
Claude Code is proprietary but officially supports hooks and subprocess usage.
|
||||
|
||||
> API keys are only needed for optional add-on agents (OpenRouter/OpenAI). For server/automated deployments, API key authentication is recommended per [Anthropic's guidance](https://code.claude.com/docs/en/legal-and-compliance).
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# aipass — cold-clone entry point (pre-venv launcher)
|
||||
#
|
||||
# The first command after cloning:
|
||||
# git clone https://github.com/AIOSAI/AIPass.git
|
||||
# cd AIPass
|
||||
# ./aipass install
|
||||
#
|
||||
# Pre-setup: only `install` is available — delegates to setup.sh.
|
||||
# Post-setup: forwards everything to the venv-installed aipass binary.
|
||||
#
|
||||
# Stdlib-only, zero AIPass imports, zero third-party deps.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# --- Post-setup: forward to the real binary ---
|
||||
if [[ -x "$SCRIPT_DIR/.venv/bin/aipass" ]]; then
|
||||
exec "$SCRIPT_DIR/.venv/bin/aipass" "$@"
|
||||
fi
|
||||
if [[ -x "$SCRIPT_DIR/.venv/Scripts/aipass.exe" ]]; then
|
||||
exec "$SCRIPT_DIR/.venv/Scripts/aipass.exe" "$@"
|
||||
fi
|
||||
if [[ -x "$SCRIPT_DIR/.venv/Scripts/aipass" ]]; then
|
||||
exec "$SCRIPT_DIR/.venv/Scripts/aipass" "$@"
|
||||
fi
|
||||
|
||||
# --- Pre-setup: only 'install' works ---
|
||||
if [[ "${1:-}" == "install" ]]; then
|
||||
shift
|
||||
exec bash "$SCRIPT_DIR/setup.sh" "$@"
|
||||
fi
|
||||
|
||||
# --- Help (no venv, no 'install' verb) ---
|
||||
cat <<'HELP'
|
||||
AIPass is not set up yet.
|
||||
|
||||
./aipass install # set up AIPass (venv + deps + hooks)
|
||||
./aipass install --no-init # set up without the guided first-project setup
|
||||
./aipass install --project DIR # set the first-project directory
|
||||
|
||||
After setup, all aipass commands become available:
|
||||
./aipass doctor # system health
|
||||
./aipass help # how-does-X-work Q&A
|
||||
./aipass init run # scaffold a new project
|
||||
|
||||
Quick start:
|
||||
git clone https://github.com/AIOSAI/AIPass.git
|
||||
cd AIPass
|
||||
./aipass install
|
||||
HELP
|
||||
@@ -0,0 +1,49 @@
|
||||
[← Back to AIPass](../README.md)
|
||||
|
||||
# Projects
|
||||
|
||||
> Your projects, built with AIPass. **Private by default** — published only if and when you choose.
|
||||
|
||||
This folder is where your own projects live. Create one with `aipass new <name>` and build whatever you want here — a tool, an app, an experiment, something nobody else will ever see. Each project is **its own separate git repository**, and the AIPass repo ignores everything in this folder (only this README ships). Nothing you build here can leak into the AIPass repo, appear in its history, or end up in anyone's pull request.
|
||||
|
||||
## What a project is
|
||||
|
||||
Every project is born complete:
|
||||
|
||||
- **Own git repo** — initialized locally at creation. Local means local: no remote, no publishing, nothing leaves your machine.
|
||||
- **Own registry** — a sealed `*_REGISTRY.json` seating the project's owner.
|
||||
- **Resident agent** — a full AIPass citizen living at `src/<name>/<name>/`, with identity, memory, and a mailbox, ready to work the project.
|
||||
- **AIPass scaffold** — the same `.aipass/` prompt structure and conventions as the main ecosystem, so any agent (or human) knows their way around immediately.
|
||||
|
||||
Projects use AIPass; they don't live inside its repo. The ecosystem is the workshop — what you build in it is yours.
|
||||
|
||||
## Going public — optional, deliberate
|
||||
|
||||
If a project is ready for the world, publishing is an explicit choice: create a GitHub repository for it and push. Until you do that, it exists only on your machine.
|
||||
|
||||
Projects from the AIPass family that chose to go public:
|
||||
|
||||
| Project | What it is | Repo |
|
||||
|---|---|---|
|
||||
| **Earmark** | Read code and docs aloud in VS Code with local Piper TTS — pause, resume, pick up where you left off. Ear + bookmark: the plan is notes anchored to where you paused. First public AIPass project. | [AIOSAI/earmark](https://github.com/AIOSAI/earmark) |
|
||||
| **aipass-site** | The [aipass.ai](https://aipass.ai) website — AIPass's front door on the web. | [AIOSAI/aipass-site](https://github.com/AIOSAI/aipass-site) |
|
||||
|
||||
Projects in residence (private, not yet published):
|
||||
|
||||
| Project | What it is |
|
||||
|---|---|
|
||||
| **Speakeasy** | System-wide voice-to-text: press a hotkey, speak, text lands at your cursor in any app. Local Whisper (faster-whisper), VAD, zero cloud. The voice-IN half of the loop Earmark's voice-OUT completes. Repo moved from ~/Projects/Speakeasy 2026-07-21, own git history intact. |
|
||||
|
||||
## Creating one
|
||||
|
||||
```bash
|
||||
aipass new <name> # empty template + resident agent
|
||||
aipass new <name> --template python
|
||||
aipass new <name> --no-agent
|
||||
```
|
||||
|
||||
The project lands in `projects/<name>`, git-initialized, registry sealed, agent seated — and private until you decide otherwise.
|
||||
|
||||
---
|
||||
|
||||
[← Back to AIPass](../README.md)
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "aipass"
|
||||
version = "2.6.1"
|
||||
version = "2.7.3"
|
||||
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"
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
#
|
||||
# AIPass setup script
|
||||
# Creates a venv, installs the package in editable mode, and verifies CLI entry points.
|
||||
# On interactive terminals it then chains into `aipass init run` to scaffold a first
|
||||
# project (DPLAN-0234: one command does setup + init).
|
||||
#
|
||||
# Usage: ./setup.sh [--no-init] [--with-init] [--project <dir>] [--no-symlink] [--force-symlink]
|
||||
# --no-init skip the first-project init chain
|
||||
# --with-init force the init chain even headless (init runs --non-interactive)
|
||||
# --project <dir> first-project directory (default: ~/aipass-project)
|
||||
# --no-symlink do not create/modify global drone/aipass CLI symlinks
|
||||
# --force-symlink repoint a global symlink even if it points at a different install (#660)
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
@@ -27,6 +36,31 @@ case "${OSTYPE:-}" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- Args ---
|
||||
# Mirrors the `aipass install` handoff rules: --no-init wins, --with-init forces,
|
||||
# default (auto) chains into init on interactive terminals only — CI/headless skip.
|
||||
RUN_INIT="auto"
|
||||
INIT_PROJECT=""
|
||||
SKIP_SYMLINK="no"
|
||||
FORCE_SYMLINK="no"
|
||||
PREV_ARG=""
|
||||
for arg in "$@"; do
|
||||
if [ "$PREV_ARG" = "--project" ]; then
|
||||
INIT_PROJECT="$arg"
|
||||
PREV_ARG=""
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--no-init) RUN_INIT="no" ;;
|
||||
--with-init) RUN_INIT="yes" ;;
|
||||
--no-symlink) SKIP_SYMLINK="yes" ;;
|
||||
--force-symlink) FORCE_SYMLINK="yes" ;;
|
||||
--project=*) INIT_PROJECT="${arg#--project=}" ;;
|
||||
--project) PREV_ARG="--project" ;;
|
||||
*) echo "WARN: unknown argument '$arg' (ignored)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "=== AIPass Setup ==="
|
||||
echo "Repo root: $SCRIPT_DIR"
|
||||
echo ""
|
||||
@@ -190,8 +224,8 @@ fi
|
||||
echo "Upgrading pip ..."
|
||||
"$VENV_PYTHON" -m pip install --upgrade pip --quiet
|
||||
|
||||
echo "Installing aipass in editable mode (with dev + memory extras) ..."
|
||||
"$VENV_PYTHON" -m pip install -e ".[dev,memory]" --quiet
|
||||
echo "Installing aipass in editable mode (with dev + memory extras) — this can take a few minutes while the memory wheels build ..."
|
||||
"$VENV_PYTHON" -m pip install -e ".[dev,memory]"
|
||||
|
||||
# --- Detect shadowing drone installs (Windows) ---
|
||||
# Issues #317 + #321: system-Python pip or legacy npm aipass-drone can shadow venv drone.exe.
|
||||
@@ -605,7 +639,7 @@ if [ -f "$SCRIPT_DIR/src/aipass/hooks/apps/handlers/bridges/claude.py" ]; then
|
||||
echo "Installing Claude Code hooks ..."
|
||||
mkdir -p "$HOME/.claude"
|
||||
|
||||
"$PYTHON" - "$SCRIPT_DIR" "$CLAUDE_SETTINGS" << 'PYEOF'
|
||||
"$PYTHON" - "$SCRIPT_DIR" "$CLAUDE_SETTINGS" "$IS_WINDOWS" << 'PYEOF'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -613,11 +647,16 @@ from pathlib import Path
|
||||
|
||||
repo_root = sys.argv[1]
|
||||
settings_path = Path(sys.argv[2])
|
||||
is_windows = len(sys.argv) > 3 and sys.argv[3] == "1"
|
||||
|
||||
# Bridge entry point — all hooks route through the engine via this bridge.
|
||||
# Uses $AIPASS_HOME env var (injected into settings.env below) so the
|
||||
# settings file stays relocatable.
|
||||
bridge = "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py"
|
||||
# settings file stays relocatable. CC on Windows runs hooks via Git Bash
|
||||
# (which must exist for setup.sh to have run), so $VAR expansion works —
|
||||
# but the venv interpreter lives at Scripts/python.exe there, not bin/python3
|
||||
# (@hooks assessment, DPLAN-0234 Strand C).
|
||||
venv_python = ".venv/Scripts/python.exe" if is_windows else ".venv/bin/python3"
|
||||
bridge = f"$AIPASS_HOME/{venv_python} $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py"
|
||||
|
||||
# Load existing settings or start fresh
|
||||
if settings_path.exists():
|
||||
@@ -629,7 +668,8 @@ else:
|
||||
# UserPromptSubmit: 5 separate entries (EventType:hook_name) to avoid output merging
|
||||
# PreToolUse, PostToolUse, SubagentStop, Stop, Notification: single aggregate entries
|
||||
# PreCompact: 3 hooks x 2 matchers (manual + auto) = 6 entries
|
||||
settings["hooks"] = {
|
||||
# SessionStart: cadence reset on startup/clear (handler skips resume itself)
|
||||
aipass_hooks = {
|
||||
"UserPromptSubmit": [
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:tier0_kernel"}]},
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:navmap"}]},
|
||||
@@ -663,8 +703,32 @@ settings["hooks"] = {
|
||||
{"matcher": "manual", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:auto_process", "timeout": 120}]},
|
||||
{"matcher": "auto", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:auto_process", "timeout": 120}]},
|
||||
],
|
||||
"SessionStart": [
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} SessionStart:cadence_reset", "timeout": 30}]},
|
||||
],
|
||||
}
|
||||
|
||||
# Merge, don't replace (DPLAN-0234 Strand C): refresh every AIPass bridge entry
|
||||
# (identified by the bridges/claude.py marker) but preserve any hooks the user
|
||||
# wired themselves. Re-runs stay idempotent; custom hooks survive reinstall.
|
||||
existing_hooks = settings.get("hooks", {})
|
||||
merged_hooks = {}
|
||||
for event in set(existing_hooks) | set(aipass_hooks):
|
||||
user_entries = [
|
||||
entry for entry in existing_hooks.get(event, [])
|
||||
if "bridges/claude.py" not in json.dumps(entry)
|
||||
]
|
||||
merged = aipass_hooks.get(event, []) + user_entries
|
||||
# Never emit an empty hook event. If this event had only stale AIPass bridge
|
||||
# entries (no current aipass_hooks definition AND no user-wired hooks), the
|
||||
# filter above orphans it to [] — a half-wired event that fires nothing,
|
||||
# written silently. Drop the key instead and say so, so the state stays honest.
|
||||
if not merged:
|
||||
print(f" ! dropped orphaned hook event (no live entries): {event}")
|
||||
continue
|
||||
merged_hooks[event] = merged
|
||||
settings["hooks"] = merged_hooks
|
||||
|
||||
# Inject AIPASS_HOME into env block so dispatched agents find AIPass
|
||||
env_block = settings.get("env", {})
|
||||
env_block["AIPASS_HOME"] = repo_root
|
||||
@@ -740,6 +804,16 @@ else
|
||||
echo "Skipping Claude hooks (bridge not found at src/aipass/hooks/apps/handlers/bridges/claude.py)"
|
||||
fi
|
||||
|
||||
# --- Install claude() boot shim (attach-if-live / start-in-tmux) ---
|
||||
# Ships via the .gitignore negation (#666). Idempotent: the installer checks for
|
||||
# its marker before appending to the shell rc, and resolves the venv Python from
|
||||
# its own location (POSIX/Windows/fallback). Composes with presence_gate seeding.
|
||||
BOOT_SHIM="$SCRIPT_DIR/src/aipass/hooks/tools/install_boot_shim.sh"
|
||||
if [ -f "$BOOT_SHIM" ]; then
|
||||
echo "Installing claude() boot shim ..."
|
||||
bash "$BOOT_SHIM" || echo " boot shim install skipped (non-fatal)"
|
||||
fi
|
||||
|
||||
# --- Install Claude Code commands (provider level) ---
|
||||
# memo.md belongs at provider level — works in all projects.
|
||||
# prep.md stays at repo root only — it's AIPass-specific.
|
||||
@@ -906,8 +980,42 @@ else
|
||||
fi
|
||||
|
||||
# --- Create global symlinks for CLI tools (Linux/macOS only) ---
|
||||
# #660: never SILENTLY hijack a global 'drone'/'aipass' that points at a
|
||||
# DIFFERENT install. safe_symlink skips a different-target link (loud warning)
|
||||
# unless --force-symlink; --no-symlink opts out of symlinking entirely.
|
||||
SYMLINK_SKIPPED=0
|
||||
safe_symlink() {
|
||||
# safe_symlink <src> <dest> [sudo] -> 0 linked · 1 skipped(diff target) · 2 ln failed
|
||||
local src="$1" dest="$2" use_sudo="${3:-}" existing=""
|
||||
if [ -L "$dest" ]; then
|
||||
existing="$(readlink "$dest" 2>/dev/null)"
|
||||
elif [ -e "$dest" ]; then
|
||||
existing="$dest (real file, not a symlink)"
|
||||
fi
|
||||
if [ -n "$existing" ] && [ "$existing" != "$src" ]; then
|
||||
if [ "$FORCE_SYMLINK" != "yes" ]; then
|
||||
echo " SKIP $dest — already points at a different install:"
|
||||
echo " $existing"
|
||||
echo " Not repointing (would hijack your existing '$(basename "$dest")'); PATH keeps the above."
|
||||
echo " Re-run 'aipass install' with --force-symlink to repoint here, or --no-symlink to skip quietly."
|
||||
SYMLINK_SKIPPED=$((SYMLINK_SKIPPED + 1))
|
||||
return 1
|
||||
fi
|
||||
echo " WARNING: repointing $dest"
|
||||
echo " from $existing"
|
||||
echo " to $src (--force-symlink)"
|
||||
fi
|
||||
if [ -n "$use_sudo" ]; then
|
||||
$use_sudo ln -sf "$src" "$dest" 2>/dev/null && return 0 || return 2
|
||||
fi
|
||||
ln -sf "$src" "$dest" 2>/dev/null && return 0 || return 2
|
||||
}
|
||||
|
||||
echo ""
|
||||
if [ "$IS_WINDOWS" -eq 1 ]; then
|
||||
if [ "$SKIP_SYMLINK" = "yes" ]; then
|
||||
echo "Skipping global CLI symlinks (--no-symlink)."
|
||||
echo " 'drone'/'aipass' resolve from $SCRIPT_DIR/.venv/bin — add it to PATH to use them."
|
||||
elif [ "$IS_WINDOWS" -eq 1 ]; then
|
||||
echo "Windows: drone available via PATH (set above)"
|
||||
elif [ "$IS_MACOS" -eq 1 ]; then
|
||||
# Mac: symlink into ~/.local/bin (user-writable, no sudo needed).
|
||||
@@ -919,9 +1027,11 @@ elif [ "$IS_MACOS" -eq 1 ]; then
|
||||
|
||||
for cmd in drone aipass; do
|
||||
if [ -f "$VENV_BIN/$cmd" ]; then
|
||||
if ln -sf "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd"; then
|
||||
rc=0
|
||||
safe_symlink "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd" || rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
echo " $LOCAL_BIN/$cmd -> $VENV_BIN/$cmd"
|
||||
else
|
||||
elif [ "$rc" -eq 2 ]; then
|
||||
echo " WARN: Could not create symlink for $cmd"
|
||||
echo " Manual fix: ln -sf $VENV_BIN/$cmd $LOCAL_BIN/$cmd"
|
||||
fi
|
||||
@@ -934,14 +1044,20 @@ else
|
||||
|
||||
for cmd in drone aipass; do
|
||||
if [ -f "$VENV_BIN/$cmd" ]; then
|
||||
if sudo ln -sf "$VENV_BIN/$cmd" "/usr/local/bin/$cmd" 2>/dev/null; then
|
||||
rc=0
|
||||
safe_symlink "$VENV_BIN/$cmd" "/usr/local/bin/$cmd" "sudo" || rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
echo " /usr/local/bin/$cmd -> $VENV_BIN/$cmd"
|
||||
LINUX_SYMLINK_DIR="/usr/local/bin"
|
||||
elif [ "$rc" -eq 1 ]; then
|
||||
: # skipped a different install — safe_symlink explained; do NOT fall back
|
||||
else
|
||||
# Fallback: user-local bin (no sudo needed)
|
||||
# sudo/ln failed (e.g. no sudo) — fall back to user-local bin
|
||||
LOCAL_BIN="$HOME/.local/bin"
|
||||
mkdir -p "$LOCAL_BIN"
|
||||
if ln -sf "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd"; then
|
||||
rc=0
|
||||
safe_symlink "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd" || rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
echo " /usr/local/bin failed (no sudo) — using $LOCAL_BIN/$cmd instead"
|
||||
LINUX_SYMLINK_DIR="$LOCAL_BIN"
|
||||
# Ensure ~/.local/bin is on PATH
|
||||
@@ -951,7 +1067,7 @@ else
|
||||
echo " ~/.local/bin added to PATH in $PROFILE"
|
||||
fi
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
else
|
||||
elif [ "$rc" -eq 2 ]; then
|
||||
echo " WARN: Could not create symlink for $cmd"
|
||||
echo " Manual fix: ln -sf $VENV_BIN/$cmd $LOCAL_BIN/$cmd"
|
||||
fi
|
||||
@@ -960,6 +1076,12 @@ else
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$SYMLINK_SKIPPED" -gt 0 ]; then
|
||||
echo ""
|
||||
echo " NOTE: $SYMLINK_SKIPPED global symlink(s) left untouched (pointed at a different install)."
|
||||
echo " Your existing 'drone'/'aipass' still work. Use --force-symlink to repoint them here."
|
||||
fi
|
||||
|
||||
# --- Result ---
|
||||
echo ""
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
@@ -982,6 +1104,77 @@ if [ "$FAIL" -eq 0 ]; then
|
||||
echo " Claude Code: hooks installed to ~/.claude/settings.json"
|
||||
command -v codex &>/dev/null && echo " Codex CLI: hooks at .codex/hooks.json + config at ~/.codex/config.toml"
|
||||
echo ""
|
||||
|
||||
# --- Chain into first-project init (DPLAN-0234: one command does setup + init) ---
|
||||
# `aipass init` refuses to run inside the engine tree, so it always targets a
|
||||
# sibling directory — never the repo itself. Decision mirrors install.py:
|
||||
# --no-init wins, --with-init forces (headless chains --non-interactive),
|
||||
# default = interactive terminals only (CI and piped shells skip).
|
||||
AIPASS_BIN=""
|
||||
if [ "$IS_WINDOWS" -eq 1 ] && [ -f "$SCRIPT_DIR/.venv/Scripts/aipass.exe" ]; then
|
||||
AIPASS_BIN="$SCRIPT_DIR/.venv/Scripts/aipass.exe"
|
||||
elif [ "$IS_WINDOWS" -eq 1 ] && [ -f "$SCRIPT_DIR/.venv/Scripts/aipass" ]; then
|
||||
AIPASS_BIN="$SCRIPT_DIR/.venv/Scripts/aipass"
|
||||
elif [ -f "$SCRIPT_DIR/.venv/bin/aipass" ]; then
|
||||
AIPASS_BIN="$SCRIPT_DIR/.venv/bin/aipass"
|
||||
elif command -v aipass &>/dev/null; then
|
||||
AIPASS_BIN="$(command -v aipass)"
|
||||
fi
|
||||
|
||||
LAUNCH_INIT=0
|
||||
INIT_HEADLESS=0
|
||||
if [ "$RUN_INIT" = "no" ]; then
|
||||
echo "Skipping first-project init (--no-init). Run 'aipass init run' in a fresh directory when ready."
|
||||
elif [ "$RUN_INIT" = "yes" ]; then
|
||||
LAUNCH_INIT=1
|
||||
if [ ! -t 0 ]; then
|
||||
INIT_HEADLESS=1
|
||||
fi
|
||||
elif [ -t 0 ] && [ -z "${CI:-}" ]; then
|
||||
LAUNCH_INIT=1
|
||||
else
|
||||
echo "Non-interactive shell — skipping first-project init. Run 'aipass init run' in a fresh directory when ready."
|
||||
fi
|
||||
|
||||
if [ "$LAUNCH_INIT" -eq 1 ]; then
|
||||
if [ -z "$AIPASS_BIN" ]; then
|
||||
echo "WARN: aipass binary not found — open a new terminal and run 'aipass init run' in a fresh directory."
|
||||
else
|
||||
DEFAULT_PROJECT_DIR="$HOME/aipass-project"
|
||||
PROJECT_DIR="$INIT_PROJECT"
|
||||
if [ -z "$PROJECT_DIR" ] && [ "$INIT_HEADLESS" -eq 0 ] && [ -t 0 ]; then
|
||||
echo "AIPass projects live in their own directory, never inside the engine repo."
|
||||
read -r -p "Set up your first project now? [Y/n]: " INIT_REPLY
|
||||
case "$INIT_REPLY" in
|
||||
[nN]*)
|
||||
PROJECT_DIR="-"
|
||||
echo " Skipped. Run 'aipass init run' in a fresh directory when ready."
|
||||
;;
|
||||
*)
|
||||
read -r -p " Project directory [$DEFAULT_PROJECT_DIR]: " INIT_INPUT
|
||||
PROJECT_DIR="${INIT_INPUT:-$DEFAULT_PROJECT_DIR}"
|
||||
;;
|
||||
esac
|
||||
elif [ -z "$PROJECT_DIR" ]; then
|
||||
PROJECT_DIR="$DEFAULT_PROJECT_DIR"
|
||||
fi
|
||||
|
||||
if [ "$PROJECT_DIR" != "-" ]; then
|
||||
mkdir -p "$PROJECT_DIR"
|
||||
INIT_CMD=("$AIPASS_BIN" init run)
|
||||
if [ "$INIT_HEADLESS" -eq 1 ]; then
|
||||
INIT_CMD+=(--non-interactive)
|
||||
fi
|
||||
echo ""
|
||||
echo "Launching guided setup in $PROJECT_DIR ..."
|
||||
if (cd "$PROJECT_DIR" && "${INIT_CMD[@]}"); then
|
||||
echo "First project initialized at $PROJECT_DIR"
|
||||
else
|
||||
echo "Init didn't complete — run 'aipass init run' in $PROJECT_DIR when ready."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "=== Setup finished with errors ==="
|
||||
echo "The venv was created and the package was installed, but one or more"
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
git clone + ./setup.sh — https://github.com/AIOSAI/AIPass
|
||||
"""
|
||||
|
||||
__version__ = "2.6.1"
|
||||
__version__ = "2.7.3"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{
|
||||
"file": "apps/handlers/dispatch/daemon.py",
|
||||
"standard": "deep_nesting",
|
||||
"reason": "3 functions: check_inbox_for_dispatch() depth 4 (priority scanning with business logic), run_daemon() depth 4 (main daemon loop), _check_lock() depth 4 (lock validation + PID liveness + cleanup)"
|
||||
"reason": "run_daemon() depth 5 (main daemon loop with retry + signal handling)"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/dispatch/wake.py",
|
||||
|
||||
@@ -11,6 +11,25 @@
|
||||
|
||||
**Status:** Operational | **Seedgo:** 100% (34/34) | **Tests:** 712 pass | **Battle Tested:** S62
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Check your inbox
|
||||
drone @ai_mail inbox
|
||||
|
||||
# View a message
|
||||
drone @ai_mail view <id>
|
||||
|
||||
# Reply and close
|
||||
drone @ai_mail reply <id> "your message"
|
||||
|
||||
# Send mail to another branch
|
||||
drone @ai_mail email @target "Subject" "Body"
|
||||
|
||||
# Dispatch (send + wake target agent)
|
||||
drone @ai_mail dispatch @target "Subject" "Body"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
@@ -62,19 +81,22 @@ The `dispatch` command sends an email and wakes the target branch in one step. D
|
||||
### Wake Pipeline
|
||||
|
||||
1. `dispatch.py` orchestrates: send email via `send_to_single()`, then wake via `wake_branch()`
|
||||
2. `wake.py` resolves the branch from the registry, finds the `claude` binary, spawns a subprocess
|
||||
2. `wake.py` resolves the branch from the registry, checks `citizen_class` (managers are mail-only — wake skips), finds the `claude` binary, spawns a subprocess
|
||||
3. `dispatch_monitor.py` wraps the claude process with safety features:
|
||||
- **Startup health check** — monitors JSONL session files for 90s, kills if no activity
|
||||
- **Auto-retry** — 3 strikes: attempt 1+2 resume, attempt 3 fresh (new session)
|
||||
- **Bounce email** — on final failure, sends error report back to sender
|
||||
- **Lock cleanup** — removes `.dispatch.lock` when agent exits
|
||||
4. After wake, `_spawn_watchdog()` auto-launches `drone @devpulse watchdog agent @target` as a detached background process
|
||||
- **Wake-back** — on agent exit, wakes the original sender so they can process the result. Wake-back sessions carry an empty sender, so chains terminate at the original dispatcher
|
||||
|
||||
### Safety Limits
|
||||
|
||||
- PID-based locking prevents concurrent agents per branch (`.dispatch.lock`)
|
||||
- Max turns per wake, max dispatches per branch per day
|
||||
- `WAKE_BLOCKLIST` protects `@devpulse` from cross-branch manual wakes
|
||||
- **Manager structural block** — branches with `citizen_class: "manager"` in their passport (e.g. `@devpulse`) are unwakeable on all wake paths. Mail delivers, wake skips
|
||||
- **Self-wake guard** — if sender equals target, wake-back is skipped (prevents self-loops)
|
||||
- **Chain termination** — wake-back sessions carry an empty sender, so the chain always stops at the original dispatcher
|
||||
- `dispatch_monitor.py` strips `AIPASS_CALLER_*` env vars to prevent parent context leaking into agent identity
|
||||
- `AIPASS_BRANCH_NAME` env var set in spawn_env for CWD-independent identity
|
||||
|
||||
|
||||
@@ -14,12 +14,19 @@ Main handles routing, modules implement functionality.
|
||||
"""
|
||||
|
||||
# Standard library imports
|
||||
import os
|
||||
import sys
|
||||
import importlib
|
||||
import signal
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
# AIPass infrastructure imports
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# CLI services for display
|
||||
from aipass.cli.apps.modules import console, error
|
||||
|
||||
# Handle broken pipe gracefully (e.g. output piped to head)
|
||||
# SIGPIPE does not exist on Windows
|
||||
if hasattr(signal, "SIGPIPE"):
|
||||
@@ -28,11 +35,12 @@ if hasattr(signal, "SIGPIPE"):
|
||||
# Dashboard integration (optional, provided by prax)
|
||||
_UPDATE_SECTION = None # type: ignore
|
||||
|
||||
# AIPass infrastructure imports
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# CLI services for display
|
||||
from aipass.cli.apps.modules import console, error
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
# =============================================================================
|
||||
# CONSTANTS & CONFIG
|
||||
@@ -235,6 +243,13 @@ def main():
|
||||
error("No modules found")
|
||||
return 1
|
||||
|
||||
if remaining_args and remaining_args[0] in ["--help", "-h"]:
|
||||
for module in modules:
|
||||
if module.handle_command(command, ["--help"]):
|
||||
return 0
|
||||
print_help()
|
||||
return 0
|
||||
|
||||
# Route command
|
||||
if route_command(command, remaining_args, modules):
|
||||
return 0
|
||||
|
||||
@@ -19,6 +19,7 @@ Architecture:
|
||||
"""
|
||||
|
||||
# CRITICAL: Use importlib to bypass local json/ directory and get stdlib json
|
||||
import os
|
||||
import sys
|
||||
import importlib.util
|
||||
|
||||
@@ -32,13 +33,20 @@ stdlib_json = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(stdlib_json)
|
||||
sys.path = _saved_path
|
||||
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Tuple
|
||||
from pathlib import Path # noqa: E402
|
||||
from datetime import datetime # noqa: E402
|
||||
from typing import Dict, Any, List, Tuple # noqa: E402
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger # noqa: E402
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler # noqa: E402
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root # noqa: E402
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -341,5 +349,7 @@ if __name__ == "__main__":
|
||||
console.print()
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
from aipass.cli.apps.modules import error as cli_error
|
||||
|
||||
cli_error(f"Error: {e}")
|
||||
raise
|
||||
|
||||
@@ -33,6 +33,8 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.dispatch.status import log_dispatch
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
from aipass.ai_mail.apps.handlers.dispatch.test_token import scan_and_ack_test_emails
|
||||
from aipass.ai_mail.apps.handlers.dispatch.wake import DEFAULT_MODEL
|
||||
|
||||
|
||||
# Infrastructure paths
|
||||
@@ -49,9 +51,6 @@ BRANCH_REGISTRY = _REPO_ROOT / "AIPASS_REGISTRY.json"
|
||||
# Graceful shutdown
|
||||
SHUTDOWN = False
|
||||
|
||||
# AIPASS-TEST token handling extracted to test_token.py
|
||||
from aipass.ai_mail.apps.handlers.dispatch.test_token import scan_and_ack_test_emails
|
||||
|
||||
|
||||
def _handle_signal(signum, _frame):
|
||||
"""Handle shutdown signals for graceful daemon stop."""
|
||||
@@ -88,6 +87,56 @@ def _write_json(filepath: Path, data: Dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _pid_alive_windows(pid: int) -> bool:
|
||||
"""Windows-safe liveness check via OpenProcess + GetExitCodeProcess."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
STILL_ACTIVE = 259
|
||||
|
||||
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
||||
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
||||
kernel32.OpenProcess.restype = wintypes.HANDLE
|
||||
kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
|
||||
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
|
||||
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
|
||||
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
exit_code = wintypes.DWORD()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||
return False
|
||||
return exit_code.value == STILL_ACTIVE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
"""Return True if the process is alive."""
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
return _pid_alive_windows(pid)
|
||||
except Exception as exc:
|
||||
logger.info("[daemon] PID %s Windows check failed (assuming alive): %s", pid, exc)
|
||||
return True
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError as exc:
|
||||
logger.info("[daemon] PID %s not found: %s", pid, exc)
|
||||
return False
|
||||
except PermissionError as exc:
|
||||
logger.info("[daemon] PID %s permission denied (alive): %s", pid, exc)
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.info("[daemon] PID %s os.kill error (assuming dead): %s", pid, exc)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]:
|
||||
"""Check if branch has an active dispatch lock. Returns lock data or None."""
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
@@ -98,14 +147,9 @@ def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]:
|
||||
data = json.load(f)
|
||||
pid = data.get("pid")
|
||||
if pid is not None:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return data # Process alive, lock valid
|
||||
except ProcessLookupError:
|
||||
logger.info("Lock PID %s dead — stale lock cleanup needed", pid)
|
||||
except PermissionError as e:
|
||||
logger.warning("[daemon] Lock PID %s permission error: %s", pid, e)
|
||||
return data # Process exists, can't signal
|
||||
if _pid_alive(pid):
|
||||
return data
|
||||
logger.info("Lock PID %s dead — stale lock cleanup needed", pid)
|
||||
# Stale lock — check age (10 min timeout)
|
||||
ts = data.get("timestamp", "")
|
||||
if ts:
|
||||
@@ -216,15 +260,10 @@ def _write_pid_file() -> bool:
|
||||
# PID file exists — check if the owning process is alive
|
||||
try:
|
||||
old_pid = int(DAEMON_PID_FILE.read_text().strip())
|
||||
try:
|
||||
os.kill(old_pid, 0)
|
||||
logger.info(f"Another daemon already running (PID {old_pid}). Exiting.")
|
||||
return False
|
||||
except ProcessLookupError:
|
||||
logger.info(f"Removing stale PID file (PID {old_pid} is dead)")
|
||||
except PermissionError:
|
||||
logger.info(f"Another daemon already running (PID {old_pid}, permission denied). Exiting.")
|
||||
if _pid_alive(old_pid):
|
||||
logger.info("Another daemon already running (PID %s). Exiting.", old_pid)
|
||||
return False
|
||||
logger.info("Removing stale PID file (PID %s is dead)", old_pid)
|
||||
except (ValueError, OSError):
|
||||
logger.info("Corrupt PID file — removing")
|
||||
|
||||
@@ -370,6 +409,8 @@ def spawn_agent(
|
||||
"-c",
|
||||
"-p",
|
||||
prompt,
|
||||
"--model",
|
||||
DEFAULT_MODEL,
|
||||
"--max-turns",
|
||||
str(max_turns),
|
||||
"--permission-mode",
|
||||
@@ -410,14 +451,19 @@ def spawn_agent(
|
||||
logger.info(f"Lock acquisition failed for {branch_email}: {lock_msg}")
|
||||
return False
|
||||
|
||||
_detach_kwargs: dict = {}
|
||||
if sys.platform == "win32":
|
||||
_detach_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
_detach_kwargs["start_new_session"] = True
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
monitor_cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
cwd=str(branch_path),
|
||||
env=spawn_env,
|
||||
**_detach_kwargs,
|
||||
)
|
||||
|
||||
monitor_pid = process.pid
|
||||
@@ -471,18 +517,74 @@ def is_protected_branch(branch_email: str) -> bool:
|
||||
return branch_email == "@devpulse"
|
||||
|
||||
|
||||
def _read_session_type(pid_str: str) -> str:
|
||||
"""Read AIPASS_SESSION_TYPE from /proc/{pid}/environ. Returns 'interactive' if unset."""
|
||||
if sys.platform != "linux":
|
||||
return "interactive"
|
||||
def _get_pid_cwd(pid_str: str) -> Optional[str]:
|
||||
"""Get the cwd of a process. Cross-platform: Linux /proc, macOS lsof."""
|
||||
if sys.platform == "linux":
|
||||
try:
|
||||
return os.readlink(f"/proc/{pid_str}/cwd")
|
||||
except (OSError, PermissionError):
|
||||
logger.info("[daemon] Cannot read cwd for PID %s", pid_str)
|
||||
return None
|
||||
if sys.platform == "darwin":
|
||||
return _get_pid_cwd_darwin(pid_str)
|
||||
logger.info("[daemon] Cannot determine cwd for PID %s on %s", pid_str, sys.platform)
|
||||
return None
|
||||
|
||||
|
||||
def _get_pid_cwd_darwin(pid_str: str) -> Optional[str]:
|
||||
"""macOS: get process cwd via lsof."""
|
||||
try:
|
||||
with open(f"/proc/{pid_str}/environ", "rb") as f:
|
||||
data = f.read()
|
||||
for entry in data.split(b"\0"):
|
||||
if entry.startswith(b"AIPASS_SESSION_TYPE="):
|
||||
return entry.split(b"=", 1)[1].decode("utf-8")
|
||||
except (OSError, PermissionError):
|
||||
logger.info("Cannot read session type for PID %s", pid_str)
|
||||
result = subprocess.run(
|
||||
["lsof", "-a", "-p", pid_str, "-d", "cwd", "-Fn"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
logger.info("[daemon] Cannot read cwd for PID %s on macOS", pid_str)
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line.startswith("n/"):
|
||||
return line[1:]
|
||||
return None
|
||||
|
||||
|
||||
def _read_session_type(pid_str: str) -> str:
|
||||
"""Read AIPASS_SESSION_TYPE from process environment. Returns 'interactive' if unset."""
|
||||
if sys.platform == "linux":
|
||||
try:
|
||||
with open(f"/proc/{pid_str}/environ", "rb") as f:
|
||||
data = f.read()
|
||||
for entry in data.split(b"\0"):
|
||||
if entry.startswith(b"AIPASS_SESSION_TYPE="):
|
||||
return entry.split(b"=", 1)[1].decode("utf-8")
|
||||
except (OSError, PermissionError):
|
||||
logger.info("[daemon] Cannot read session type for PID %s", pid_str)
|
||||
return "interactive"
|
||||
if sys.platform == "darwin":
|
||||
return _read_session_type_darwin(pid_str)
|
||||
return "interactive"
|
||||
|
||||
|
||||
def _read_session_type_darwin(pid_str: str) -> str:
|
||||
"""macOS: read AIPASS_SESSION_TYPE from ps environment output."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ps", "-p", pid_str, "-wwE", "-o", "command="],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
logger.info("[daemon] Cannot read session type for PID %s on macOS", pid_str)
|
||||
return "interactive"
|
||||
if result.returncode != 0:
|
||||
return "interactive"
|
||||
for token in result.stdout.split():
|
||||
if token.startswith("AIPASS_SESSION_TYPE="):
|
||||
return token.split("=", 1)[1]
|
||||
return "interactive"
|
||||
|
||||
|
||||
@@ -491,35 +593,25 @@ _NON_BLOCKING_SESSION_TYPES = {"dispatched", "daemon"}
|
||||
|
||||
|
||||
def _is_branch_occupied(branch_path: Path) -> bool:
|
||||
"""
|
||||
Check if an interactive Claude session is running in this branch.
|
||||
|
||||
Only interactive sessions block dispatch. Telegram, dispatched, and daemon
|
||||
sessions are idle/background and should not prevent new agent spawns.
|
||||
"""
|
||||
resolved = branch_path.resolve()
|
||||
"""Check if an interactive Claude session is running in this branch."""
|
||||
resolved = str(branch_path.resolve())
|
||||
try:
|
||||
result = subprocess.run(["pgrep", "-x", "claude"], capture_output=True, text=True, timeout=5)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
|
||||
for pid_str in result.stdout.strip().split("\n"):
|
||||
pid_str = pid_str.strip()
|
||||
if not pid_str:
|
||||
continue
|
||||
try:
|
||||
if sys.platform != "linux":
|
||||
continue
|
||||
cwd = os.readlink(f"/proc/{pid_str}/cwd")
|
||||
if Path(cwd).resolve() == resolved:
|
||||
session_type = _read_session_type(pid_str)
|
||||
if session_type not in _NON_BLOCKING_SESSION_TYPES:
|
||||
return True
|
||||
except (OSError, PermissionError, ValueError):
|
||||
logger.info("Cannot read cwd for PID %s", pid_str)
|
||||
cwd = _get_pid_cwd(pid_str)
|
||||
if cwd is None:
|
||||
continue
|
||||
if str(Path(cwd).resolve()) == resolved:
|
||||
session_type = _read_session_type(pid_str)
|
||||
if session_type not in _NON_BLOCKING_SESSION_TYPES:
|
||||
return True
|
||||
except Exception:
|
||||
logger.info("Failed to check branch occupancy for %s", branch_path)
|
||||
logger.info("[daemon] Failed to check branch occupancy for %s", branch_path)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,80 @@ def _connect_broker(repo_root: Path, branch_name: str) -> socket.socket:
|
||||
return create_identified_connection(socket_path, secret_path, branch_name)
|
||||
|
||||
|
||||
MAX_WAKE_DEPTH = 3
|
||||
|
||||
|
||||
def _wake_sender(sender: str, branch_email: str, exit_code: int, lock_file: str) -> str:
|
||||
"""Wake the dispatcher back after target completion.
|
||||
|
||||
Any citizen sender gets woken back (same availability checks as
|
||||
normal wake — interactive session, active lock, depth cap).
|
||||
|
||||
Returns a result tag for the dispatch_wake.log:
|
||||
success, blocked_occupied, blocked_locked, blocked_depth,
|
||||
skipped_sender, skipped_self, failed
|
||||
"""
|
||||
if not sender or not sender.strip():
|
||||
logger.info("[monitor] Wake-back skipped — no sender")
|
||||
return "skipped_sender"
|
||||
|
||||
normalized_sender = f"@{sender.lstrip('@').lower()}"
|
||||
normalized_target = f"@{branch_email.lstrip('@').lower()}"
|
||||
if normalized_sender == normalized_target:
|
||||
logger.info("[monitor] Wake-back skipped — sender %s is the completed agent (self-wake)", sender)
|
||||
return "skipped_self"
|
||||
|
||||
depth = int(os.environ.get("AIPASS_WAKE_DEPTH", "0"))
|
||||
if depth >= MAX_WAKE_DEPTH:
|
||||
logger.warning("[monitor] Wake-back skipped — depth %d >= max %d", depth, MAX_WAKE_DEPTH)
|
||||
return "blocked_depth"
|
||||
|
||||
try:
|
||||
from aipass.ai_mail.apps.handlers.dispatch.wake import wake_branch
|
||||
|
||||
os.environ["AIPASS_WAKE_DEPTH"] = str(depth + 1)
|
||||
wake_status, success = wake_branch(sender, auto=True, sender="")
|
||||
|
||||
if success:
|
||||
logger.info("[monitor] Wake-back: %s woken after %s completed (exit %d)", sender, branch_email, exit_code)
|
||||
return "success"
|
||||
|
||||
summary = wake_status.summary
|
||||
lower = summary.lower()
|
||||
if "interactive" in lower or "occupancy" in lower or "occupied" in lower:
|
||||
logger.info("[monitor] Wake-back blocked — sender %s has interactive session: %s", sender, summary)
|
||||
return "blocked_occupied"
|
||||
if "active agent" in lower or "lock" in lower:
|
||||
logger.info("[monitor] Wake-back blocked — sender %s has active lock: %s", sender, summary)
|
||||
return "blocked_locked"
|
||||
|
||||
logger.info("[monitor] Wake-back: %s not woken — %s", sender, summary)
|
||||
return "failed"
|
||||
except Exception as e:
|
||||
logger.warning("[monitor] Wake-back failed for %s: %s", sender, e)
|
||||
return "failed"
|
||||
|
||||
|
||||
def _log_wake_result(branch_email: str, sender: str, exit_code: int, result: str, lock_file: str):
|
||||
"""Append a wake-back result line to dispatch_wake.log under target's logs/."""
|
||||
lock_path = Path(lock_file).resolve()
|
||||
logs_dir = lock_path.parent.parent / "logs"
|
||||
log_file = logs_dir / "dispatch_wake.log"
|
||||
try:
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
line = (
|
||||
f"{time.strftime('%Y-%m-%dT%H:%M:%S')}"
|
||||
f" target={branch_email}"
|
||||
f" sender={sender}"
|
||||
f" exit_code={exit_code}"
|
||||
f" wake_result={result}\n"
|
||||
)
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
except OSError as e:
|
||||
logger.info("[monitor] Failed to write dispatch_wake.log: %s", e)
|
||||
|
||||
|
||||
def _send_bounce(branch_email: str, reason: str, sender: str, lock_file: str, stderr_log: str) -> bool:
|
||||
"""Send return-to-sender bounce email via drone."""
|
||||
subject = f"BOUNCE: Dispatch to {branch_email} failed"
|
||||
@@ -375,6 +449,9 @@ def main():
|
||||
for key in list(spawn_env.keys()):
|
||||
if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID":
|
||||
spawn_env.pop(key)
|
||||
# Pin agent context window to 200k (Sonnet 5 is 1M native; without this,
|
||||
# agents inherit 1M which causes cost + runaway risk).
|
||||
spawn_env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = "200000"
|
||||
# Strip caller identity vars to prevent dispatch context leakage.
|
||||
spawn_env.pop("AIPASS_CALLER_BRANCH", None)
|
||||
spawn_env.pop("AIPASS_CALLER_CWD", None)
|
||||
@@ -585,6 +662,10 @@ def main():
|
||||
except Exception:
|
||||
logger.info("[monitor] Desktop notification unavailable")
|
||||
|
||||
# ─── Wake-back: wake the dispatcher ────────────────────
|
||||
wake_result = _wake_sender(sender, branch_email, exit_code, lock_file)
|
||||
_log_wake_result(branch_email, sender, exit_code, wake_result, lock_file)
|
||||
|
||||
sys.exit(0 if exit_code == 0 else 1)
|
||||
|
||||
|
||||
|
||||
@@ -14,13 +14,22 @@ without triggering dispatch. Extracted from daemon.py to keep
|
||||
the daemon under the 700-line architecture threshold.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
TEST_TOKEN = "[AIPASS-TEST — do not update memories, do not execute, reply 'ack' only]"
|
||||
|
||||
|
||||
|
||||
@@ -63,13 +63,9 @@ MONITOR_SCRIPT = Path(__file__).parent / "dispatch_monitor.py"
|
||||
# Default prompt when no custom message provided
|
||||
DEFAULT_PROMPT = "Hi. Check inbox, process new emails, update memories when done."
|
||||
|
||||
# Model shorthand mapping
|
||||
MODEL_MAP = {
|
||||
"sonnet": "claude-sonnet-4-6",
|
||||
"opus": "claude-opus-4-6",
|
||||
"haiku": "claude-haiku-4-5-20251001",
|
||||
}
|
||||
DEFAULT_MODEL = "opus"
|
||||
# Model aliases — passed directly to claude CLI which resolves latest-in-class.
|
||||
KNOWN_MODEL_ALIASES: frozenset = frozenset({"sonnet", "opus", "haiku"})
|
||||
DEFAULT_MODEL = "sonnet"
|
||||
|
||||
# Branches that cannot be woken manually by cross-branch drone commands.
|
||||
# Dispatch-send path (dispatch.py._orchestrate_dispatch_send) bypasses this check.
|
||||
@@ -141,6 +137,34 @@ def _read_json(filepath: Path) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def _pid_alive_windows(pid: int) -> bool:
|
||||
"""Windows-safe liveness check via OpenProcess + GetExitCodeProcess."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
STILL_ACTIVE = 259
|
||||
|
||||
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
||||
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
||||
kernel32.OpenProcess.restype = wintypes.HANDLE
|
||||
kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
|
||||
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
|
||||
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
|
||||
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
exit_code = wintypes.DWORD()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||
return False
|
||||
return exit_code.value == STILL_ACTIVE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
def _check_lock(branch_path: Path) -> Optional[dict]:
|
||||
"""Check if branch has an active dispatch lock. Returns lock data or None."""
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
@@ -151,14 +175,9 @@ def _check_lock(branch_path: Path) -> Optional[dict]:
|
||||
data = json.load(f)
|
||||
pid = data.get("pid")
|
||||
if pid is not None:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return data # Process alive, lock valid
|
||||
except ProcessLookupError:
|
||||
logger.info("[wake] Lock PID %s dead — cleaning stale lock", pid)
|
||||
except PermissionError as e:
|
||||
logger.warning("[wake] Lock PID %s permission error: %s", pid, e)
|
||||
return data # Process exists but can't signal — treat as active
|
||||
if _check_pid_alive(pid):
|
||||
return data
|
||||
logger.info("[wake] Lock PID %s dead — cleaning stale lock", pid)
|
||||
# Stale lock — check age (10 min timeout)
|
||||
ts = data.get("timestamp", "")
|
||||
if ts:
|
||||
@@ -210,18 +229,74 @@ def _load_config() -> dict:
|
||||
return config
|
||||
|
||||
|
||||
def _read_session_type(pid_str: str) -> str:
|
||||
"""Read AIPASS_SESSION_TYPE from /proc/{pid}/environ. Returns 'interactive' if unset."""
|
||||
if sys.platform != "linux":
|
||||
return "interactive"
|
||||
def _get_pid_cwd(pid_str: str) -> Optional[str]:
|
||||
"""Get the cwd of a process. Cross-platform: Linux /proc, macOS lsof."""
|
||||
if sys.platform == "linux":
|
||||
try:
|
||||
return os.readlink(f"/proc/{pid_str}/cwd")
|
||||
except (OSError, PermissionError):
|
||||
logger.info("[wake] Cannot read cwd for PID %s", pid_str)
|
||||
return None
|
||||
if sys.platform == "darwin":
|
||||
return _get_pid_cwd_darwin(pid_str)
|
||||
logger.info("[wake] Cannot determine cwd for PID %s on %s", pid_str, sys.platform)
|
||||
return None
|
||||
|
||||
|
||||
def _get_pid_cwd_darwin(pid_str: str) -> Optional[str]:
|
||||
"""macOS: get process cwd via lsof."""
|
||||
try:
|
||||
with open(f"/proc/{pid_str}/environ", "rb") as f:
|
||||
data = f.read()
|
||||
for entry in data.split(b"\0"):
|
||||
if entry.startswith(b"AIPASS_SESSION_TYPE="):
|
||||
return entry.split(b"=", 1)[1].decode("utf-8")
|
||||
except (OSError, PermissionError):
|
||||
logger.info("[wake] Cannot read session type for PID %s", pid_str)
|
||||
result = subprocess.run(
|
||||
["lsof", "-a", "-p", pid_str, "-d", "cwd", "-Fn"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
logger.info("[wake] Cannot read cwd for PID %s on macOS", pid_str)
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line.startswith("n/"):
|
||||
return line[1:]
|
||||
return None
|
||||
|
||||
|
||||
def _read_session_type(pid_str: str) -> str:
|
||||
"""Read AIPASS_SESSION_TYPE from process environment. Returns 'interactive' if unset."""
|
||||
if sys.platform == "linux":
|
||||
try:
|
||||
with open(f"/proc/{pid_str}/environ", "rb") as f:
|
||||
data = f.read()
|
||||
for entry in data.split(b"\0"):
|
||||
if entry.startswith(b"AIPASS_SESSION_TYPE="):
|
||||
return entry.split(b"=", 1)[1].decode("utf-8")
|
||||
except (OSError, PermissionError):
|
||||
logger.info("[wake] Cannot read session type for PID %s", pid_str)
|
||||
return "interactive"
|
||||
if sys.platform == "darwin":
|
||||
return _read_session_type_darwin(pid_str)
|
||||
return "interactive"
|
||||
|
||||
|
||||
def _read_session_type_darwin(pid_str: str) -> str:
|
||||
"""macOS: read AIPASS_SESSION_TYPE from ps environment output."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ps", "-p", pid_str, "-wwE", "-o", "command="],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
logger.info("[wake] Cannot read session type for PID %s on macOS", pid_str)
|
||||
return "interactive"
|
||||
if result.returncode != 0:
|
||||
return "interactive"
|
||||
for token in result.stdout.split():
|
||||
if token.startswith("AIPASS_SESSION_TYPE="):
|
||||
return token.split("=", 1)[1]
|
||||
return "interactive"
|
||||
|
||||
|
||||
@@ -240,17 +315,13 @@ def _is_branch_occupied(branch_path: Path) -> bool:
|
||||
pid_str = pid_str.strip()
|
||||
if not pid_str:
|
||||
continue
|
||||
try:
|
||||
if sys.platform != "linux":
|
||||
continue
|
||||
cwd = os.readlink(f"/proc/{pid_str}/cwd")
|
||||
if str(Path(cwd).resolve()) == resolved:
|
||||
session_type = _read_session_type(pid_str)
|
||||
if session_type not in _NON_BLOCKING_SESSION_TYPES:
|
||||
return True
|
||||
except (OSError, PermissionError, ValueError):
|
||||
logger.info("[wake] Cannot read cwd for PID %s", pid_str)
|
||||
cwd = _get_pid_cwd(pid_str)
|
||||
if cwd is None:
|
||||
continue
|
||||
if str(Path(cwd).resolve()) == resolved:
|
||||
session_type = _read_session_type(pid_str)
|
||||
if session_type not in _NON_BLOCKING_SESSION_TYPES:
|
||||
return True
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
logger.info("[wake] Failed to check branch occupancy")
|
||||
return False
|
||||
@@ -273,21 +344,38 @@ def _clean_zombies() -> int:
|
||||
|
||||
def _check_pid_alive(pid: int) -> bool:
|
||||
"""Check if a process is alive (not zombie)."""
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
return _pid_alive_windows(pid)
|
||||
except Exception as exc:
|
||||
logger.info("[wake] PID %s Windows check failed (assuming alive): %s", pid, exc)
|
||||
return True
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
# Also verify not zombie via /proc (Linux only)
|
||||
if sys.platform == "linux":
|
||||
with open(f"/proc/{pid}/status", "r") as f:
|
||||
for line in f:
|
||||
if line.startswith("State:"):
|
||||
return "Z" not in line
|
||||
return True
|
||||
except (ProcessLookupError, FileNotFoundError) as e:
|
||||
logger.warning("[wake] PID %s not found: %s", pid, e)
|
||||
except ProcessLookupError as exc:
|
||||
logger.warning("[wake] PID %s not found: %s", pid, exc)
|
||||
return False
|
||||
except PermissionError as e:
|
||||
logger.warning("[wake] PID %s permission denied: %s", pid, e)
|
||||
return True # Exists but can't check — assume alive
|
||||
except PermissionError as exc:
|
||||
logger.warning("[wake] PID %s permission denied: %s", pid, exc)
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.warning("[wake] PID %s os.kill error (assuming dead): %s", pid, exc)
|
||||
return False
|
||||
if sys.platform == "linux" and _is_zombie_linux(pid):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _is_zombie_linux(pid: int) -> bool:
|
||||
"""Return True if PID is a zombie (Linux /proc/status check)."""
|
||||
try:
|
||||
with open(f"/proc/{pid}/status", "r") as f:
|
||||
for line in f:
|
||||
if line.startswith("State:"):
|
||||
return "Z" in line
|
||||
except FileNotFoundError as exc:
|
||||
logger.warning("[wake] PID %s /proc not found: %s", pid, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _spawn_in_systemd_scope(monitor_cmd, branch_path, spawn_env, branch_email, lock_file_path, custom_message, status):
|
||||
@@ -450,14 +538,27 @@ def wake_branch(
|
||||
branch_path, email = result
|
||||
status.ok("resolve", f"{email} → {branch_path}")
|
||||
|
||||
# Step 3: Zombie check (pre-flight)
|
||||
# Step 3: Manager check — managers are never woken, mail only
|
||||
passport_file = branch_path / ".trinity" / "passport.json"
|
||||
try:
|
||||
with open(passport_file, "r", encoding="utf-8") as f:
|
||||
passport = json.load(f)
|
||||
citizen_class = passport.get("identity", {}).get("citizen_class", "")
|
||||
if citizen_class == "manager":
|
||||
status.info("manager", f"{email} is a manager — mail only, wake skipped")
|
||||
logger.info("[wake] %s is citizen_class=manager — wake skipped, mail delivered", email)
|
||||
return status, True
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError) as exc:
|
||||
logger.info("[wake] Could not read passport for %s: %s", email, exc)
|
||||
|
||||
# Step 4: Zombie check (pre-flight)
|
||||
zombie_count = _clean_zombies()
|
||||
if zombie_count > 0:
|
||||
status.warn("zombies", f"{zombie_count} zombie Claude process(es) detected")
|
||||
else:
|
||||
status.ok("pre-flight", "No zombie processes")
|
||||
|
||||
# Step 4: Lock check
|
||||
# Step 5: Lock check
|
||||
existing = _check_lock(branch_path)
|
||||
if existing is not None:
|
||||
pid = existing.get("pid", "?")
|
||||
@@ -473,7 +574,7 @@ def wake_branch(
|
||||
|
||||
status.ok("lock", "No active lock — agent is sleeping")
|
||||
|
||||
# Step 5: Occupancy check
|
||||
# Step 6: Occupancy check
|
||||
if _is_branch_occupied(branch_path):
|
||||
status.warn("occupancy", f"Interactive Claude session in {branch_path}")
|
||||
status.fail("blocked", "Cannot spawn — interactive session running")
|
||||
@@ -482,12 +583,12 @@ def wake_branch(
|
||||
|
||||
status.ok("occupancy", "No interactive session")
|
||||
|
||||
# Step 6: Build spawn command
|
||||
# Step 7: Build spawn command
|
||||
config = _load_config()
|
||||
max_turns = config.get("max_turns_per_wake", 100)
|
||||
|
||||
# Resolve model: shorthand -> full ID, or pass through if already a full ID
|
||||
resolved_model = MODEL_MAP.get(model or DEFAULT_MODEL, model or MODEL_MAP[DEFAULT_MODEL])
|
||||
# Pass model directly to CLI — aliases resolve latest-in-class automatically
|
||||
resolved_model = model or DEFAULT_MODEL
|
||||
|
||||
lock_file_path = str(branch_path / ".ai_mail.local" / ".dispatch.lock")
|
||||
if custom_message:
|
||||
@@ -578,14 +679,19 @@ def wake_branch(
|
||||
|
||||
if not spawned_via_scope:
|
||||
try:
|
||||
_detach_kwargs: dict = {}
|
||||
if sys.platform == "win32":
|
||||
_detach_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
_detach_kwargs["start_new_session"] = True
|
||||
process = subprocess.Popen(
|
||||
monitor_cmd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
cwd=str(branch_path),
|
||||
env=spawn_env,
|
||||
**_detach_kwargs,
|
||||
)
|
||||
|
||||
monitor_pid = process.pid
|
||||
@@ -671,7 +777,7 @@ if __name__ == "__main__":
|
||||
print(" --fresh Start fresh session (claude -p) instead of resuming (claude -c -p)")
|
||||
print(" --auto Respect autonomous_pause (used by daemon). Manual wake ignores it.")
|
||||
print(" --sender @branch Set return-to-sender for bounce emails (default: @devpulse)")
|
||||
print(" --model NAME Model to use: opus (default), sonnet, haiku, or full model ID")
|
||||
print(" --model NAME Model to use: sonnet (default), opus, haiku, or full model ID")
|
||||
print()
|
||||
print("Output: Step-by-step status of the dispatch pipeline:")
|
||||
print(" ✅ resolve → @branch found at /path/to/branch")
|
||||
|
||||
@@ -14,6 +14,8 @@ Solves the BRANCH DETECTION FAILED problem when external projects call drone
|
||||
— contacts lookup works even when CWD-walking cannot identify the caller.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
|
||||
@@ -21,6 +23,13 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
CONTACTS_FILE = find_repo_root() / "src/aipass/ai_mail/.ai_mail.local/contacts.json"
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ Independent handler - no module dependencies.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
@@ -21,6 +23,13 @@ from typing import Dict, Optional
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
# Lazy imports
|
||||
_append_footer = None
|
||||
|
||||
|
||||
@@ -15,15 +15,23 @@ Independent handler - no module dependencies.
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, Tuple, List, Optional, Callable
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root, find_project_root
|
||||
from aipass.ai_mail.apps.handlers.registry.read import get_all_branches
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
_REPO_ROOT = find_repo_root()
|
||||
|
||||
@@ -205,6 +213,44 @@ def _resolve_reply_path() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _check_cross_project_boundary(recipient_path: Path, sender_email: str) -> Tuple[bool, str]:
|
||||
"""Refuse mail when sender and recipient are in different projects.
|
||||
|
||||
Compares project roots (first *_REGISTRY.json found walking up) for the
|
||||
sender (from AIPASS_CALLER_CWD) and recipient (from resolved branch path).
|
||||
Same-project and host-to-host mail passes through unchanged.
|
||||
|
||||
Returns:
|
||||
(True, error_message) to refuse, (False, "") to allow.
|
||||
"""
|
||||
caller_cwd = os.environ.get("AIPASS_CALLER_CWD", "")
|
||||
if not caller_cwd:
|
||||
return False, ""
|
||||
|
||||
sender_root = find_project_root(Path(caller_cwd))
|
||||
if sender_root is None:
|
||||
return False, ""
|
||||
|
||||
recipient_root = find_project_root(recipient_path)
|
||||
if recipient_root is None:
|
||||
return False, ""
|
||||
|
||||
if sender_root == recipient_root:
|
||||
return False, ""
|
||||
|
||||
sender_name = sender_email or os.environ.get("AIPASS_CALLER_BRANCH", "unknown")
|
||||
logger.warning(
|
||||
"[delivery] cross-project mail refused: sender root %s != recipient root %s",
|
||||
sender_root,
|
||||
recipient_root,
|
||||
)
|
||||
return True, (
|
||||
f"Cross-project mail refused: {sender_name} (project: {sender_root.name}) "
|
||||
f"cannot send to this branch (project: {recipient_root.name}). "
|
||||
f"Use the feedback channel for cross-project communication."
|
||||
)
|
||||
|
||||
|
||||
def deliver_email_to_branch(
|
||||
to_branch: str, email_data: Dict, on_delivered: Optional[Callable] = None
|
||||
) -> Tuple[bool, str]:
|
||||
@@ -275,6 +321,11 @@ def deliver_email_to_branch(
|
||||
if not branch_path.is_absolute():
|
||||
branch_path = (_REPO_ROOT / branch_path).resolve()
|
||||
|
||||
# Cross-project boundary: refuse mail when sender and recipient are in different projects
|
||||
refused, refusal_msg = _check_cross_project_boundary(branch_path, sender_email)
|
||||
if refused:
|
||||
return False, refusal_msg
|
||||
|
||||
# Find the branch's .ai_mail.local/inbox.json file
|
||||
if branch_path == Path("/") or branch_path == _REPO_ROOT:
|
||||
inbox_file = _REPO_ROOT / ".ai_mail.local" / "inbox.json"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: footer.py
|
||||
# Description: Email Footer Handler
|
||||
# Version: 1.0.0
|
||||
# Version: 1.1.0
|
||||
# Created: 2026-01-29
|
||||
# Modified: 2026-01-29
|
||||
# Modified: 2026-07-14
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
@@ -19,6 +19,8 @@ from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
# Standard footer for all outgoing emails
|
||||
STANDARD_FOOTER = """
|
||||
---
|
||||
▶ BUILD/EDIT task? First: drone @trigger medic mute @<your-branch> — auto-expires 24h, no unmute.
|
||||
|
||||
⚠️ TASK CHECKLIST (before marking complete):
|
||||
□ SEEDGO CHECK → drone @seedgo audit @branch (80%+)
|
||||
□ UPDATE MEMORIES → Your .trinity/local.json records this work
|
||||
|
||||
@@ -14,12 +14,21 @@ Independent handler - no module dependencies.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Dict, Optional
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
REGISTRY_PATH = find_repo_root() / "AIPASS_REGISTRY.json"
|
||||
|
||||
@@ -164,10 +173,10 @@ if __name__ == "__main__":
|
||||
console.print(" - format_email_list_item(index, email_data, show_unread) -> str")
|
||||
console.print()
|
||||
console.print("HANDLER CHARACTERISTICS:")
|
||||
console.print(" ✓ Independent - no module dependencies")
|
||||
console.print(" ✓ Can import Prax (service provider)")
|
||||
console.print(" ✓ Pure business logic")
|
||||
console.print(" ✗ CANNOT import parent modules")
|
||||
console.print(" [green]+[/green] Independent - no module dependencies")
|
||||
console.print(" [green]+[/green] Can import Prax (service provider)")
|
||||
console.print(" [green]+[/green] Pure business logic")
|
||||
console.print(" [dim]-[/dim] CANNOT import parent modules")
|
||||
console.print()
|
||||
console.print("USAGE FROM MODULES:")
|
||||
console.print(" from ai_mail.apps.handlers.email.format import format_email_preview")
|
||||
|
||||
@@ -16,6 +16,8 @@ v3.0.0: Now uses deleted/ directory with individual JSON files (like sent/).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Tuple, Optional, Any
|
||||
@@ -23,6 +25,13 @@ from typing import Dict, Tuple, Optional, Any
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
# Lazy import for inbox file lock
|
||||
_inbox_lock = None
|
||||
|
||||
@@ -14,12 +14,21 @@ Independent handler - no module dependencies.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
# Lazy import for inbox file lock
|
||||
_inbox_lock = None
|
||||
|
||||
@@ -125,10 +134,10 @@ if __name__ == "__main__":
|
||||
console.print(" - load_inbox(inbox_file) -> Dict")
|
||||
console.print()
|
||||
console.print("HANDLER CHARACTERISTICS:")
|
||||
console.print(" ✓ Independent - no module dependencies")
|
||||
console.print(" ✓ Can import Prax (service provider)")
|
||||
console.print(" ✓ Pure business logic")
|
||||
console.print(" ✗ CANNOT import parent modules")
|
||||
console.print(" [green]+[/green] Independent - no module dependencies")
|
||||
console.print(" [green]+[/green] Can import Prax (service provider)")
|
||||
console.print(" [green]+[/green] Pure business logic")
|
||||
console.print(" [dim]-[/dim] CANNOT import parent modules")
|
||||
console.print()
|
||||
console.print("USAGE FROM MODULES:")
|
||||
console.print(" from aipass.ai_mail.apps.handlers.email.inbox_ops import load_inbox")
|
||||
|
||||
@@ -31,6 +31,13 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
# Purge configuration
|
||||
MAX_EMAILS = 10
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ Handles replying to emails and auto-closing the original.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, Tuple, Optional
|
||||
@@ -22,6 +24,13 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.email.delivery import deliver_to_inbox_file
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
# Services imported in __main__ only (handlers should not display)
|
||||
|
||||
|
||||
|
||||
@@ -13,10 +13,21 @@ Provides repo root discovery used across all handler files.
|
||||
Consolidated from 8 identical copies per DPLAN-0036 audit.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def find_repo_root() -> Path:
|
||||
"""Walk up from this file to find AIPASS_REGISTRY.json (repo root)."""
|
||||
@@ -27,6 +38,23 @@ def find_repo_root() -> Path:
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def find_project_root(start: Path) -> Optional[Path]:
|
||||
"""Walk up from *start* to find the first *_REGISTRY.json (project root).
|
||||
|
||||
Returns the directory containing the registry, or None if not found.
|
||||
Stops at filesystem root.
|
||||
"""
|
||||
current = start.resolve()
|
||||
for candidate in [current] + list(current.parents):
|
||||
try:
|
||||
if any(candidate.glob("*_REGISTRY.json")):
|
||||
return candidate
|
||||
except OSError as exc:
|
||||
logger.warning("[paths] find_project_root: glob failed at %s: %s", candidate, exc)
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ Handler Independence:
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
@@ -28,6 +30,13 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
# Constants
|
||||
MODULE_NAME = "registry.read"
|
||||
|
||||
@@ -17,6 +17,7 @@ Walks up directory tree to find branch root (has .trinity/passport.json).
|
||||
# IMPORTS
|
||||
# =============================================
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
@@ -25,6 +26,13 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
@@ -297,10 +305,10 @@ if __name__ == "__main__":
|
||||
console.print(" - get_branch_info_from_registry(branch_path) -> Optional[Dict]")
|
||||
console.print()
|
||||
console.print("HANDLER CHARACTERISTICS:")
|
||||
console.print(" ✓ Independent - no module dependencies")
|
||||
console.print(" ✓ Can import Prax (service provider)")
|
||||
console.print(" ✓ Pure business logic")
|
||||
console.print(" ✗ CANNOT import parent modules")
|
||||
console.print(" [green]+[/green] Independent - no module dependencies")
|
||||
console.print(" [green]+[/green] Can import Prax (service provider)")
|
||||
console.print(" [green]+[/green] Pure business logic")
|
||||
console.print(" [dim]-[/dim] CANNOT import parent modules")
|
||||
console.print()
|
||||
console.print("DETECTION FLOW:")
|
||||
console.print(" 1. Get current working directory (PWD)")
|
||||
|
||||
@@ -14,7 +14,6 @@ Delegates all business logic to handlers.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
@@ -24,6 +23,13 @@ from aipass.cli.apps.modules import console, error
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.dispatch.status import load_dispatch_log, check_pid_status, calculate_age
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print help for dispatch commands."""
|
||||
@@ -41,7 +47,6 @@ DISPATCH (send + wake):
|
||||
drone @ai_mail dispatch @branch "Subject" "Body" --fresh # Send + fresh wake
|
||||
drone @ai_mail dispatch @branch "Subject" "Body" --model opus # Send + wake with Opus
|
||||
drone @ai_mail dispatch @branch "Subject" "Body" --no-memory-save
|
||||
drone @ai_mail dispatch @branch "Subject" "Body" --no-watchdog # Skip auto-watchdog
|
||||
|
||||
WAKE ONLY:
|
||||
drone @ai_mail dispatch wake @branch # Wake with default inbox check
|
||||
@@ -219,7 +224,6 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool:
|
||||
# Parse flags
|
||||
use_fresh = False
|
||||
no_memory_save = False
|
||||
no_watchdog = False
|
||||
from_branch = None
|
||||
use_model = None
|
||||
filtered = []
|
||||
@@ -234,7 +238,6 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool:
|
||||
i += 1
|
||||
continue
|
||||
if args[i] == "--no-watchdog":
|
||||
no_watchdog = True
|
||||
i += 1
|
||||
continue
|
||||
if args[i] == "--from" and i + 1 < len(args):
|
||||
@@ -342,57 +345,12 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool:
|
||||
if not wake_ok:
|
||||
logger.warning("[dispatch] Wake failed for %s — email was sent", target)
|
||||
error(f"Email sent but wake failed — retry: drone @ai_mail dispatch wake {target}")
|
||||
elif not no_watchdog:
|
||||
_spawn_watchdog(target)
|
||||
else:
|
||||
console.print(f"[dim]Wake-back enabled — sender will be woken when {target} completes (if available)[/dim]")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _spawn_watchdog(target: str) -> None:
|
||||
"""Auto-spawn devpulse watchdog as a detached background process."""
|
||||
from aipass.ai_mail.apps.handlers.registry.read import get_branch_by_email
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
|
||||
devpulse_info = get_branch_by_email("@devpulse")
|
||||
if not devpulse_info:
|
||||
logger.warning("[dispatch] Cannot spawn watchdog — @devpulse not in registry")
|
||||
return
|
||||
|
||||
_repo_root = find_repo_root()
|
||||
devpulse_path = devpulse_info.get("path", "")
|
||||
if not devpulse_path:
|
||||
logger.warning("[dispatch] Cannot spawn watchdog — @devpulse has no path")
|
||||
return
|
||||
|
||||
devpulse_dir = Path(devpulse_path)
|
||||
if not devpulse_dir.is_absolute():
|
||||
devpulse_dir = _repo_root / devpulse_dir
|
||||
|
||||
if not devpulse_dir.is_dir():
|
||||
logger.warning("[dispatch] Cannot spawn watchdog — devpulse dir not found: %s", devpulse_dir)
|
||||
return
|
||||
|
||||
cmd = ["drone", "@devpulse", "watchdog", "agent", target]
|
||||
|
||||
spawn_env = os.environ.copy()
|
||||
local_bin = str(Path.home() / ".local" / "bin")
|
||||
if local_bin not in spawn_env.get("PATH", ""):
|
||||
spawn_env["PATH"] = local_bin + ":" + spawn_env.get("PATH", "")
|
||||
|
||||
try:
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
cwd=str(devpulse_dir),
|
||||
env=spawn_env,
|
||||
)
|
||||
console.print(f"[green]Watchdog armed for {target}[/green]")
|
||||
except Exception as e:
|
||||
logger.warning("[dispatch] Watchdog spawn failed for %s: %s", target, e)
|
||||
|
||||
|
||||
def _orchestrate_daemon() -> bool:
|
||||
"""Orchestrate daemon startup."""
|
||||
logger.info("[dispatch] Starting dispatch daemon")
|
||||
|
||||
@@ -19,6 +19,7 @@ Module Pattern:
|
||||
- NO business logic in this file
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
@@ -42,6 +43,13 @@ from aipass.ai_mail.apps.handlers.email.inbox_resolve import resolve_inbox_targe
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
from aipass.ai_mail.apps.modules.email_send import handle_send
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
_AI_MAIL_DIR = Path(__file__).resolve().parents[2]
|
||||
_REPO_ROOT = find_repo_root()
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ broadcast, and dispatch trigger. Extracted from email.py to keep modules
|
||||
under the size threshold.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
@@ -37,6 +39,13 @@ from aipass.ai_mail.apps.handlers.email.error_dispatch import dispatch_send_erro
|
||||
from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args, resolve_dispatch_target
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
_AI_MAIL_DIR = Path(__file__).resolve().parents[2]
|
||||
_REPO_ROOT = find_repo_root()
|
||||
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"""Tests for dispatch daemon handler -- config loading, state management, inbox scanning."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
from datetime import datetime, date, timedelta
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
import aipass.ai_mail.apps.handlers.dispatch.daemon as daemon_mod
|
||||
from aipass.ai_mail.apps.handlers.dispatch.daemon import (
|
||||
@@ -25,6 +26,17 @@ from aipass.ai_mail.apps.handlers.dispatch.daemon import (
|
||||
get_registered_branches,
|
||||
check_inbox_for_dispatch,
|
||||
is_protected_branch,
|
||||
_handle_signal,
|
||||
_check_lock,
|
||||
_acquire_lock,
|
||||
_is_registered_sender,
|
||||
poll_cycle,
|
||||
_write_pid_file,
|
||||
_remove_pid_file,
|
||||
_read_session_type,
|
||||
_is_branch_occupied,
|
||||
spawn_agent,
|
||||
run_daemon,
|
||||
)
|
||||
|
||||
|
||||
@@ -764,26 +776,6 @@ def test_poll_cycle_absolute_path_unchanged(tmp_path, monkeypatch):
|
||||
assert spawned_paths[0] == branch_dir
|
||||
|
||||
|
||||
# ---- Additional imports for new tests --------------------------------
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, mock_open
|
||||
|
||||
from aipass.ai_mail.apps.handlers.dispatch.daemon import (
|
||||
_handle_signal,
|
||||
_check_lock,
|
||||
_acquire_lock,
|
||||
_is_registered_sender,
|
||||
poll_cycle,
|
||||
_write_pid_file,
|
||||
_remove_pid_file,
|
||||
_read_session_type,
|
||||
_is_branch_occupied,
|
||||
spawn_agent,
|
||||
run_daemon,
|
||||
)
|
||||
|
||||
|
||||
# ---- _handle_signal tests --------------------------------------
|
||||
|
||||
|
||||
@@ -814,7 +806,7 @@ def test_check_lock_alive_pid(tmp_path, monkeypatch):
|
||||
lock_data = {"pid": 99999, "timestamp": datetime.now().isoformat()}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(os, "kill", lambda pid, sig: None)
|
||||
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: True)
|
||||
|
||||
result = _check_lock(tmp_path)
|
||||
|
||||
@@ -823,17 +815,14 @@ def test_check_lock_alive_pid(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_check_lock_dead_pid(tmp_path, monkeypatch):
|
||||
"""Lock with dead PID (ProcessLookupError) is cleaned up."""
|
||||
"""Lock with dead PID is cleaned up."""
|
||||
lock_dir = tmp_path / ".ai_mail.local"
|
||||
lock_dir.mkdir(parents=True)
|
||||
lock_file = lock_dir / ".dispatch.lock"
|
||||
lock_data = {"pid": 99999, "timestamp": datetime.now().isoformat()}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
|
||||
def _raise_process_lookup(pid, sig):
|
||||
raise ProcessLookupError("No such process")
|
||||
|
||||
monkeypatch.setattr(os, "kill", _raise_process_lookup)
|
||||
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: False)
|
||||
|
||||
result = _check_lock(tmp_path)
|
||||
|
||||
@@ -849,10 +838,7 @@ def test_check_lock_permission_error(tmp_path, monkeypatch):
|
||||
lock_data = {"pid": 99999, "timestamp": datetime.now().isoformat()}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
|
||||
def _raise_permission(pid, sig):
|
||||
raise PermissionError("Operation not permitted")
|
||||
|
||||
monkeypatch.setattr(os, "kill", _raise_permission)
|
||||
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: True)
|
||||
|
||||
result = _check_lock(tmp_path)
|
||||
|
||||
@@ -869,10 +855,7 @@ def test_check_lock_stale_over_10min_removed(tmp_path, monkeypatch):
|
||||
lock_data = {"pid": 99999, "timestamp": old_time}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
|
||||
def _raise_process_lookup(pid, sig):
|
||||
raise ProcessLookupError("No such process")
|
||||
|
||||
monkeypatch.setattr(os, "kill", _raise_process_lookup)
|
||||
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: False)
|
||||
|
||||
result = _check_lock(tmp_path)
|
||||
|
||||
@@ -889,10 +872,7 @@ def test_check_lock_stale_under_10min_dead_pid_removed(tmp_path, monkeypatch):
|
||||
lock_data = {"pid": 99999, "timestamp": recent_time}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
|
||||
def _raise_process_lookup(pid, sig):
|
||||
raise ProcessLookupError("No such process")
|
||||
|
||||
monkeypatch.setattr(os, "kill", _raise_process_lookup)
|
||||
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: False)
|
||||
|
||||
result = _check_lock(tmp_path)
|
||||
|
||||
@@ -1015,6 +995,7 @@ def test_write_pid_file_existing_dead_pid(tmp_path, monkeypatch):
|
||||
|
||||
def test_write_pid_file_existing_permission_error(tmp_path, monkeypatch):
|
||||
"""Existing PID file with PermissionError on kill returns False."""
|
||||
monkeypatch.setattr("sys.platform", "linux")
|
||||
pid_file = tmp_path / "daemon.pid"
|
||||
pid_file.write_text("888888", encoding="utf-8")
|
||||
monkeypatch.setattr(daemon_mod, "DAEMON_PID_FILE", pid_file)
|
||||
@@ -1336,6 +1317,48 @@ def test_spawn_agent_strips_claude_env_vars(tmp_path, monkeypatch):
|
||||
assert captured_env.get("AIPASS_SESSION_TYPE") == "daemon"
|
||||
|
||||
|
||||
def test_spawn_agent_claude_cmd_includes_model_flag(tmp_path):
|
||||
"""Poller-triggered spawn passes --model DEFAULT_MODEL to the claude invocation."""
|
||||
branch_path = tmp_path / "branch"
|
||||
branch_path.mkdir()
|
||||
(branch_path / "logs").mkdir()
|
||||
|
||||
message = {"from": "@devpulse", "id": "msg1", "subject": "Test task"}
|
||||
config = {"max_turns_per_wake": 50}
|
||||
state = {"daily_counts": {}, "session_cycles": {}}
|
||||
|
||||
captured_args = []
|
||||
|
||||
def capture_popen(*args, **kwargs):
|
||||
captured_args.append(args[0] if args else kwargs.get("args"))
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.pid = 22222
|
||||
return mock_proc
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen",
|
||||
side_effect=capture_popen,
|
||||
),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock",
|
||||
return_value=(True, "Lock acquired"),
|
||||
),
|
||||
patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification",
|
||||
create=True,
|
||||
),
|
||||
):
|
||||
result = spawn_agent(branch_path, "@testbranch", message, config, state)
|
||||
|
||||
assert result is True
|
||||
monitor_cmd = captured_args[0]
|
||||
assert "--model" in monitor_cmd
|
||||
model_idx = monitor_cmd.index("--model")
|
||||
assert monitor_cmd[model_idx + 1] == daemon_mod.DEFAULT_MODEL
|
||||
|
||||
|
||||
def test_spawn_agent_prompt_includes_reply_id(tmp_path):
|
||||
"""Prompt includes explicit reply command with the dispatch email ID."""
|
||||
branch_path = tmp_path / "branch"
|
||||
|
||||
@@ -17,6 +17,7 @@ from unittest.mock import patch, MagicMock
|
||||
|
||||
import aipass.ai_mail.apps.handlers.email.delivery as delivery_mod
|
||||
from aipass.ai_mail.apps.handlers.email.delivery import (
|
||||
_check_cross_project_boundary,
|
||||
_migrate_inbox_format,
|
||||
_is_private_branch_email,
|
||||
_resolve_reply_path,
|
||||
@@ -493,3 +494,121 @@ def test_deliver_stores_reply_path_from_env(tmp_path, repo_root, noop_inbox_lock
|
||||
msg = inbox["messages"][0]
|
||||
assert "reply_path" in msg
|
||||
assert msg["reply_path"] == str(inbox_file)
|
||||
|
||||
|
||||
# ---- _check_cross_project_boundary() tests ------------------------------
|
||||
|
||||
|
||||
def test_cross_project_no_caller_cwd_allows(tmp_path, monkeypatch):
|
||||
"""No AIPASS_CALLER_CWD → host-internal, always allowed."""
|
||||
monkeypatch.delenv("AIPASS_CALLER_CWD", raising=False)
|
||||
refused, _ = _check_cross_project_boundary(tmp_path, "@sender")
|
||||
assert refused is False
|
||||
|
||||
|
||||
def test_cross_project_same_root_allows(tmp_path, monkeypatch):
|
||||
"""Sender and recipient in the same project → allowed."""
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
sender_dir = tmp_path / "src" / "branch_a"
|
||||
sender_dir.mkdir(parents=True)
|
||||
recipient_dir = tmp_path / "src" / "branch_b"
|
||||
recipient_dir.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_dir))
|
||||
refused, _ = _check_cross_project_boundary(recipient_dir, "@branch_b")
|
||||
assert refused is False
|
||||
|
||||
|
||||
def test_cross_project_different_roots_refuses(tmp_path, monkeypatch):
|
||||
"""Sender in nested project, recipient in host → refused."""
|
||||
host = tmp_path / "host"
|
||||
host.mkdir()
|
||||
(host / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
recipient_dir = host / "src" / "devpulse"
|
||||
recipient_dir.mkdir(parents=True)
|
||||
|
||||
project = host / "projects" / "myproj"
|
||||
project.mkdir(parents=True)
|
||||
(project / "MYPROJ_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
sender_dir = project / "src"
|
||||
sender_dir.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_dir))
|
||||
refused, msg = _check_cross_project_boundary(recipient_dir, "@proj_agent")
|
||||
assert refused is True
|
||||
assert "Cross-project mail refused" in msg
|
||||
assert "feedback channel" in msg
|
||||
|
||||
|
||||
def test_cross_project_sender_no_registry_allows(tmp_path, monkeypatch):
|
||||
"""Sender in dir with no registry → cannot determine boundary, allow."""
|
||||
isolated = tmp_path / "nowhere"
|
||||
isolated.mkdir()
|
||||
monkeypatch.setenv("AIPASS_CALLER_CWD", str(isolated))
|
||||
|
||||
recipient = tmp_path / "host" / "branch"
|
||||
recipient.mkdir(parents=True)
|
||||
(tmp_path / "host" / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
refused, _ = _check_cross_project_boundary(recipient, "@branch")
|
||||
assert refused is False
|
||||
|
||||
|
||||
def test_cross_project_recipient_no_registry_allows(tmp_path, monkeypatch):
|
||||
"""Recipient in dir with no registry → cannot determine boundary, allow."""
|
||||
sender_dir = tmp_path / "host" / "src"
|
||||
sender_dir.mkdir(parents=True)
|
||||
(tmp_path / "host" / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_dir))
|
||||
|
||||
recipient = tmp_path / "orphan"
|
||||
recipient.mkdir()
|
||||
|
||||
refused, _ = _check_cross_project_boundary(recipient, "@orphan")
|
||||
assert refused is False
|
||||
|
||||
|
||||
def test_cross_project_delivery_e2e_refused(tmp_path, repo_root, noop_inbox_lock, monkeypatch):
|
||||
"""End-to-end: delivery from nested project to host branch is refused."""
|
||||
host_root = repo_root
|
||||
(host_root / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
branches = _setup_branch(tmp_path)
|
||||
|
||||
project = host_root / "projects" / "testproj"
|
||||
project.mkdir(parents=True)
|
||||
(project / "TESTPROJ_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
sender_cwd = project / "src"
|
||||
sender_cwd.mkdir()
|
||||
|
||||
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_cwd))
|
||||
|
||||
with patch.object(delivery_mod, "get_all_branches", return_value=branches):
|
||||
success, error = deliver_email_to_branch(
|
||||
"@target",
|
||||
_make_email_data(sender="@testproj"),
|
||||
)
|
||||
|
||||
assert success is False
|
||||
assert "Cross-project mail refused" in error
|
||||
|
||||
|
||||
def test_cross_project_delivery_same_project_allowed(tmp_path, repo_root, noop_inbox_lock, monkeypatch):
|
||||
"""End-to-end: delivery within the same project is allowed."""
|
||||
(repo_root / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
branches = _setup_branch(tmp_path)
|
||||
|
||||
sender_cwd = tmp_path / "src" / "other_branch"
|
||||
sender_cwd.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_cwd))
|
||||
|
||||
with patch.object(delivery_mod, "get_all_branches", return_value=branches):
|
||||
success, error = deliver_email_to_branch(
|
||||
"@target",
|
||||
_make_email_data(),
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert error == ""
|
||||
|
||||
@@ -969,168 +969,18 @@ class TestPrintIntrospection:
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _spawn_watchdog
|
||||
# Wake-back messaging (TDPLAN-0012 — retired _spawn_watchdog)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSpawnWatchdog:
|
||||
"""Tests for _spawn_watchdog."""
|
||||
|
||||
def test_spawns_detached_subprocess(self, monkeypatch, tmp_path):
|
||||
"""Successful watchdog spawn calls Popen with correct args."""
|
||||
devpulse_dir = tmp_path / "src" / "aipass" / "devpulse"
|
||||
devpulse_dir.mkdir(parents=True)
|
||||
class TestWakeBackMessaging:
|
||||
"""Tests for honest wake-back messaging after watchdog retirement."""
|
||||
|
||||
def test_wake_back_message_on_successful_wake(self, monkeypatch):
|
||||
"""Successful send + wake prints wake-back enabled message."""
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
popen_calls: list[dict] = []
|
||||
mock_popen = MagicMock()
|
||||
|
||||
def tracking_popen(cmd, **kwargs):
|
||||
"""Capture Popen arguments."""
|
||||
popen_calls.append({"cmd": cmd, **kwargs})
|
||||
return mock_popen
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"{_H_REG}.get_branch_by_email",
|
||||
return_value={"email": "@devpulse", "path": str(devpulse_dir)},
|
||||
),
|
||||
patch(f"{MOD}.subprocess.Popen", side_effect=tracking_popen),
|
||||
):
|
||||
from aipass.ai_mail.apps.modules.dispatch import _spawn_watchdog
|
||||
|
||||
_spawn_watchdog("@flow")
|
||||
|
||||
assert len(popen_calls) == 1
|
||||
assert popen_calls[0]["cmd"] == ["drone", "@devpulse", "watchdog", "agent", "@flow"]
|
||||
assert popen_calls[0]["start_new_session"] is True
|
||||
assert popen_calls[0]["cwd"] == str(devpulse_dir)
|
||||
combined = " ".join(printed)
|
||||
assert "Watchdog armed for @flow" in combined
|
||||
|
||||
def test_devpulse_not_in_registry(self, monkeypatch):
|
||||
"""No spawn when @devpulse not found in registry."""
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
with (
|
||||
patch(f"{_H_REG}.get_branch_by_email", return_value=None),
|
||||
patch(f"{MOD}.subprocess.Popen") as mock_popen,
|
||||
):
|
||||
from aipass.ai_mail.apps.modules.dispatch import _spawn_watchdog
|
||||
|
||||
_spawn_watchdog("@flow")
|
||||
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
def test_devpulse_no_path(self, monkeypatch):
|
||||
"""No spawn when @devpulse has empty path."""
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"{_H_REG}.get_branch_by_email",
|
||||
return_value={"email": "@devpulse", "path": ""},
|
||||
),
|
||||
patch(f"{MOD}.subprocess.Popen") as mock_popen,
|
||||
):
|
||||
from aipass.ai_mail.apps.modules.dispatch import _spawn_watchdog
|
||||
|
||||
_spawn_watchdog("@flow")
|
||||
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
def test_devpulse_dir_missing(self, monkeypatch, tmp_path):
|
||||
"""No spawn when devpulse directory doesn't exist."""
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"{_H_REG}.get_branch_by_email",
|
||||
return_value={"email": "@devpulse", "path": str(tmp_path / "nonexistent")},
|
||||
),
|
||||
patch(f"{MOD}.subprocess.Popen") as mock_popen,
|
||||
):
|
||||
from aipass.ai_mail.apps.modules.dispatch import _spawn_watchdog
|
||||
|
||||
_spawn_watchdog("@flow")
|
||||
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
def test_popen_failure_warns_but_does_not_raise(self, monkeypatch, tmp_path):
|
||||
"""Popen failure logs warning but doesn't propagate."""
|
||||
devpulse_dir = tmp_path / "src" / "aipass" / "devpulse"
|
||||
devpulse_dir.mkdir(parents=True)
|
||||
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"{_H_REG}.get_branch_by_email",
|
||||
return_value={"email": "@devpulse", "path": str(devpulse_dir)},
|
||||
),
|
||||
patch(f"{MOD}.subprocess.Popen", side_effect=FileNotFoundError("drone not found")),
|
||||
):
|
||||
from aipass.ai_mail.apps.modules.dispatch import _spawn_watchdog
|
||||
|
||||
_spawn_watchdog("@flow")
|
||||
|
||||
# Should not raise — watchdog is optional
|
||||
|
||||
def test_relative_devpulse_path_resolved(self, monkeypatch, tmp_path):
|
||||
"""Relative path from registry is resolved against repo root."""
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
popen_calls: list[dict] = []
|
||||
|
||||
def tracking_popen(cmd, **kwargs):
|
||||
"""Capture Popen arguments."""
|
||||
popen_calls.append({"cmd": cmd, **kwargs})
|
||||
return MagicMock()
|
||||
|
||||
from aipass.ai_mail.apps.modules import dispatch as dispatch_mod
|
||||
|
||||
real_repo_root = dispatch_mod.Path(__file__).resolve().parents[4]
|
||||
devpulse_dir = real_repo_root / "src" / "aipass" / "devpulse"
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"{_H_REG}.get_branch_by_email",
|
||||
return_value={"email": "@devpulse", "path": "src/aipass/devpulse"},
|
||||
),
|
||||
patch(f"{MOD}.subprocess.Popen", side_effect=tracking_popen),
|
||||
):
|
||||
from aipass.ai_mail.apps.modules.dispatch import _spawn_watchdog
|
||||
|
||||
_spawn_watchdog("@flow")
|
||||
|
||||
if devpulse_dir.is_dir():
|
||||
assert len(popen_calls) == 1
|
||||
assert "devpulse" in popen_calls[0]["cwd"]
|
||||
else:
|
||||
assert len(popen_calls) == 0
|
||||
|
||||
|
||||
class TestDispatchSendWatchdogIntegration:
|
||||
"""Tests for watchdog integration in _orchestrate_dispatch_send."""
|
||||
|
||||
def test_watchdog_spawned_after_successful_wake(self, monkeypatch):
|
||||
"""Watchdog is spawned after successful send + wake."""
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
watchdog_calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
f"{MOD}._spawn_watchdog",
|
||||
lambda target: watchdog_calls.append(target),
|
||||
)
|
||||
|
||||
patches = _send_patches()
|
||||
with patches:
|
||||
from aipass.ai_mail.apps.modules.dispatch import _orchestrate_dispatch_send
|
||||
@@ -1138,21 +988,17 @@ class TestDispatchSendWatchdogIntegration:
|
||||
result = _orchestrate_dispatch_send(["@target", "Subject", "Body"])
|
||||
|
||||
assert result is True
|
||||
assert watchdog_calls == ["@target"]
|
||||
combined = " ".join(printed)
|
||||
assert "Wake-back enabled" in combined
|
||||
assert "Watchdog armed" not in combined
|
||||
|
||||
def test_watchdog_not_spawned_on_wake_failure(self, monkeypatch):
|
||||
"""Watchdog is NOT spawned when wake fails."""
|
||||
def test_no_wake_back_message_on_wake_failure(self, monkeypatch):
|
||||
"""No wake-back message when wake fails."""
|
||||
errors: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.error", lambda msg: errors.append(msg))
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
watchdog_calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
f"{MOD}._spawn_watchdog",
|
||||
lambda target: watchdog_calls.append(target),
|
||||
)
|
||||
|
||||
mock_status = MagicMock()
|
||||
mock_status.format.return_value = "WAKE FAILED"
|
||||
patches = _send_patches(
|
||||
@@ -1165,19 +1011,14 @@ class TestDispatchSendWatchdogIntegration:
|
||||
|
||||
_orchestrate_dispatch_send(["@target", "Subject", "Body"])
|
||||
|
||||
assert watchdog_calls == []
|
||||
combined = " ".join(printed)
|
||||
assert "Wake-back enabled" not in combined
|
||||
|
||||
def test_no_watchdog_flag_skips_spawn(self, monkeypatch):
|
||||
"""--no-watchdog flag prevents watchdog spawn."""
|
||||
def test_no_watchdog_flag_still_accepted(self, monkeypatch):
|
||||
"""--no-watchdog flag is consumed without error (backward compat)."""
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
watchdog_calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
f"{MOD}._spawn_watchdog",
|
||||
lambda target: watchdog_calls.append(target),
|
||||
)
|
||||
|
||||
patches = _send_patches()
|
||||
with patches:
|
||||
from aipass.ai_mail.apps.modules.dispatch import _orchestrate_dispatch_send
|
||||
@@ -1185,21 +1026,14 @@ class TestDispatchSendWatchdogIntegration:
|
||||
result = _orchestrate_dispatch_send(["@target", "Subject", "Body", "--no-watchdog"])
|
||||
|
||||
assert result is True
|
||||
assert watchdog_calls == []
|
||||
|
||||
def test_watchdog_not_spawned_on_send_failure(self, monkeypatch):
|
||||
"""Watchdog is NOT spawned when send fails."""
|
||||
def test_no_watchdog_message_on_send_failure(self, monkeypatch):
|
||||
"""No wake-back message when send fails."""
|
||||
errors: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.error", lambda msg: errors.append(msg))
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
watchdog_calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
f"{MOD}._spawn_watchdog",
|
||||
lambda target: watchdog_calls.append(target),
|
||||
)
|
||||
|
||||
patches = _send_patches(
|
||||
{
|
||||
f"{_H_SEND}.send_to_single": MagicMock(return_value=(False, "error")),
|
||||
@@ -1210,4 +1044,5 @@ class TestDispatchSendWatchdogIntegration:
|
||||
|
||||
_orchestrate_dispatch_send(["@target", "Subject", "Body"])
|
||||
|
||||
assert watchdog_calls == []
|
||||
combined = " ".join(printed)
|
||||
assert "Wake-back enabled" not in combined
|
||||
|
||||
@@ -19,15 +19,18 @@ from unittest.mock import MagicMock
|
||||
|
||||
import aipass.ai_mail.apps.handlers.dispatch.dispatch_monitor as mod
|
||||
from aipass.ai_mail.apps.handlers.dispatch.dispatch_monitor import (
|
||||
MAX_WAKE_DEPTH,
|
||||
_check_jsonl_activity,
|
||||
_check_rate_limited,
|
||||
_get_jsonl_projects_dir,
|
||||
_is_sandbox_enabled,
|
||||
_kill_process,
|
||||
_log_wake_result,
|
||||
_make_fresh_cmd,
|
||||
_run_with_startup_check,
|
||||
_send_bounce,
|
||||
_snapshot_jsonl_sizes,
|
||||
_wake_sender,
|
||||
_wrap_for_sandbox,
|
||||
main,
|
||||
)
|
||||
@@ -52,6 +55,13 @@ def _suppress_logger(monkeypatch):
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _suppress_wake(monkeypatch):
|
||||
"""Prevent _wake_sender from importing/calling real wake_branch in unrelated tests."""
|
||||
monkeypatch.setattr(mod, "_wake_sender", MagicMock(return_value="skipped_sender"))
|
||||
monkeypatch.setattr(mod, "_log_wake_result", MagicMock())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stderr_log(tmp_path):
|
||||
"""Create a stderr log file and return its path string."""
|
||||
@@ -1824,3 +1834,328 @@ finally:
|
||||
finally:
|
||||
broker.stop()
|
||||
t.join(timeout=3)
|
||||
|
||||
|
||||
# === Wake-back tests (TDPLAN-0012) ==========================================
|
||||
|
||||
|
||||
class TestWakeSender:
|
||||
"""_wake_sender guards and wake-back dispatch."""
|
||||
|
||||
def test_skips_empty_sender(self, monkeypatch):
|
||||
"""Empty sender returns skipped_sender."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
result = _wake_sender("", "@target", 0, "/fake/lock")
|
||||
assert result == "skipped_sender"
|
||||
|
||||
def test_skips_whitespace_sender(self, monkeypatch):
|
||||
"""Whitespace-only sender returns skipped_sender."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
result = _wake_sender(" ", "@target", 0, "/fake/lock")
|
||||
assert result == "skipped_sender"
|
||||
|
||||
def test_skips_self_wake(self, monkeypatch):
|
||||
"""Sender equal to completed agent returns skipped_self."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
result = _wake_sender("@trigger", "@trigger", 0, "/fake/lock")
|
||||
assert result == "skipped_self"
|
||||
|
||||
def test_skips_self_wake_case_insensitive(self, monkeypatch):
|
||||
"""Self-wake guard is case-insensitive."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
result = _wake_sender("Trigger", "@TRIGGER", 0, "/fake/lock")
|
||||
assert result == "skipped_self"
|
||||
|
||||
def test_wake_back_carries_empty_sender(self, monkeypatch):
|
||||
"""Wake-back session carries empty sender to terminate the chain."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
|
||||
mock_status = MagicMock()
|
||||
mock_status.summary = "ok"
|
||||
mock_wake = MagicMock(return_value=(mock_status, True))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
|
||||
mock_wake,
|
||||
)
|
||||
_wake_sender("@prax", "@trigger", 0, "/fake/lock")
|
||||
mock_wake.assert_called_once_with("@prax", auto=True, sender="")
|
||||
|
||||
def test_any_citizen_reaches_wake_branch(self, monkeypatch):
|
||||
"""Any citizen sender reaches wake_branch."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
|
||||
mock_status = MagicMock()
|
||||
mock_status.summary = "ok"
|
||||
mock_wake = MagicMock(return_value=(mock_status, True))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
|
||||
mock_wake,
|
||||
)
|
||||
result = _wake_sender("@prax", "@target", 0, "/fake/lock")
|
||||
assert result == "success"
|
||||
mock_wake.assert_called_once()
|
||||
|
||||
def test_depth_cap_blocks(self, monkeypatch):
|
||||
"""AIPASS_WAKE_DEPTH >= MAX_WAKE_DEPTH returns blocked_depth."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.setenv("AIPASS_WAKE_DEPTH", str(MAX_WAKE_DEPTH))
|
||||
result = _wake_sender("@prax", "@target", 0, "/fake/lock")
|
||||
assert result == "blocked_depth"
|
||||
|
||||
def test_depth_cap_over_max_blocks(self, monkeypatch):
|
||||
"""Depth above max also blocks."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.setenv("AIPASS_WAKE_DEPTH", str(MAX_WAKE_DEPTH + 5))
|
||||
result = _wake_sender("@prax", "@target", 0, "/fake/lock")
|
||||
assert result == "blocked_depth"
|
||||
|
||||
def test_success_on_wake(self, monkeypatch):
|
||||
"""Successful wake_branch call returns success."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
|
||||
|
||||
mock_status = MagicMock()
|
||||
mock_status.summary = "ok"
|
||||
mock_wake = MagicMock(return_value=(mock_status, True))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
|
||||
mock_wake,
|
||||
)
|
||||
|
||||
result = _wake_sender("@trigger", "@target", 0, "/fake/lock")
|
||||
assert result == "success"
|
||||
mock_wake.assert_called_once_with("@trigger", auto=True, sender="")
|
||||
|
||||
def test_blocked_locked_on_lock_failure(self, monkeypatch):
|
||||
"""wake_branch failing with lock-related message returns blocked_locked."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
|
||||
|
||||
mock_status = MagicMock()
|
||||
mock_status.summary = "lock: Active agent (PID 1234)"
|
||||
mock_wake = MagicMock(return_value=(mock_status, False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
|
||||
mock_wake,
|
||||
)
|
||||
|
||||
result = _wake_sender("@prax", "@target", 0, "/fake/lock")
|
||||
assert result == "blocked_locked"
|
||||
|
||||
def test_blocked_occupied_on_interactive(self, monkeypatch):
|
||||
"""wake_branch failing with occupancy message returns blocked_occupied."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
|
||||
|
||||
mock_status = MagicMock()
|
||||
mock_status.summary = "blocked: Cannot spawn — interactive session running"
|
||||
mock_wake = MagicMock(return_value=(mock_status, False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
|
||||
mock_wake,
|
||||
)
|
||||
|
||||
result = _wake_sender("@trigger", "@target", 0, "/fake/lock")
|
||||
assert result == "blocked_occupied"
|
||||
|
||||
def test_failed_on_exception(self, monkeypatch):
|
||||
"""Exception during wake returns failed."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
|
||||
MagicMock(side_effect=RuntimeError("broken")),
|
||||
)
|
||||
|
||||
result = _wake_sender("@prax", "@target", 0, "/fake/lock")
|
||||
assert result == "failed"
|
||||
|
||||
def test_depth_incremented_before_wake(self, monkeypatch):
|
||||
"""AIPASS_WAKE_DEPTH is incremented before calling wake_branch."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.setenv("AIPASS_WAKE_DEPTH", "1")
|
||||
|
||||
captured_depth = []
|
||||
|
||||
def capture_wake(*args, **kwargs):
|
||||
captured_depth.append(os.environ.get("AIPASS_WAKE_DEPTH"))
|
||||
mock_status = MagicMock()
|
||||
mock_status.summary = "ok"
|
||||
return mock_status, True
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
|
||||
capture_wake,
|
||||
)
|
||||
|
||||
_wake_sender("@trigger", "@target", 0, "/fake/lock")
|
||||
assert captured_depth == ["2"]
|
||||
|
||||
def test_wake_called_on_failure_exit(self, monkeypatch):
|
||||
"""Wake fires on non-zero exit code too."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
|
||||
|
||||
mock_status = MagicMock()
|
||||
mock_status.summary = "ok"
|
||||
mock_wake = MagicMock(return_value=(mock_status, True))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
|
||||
mock_wake,
|
||||
)
|
||||
|
||||
result = _wake_sender("@prax", "@target", 1, "/fake/lock")
|
||||
assert result == "success"
|
||||
mock_wake.assert_called_once()
|
||||
|
||||
|
||||
class TestLogWakeResult:
|
||||
"""_log_wake_result writes to dispatch_wake.log."""
|
||||
|
||||
def test_creates_log_file(self, tmp_path):
|
||||
"""Log file created under target's logs/ directory."""
|
||||
lock = tmp_path / "branch" / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock.parent.mkdir(parents=True)
|
||||
lock.write_text("{}", encoding="utf-8")
|
||||
logs_dir = tmp_path / "branch" / "logs"
|
||||
|
||||
_log_wake_result("@target", "@sender", 0, "success", str(lock))
|
||||
|
||||
log_file = logs_dir / "dispatch_wake.log"
|
||||
assert log_file.exists()
|
||||
content = log_file.read_text(encoding="utf-8")
|
||||
assert "target=@target" in content
|
||||
assert "sender=@sender" in content
|
||||
assert "exit_code=0" in content
|
||||
assert "wake_result=success" in content
|
||||
|
||||
def test_appends_to_existing(self, tmp_path):
|
||||
"""Subsequent calls append, not overwrite."""
|
||||
lock = tmp_path / "branch" / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock.parent.mkdir(parents=True)
|
||||
lock.write_text("{}", encoding="utf-8")
|
||||
logs_dir = tmp_path / "branch" / "logs"
|
||||
logs_dir.mkdir(parents=True)
|
||||
log_file = logs_dir / "dispatch_wake.log"
|
||||
log_file.write_text("existing line\n", encoding="utf-8")
|
||||
|
||||
_log_wake_result("@target", "@sender", 0, "success", str(lock))
|
||||
|
||||
lines = log_file.read_text(encoding="utf-8").strip().split("\n")
|
||||
assert len(lines) == 2
|
||||
assert lines[0] == "existing line"
|
||||
assert "wake_result=success" in lines[1]
|
||||
|
||||
def test_all_result_values(self, tmp_path):
|
||||
"""All result enum values are logged correctly."""
|
||||
lock = tmp_path / "branch" / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock.parent.mkdir(parents=True)
|
||||
lock.write_text("{}", encoding="utf-8")
|
||||
|
||||
for result_tag in (
|
||||
"success",
|
||||
"blocked_occupied",
|
||||
"blocked_locked",
|
||||
"blocked_depth",
|
||||
"skipped_sender",
|
||||
"failed",
|
||||
):
|
||||
_log_wake_result("@t", "@s", 0, result_tag, str(lock))
|
||||
|
||||
log_file = tmp_path / "branch" / "logs" / "dispatch_wake.log"
|
||||
lines = log_file.read_text(encoding="utf-8").strip().split("\n")
|
||||
assert len(lines) == 6
|
||||
|
||||
|
||||
class TestWakeBackIntegration:
|
||||
"""Wake-back wired into main() — fires after lock cleanup on both paths."""
|
||||
|
||||
def test_wake_called_on_success(self, monkeypatch, main_argv):
|
||||
"""_wake_sender called with correct args after successful agent run."""
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
|
||||
wake_calls = []
|
||||
|
||||
def track_wake(sender, branch_email, exit_code, lf):
|
||||
wake_calls.append((sender, branch_email, exit_code))
|
||||
return "success"
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(mod, "_wake_sender", track_wake)
|
||||
monkeypatch.setattr(mod, "_log_wake_result", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
assert len(wake_calls) == 1
|
||||
assert wake_calls[0] == ("@sender", "@test_branch", 0)
|
||||
|
||||
def test_wake_called_on_failure(self, monkeypatch, main_argv):
|
||||
"""_wake_sender called after all attempts fail (in addition to bounce)."""
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
|
||||
wake_calls = []
|
||||
mock_bounce = MagicMock()
|
||||
|
||||
def track_wake(sender, branch_email, exit_code, lf):
|
||||
wake_calls.append((sender, branch_email, exit_code))
|
||||
return "success"
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(side_effect=[(1, False), (1, False), (1, False)]))
|
||||
monkeypatch.setattr(mod, "_send_bounce", mock_bounce)
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(mod, "_wake_sender", track_wake)
|
||||
monkeypatch.setattr(mod, "_log_wake_result", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"time",
|
||||
MagicMock(time=time.time, strftime=time.strftime, sleep=MagicMock()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
mock_bounce.assert_called_once()
|
||||
assert len(wake_calls) == 1
|
||||
assert wake_calls[0][2] != 0
|
||||
|
||||
def test_log_wake_result_called(self, monkeypatch, main_argv):
|
||||
"""_log_wake_result called with wake result after main completes."""
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
|
||||
log_calls = []
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(mod, "_wake_sender", MagicMock(return_value="success"))
|
||||
monkeypatch.setattr(mod, "_log_wake_result", lambda *a: log_calls.append(a))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
assert len(log_calls) == 1
|
||||
branch_email, sender, exit_code, result, lf = log_calls[0]
|
||||
assert branch_email == "@test_branch"
|
||||
assert sender == "@sender"
|
||||
assert exit_code == 0
|
||||
assert result == "success"
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
# Modified: 2026-04-03
|
||||
# =============================================
|
||||
|
||||
"""Tests for paths module -- repo root discovery."""
|
||||
"""Tests for paths module -- repo root discovery and project root resolution."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import aipass.ai_mail.apps.handlers.paths as mod
|
||||
from aipass.ai_mail.apps.handlers.paths import find_project_root
|
||||
|
||||
|
||||
# --- Fixtures --------------------------------------------------------
|
||||
@@ -84,3 +85,53 @@ def test_find_repo_root_finds_registry_in_same_dir(tmp_path, monkeypatch):
|
||||
|
||||
result = mod.find_repo_root()
|
||||
assert result == tmp_path
|
||||
|
||||
|
||||
# --- find_project_root tests --------------------------------------------
|
||||
|
||||
|
||||
def test_find_project_root_finds_registry(tmp_path):
|
||||
"""Returns directory containing *_REGISTRY.json."""
|
||||
project = tmp_path / "projects" / "myproj"
|
||||
deep = project / "src" / "pkg"
|
||||
deep.mkdir(parents=True)
|
||||
(project / "MYPROJ_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
assert find_project_root(deep) == project
|
||||
|
||||
|
||||
def test_find_project_root_finds_host_registry(tmp_path):
|
||||
"""Returns host repo root when AIPASS_REGISTRY.json is the first hit."""
|
||||
host = tmp_path / "repo"
|
||||
branch = host / "src" / "aipass" / "branch"
|
||||
branch.mkdir(parents=True)
|
||||
(host / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
assert find_project_root(branch) == host
|
||||
|
||||
|
||||
def test_find_project_root_stops_at_first_registry(tmp_path):
|
||||
"""Nested project registry is found before the host registry."""
|
||||
host = tmp_path / "repo"
|
||||
project = host / "projects" / "inner"
|
||||
deep = project / "src"
|
||||
deep.mkdir(parents=True)
|
||||
(host / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
(project / "INNER_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
assert find_project_root(deep) == project
|
||||
|
||||
|
||||
def test_find_project_root_none_when_no_registry(tmp_path):
|
||||
"""Returns None when no *_REGISTRY.json is found anywhere."""
|
||||
deep = tmp_path / "a" / "b" / "c"
|
||||
deep.mkdir(parents=True)
|
||||
|
||||
assert find_project_root(deep) is None
|
||||
|
||||
|
||||
def test_find_project_root_at_start_dir(tmp_path):
|
||||
"""Returns start dir itself when it contains the registry."""
|
||||
(tmp_path / "PROJ_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
assert find_project_root(tmp_path) == tmp_path
|
||||
|
||||
@@ -20,12 +20,16 @@ from aipass.ai_mail.apps.handlers.dispatch.wake import (
|
||||
_read_json,
|
||||
_check_lock,
|
||||
_check_pid_alive,
|
||||
_get_pid_cwd,
|
||||
_get_pid_cwd_darwin,
|
||||
_read_session_type,
|
||||
_read_session_type_darwin,
|
||||
_is_zombie_linux,
|
||||
_clean_zombies,
|
||||
_find_claude_bin,
|
||||
resolve_branch,
|
||||
DispatchStatus,
|
||||
MODEL_MAP,
|
||||
KNOWN_MODEL_ALIASES,
|
||||
DEFAULT_MODEL,
|
||||
_acquire_lock,
|
||||
_load_config,
|
||||
@@ -138,6 +142,7 @@ def test_check_pid_alive_dead(monkeypatch):
|
||||
|
||||
def test_check_pid_alive_permission_error(monkeypatch):
|
||||
"""PermissionError means process exists but cannot signal -- returns True."""
|
||||
monkeypatch.setattr("sys.platform", "linux")
|
||||
monkeypatch.setattr(os, "kill", _raise_permission)
|
||||
assert _check_pid_alive(1) is True
|
||||
|
||||
@@ -201,11 +206,126 @@ def test_read_session_type_not_set(monkeypatch, tmp_path):
|
||||
|
||||
|
||||
def test_read_session_type_non_linux(monkeypatch):
|
||||
"""Non-linux platform returns 'interactive' immediately."""
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
"""Non-linux, non-darwin platform returns 'interactive' immediately."""
|
||||
monkeypatch.setattr("sys.platform", "win32")
|
||||
assert _read_session_type("999") == "interactive"
|
||||
|
||||
|
||||
def test_read_session_type_darwin_found(monkeypatch):
|
||||
"""macOS: reads AIPASS_SESSION_TYPE from ps -wwE output."""
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
|
||||
class FakeResult:
|
||||
returncode = 0
|
||||
stdout = "/usr/bin/claude AIPASS_SESSION_TYPE=dispatched HOME=/Users/u"
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: FakeResult())
|
||||
assert _read_session_type("123") == "dispatched"
|
||||
|
||||
|
||||
def test_read_session_type_darwin_not_set(monkeypatch):
|
||||
"""macOS: missing env var returns 'interactive'."""
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
|
||||
class FakeResult:
|
||||
returncode = 0
|
||||
stdout = "/usr/bin/claude HOME=/Users/u"
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: FakeResult())
|
||||
assert _read_session_type("456") == "interactive"
|
||||
|
||||
|
||||
def test_read_session_type_darwin_ps_failure(monkeypatch):
|
||||
"""macOS: ps failure returns 'interactive'."""
|
||||
assert _read_session_type_darwin("999") == "interactive"
|
||||
|
||||
|
||||
# --- _get_pid_cwd tests ------------------------------------------------
|
||||
|
||||
|
||||
def test_get_pid_cwd_linux(monkeypatch, tmp_path):
|
||||
"""Linux: reads /proc/{pid}/cwd via readlink."""
|
||||
monkeypatch.setattr("sys.platform", "linux")
|
||||
target = str(tmp_path / "project")
|
||||
monkeypatch.setattr(os, "readlink", lambda p: target)
|
||||
assert _get_pid_cwd("100") == target
|
||||
|
||||
|
||||
def test_get_pid_cwd_linux_oserror(monkeypatch):
|
||||
"""Linux: OSError returns None."""
|
||||
monkeypatch.setattr("sys.platform", "linux")
|
||||
monkeypatch.setattr(os, "readlink", lambda p: (_ for _ in ()).throw(OSError("no proc")))
|
||||
assert _get_pid_cwd("100") is None
|
||||
|
||||
|
||||
def test_get_pid_cwd_darwin(monkeypatch, tmp_path):
|
||||
"""macOS: reads cwd via lsof."""
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
target = "/tmp/pytest-project"
|
||||
|
||||
class FakeResult:
|
||||
returncode = 0
|
||||
stdout = f"p100\nn{target}\n"
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: FakeResult())
|
||||
assert _get_pid_cwd("100") == target
|
||||
|
||||
|
||||
def test_get_pid_cwd_darwin_failure(monkeypatch):
|
||||
"""macOS: lsof failure returns None."""
|
||||
|
||||
class FailedResult:
|
||||
returncode = 1
|
||||
stdout = ""
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: FailedResult())
|
||||
assert _get_pid_cwd_darwin("999") is None
|
||||
|
||||
|
||||
def test_get_pid_cwd_unsupported_platform(monkeypatch):
|
||||
"""Unsupported platform returns None."""
|
||||
monkeypatch.setattr("sys.platform", "win32")
|
||||
assert _get_pid_cwd("100") is None
|
||||
|
||||
|
||||
# --- _is_zombie_linux tests --------------------------------------------
|
||||
|
||||
|
||||
def test_is_zombie_linux_not_zombie(monkeypatch, tmp_path):
|
||||
"""Non-zombie process returns False."""
|
||||
status_file = tmp_path / "status"
|
||||
status_file.write_text("Name:\tclaude\nState:\tS (sleeping)\nPid:\t42\n")
|
||||
monkeypatch.setattr(
|
||||
"builtins.open",
|
||||
_fake_open_factory(str(status_file), {"/proc/42/status": str(status_file)}),
|
||||
)
|
||||
assert _is_zombie_linux(42) is False
|
||||
|
||||
|
||||
def test_is_zombie_linux_zombie(monkeypatch, tmp_path):
|
||||
"""Zombie process returns True."""
|
||||
status_file = tmp_path / "status"
|
||||
status_file.write_text("Name:\tclaude\nState:\tZ (zombie)\nPid:\t42\n")
|
||||
monkeypatch.setattr(
|
||||
"builtins.open",
|
||||
_fake_open_factory(str(status_file), {"/proc/42/status": str(status_file)}),
|
||||
)
|
||||
assert _is_zombie_linux(42) is True
|
||||
|
||||
|
||||
def test_is_zombie_linux_no_proc(monkeypatch):
|
||||
"""Missing /proc entry returns False (not zombie, just gone)."""
|
||||
_real_open = open
|
||||
|
||||
def _fake_open(path, *a, **kw):
|
||||
if "/proc/99999/" in str(path):
|
||||
raise FileNotFoundError(path)
|
||||
return _real_open(path, *a, **kw)
|
||||
|
||||
monkeypatch.setattr("builtins.open", _fake_open)
|
||||
assert _is_zombie_linux(99999) is False
|
||||
|
||||
|
||||
# --- _check_lock tests ------------------------------------------------
|
||||
|
||||
|
||||
@@ -222,7 +342,7 @@ def test_check_lock_alive_pid(tmp_path, monkeypatch):
|
||||
lock_file = lock_dir / ".dispatch.lock"
|
||||
lock_data = {"pid": 1234, "timestamp": "2026-03-29T10:00:00"}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
monkeypatch.setattr(os, "kill", lambda pid, sig: None)
|
||||
monkeypatch.setattr(wake_mod, "_check_pid_alive", lambda pid: True)
|
||||
result = _check_lock(tmp_path)
|
||||
assert result is not None
|
||||
assert result["pid"] == 1234
|
||||
@@ -235,7 +355,7 @@ def test_check_lock_dead_pid_removes_lock(tmp_path, monkeypatch):
|
||||
lock_file = lock_dir / ".dispatch.lock"
|
||||
lock_data = {"pid": 99999, "timestamp": "2026-03-29T10:00:00"}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
monkeypatch.setattr(os, "kill", _raise_process_lookup)
|
||||
monkeypatch.setattr(wake_mod, "_check_pid_alive", lambda pid: False)
|
||||
result = _check_lock(tmp_path)
|
||||
assert result is None
|
||||
assert not lock_file.exists()
|
||||
@@ -257,6 +377,7 @@ def test_check_lock_stale_old_timestamp(tmp_path, monkeypatch):
|
||||
|
||||
def test_check_lock_permission_error_treated_active(tmp_path, monkeypatch):
|
||||
"""Lock PID that raises PermissionError is treated as active."""
|
||||
monkeypatch.setattr("sys.platform", "linux")
|
||||
lock_dir = tmp_path / ".ai_mail.local"
|
||||
lock_dir.mkdir(parents=True)
|
||||
lock_file = lock_dir / ".dispatch.lock"
|
||||
@@ -451,24 +572,22 @@ def _fake_open_factory(real_status_path, mapping):
|
||||
# --- Model flag tests ---------------------------------------------------
|
||||
|
||||
|
||||
def test_model_map_has_expected_entries():
|
||||
"""MODEL_MAP should contain sonnet, opus, haiku shorthand mappings."""
|
||||
assert "sonnet" in MODEL_MAP
|
||||
assert "opus" in MODEL_MAP
|
||||
assert "haiku" in MODEL_MAP
|
||||
assert "claude-sonnet-4-6" in MODEL_MAP["sonnet"]
|
||||
assert "claude-opus-4-6" in MODEL_MAP["opus"]
|
||||
def test_known_model_aliases_has_expected_entries():
|
||||
"""KNOWN_MODEL_ALIASES should contain sonnet, opus, haiku."""
|
||||
assert "sonnet" in KNOWN_MODEL_ALIASES
|
||||
assert "opus" in KNOWN_MODEL_ALIASES
|
||||
assert "haiku" in KNOWN_MODEL_ALIASES
|
||||
|
||||
|
||||
def test_default_model_is_opus():
|
||||
"""Default model should be opus."""
|
||||
assert DEFAULT_MODEL == "opus"
|
||||
def test_default_model_is_sonnet():
|
||||
"""Default model should be sonnet."""
|
||||
assert DEFAULT_MODEL == "sonnet"
|
||||
|
||||
|
||||
def test_model_map_values_are_full_ids():
|
||||
"""All MODEL_MAP values should be full claude model IDs."""
|
||||
for key, value in MODEL_MAP.items():
|
||||
assert value.startswith("claude-"), f"{key} -> {value} doesn't start with 'claude-'"
|
||||
def test_known_model_aliases_are_bare_names():
|
||||
"""All KNOWN_MODEL_ALIASES should be bare alias names (no 'claude-' prefix)."""
|
||||
for alias in KNOWN_MODEL_ALIASES:
|
||||
assert not alias.startswith("claude-"), f"{alias} should be a bare alias"
|
||||
|
||||
|
||||
# --- _find_claude_bin tests ------------------------------------------
|
||||
@@ -864,6 +983,57 @@ class TestWakeBranch:
|
||||
assert ok is False
|
||||
assert any(s[0] == "fail" and "resolve" in s[1] for s in status.steps)
|
||||
|
||||
# --- manager check ---
|
||||
|
||||
def test_manager_target_skips_wake(self, tmp_path, monkeypatch):
|
||||
"""Target with citizen_class=manager returns True (mail only, no wake)."""
|
||||
branch_path = _make_wake_fixtures(tmp_path, monkeypatch)
|
||||
trinity = branch_path / ".trinity"
|
||||
trinity.mkdir(parents=True, exist_ok=True)
|
||||
(trinity / "passport.json").write_text(
|
||||
json.dumps({"identity": {"citizen_class": "manager"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
status, ok = wake_branch("@testbranch")
|
||||
assert ok is True
|
||||
assert any(s[1] == "manager" for s in status.steps)
|
||||
|
||||
def test_non_manager_target_continues(self, tmp_path, monkeypatch):
|
||||
"""Target with non-manager citizen_class proceeds to spawn."""
|
||||
branch_path = _make_wake_fixtures(tmp_path, monkeypatch)
|
||||
trinity = branch_path / ".trinity"
|
||||
trinity.mkdir(parents=True, exist_ok=True)
|
||||
(trinity / "passport.json").write_text(
|
||||
json.dumps({"identity": {"citizen_class": "aipass_framework"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_patch_wake_deps(monkeypatch, _clean_zombies=lambda: 0)
|
||||
monkeypatch.setattr("subprocess.Popen", lambda *a, **kw: _FakeProc())
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.notify.send_notification",
|
||||
lambda *a, **kw: None,
|
||||
raising=False,
|
||||
)
|
||||
status, ok = wake_branch("@testbranch")
|
||||
assert ok is True
|
||||
assert not any(s[1] == "manager" for s in status.steps)
|
||||
|
||||
def test_missing_passport_continues(self, tmp_path, monkeypatch):
|
||||
"""No passport.json — wake proceeds normally."""
|
||||
_make_wake_fixtures(tmp_path, monkeypatch)
|
||||
_patch_wake_deps(monkeypatch, _clean_zombies=lambda: 0)
|
||||
monkeypatch.setattr("subprocess.Popen", lambda *a, **kw: _FakeProc())
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.notify.send_notification",
|
||||
lambda *a, **kw: None,
|
||||
raising=False,
|
||||
)
|
||||
status, ok = wake_branch("@testbranch")
|
||||
assert ok is True
|
||||
assert not any(s[1] == "manager" for s in status.steps)
|
||||
|
||||
# --- zombie check ---
|
||||
|
||||
def test_zombie_check_warns_but_continues(self, tmp_path, monkeypatch):
|
||||
"""Zombie detected adds warning but dispatch continues."""
|
||||
_make_wake_fixtures(tmp_path, monkeypatch)
|
||||
|
||||
@@ -18,7 +18,7 @@ Not suggestions. Violating = bug.
|
||||
|
||||
## What I Do
|
||||
|
||||
- Guide new users through `aipass init` (12 stages: welcome, system detect, doctor, profile, style questions, tool choice, docker offer, first agent, ping sweep, smoke test, handoff, done)
|
||||
- Guide new users through `aipass init` (10 stages: welcome, system detect, profile, style questions, tool choice, first agent, ping sweep, smoke test, handoff, done)
|
||||
- Answer "how does X work?" via `aipass help` — live README reads, offer depth, route branch experts
|
||||
- Run `aipass doctor` — aggregate seedgo, pytest, registry, hooks, git state, AIPASS_HOME
|
||||
- Remember user — name, OS, preferred CLI, setup progress `.trinity/local.json`
|
||||
@@ -30,7 +30,7 @@ Not suggestions. Violating = bug.
|
||||
aipass # Help banner with all commands
|
||||
aipass help [q] # Chatbot Q&A over branch READMEs
|
||||
aipass doctor # System health aggregation
|
||||
aipass init # 12-stage guided setup for new users, resumable
|
||||
aipass init # 10-stage guided setup for new users, resumable
|
||||
aipass profile # Show/edit what I know about the user
|
||||
aipass --version
|
||||
```
|
||||
@@ -53,7 +53,7 @@ apps/
|
||||
├── modules/
|
||||
│ ├── doctor.py # System health aggregation
|
||||
│ ├── help_chat.py # README-backed Q&A
|
||||
│ ├── init_flow.py # 12-stage guided setup, resumable
|
||||
│ ├── init_flow.py # 10-stage guided setup, resumable
|
||||
│ ├── handoff.py # CLI handoff (tmux / wt.exe)
|
||||
│ └── profile.py # User profile read/write
|
||||
└── handlers/
|
||||
@@ -77,8 +77,33 @@ apps/
|
||||
- **Never pretend.** Don't know → say so, offer find out or ask branch expert.
|
||||
- **Clean handoffs.** Every init stage saves `setup_progress` `.trinity/local.json` — resume works.
|
||||
|
||||
## Welcome Mode — Fresh Install
|
||||
|
||||
Trigger: first message mentions "Fresh AIPass install" or you detect a fresh install context.
|
||||
|
||||
**Opening — three jobs in one tight block:**
|
||||
1. Say who you are and what you know: "I'm the AIPass concierge — I know this framework, every agent in it, and I'll remember what we set up."
|
||||
2. Show 3-5 concrete starters with exact commands:
|
||||
- `drone systems` — see every agent in the ecosystem
|
||||
- `drone @prax monitor run` — watch the system work live (leave this running in another terminal)
|
||||
- `aipass doctor` — check what's healthy and what needs wiring
|
||||
- `aipass help "how does memory work?"` — ask me anything about the framework
|
||||
- `drone @hooks hooksound` — toggle sound notifications (hear hooks firing as you work, or mute if distracting)
|
||||
3. Ask their name ONCE: "What should I call you? I'll remember it — next time you open this, I'll know who you are. Skip if you'd rather not." Accept skip gracefully. Never re-ask.
|
||||
|
||||
**Deferred triage (~turn 5):** After rapport is built, suggest completing setup. Frame it as "every machine is different — let's see what yours needs" rather than dumping a checklist.
|
||||
|
||||
**Hooks-first verification:** The first real setup task. Dispatch @hooks to investigate and report: `drone @ai_mail dispatch @hooks "Hooks health check" "Check if hooks are wired correctly for this installation. Include trust-registry enrollment status. Report what's green and what needs wiring."` Then check your inbox conversationally: `drone @ai_mail inbox`
|
||||
|
||||
**Setup DPLAN:** When the user is ready for the full setup pass, create a setup plan seeded from the cross-OS checklist: `drone @flow create . "Machine setup — post-install verification"` and reference `aipass doctor --cross-os` for the machine-specific gaps.
|
||||
|
||||
**Windows detected:** If system detection shows Windows (not WSL), recommend WSL: "AIPass works best on Linux/macOS or WSL. Want me to walk you through setting up WSL?" Offer a playbook.
|
||||
|
||||
**Feedback pulse — mention once:** "How's the experience so far? Your feedback is hugely appreciated — this is an open-source project and fresh-machine experience is the data we can't get any other way. https://github.com/AIOSAI/AIPass/issues — or turn reminders off anytime: `aipass feedback off`"
|
||||
|
||||
**Every suggestion ships its exact command.** Never say "you can check the agents" — say "run `drone systems` to see every agent."
|
||||
|
||||
## Known Gotchas
|
||||
|
||||
- **Status: under construction.** Whole branch gitignored. Do not PR anything this directory until Phase 8 reveal (DPLAN-0136).
|
||||
- **`aipass` binary currently `cli` branch's `aipass init`** — project bootstrap, not citizen creation. Eventually this CLI entry moves here. Until then, use `drone @spawn create` citizen creation.
|
||||
- **`aipass` binary is THIS branch's CLI** — installed on PATH, ships publicly (post-FPLAN-0333). init/install/new/doctor/help/profile/trust/feedback all route here. Citizen creation inside the host framework is still `drone @spawn create`.
|
||||
- **Test-convention tokens need buy-in.** Core agents don't yet recognize `[AIPASS-TEST — ...]`. Coordinating @ai_mail before pinging anyone.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"metadata": {
|
||||
"version": "2.0.0",
|
||||
"created": "2026-04-16",
|
||||
"description": "Bypass config for @aipass citizen. Modules operational: concierge/init/doctor/handoff/profile built and tested. Bypasses cover: binary-invocation introspection pattern (bare aipass <cmd> runs, --info for introspection), thin entry-point router (aipass.py uses bare print, no CLI imports), pure-python bootstrap (bootstrap.py/scaffold_content.py run before AIPass services exist), test-isolation patterns (architecture/encapsulation for tests/ directory), and CLI flag name references (permission_flags in test assertions and handoff platform).",
|
||||
"last_updated": "2026-06-02"
|
||||
"description": "Bypass config for @aipass citizen. Modules operational: concierge/init/doctor/handoff/profile/install built and tested. Bypasses cover: binary-invocation introspection pattern (bare aipass <cmd> runs, --info for introspection), thin entry-point router (aipass.py uses bare print, no CLI imports), pure-python bootstrap (bootstrap.py/scaffold_content.py run before AIPass services exist), test-isolation patterns (architecture/encapsulation for tests/ directory), and CLI flag name references (permission_flags in test assertions and handoff platform).",
|
||||
"last_updated": "2026-07-05"
|
||||
},
|
||||
"bypass": [
|
||||
{
|
||||
@@ -31,20 +31,10 @@
|
||||
"standard": "cli",
|
||||
"reason": "Session info must print immediately after tmux spawn — returning data to module layer would lose the timing context. User needs attach/kill instructions right when the session starts."
|
||||
},
|
||||
{
|
||||
"file": "apps/aipass.py",
|
||||
"standard": "cli",
|
||||
"reason": "Thin command router — discovers and routes to modules, which own the CLI service layer. Adding console/header imports here couples the bootstrap entry point to Rich for 4 status lines."
|
||||
},
|
||||
{
|
||||
"file": "apps/aipass.py",
|
||||
"standard": "debug_print",
|
||||
"reason": "Thin command router uses bare print() for version output and help banner (4 calls). These run before module discovery — importing Rich console for bootstrap output adds startup overhead for minimal benefit."
|
||||
},
|
||||
{
|
||||
"file": "apps/aipass.py",
|
||||
"standard": "introspection",
|
||||
"reason": "Thin command router, not a module — it has no domain to introspect. Modules handle their own introspection via --info. No print_introspection() needed."
|
||||
"reason": "Entry point router — introspection is in print_introspection() called from main() on no-args/--help. Not a module with handle_command()."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/doctor.py",
|
||||
@@ -91,6 +81,21 @@
|
||||
"standard": "permission_flags",
|
||||
"reason": "Assertions verify that the CLI flag name appears/absent in handoff_command output. String is in assertion context only — not a permission bypass in this file."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_install.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_install.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Unit tests must import the install module directly to test home resolution, clone, and setup orchestration in isolation. Entry-point imports would defeat the purpose of unit testing."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_launcher.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_ping_sweep.py",
|
||||
"standard": "architecture",
|
||||
@@ -242,14 +247,34 @@
|
||||
"reason": "aipass is binary-invoked: aipass doctor runs the command; introspection via --info"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/doctor_fix.py",
|
||||
"file": "apps/modules/_doctor_fix.py",
|
||||
"standard": "introspection",
|
||||
"reason": "aipass is binary-invoked: bare invocation shows usage; introspection via --info"
|
||||
"reason": "Private helper module for doctor.py — not directly invokable, no introspection needed."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/doctor_wire.py",
|
||||
"file": "apps/modules/_doctor_wire.py",
|
||||
"standard": "introspection",
|
||||
"reason": "aipass is binary-invoked: bare invocation shows usage; introspection via --info"
|
||||
"reason": "Private helper module for doctor.py — not directly invokable, no introspection needed."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/_doctor_fix.py",
|
||||
"standard": "naming",
|
||||
"reason": "Leading underscore is intentional — hides from module discovery to pass cli_ux no_internal_modules check."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/_doctor_wire.py",
|
||||
"standard": "naming",
|
||||
"reason": "Leading underscore is intentional — hides from module discovery to pass cli_ux no_internal_modules check."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/_doctor_fix.py",
|
||||
"standard": "meta",
|
||||
"reason": "Private helper module for doctor.py — meta name matches actual filename _doctor_fix.py."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/_doctor_wire.py",
|
||||
"standard": "meta",
|
||||
"reason": "Private helper module for doctor.py — meta name matches actual filename _doctor_wire.py."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/handoff.py",
|
||||
@@ -271,11 +296,36 @@
|
||||
"standard": "introspection",
|
||||
"reason": "aipass is binary-invoked: aipass profile runs the command; introspection via --info"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/install.py",
|
||||
"standard": "introspection",
|
||||
"reason": "aipass is binary-invoked: bare 'aipass install' runs the bootstrap; introspection via --info (same pattern as doctor/init_flow/profile)."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/install.py",
|
||||
"standard": "modules",
|
||||
"reason": "Thin bootstrap orchestrator — preps the target/project dir (mkdir) immediately before shelling out to git clone / setup.sh / aipass init. Pre-subprocess dir prep, not business file ops; a handler adds indirection for 2 mkdir calls in a linear flow (same pattern as init_flow/doctor)."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/json/json_handler.py",
|
||||
"standard": "test_quality",
|
||||
"reason": "save_json now raises ValueError on invalid structure (aipass.aipass.shared contract, TDPLAN-0006 P2). Tested via pytest.raises — no False return path to test."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_cross_os.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_cross_os.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Unit tests must import the cross_os handler directly to test the parser/filter in isolation. Entry-point imports would defeat the purpose of unit testing."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_cross_os.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods use descriptive names (test_filter_win32_gets_win_and_all, test_missing_doc_raises) that are self-documenting. Adding docstrings to every test function adds noise without value."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_sandbox_check.py",
|
||||
"standard": "architecture",
|
||||
@@ -335,6 +385,41 @@
|
||||
"file": "shared/json_ops.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "backup_json() is consumed by @spawn (cross-branch caller). Appears unused in @aipass-only scan but is a shared API."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/trust.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Imports frozen trust_registry interface (enroll/revoke/is_trusted/read_registry) from @hooks by DPLAN-0244 design. Cross-branch import required — the registry module lives in hooks, consumers live in aipass."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/trust.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "No JSON file operations — trust.py is a thin CLI wrapper that delegates all JSON I/O to the trust_registry module in @hooks. No json_handler needed."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/trust.py",
|
||||
"standard": "introspection",
|
||||
"reason": "aipass is binary-invoked: bare 'aipass trust' shows registry table; introspection via --info"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/init/bootstrap.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Imports enroll() from @hooks trust_registry (DPLAN-0244 frozen interface). Cross-branch import required — init must enroll projects in the trust registry after writing hooks.json."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/init/bootstrap.py",
|
||||
"standard": "handlers",
|
||||
"reason": "Imports enroll() from @hooks trust_registry by DPLAN-0244 design. Cross-handler import required — bootstrap auto-enrolls projects after hooks.json creation/merge."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_trust.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_trust.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Tests import trust_registry directly to verify enrollment/revocation in isolation with monkeypatched REGISTRY_PATH."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+46
-13
@@ -1,11 +1,23 @@
|
||||
# AIPASS
|
||||
|
||||
Concierge and librarian for AIPass. Greets new users, walks them through setup, answers how-things-work questions, hands off to their chosen CLI.
|
||||
The friendly front door for AIPass. Walks new users through setup, runs system diagnostics, answers documentation questions, and creates projects inside the AIPass environment.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
aipass # Show available commands
|
||||
aipass doctor # Check system health
|
||||
aipass help what does drone do # Search branch documentation
|
||||
aipass new myapp --template python # Create a new project
|
||||
aipass adopt myapp --dry-run # Preview adopting an existing projects/ dir
|
||||
aipass init # Guided setup (10 stages, resumable)
|
||||
```
|
||||
|
||||
## Invoke
|
||||
|
||||
```
|
||||
drone @aipass <command>
|
||||
aipass <command> [options]
|
||||
aipass <command> --help
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -15,25 +27,33 @@ aipass/
|
||||
├── apps/
|
||||
│ ├── aipass.py # Entry point — subcommand dispatch
|
||||
│ ├── modules/
|
||||
│ │ ├── doctor.py # System health aggregation
|
||||
│ │ ├── doctor_fix.py # Remediation report (--fix, --json)
|
||||
│ │ ├── doctor_wire.py # Auto-wire provider settings + stale-deny re-export
|
||||
│ │ ├── doctor.py # System health aggregation + cross-OS pre-flight (--cross-os)
|
||||
│ │ ├── _doctor_fix.py # Remediation report (--fix, --json) [internal]
|
||||
│ │ ├── _doctor_wire.py # Auto-wire provider settings + stale-deny re-export [internal]
|
||||
│ │ ├── handoff.py # CLI handoff (placeholder)
|
||||
│ │ ├── help_chat.py # README-backed Q&A (reads via readme_map handler)
|
||||
│ │ ├── init_flow.py # 12-stage guided setup
|
||||
│ │ └── profile.py # User profile read/write
|
||||
│ │ ├── init_flow.py # 10-stage guided setup
|
||||
│ │ ├── install.py # aipass install — one-command bootstrap (clone + setup + init)
|
||||
│ │ ├── new_project.py # aipass new — create projects inside the installation
|
||||
│ │ ├── adopt.py # aipass adopt — bring an existing projects/ dir into AIPass
|
||||
│ │ ├── profile.py # User profile read/write
|
||||
│ │ ├── trust.py # Trust registry — aipass trust / aipass revoke
|
||||
│ │ └── feedback.py # Feedback pulse toggle — aipass feedback on/off
|
||||
│ ├── handlers/
|
||||
│ │ ├── cross_os/ # Cross-OS pre-flight: gap_registry, preflight, run_record
|
||||
│ │ ├── handoff_platform/ # Platform-specific handoff detection
|
||||
│ │ ├── init/ # bootstrap.py, scaffold_content.py
|
||||
│ │ ├── new_project/ # Project creation logic (registry, template, scaffold, git init)
|
||||
│ │ │ └── adopt.py # Project adoption logic (additive scaffold onto an existing dir)
|
||||
│ │ ├── json/ # JSON read/write utilities
|
||||
│ │ ├── ping_sweep/ # Branch reachability verification
|
||||
│ │ ├── provider_reconcile.py # Stale deny-rule detection + fix
|
||||
│ │ ├── provider_reconcile.py # Stale deny-rule detection + fix
|
||||
│ │ ├── readme_map/ # Live file reads + branch routing
|
||||
│ │ ├── structure_scan/ # Agent placement + pollution detection
|
||||
│ │ ├── system_detect/ # OS, shell, Python, RAM, CPU
|
||||
│ │ └── ui/ # Progress bars, menus, banners
|
||||
│ └── plugins/
|
||||
├── tests/ # 432 passing
|
||||
├── tests/ # 785 passing
|
||||
├── requirements.project.txt # Project-specific Python dependencies
|
||||
├── .trinity/ # Identity + session history + observations
|
||||
└── README.md
|
||||
@@ -43,13 +63,26 @@ aipass/
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `aipass` | Help banner |
|
||||
| `aipass` | Show available commands |
|
||||
| `aipass help [Q]` | README-backed Q&A with branch routing |
|
||||
| `aipass doctor` | System health — structure, registry, hooks, pytest |
|
||||
| `aipass doctor --fix` | Remediation report with `drone @spawn repair` commands |
|
||||
| `aipass doctor --json` | JSON output for structure scan results |
|
||||
| `aipass init` | 12-stage guided setup (resumable) |
|
||||
| `aipass doctor --cross-os` | Cross-OS pre-flight — OS-gap cross-ref + routing/versions/hookstatus |
|
||||
| `aipass doctor --cross-os --e2e` | ...also runs the real e2e wiring suite (heavy, opt-in) |
|
||||
| `aipass doctor --cross-os --record [PATH]` | Write a machine-filled Run Record for the human acceptance pass |
|
||||
| `aipass init` | 10-stage guided setup (resumable) |
|
||||
| `aipass install` | One-command bootstrap — clone + setup.sh + hooks, then hand off to init |
|
||||
| `aipass profile` | Show/edit user profile |
|
||||
| `aipass new <name>` | Create a project in projects/ — own git repo, AIPass scaffold, resident agent |
|
||||
| `aipass new <name> --template python` | Create with Python template (pyproject + src/) |
|
||||
| `aipass new <name> --no-agent` | Create without resident agent |
|
||||
| `aipass adopt <name>` | Turn an existing `projects/<name>` directory into a full project — additive scaffold only |
|
||||
| `aipass adopt <name> --no-agent` | Adopt without a resident agent |
|
||||
| `aipass adopt <name> --dry-run` | Preview what adoption would do, writes nothing |
|
||||
| `aipass trust [path]` | Show enrolled projects or enroll a project in the trust registry |
|
||||
| `aipass revoke <path>` | Remove a project from the trust registry |
|
||||
| `aipass feedback on/off` | Toggle the feedback reminder pulse (delegates to @hooks) |
|
||||
| `aipass --version` | Version |
|
||||
|
||||
## Integration Points
|
||||
@@ -70,7 +103,7 @@ Humans only. Nothing in AIPass depends on this branch.
|
||||
|
||||
## Tests
|
||||
|
||||
432 passing — `pytest src/aipass/aipass/tests/`
|
||||
723 passing — `pytest src/aipass/aipass/tests/`
|
||||
|
||||
## Known Issues
|
||||
|
||||
@@ -78,4 +111,4 @@ Humans only. Nothing in AIPass depends on this branch.
|
||||
|
||||
## Last Updated
|
||||
|
||||
Last Updated: 2026-06-05
|
||||
Last Updated: 2026-07-17
|
||||
|
||||
@@ -18,6 +18,7 @@ Auto-discovery architecture:
|
||||
import os
|
||||
import sys
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
from pathlib import Path
|
||||
from typing import List, Any
|
||||
|
||||
@@ -34,8 +35,25 @@ if sys.platform == "win32":
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
from aipass.cli.apps.modules import console, error
|
||||
from aipass.prax import logger
|
||||
|
||||
# =============================================================================
|
||||
# COMMANDS — public-facing labels and descriptions
|
||||
# =============================================================================
|
||||
|
||||
_PUBLIC_COMMANDS = {
|
||||
"adopt": "Turn an existing projects/ directory into a full project",
|
||||
"doctor": "System health — structure, registry, hooks, tests",
|
||||
"help": "README-backed Q&A — ask about any branch",
|
||||
"init": "Guided setup for new users (10 stages, resumable)",
|
||||
"install": "One-command bootstrap — clone + setup + init",
|
||||
"new": "Create a project inside AIPass",
|
||||
"profile": "Show/edit user profile",
|
||||
"trust": "Trust registry — enroll/revoke projects",
|
||||
"feedback": "Toggle the feedback reminder on/off",
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# MODULE DISCOVERY
|
||||
# =============================================================================
|
||||
@@ -43,9 +61,13 @@ from aipass.prax import logger
|
||||
MODULES_DIR = Path(__file__).parent / "modules"
|
||||
|
||||
|
||||
_import_failures: dict[str, Exception] = {}
|
||||
|
||||
|
||||
def discover_modules() -> List[Any]:
|
||||
"""Auto-discover modules in modules/ directory."""
|
||||
modules = []
|
||||
_import_failures.clear()
|
||||
|
||||
if not MODULES_DIR.exists():
|
||||
return modules
|
||||
@@ -62,18 +84,105 @@ def discover_modules() -> List[Any]:
|
||||
modules.append(module)
|
||||
except Exception as e:
|
||||
logger.error(f"[AIPASS] Failed to load module {module_name}: {e}")
|
||||
_import_failures[file_path.stem] = e
|
||||
|
||||
return modules
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELP OUTPUT — house pattern (cli_ux)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def print_introspection(modules: List[Any] | None = None) -> None:
|
||||
"""Bare invocation — title, purpose, public commands, --help pointer."""
|
||||
console.print()
|
||||
console.print("[bold cyan]AIPASS — Concierge & Setup[/bold cyan]")
|
||||
console.print("[dim]The friendly front door for AIPass. Setup, diagnostics, documentation, project creation.[/dim]")
|
||||
console.print()
|
||||
|
||||
if modules is None:
|
||||
modules = discover_modules()
|
||||
|
||||
commands = []
|
||||
for module in modules:
|
||||
name = getattr(module, "COMMAND", None)
|
||||
if name and name in _PUBLIC_COMMANDS:
|
||||
commands.append((name, _PUBLIC_COMMANDS[name]))
|
||||
commands.sort()
|
||||
|
||||
if commands:
|
||||
console.print("[yellow]Commands:[/yellow]")
|
||||
for name, desc in commands:
|
||||
console.print(f" [green]{name:16}[/green] [dim]{desc}[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[dim]Run 'aipass --help' for usage and examples[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help(modules: List[Any] | None = None) -> None:
|
||||
"""Full help — usage, commands, examples."""
|
||||
console.print()
|
||||
console.print("[bold cyan]AIPASS — Concierge & Setup[/bold cyan]")
|
||||
console.print("[dim]The friendly front door for AIPass. Setup, diagnostics, documentation, project creation.[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Usage:[/yellow]")
|
||||
console.print(" [green]aipass[/green] [dim]<command>[/dim] [dim][options][/dim]")
|
||||
console.print(" [green]aipass[/green] [dim]Show commands[/dim]")
|
||||
console.print(" [green]aipass[/green] [dim]<command>[/dim] [dim]--help[/dim] [dim]Help for a command[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Commands:[/yellow]")
|
||||
console.print(
|
||||
" [green]doctor[/green] [dim]System health — structure, registry, hooks, tests[/dim]"
|
||||
)
|
||||
console.print(" [green]doctor --fix[/green] [dim]Remediation report with repair commands[/dim]")
|
||||
console.print(" [green]doctor --json[/green] [dim]JSON output for structure scan[/dim]")
|
||||
console.print(" [green]doctor --cross-os[/green] [dim]Cross-OS pre-flight check[/dim]")
|
||||
console.print(" [green]help <question>[/green] [dim]Search branch documentation (Q&A)[/dim]")
|
||||
console.print(
|
||||
" [green]init[/green] [dim]Guided setup for new users (10 stages, resumable)[/dim]"
|
||||
)
|
||||
console.print(
|
||||
" [green]install[/green] [dim]One-command bootstrap — clone + setup.sh + hooks[/dim]"
|
||||
)
|
||||
console.print(" [green]new <name>[/green] [dim]Create a project inside AIPass[/dim]")
|
||||
console.print(
|
||||
" [green]adopt <name>[/green] "
|
||||
"[dim]Turn an existing projects/ directory into a full project[/dim]"
|
||||
)
|
||||
console.print(" [green]profile[/green] [dim]Show/edit user profile[/dim]")
|
||||
console.print(
|
||||
" [green]trust[/green] [dim][path][/dim] [dim]Trust registry — enroll/revoke projects[/dim]"
|
||||
)
|
||||
console.print(" [green]--version[/green] [dim]Show version[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Examples:[/yellow]")
|
||||
console.print(" [green]aipass doctor[/green] [dim]Check system health[/dim]")
|
||||
console.print(" [green]aipass help what does drone do[/green] [dim]Search documentation[/dim]")
|
||||
console.print(" [green]aipass new myapp --template python[/green] [dim]Create a Python project[/dim]")
|
||||
console.print(" [green]aipass adopt myapp --dry-run[/green] [dim]Preview adopting projects/myapp[/dim]")
|
||||
console.print(" [green]aipass init[/green] [dim]Start guided setup[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
|
||||
"""Route command to appropriate module."""
|
||||
"""Route command to appropriate module.
|
||||
|
||||
Returns True on success. Raises on handler crash so callers can
|
||||
distinguish 'not found' (False) from 'found but broken'.
|
||||
"""
|
||||
for module in modules:
|
||||
try:
|
||||
if module.handle_command(command, args):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[AIPASS] Module {module.__name__} error: {e}")
|
||||
mod_name = module.__name__.split(".")[-1]
|
||||
logger.error(f"[AIPASS] Module {mod_name} crashed: {e}")
|
||||
raise
|
||||
return False
|
||||
|
||||
|
||||
@@ -88,25 +197,55 @@ def main():
|
||||
args = sys.argv[1:]
|
||||
|
||||
if len(args) > 0 and args[0] in ["--version", "-V"]:
|
||||
print("aipass 0.1.0")
|
||||
try:
|
||||
version = importlib.metadata.version("aipass")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
logger.info("[AIPASS] Package metadata not found, version unknown")
|
||||
version = "unknown"
|
||||
console.print(f"aipass {version}")
|
||||
return 0
|
||||
|
||||
show_root_help = len(args) == 0 or args[0] in ["--help", "-h"] or (args[0] == "help" and len(args) == 1)
|
||||
if show_root_help:
|
||||
print(f"AIPASS - {len(modules)} modules discovered")
|
||||
for module in modules:
|
||||
name = module.__name__.split(".")[-1]
|
||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||
print(f" {name:20} {desc}")
|
||||
if not args:
|
||||
print_introspection(modules)
|
||||
return 0
|
||||
|
||||
if args[0] in ("--help", "-h"):
|
||||
print_help(modules)
|
||||
return 0
|
||||
|
||||
command = args[0]
|
||||
remaining = args[1:] if len(args) > 1 else []
|
||||
|
||||
if route_command(command, remaining, modules):
|
||||
return 0
|
||||
# Subcommand --help guard: intercept before dispatch
|
||||
if remaining and remaining[0] in ("--help", "-h"):
|
||||
for module in modules:
|
||||
if module.handle_command(command, ["--help"]):
|
||||
return 0
|
||||
console.print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
print(f"Unknown command: {command}")
|
||||
try:
|
||||
if route_command(command, remaining, modules):
|
||||
return 0
|
||||
except Exception as e:
|
||||
error(f"'{command}' crashed: {e}")
|
||||
logger.error(f"[AIPASS] '{command}' traceback", exc_info=True)
|
||||
return 1
|
||||
|
||||
if command.startswith("@"):
|
||||
console.print(f"{command} is a drone routing target, not an aipass command.")
|
||||
console.print("aipass is your front-door CLI; drone is the agent router — two separate tools.")
|
||||
console.print()
|
||||
console.print(f" Reach an agent: drone {command} ... · drone systems")
|
||||
console.print(" aipass commands: aipass --help")
|
||||
return 1
|
||||
|
||||
for stem, err in _import_failures.items():
|
||||
if command in (stem, stem.replace("_", "")):
|
||||
error(f"'{command}' failed to load: {err}")
|
||||
return 1
|
||||
|
||||
console.print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""cross_os — gap-registry cross-reference + non-mutating pre-flight runners."""
|
||||
|
||||
from aipass.aipass.apps.handlers.cross_os.gap_registry import ( # type: ignore[import-not-found]
|
||||
CrossOsGap,
|
||||
CrossOsGapError,
|
||||
find_gap_doc,
|
||||
gaps_for_platform,
|
||||
load_gaps,
|
||||
os_matches,
|
||||
parse_gap_registry,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.cross_os.preflight import ( # type: ignore[import-not-found]
|
||||
PreflightResult,
|
||||
check_hookstatus,
|
||||
check_routing,
|
||||
check_versions,
|
||||
find_e2e_dir,
|
||||
run_e2e,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.cross_os.run_record import ( # type: ignore[import-not-found]
|
||||
RunRecordError,
|
||||
build_run_record,
|
||||
default_record_path,
|
||||
generate_run_record,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CrossOsGap",
|
||||
"CrossOsGapError",
|
||||
"PreflightResult",
|
||||
"RunRecordError",
|
||||
"build_run_record",
|
||||
"check_hookstatus",
|
||||
"check_routing",
|
||||
"check_versions",
|
||||
"default_record_path",
|
||||
"find_e2e_dir",
|
||||
"find_gap_doc",
|
||||
"gaps_for_platform",
|
||||
"generate_run_record",
|
||||
"load_gaps",
|
||||
"os_matches",
|
||||
"parse_gap_registry",
|
||||
"run_e2e",
|
||||
]
|
||||
@@ -0,0 +1,167 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: gap_registry.py
|
||||
# Description: Live-parse the cross-OS gap registry and filter by platform
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-02
|
||||
# Modified: 2026-07-02
|
||||
# =============================================
|
||||
|
||||
"""Cross-OS gap registry parser — Layer-3-lite pre-flight source.
|
||||
|
||||
Live-reads ``tests/CROSS_OS_TESTING.md`` (read-only to @aipass), parses the
|
||||
"Known cross-OS gap registry" markdown table, and filters rows to the running
|
||||
platform. This is a *machine pre-flight* — it surfaces tracked OS-specific gaps
|
||||
for the box the user is on. It NEVER claims the checklist's human acceptance
|
||||
green ("you watched it work on that OS").
|
||||
|
||||
Fail-to-error contract: if the doc is missing, the section is absent, or no data
|
||||
rows can be parsed, functions raise ``CrossOsGapError`` — they never silently
|
||||
return "no gaps". Callers must surface the error as a WARN/FAIL, not swallow it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, NamedTuple
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
# Path of the source-of-truth doc, relative to the repo root.
|
||||
DOC_RELATIVE = Path("tests") / "CROSS_OS_TESTING.md"
|
||||
|
||||
# Case-insensitive marker identifying the registry section header line.
|
||||
_SECTION_MARKER = "known cross-os gap registry"
|
||||
|
||||
# Expected column count in the registry table: # | Gap | OS | Symptom | Owner | Status
|
||||
_EXPECTED_COLUMNS = 6
|
||||
|
||||
|
||||
class CrossOsGapError(RuntimeError):
|
||||
"""Raised when the cross-OS gap registry cannot be located or parsed."""
|
||||
|
||||
|
||||
class CrossOsGap(NamedTuple):
|
||||
"""One row of the Known cross-OS gap registry table."""
|
||||
|
||||
number: str
|
||||
gap: str
|
||||
os: str
|
||||
symptom: str
|
||||
owner: str
|
||||
status: str
|
||||
|
||||
|
||||
def find_gap_doc(start: Path | None = None) -> Path:
|
||||
"""Search upward from ``start`` (or this file) for ``tests/CROSS_OS_TESTING.md``.
|
||||
|
||||
Portable — derives the repo root by walking ancestors rather than hardcoding
|
||||
an absolute path.
|
||||
|
||||
Raises:
|
||||
CrossOsGapError: if the doc is not found in any ancestor directory.
|
||||
"""
|
||||
base = (start or Path(__file__)).resolve()
|
||||
for parent in [base, *base.parents]:
|
||||
candidate = parent / DOC_RELATIVE
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
raise CrossOsGapError(f"cross-OS testing doc not found (searched upward from {base} for {DOC_RELATIVE})")
|
||||
|
||||
|
||||
def parse_gap_registry(text: str) -> List[CrossOsGap]:
|
||||
"""Parse the 'Known cross-OS gap registry' table out of the doc text.
|
||||
|
||||
Only rows whose first cell begins with a digit are treated as data rows,
|
||||
which naturally skips the header and the ``|---|`` separator.
|
||||
|
||||
Raises:
|
||||
CrossOsGapError: if the section is missing or no data rows parse.
|
||||
"""
|
||||
lines = text.splitlines()
|
||||
|
||||
section_idx = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.lstrip().startswith("#") and _SECTION_MARKER in line.lower():
|
||||
section_idx = i
|
||||
break
|
||||
if section_idx is None:
|
||||
raise CrossOsGapError("'Known cross-OS gap registry' section not found in doc")
|
||||
|
||||
gaps: List[CrossOsGap] = []
|
||||
for line in lines[section_idx + 1 :]:
|
||||
stripped = line.strip()
|
||||
# Stop at the next top-level section.
|
||||
if stripped.startswith("## "):
|
||||
break
|
||||
if not stripped.startswith("|"):
|
||||
continue
|
||||
cells = [c.strip() for c in stripped.strip("|").split("|")]
|
||||
if len(cells) < _EXPECTED_COLUMNS:
|
||||
continue
|
||||
number = cells[0]
|
||||
# Skip header ("#") and separator ("---") rows — data rows start with a digit.
|
||||
if not number or not number[0].isdigit():
|
||||
continue
|
||||
gaps.append(
|
||||
CrossOsGap(
|
||||
number=number,
|
||||
gap=cells[1],
|
||||
os=cells[2],
|
||||
symptom=cells[3],
|
||||
owner=cells[4],
|
||||
status=cells[5],
|
||||
)
|
||||
)
|
||||
|
||||
if not gaps:
|
||||
raise CrossOsGapError("gap registry table found but no data rows could be parsed")
|
||||
return gaps
|
||||
|
||||
|
||||
def load_gaps(start: Path | None = None) -> List[CrossOsGap]:
|
||||
"""Locate, read, and parse the full gap registry.
|
||||
|
||||
Raises:
|
||||
CrossOsGapError: on missing/unreadable doc or unparseable table.
|
||||
"""
|
||||
doc = find_gap_doc(start)
|
||||
try:
|
||||
raw = doc.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise CrossOsGapError(f"cross-OS testing doc unreadable at {doc}: {exc}") from exc
|
||||
logger.info("[cross_os] parsing gap registry from %s", doc)
|
||||
return parse_gap_registry(raw)
|
||||
|
||||
|
||||
def os_matches(os_cell: str, platform_name: str) -> bool:
|
||||
"""Return True if an OS cell applies to ``platform_name`` (a ``sys.platform`` value).
|
||||
|
||||
Mapping (case-insensitive): 'all' matches every platform; 'win' matches
|
||||
win32; 'mac' matches darwin. So 'Win/mac' matches both win32 and darwin.
|
||||
"""
|
||||
cell = os_cell.lower()
|
||||
if "all" in cell:
|
||||
return True
|
||||
if platform_name == "win32":
|
||||
return "win" in cell
|
||||
if platform_name == "darwin":
|
||||
return "mac" in cell
|
||||
return False
|
||||
|
||||
|
||||
def gaps_for_platform(platform_name: str | None = None, start: Path | None = None) -> List[CrossOsGap]:
|
||||
"""Return only the gap rows relevant to ``platform_name`` (defaults to ``sys.platform``).
|
||||
|
||||
Raises:
|
||||
CrossOsGapError: on any load/parse failure (never silently empty).
|
||||
"""
|
||||
plat = platform_name or sys.platform
|
||||
gaps = load_gaps(start)
|
||||
relevant = [g for g in gaps if os_matches(g.os, plat)]
|
||||
json_handler.log_operation(
|
||||
"cross_os_gap_lookup",
|
||||
{"platform": plat, "total": len(gaps), "relevant": len(relevant)},
|
||||
)
|
||||
return relevant
|
||||
@@ -0,0 +1,230 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: preflight.py
|
||||
# Description: Non-mutating cross-OS pre-flight runners (routing/version/hooks/e2e)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-02
|
||||
# Modified: 2026-07-02
|
||||
# =============================================
|
||||
|
||||
"""Cross-OS pre-flight runners — Layer-3-lite machine checks.
|
||||
|
||||
Each runner probes one machine-provable slice of the cross-OS acceptance
|
||||
checklist and returns a small ``PreflightResult(name, ok, detail)``. Everything
|
||||
here is *non-mutating* and NEVER wakes a citizen: the drone routes exercised
|
||||
(``drone systems``, ``drone @ai_mail --help``, ``drone @hooks status``) only
|
||||
print/route — they do not dispatch work to a branch.
|
||||
|
||||
Robustness contract: every subprocess has a timeout and every runner catches
|
||||
``FileNotFoundError`` / ``TimeoutExpired`` (and other ``OSError``) and turns it
|
||||
into ``ok=False`` with a clear ``detail`` — a runner never crashes the caller.
|
||||
|
||||
These are pre-flight rows. They can NEVER claim the checklist's human green
|
||||
("you watched it work on that OS"); callers must label them pre-flight.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, NamedTuple, Sequence, Tuple
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
# Directory (relative to repo root) holding the Layer-2 e2e wiring suite.
|
||||
E2E_RELATIVE = Path("tests") / "e2e"
|
||||
|
||||
# Subprocess timeouts (seconds). Light routes are quick; e2e is heavy (it builds
|
||||
# a wheel and installs it into a fresh venv) so it gets a generous budget.
|
||||
_ROUTE_TIMEOUT = 20
|
||||
_VERSION_TIMEOUT = 15
|
||||
_HOOKSTATUS_TIMEOUT = 20
|
||||
_E2E_TIMEOUT = 600
|
||||
|
||||
# Detail prefix marking an e2e result that could NOT run (infra), so callers can
|
||||
# map it to WARN rather than FAIL. Real pytest failures do not use this prefix.
|
||||
E2E_UNRUNNABLE_PREFIX = "could not run"
|
||||
|
||||
|
||||
class PreflightResult(NamedTuple):
|
||||
"""One non-mutating pre-flight probe outcome."""
|
||||
|
||||
name: str
|
||||
ok: bool
|
||||
detail: str
|
||||
|
||||
|
||||
def _run(cmd: Sequence[str], timeout: int, cwd: str | None = None) -> Tuple[int | None, str]:
|
||||
"""Run ``cmd`` non-interactively; return ``(returncode, combined_output)``.
|
||||
|
||||
Never raises for the expected failure modes: a missing binary yields
|
||||
``(None, "not found: ...")`` and a timeout yields ``(None, "timed out ...")``.
|
||||
"""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
list(cmd),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=cwd,
|
||||
)
|
||||
return proc.returncode, (proc.stdout or "") + (proc.stderr or "")
|
||||
except FileNotFoundError as exc:
|
||||
logger.warning("[cross_os.preflight] binary not found for %s: %s", cmd, exc)
|
||||
return None, f"not found: {cmd[0]}"
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
logger.warning("[cross_os.preflight] %s timed out after %ss: %s", cmd, timeout, exc)
|
||||
return None, f"timed out after {timeout}s"
|
||||
except OSError as exc:
|
||||
logger.warning("[cross_os.preflight] error running %s: %s", cmd, exc)
|
||||
return None, f"error running {cmd[0]}: {exc}"
|
||||
|
||||
|
||||
def _first_line(output: str) -> str:
|
||||
"""Return the first non-empty stripped line of ``output`` (or "")."""
|
||||
for line in output.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped:
|
||||
return stripped
|
||||
return ""
|
||||
|
||||
|
||||
def check_routing() -> PreflightResult:
|
||||
"""Verify drone routing (Phase 4): ``drone systems`` + one subprocess route.
|
||||
|
||||
Both ``drone systems`` (4.1) and ``drone @ai_mail --help`` (4.2) must exit 0.
|
||||
These are non-mutating routes — ``--help`` prints usage; neither dispatches
|
||||
work to a citizen.
|
||||
"""
|
||||
sys_rc, sys_out = _run(["drone", "systems"], _ROUTE_TIMEOUT)
|
||||
route_rc, route_out = _run(["drone", "@ai_mail", "--help"], _ROUTE_TIMEOUT)
|
||||
|
||||
ok = sys_rc == 0 and route_rc == 0
|
||||
if ok:
|
||||
detail = "drone systems exit 0; @ai_mail route exit 0"
|
||||
else:
|
||||
problems: List[str] = []
|
||||
if sys_rc != 0:
|
||||
problems.append(f"drone systems -> {sys_rc or _first_line(sys_out)}")
|
||||
if route_rc != 0:
|
||||
problems.append(f"@ai_mail --help -> {route_rc or _first_line(route_out)}")
|
||||
detail = "; ".join(problems)
|
||||
return PreflightResult("routing", ok, detail)
|
||||
|
||||
|
||||
def check_versions() -> PreflightResult:
|
||||
"""Verify ``drone --version`` and ``aipass --version`` exit 0 (Phase 1.3).
|
||||
|
||||
Captures the version strings into the detail.
|
||||
"""
|
||||
drone_rc, drone_out = _run(["drone", "--version"], _VERSION_TIMEOUT)
|
||||
aipass_rc, aipass_out = _run(["aipass", "--version"], _VERSION_TIMEOUT)
|
||||
|
||||
def _ver(label: str, rc: int | None, out: str) -> str:
|
||||
if rc == 0:
|
||||
return _first_line(out)
|
||||
return f"{label} --version failed ({rc}: {_first_line(out)})"
|
||||
|
||||
ok = drone_rc == 0 and aipass_rc == 0
|
||||
detail = f"{_ver('drone', drone_rc, drone_out)}; {_ver('aipass', aipass_rc, aipass_out)}"
|
||||
return PreflightResult("versions", ok, detail)
|
||||
|
||||
|
||||
def check_hookstatus() -> PreflightResult:
|
||||
"""Verify the @hooks per-project config renders (Phase 6.3).
|
||||
|
||||
Note: the checklist labels this ``drone @hooks hookstatus``, but that command
|
||||
name does not route on the current drone build (it returns "Unknown command"
|
||||
— gap #9 in the wild). The real, non-mutating subcommand that renders the
|
||||
per-project hook config is ``drone @hooks status``, which is what Phase 6.3
|
||||
actually verifies, so that is what we probe.
|
||||
"""
|
||||
rc, out = _run(["drone", "@hooks", "status"], _HOOKSTATUS_TIMEOUT)
|
||||
ok = rc == 0
|
||||
detail = _first_line(out) if ok else f"drone @hooks status exit {rc}: {_first_line(out)}"
|
||||
return PreflightResult("hookstatus", ok, detail)
|
||||
|
||||
|
||||
def find_e2e_dir(start: Path | None = None) -> Path | None:
|
||||
"""Search upward from ``start`` (or this file) for ``tests/e2e``.
|
||||
|
||||
Portable — walks ancestors rather than hardcoding a path (mirrors the
|
||||
gap_registry ``find_gap_doc`` pattern). Returns ``None`` if not found.
|
||||
"""
|
||||
base = (start or Path(__file__)).resolve()
|
||||
for parent in [base, *base.parents]:
|
||||
candidate = parent / E2E_RELATIVE
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_pytest(repo_root: Path) -> Tuple[List[str], str] | None:
|
||||
"""Resolve a pytest invocation for the current box.
|
||||
|
||||
Prefers ``<repo>/.venv/<bin>/pytest`` (system ``python`` may be absent);
|
||||
falls back to ``sys.executable -m pytest``. Returns ``(argv_prefix, source)``
|
||||
or ``None`` if neither can be resolved.
|
||||
"""
|
||||
bin_dir = "Scripts" if os.name == "nt" else "bin"
|
||||
exe = "pytest.exe" if os.name == "nt" else "pytest"
|
||||
venv_pytest = repo_root / ".venv" / bin_dir / exe
|
||||
if venv_pytest.is_file():
|
||||
return [str(venv_pytest)], "venv pytest"
|
||||
if sys.executable:
|
||||
return [sys.executable, "-m", "pytest"], "sys.executable -m pytest"
|
||||
return None
|
||||
|
||||
|
||||
def run_e2e(start: Path | None = None) -> PreflightResult:
|
||||
"""Run the Layer-2 e2e wiring suite (``pytest tests/e2e -q``). HEAVY.
|
||||
|
||||
Only call this when explicitly opted into (``--e2e``): the suite builds the
|
||||
aipass wheel and installs it into a fresh venv. ``ok=True`` only when every
|
||||
test passes (pytest exit 0). Un-runnable cases (dir missing, no pytest,
|
||||
timeout, crash) return ``ok=False`` with a ``could not run`` detail so the
|
||||
caller can render them as WARN rather than a hard FAIL.
|
||||
"""
|
||||
e2e_dir = find_e2e_dir(start)
|
||||
if e2e_dir is None:
|
||||
return PreflightResult("e2e", False, f"{E2E_UNRUNNABLE_PREFIX}: e2e dir not found")
|
||||
|
||||
repo_root = e2e_dir.parent.parent
|
||||
resolved = _resolve_pytest(repo_root)
|
||||
if resolved is None:
|
||||
detail = f"{E2E_UNRUNNABLE_PREFIX}: pytest unavailable (no venv pytest, no interpreter)"
|
||||
return _log_e2e(PreflightResult("e2e", False, detail))
|
||||
|
||||
argv_prefix, source = resolved
|
||||
logger.info("[cross_os.preflight] running e2e suite via %s (cwd=%s)", source, repo_root)
|
||||
rc, out = _run([*argv_prefix, str(e2e_dir), "-q"], _E2E_TIMEOUT, cwd=str(repo_root))
|
||||
|
||||
if rc is None:
|
||||
# Timeout / missing binary / OSError — infra, not a real test failure.
|
||||
return _log_e2e(PreflightResult("e2e", False, f"{E2E_UNRUNNABLE_PREFIX}: {out}"))
|
||||
|
||||
summary = _e2e_summary(out)
|
||||
if rc == 0:
|
||||
return _log_e2e(PreflightResult("e2e", True, summary))
|
||||
# Non-zero: could be real failures OR pytest itself being absent under
|
||||
# `python -m pytest`. Distinguish so the caller can WARN vs FAIL.
|
||||
if "no module named pytest" in out.lower():
|
||||
return _log_e2e(PreflightResult("e2e", False, f"{E2E_UNRUNNABLE_PREFIX}: pytest not importable"))
|
||||
return _log_e2e(PreflightResult("e2e", False, summary))
|
||||
|
||||
|
||||
def _log_e2e(result: PreflightResult) -> PreflightResult:
|
||||
"""Record the e2e pre-flight outcome via json_handler, then return it."""
|
||||
json_handler.log_operation("cross_os_e2e_preflight", {"ok": result.ok, "detail": result.detail})
|
||||
return result
|
||||
|
||||
|
||||
def _e2e_summary(output: str) -> str:
|
||||
"""Extract pytest's terminal summary line (e.g. '14 passed', '2 failed...')."""
|
||||
for line in reversed(output.splitlines()):
|
||||
stripped = line.strip().strip("=").strip()
|
||||
if any(tok in stripped for tok in ("passed", "failed", "error", "no tests ran")):
|
||||
return stripped
|
||||
return _first_line(output) or "no pytest summary parsed"
|
||||
@@ -0,0 +1,243 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: run_record.py
|
||||
# Description: Machine pre-flight Run Record generator for the cross-OS checklist
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-02
|
||||
# Modified: 2026-07-02
|
||||
# =============================================
|
||||
|
||||
"""Cross-OS Run Record generator — Layer-3-lite machine pre-flight DRAFT.
|
||||
|
||||
Emits a text Run Record block modelled on the "## Run Record" fenced template in
|
||||
``tests/CROSS_OS_TESTING.md`` (that block is the format source-of-truth). The
|
||||
block is *constructed in code* rather than string-substituted into the parsed
|
||||
template on purpose: the load-bearing invariant here is the machine/human
|
||||
boundary — the machine fills ONLY what it can prove (env facts + the
|
||||
non-mutating pre-flight rows) and every human-only row (clean install,
|
||||
interactive init, daemons, audible sound, PTY, per-branch matrix, overall
|
||||
verdict, commit, tester) is left blank or marked ``— human``. Constructing the
|
||||
block gives exact control over that boundary; substituting values line-by-line
|
||||
into the free-text template would be brittle and could silently tick a human row.
|
||||
|
||||
Env facts are detected with stdlib (``platform`` / ``os``) directly — the same
|
||||
detection ``system_detector`` performs — because the seedgo cross-handler rule
|
||||
forbids this handler importing another handler.
|
||||
|
||||
A machine can NEVER claim the checklist's human green ("you watched it work on
|
||||
that OS"). This artifact is explicitly a pre-flight DRAFT that a person must
|
||||
complete by running the real Layer-3 acceptance pass.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.aipass.shared.registry_discovery import find_registry
|
||||
|
||||
from aipass.aipass.apps.handlers.cross_os.gap_registry import CrossOsGapError, gaps_for_platform
|
||||
from aipass.aipass.apps.handlers.cross_os.preflight import (
|
||||
E2E_UNRUNNABLE_PREFIX,
|
||||
check_hookstatus,
|
||||
check_routing,
|
||||
run_e2e,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
# Marker glyphs for the record. Match the doc's Run Record block, which uses the
|
||||
# ✅ / ❌ emoji rather than the terminal ✓ / ✗ glyphs.
|
||||
_PASS = "✅"
|
||||
_FAIL = "❌"
|
||||
# Un-ticked human box — a person must still run and mark the real pass.
|
||||
_HUMAN = "⬜ — human"
|
||||
|
||||
# Horizontal rule used by the doc's Run Record block.
|
||||
_RULE = "─────────────────────────────────────────────"
|
||||
|
||||
|
||||
class RunRecordError(RuntimeError):
|
||||
"""Raised when the Run Record file cannot be written."""
|
||||
|
||||
|
||||
def _os_name() -> str:
|
||||
"""OS family name (e.g. 'Linux', 'Darwin', 'Windows')."""
|
||||
return platform.system() or "unknown"
|
||||
|
||||
|
||||
def _resolve_aipass_home() -> str:
|
||||
"""Resolve AIPASS_HOME from the env, else the discovered registry parent.
|
||||
|
||||
Returns "" (blank) if neither is available — a blank row is honest; we never
|
||||
invent a path.
|
||||
"""
|
||||
home = os.environ.get("AIPASS_HOME", "").strip()
|
||||
if home:
|
||||
return home
|
||||
registry = find_registry(package_root=str(Path(__file__).resolve().parent))
|
||||
if registry.exists():
|
||||
return str(registry.parent)
|
||||
return ""
|
||||
|
||||
|
||||
def _env_lines() -> List[str]:
|
||||
"""Build the machine-auto-filled environment header lines (Phase 0 facts)."""
|
||||
os_desc = f"{_os_name()} {platform.release()}".strip()
|
||||
shell_path = os.environ.get("SHELL", "")
|
||||
shell_name = Path(shell_path).name if shell_path else "unknown"
|
||||
term = os.environ.get("TERM", "").strip() or "unknown"
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
return [
|
||||
"AIPass Cross-OS Run Record",
|
||||
f"Machine/VM : {platform.node() or 'unknown'}",
|
||||
f"OS + version : {os_desc}",
|
||||
f"Arch : {platform.machine() or 'unknown'}",
|
||||
f"Python : {platform.python_version()}",
|
||||
f"Shell / term : {shell_name} / {term}",
|
||||
f"AIPASS_HOME : {_resolve_aipass_home()}",
|
||||
# Commit stays blank — no git here; a human fills it (hint inline).
|
||||
"Commit (drone @git log -1) : ← fill via drone @git log -1",
|
||||
# Tester stays blank (human); Date is machine-knowable.
|
||||
f"Tester : Date : {today}",
|
||||
]
|
||||
|
||||
|
||||
def _verdict(ok: bool) -> str:
|
||||
"""Map a pre-flight boolean to the machine glyph + a pre-flight label."""
|
||||
glyph = _PASS if ok else _FAIL
|
||||
return f"{glyph} pre-flight (machine)"
|
||||
|
||||
|
||||
def _phase2_line(run_heavy_e2e: bool) -> str:
|
||||
"""Phase 2 (e2e) line — only auto-filled when the heavy suite was opted into."""
|
||||
label = "Phase 2 e2e suite (14/14) ...."
|
||||
if not run_heavy_e2e:
|
||||
return f"{label} {_HUMAN} notes: — not run (pass --e2e to run it)"
|
||||
result = run_e2e()
|
||||
if result.ok:
|
||||
return f"{label} {_verdict(True)} notes: {result.detail}"
|
||||
if result.detail.startswith(E2E_UNRUNNABLE_PREFIX):
|
||||
# Infra could not run the suite — WARN-ish, not a proven fail.
|
||||
return f"{label} ⚠️ could not run (machine) notes: {result.detail}"
|
||||
return f"{label} {_verdict(False)} notes: {result.detail}"
|
||||
|
||||
|
||||
def _phase_lines(run_heavy_e2e: bool) -> List[str]:
|
||||
"""Build the Phase 0–7 + per-branch lines, machine-proving only what it can."""
|
||||
routing = check_routing()
|
||||
hooks = check_hookstatus()
|
||||
|
||||
return [
|
||||
# Phase 0 — the machine captured the env above, so this is proven.
|
||||
f"Phase 0 env capture .......... {_verdict(True)} notes: captured above",
|
||||
# Phase 1 — clean install (setup.sh / .venv) is pre-init + human.
|
||||
f"Phase 1 clean install ........ {_HUMAN} notes:",
|
||||
_phase2_line(run_heavy_e2e),
|
||||
# Phase 3 — real scaffold + interactive init is human (PTY).
|
||||
f"Phase 3 aipass init .......... {_HUMAN} notes:",
|
||||
# Phase 4 — drone routing is non-mutating and machine-provable.
|
||||
f"Phase 4 drone routing ........ {_verdict(routing.ok)} notes: {routing.detail}",
|
||||
# Phase 5 — daemons (os.kill / start_new_session) are mutating + human.
|
||||
f"Phase 5 daemons .............. {_HUMAN} notes:",
|
||||
# Phase 6 — hookstatus config renders (machine); audible sound is human.
|
||||
f"Phase 6 hooks + sound ........ {_verdict(hooks.ok)} hookstatus; 🔊 sound {_HUMAN} notes: {hooks.detail}",
|
||||
# Phase 7 — interactive PTY layer is human-only.
|
||||
f"Phase 7 interactive .......... {_HUMAN} notes:",
|
||||
# Per-branch smoke matrix is a human pass.
|
||||
f"Per-branch matrix (13) ....... {_HUMAN} reds:",
|
||||
]
|
||||
|
||||
|
||||
def _watch_lines(platform_name: str) -> List[str]:
|
||||
"""Build the tracked-gap watch-item lines for this platform.
|
||||
|
||||
Reads the live gap registry; on any registry error, degrades to a single
|
||||
note line (never crashes the record — it is primarily an env artifact).
|
||||
"""
|
||||
lines = ["Watch items (tracked cross-OS gaps for this platform):"]
|
||||
try:
|
||||
gaps = gaps_for_platform(platform_name)
|
||||
except CrossOsGapError as exc:
|
||||
logger.warning("[cross_os.run_record] gap registry unavailable: %s", exc)
|
||||
lines.append(f" - registry unavailable — {exc}")
|
||||
return lines
|
||||
|
||||
if not gaps:
|
||||
lines.append(f" - none tracked for {platform_name}")
|
||||
return lines
|
||||
|
||||
for gap in gaps:
|
||||
lines.append(f" - gap #{gap.number} [{gap.status}] {gap.symptom} — owner {gap.owner}")
|
||||
return lines
|
||||
|
||||
|
||||
def build_run_record(run_heavy_e2e: bool = False, platform_name: str | None = None) -> str:
|
||||
"""Build the full machine pre-flight Run Record text block.
|
||||
|
||||
Auto-fills the env header + the machine-provable phase rows (0/4/6, plus 2
|
||||
when ``run_heavy_e2e``); leaves every human-only row blank/marked. The output
|
||||
mirrors the "## Run Record" template in tests/CROSS_OS_TESTING.md.
|
||||
"""
|
||||
plat = platform_name or sys.platform
|
||||
|
||||
header = [
|
||||
"NOTE: machine pre-flight DRAFT — rows marked '(machine)' are auto-captured pre-flight",
|
||||
"only; they are NOT the checklist's human green. A human must complete every '— human'",
|
||||
"row and run the real Layer-3 acceptance pass before this record counts.",
|
||||
"",
|
||||
]
|
||||
|
||||
body: List[str] = []
|
||||
body.append(_RULE)
|
||||
body.extend(_env_lines())
|
||||
body.append(_RULE)
|
||||
body.extend(_phase_lines(run_heavy_e2e))
|
||||
body.append(_RULE)
|
||||
body.extend(_watch_lines(plat))
|
||||
body.append("")
|
||||
body.append("New gaps found (file + assign):")
|
||||
body.append("")
|
||||
body.append(f"Overall verdict: {_HUMAN} (PASS / PARTIAL / FAIL — after the real pass)")
|
||||
body.append(_RULE)
|
||||
|
||||
return "\n".join([*header, *body]) + "\n"
|
||||
|
||||
|
||||
def default_record_path() -> Path:
|
||||
"""Return the default record path in CWD (mirrors the doc's log naming)."""
|
||||
return Path.cwd() / f"aipass-crossos-record-{_os_name().lower()}.txt"
|
||||
|
||||
|
||||
def generate_run_record(path: str | None = None, run_heavy_e2e: bool = False) -> Path:
|
||||
"""Build and write the Run Record; return the written path.
|
||||
|
||||
Args:
|
||||
path: Destination file (``--record`` value). If None, a sensible default
|
||||
in CWD is used.
|
||||
run_heavy_e2e: When True, run and record the heavy Phase-2 e2e result.
|
||||
|
||||
Raises:
|
||||
RunRecordError: if the file cannot be written (OSError) — never crashes.
|
||||
"""
|
||||
content = build_run_record(run_heavy_e2e=run_heavy_e2e)
|
||||
target = Path(path) if path else default_record_path()
|
||||
try:
|
||||
parent = target.parent
|
||||
if parent and not parent.exists():
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
except OSError as exc:
|
||||
logger.error("[cross_os.run_record] could not write record to %s: %s", target, exc)
|
||||
raise RunRecordError(f"could not write Run Record to {target}: {exc}") from exc
|
||||
|
||||
json_handler.log_operation(
|
||||
"cross_os_run_record",
|
||||
{"path": str(target), "e2e": run_heavy_e2e},
|
||||
)
|
||||
logger.info("[cross_os.run_record] wrote machine pre-flight record to %s", target)
|
||||
return target
|
||||
@@ -11,9 +11,10 @@
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import (
|
||||
_sanitize_name,
|
||||
init_project,
|
||||
is_projects_child,
|
||||
update_project,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.init.scaffold_content import (
|
||||
from aipass.aipass.shared.scaffold_content import (
|
||||
global_prompt_md,
|
||||
inbox_json,
|
||||
prep_md,
|
||||
@@ -25,6 +26,7 @@ __all__ = [
|
||||
"global_prompt_md",
|
||||
"inbox_json",
|
||||
"init_project",
|
||||
"is_projects_child",
|
||||
"prep_md",
|
||||
"update_project",
|
||||
"with_source",
|
||||
|
||||
@@ -30,7 +30,6 @@ RULES:
|
||||
- No hardcoded paths
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -39,10 +38,22 @@ import uuid
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.aipass.apps.handlers.init import scaffold_content as sc
|
||||
from aipass.aipass.shared import scaffold_content as sc
|
||||
from aipass.aipass.shared.project_home import (
|
||||
_claude_local_settings,
|
||||
_claude_settings,
|
||||
_detect_aipass_home,
|
||||
_enroll_project,
|
||||
is_projects_child,
|
||||
is_throwaway_path,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_STALE_MANAGED_FILES: list[Path] = [
|
||||
Path(".aipass") / "aipass_global_prompt.md",
|
||||
]
|
||||
|
||||
|
||||
def _sanitize_name(raw: str) -> str:
|
||||
"""Sanitize a project name for use in filenames.
|
||||
@@ -53,23 +64,6 @@ def _sanitize_name(raw: str) -> str:
|
||||
return re.sub(r"[^A-Z0-9_-]", "_", raw.upper()).strip("_")
|
||||
|
||||
|
||||
def _detect_aipass_home() -> str | None:
|
||||
"""Detect the AIPass installation root from the aipass package location.
|
||||
|
||||
Returns the parent of the src/ directory (the repo root).
|
||||
Returns None if detection fails.
|
||||
"""
|
||||
try:
|
||||
spec = importlib.util.find_spec("aipass")
|
||||
if spec and spec.origin:
|
||||
# aipass/__init__.py lives at src/aipass/__init__.py
|
||||
# parent = src/aipass/, parent.parent = src/, parent.parent.parent = AIPass root
|
||||
return str(Path(spec.origin).resolve().parent.parent.parent)
|
||||
except Exception as exc:
|
||||
logger.info("AIPASS_HOME detection skipped: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _hook_fingerprint(hook_entry: dict) -> str:
|
||||
"""Extract a comparable fingerprint from a hook entry."""
|
||||
commands = []
|
||||
@@ -108,10 +102,11 @@ def _merge_settings(existing: dict, generated: dict) -> dict:
|
||||
if cleaned_hooks:
|
||||
merged["hooks"] = cleaned_hooks
|
||||
|
||||
# Merge env: generated wins for AIPASS_HOME, preserve user additions
|
||||
existing_env = existing.get("env", {})
|
||||
generated_env = generated.get("env", {})
|
||||
merged["env"] = {**existing_env, **generated_env}
|
||||
# env: AIPASS_HOME is machine-local — never tracked (see settings.local.json).
|
||||
# Strip it from any previously-tracked settings.json; preserve other user vars.
|
||||
existing_env = {k: v for k, v in existing.get("env", {}).items() if k != "AIPASS_HOME"}
|
||||
if existing_env:
|
||||
merged["env"] = existing_env
|
||||
|
||||
# Merge permissions: union deny/ask lists
|
||||
existing_perms = existing.get("permissions", {})
|
||||
@@ -185,40 +180,13 @@ def _merge_hooks_json(existing: dict, template: dict) -> dict:
|
||||
return merged
|
||||
|
||||
|
||||
def _claude_settings(aipass_home: str | None = None) -> str:
|
||||
"""Generate .claude/settings.json — env and permissions only.
|
||||
|
||||
Hooks are NOT wired at the project level. All AIPass hooks
|
||||
(prompt injection, identity, email, pre-compact, edit gates) fire
|
||||
from provider settings (~/.claude/settings.json), installed by
|
||||
setup.sh. Provider hooks use CWD-walking patterns that work from
|
||||
any directory in any project.
|
||||
|
||||
Project settings only contain:
|
||||
- env.AIPASS_HOME (so hooks can find the AIPass installation)
|
||||
- permissions.deny (basic safety rails)
|
||||
|
||||
Args:
|
||||
aipass_home: Optional AIPass installation root to add as env.AIPASS_HOME.
|
||||
"""
|
||||
data: dict = {}
|
||||
|
||||
data["permissions"] = {
|
||||
"deny": [
|
||||
"Bash(git push --force*)",
|
||||
"Bash(git reset --hard*)",
|
||||
"EnterPlanMode",
|
||||
],
|
||||
}
|
||||
|
||||
if aipass_home:
|
||||
data["env"] = {"AIPASS_HOME": aipass_home}
|
||||
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def _guard_init(target: Path) -> None:
|
||||
def _guard_init(target: Path, *, allow_projects_child: bool = False) -> None:
|
||||
"""Block init if target is inside an agent branch or existing project.
|
||||
|
||||
When *allow_projects_child* is True, the nested-project checks are
|
||||
skipped for targets that are ``<host>/projects/<name>``. This is used
|
||||
by ``aipass new`` to create projects inside the installation.
|
||||
|
||||
Raises RuntimeError with explanation if init should not proceed.
|
||||
"""
|
||||
target = target.resolve()
|
||||
@@ -240,6 +208,8 @@ def _guard_init(target: Path) -> None:
|
||||
# Block: target already has a registry (is already a project)
|
||||
for f in target.iterdir() if target.is_dir() else []:
|
||||
if f.is_file() and f.name.endswith("_REGISTRY.json"):
|
||||
if allow_projects_child and is_projects_child(target):
|
||||
break
|
||||
raise RuntimeError(
|
||||
f"BLOCKED: '{target}' is already an AIPass project (has {f.name}). "
|
||||
"Use 'aipass init update' to upgrade an existing project."
|
||||
@@ -250,6 +220,8 @@ def _guard_init(target: Path) -> None:
|
||||
continue
|
||||
for f in parent.iterdir():
|
||||
if f.is_file() and f.name.endswith("_REGISTRY.json"):
|
||||
if allow_projects_child and is_projects_child(target):
|
||||
return
|
||||
raise RuntimeError(
|
||||
f"BLOCKED: '{target}' is inside AIPass project at '{parent}' (has {f.name}). "
|
||||
"Cannot create a nested project."
|
||||
@@ -258,12 +230,18 @@ def _guard_init(target: Path) -> None:
|
||||
break
|
||||
|
||||
|
||||
def init_project(target: Path, project_name: str | None = None) -> dict:
|
||||
def init_project(
|
||||
target: Path,
|
||||
project_name: str | None = None,
|
||||
*,
|
||||
allow_projects_child: bool = False,
|
||||
) -> dict:
|
||||
"""Initialize an AIPass project in the target directory.
|
||||
|
||||
Args:
|
||||
target: Directory to initialize
|
||||
project_name: Name for the registry (defaults to directory name)
|
||||
allow_projects_child: When True, allow init inside ``<host>/projects/<name>``.
|
||||
|
||||
Returns:
|
||||
dict with registry_id, registry_file, project_name, target, created_files
|
||||
@@ -273,7 +251,7 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
|
||||
RuntimeError: If target is inside an agent branch or existing project
|
||||
"""
|
||||
target = target.resolve()
|
||||
_guard_init(target)
|
||||
_guard_init(target, allow_projects_child=allow_projects_child)
|
||||
if not target.exists():
|
||||
target.mkdir(parents=True)
|
||||
|
||||
@@ -332,6 +310,7 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
|
||||
if template.is_file():
|
||||
shutil.copy2(str(template), str(hooks_json_path))
|
||||
created.append(str(hooks_json_path))
|
||||
_enroll_project(target)
|
||||
else:
|
||||
logger.info("hooks template not found at %s — skipping", template)
|
||||
|
||||
@@ -369,16 +348,23 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
|
||||
gitignore_path.write_text(sc.gitignore(), encoding="utf-8")
|
||||
created.append(str(gitignore_path))
|
||||
|
||||
# 9. .claude/settings.json
|
||||
# 9. .claude/settings.json — tracked, permissions only (no machine-local paths)
|
||||
claude_dir = target / ".claude"
|
||||
claude_dir.mkdir(exist_ok=True)
|
||||
|
||||
settings_path = claude_dir / "settings.json"
|
||||
if not settings_path.exists():
|
||||
settings_path.write_text(_claude_settings(aipass_home), encoding="utf-8")
|
||||
settings_path.write_text(_claude_settings(), encoding="utf-8")
|
||||
created.append(str(settings_path))
|
||||
|
||||
# 9b. .claude/commands/prep.md — /prep session wrap-up slash command
|
||||
# 9b. .claude/settings.local.json — machine-local AIPASS_HOME (gitignored)
|
||||
if aipass_home and not is_throwaway_path(aipass_home):
|
||||
local_settings_path = claude_dir / "settings.local.json"
|
||||
if not local_settings_path.exists():
|
||||
local_settings_path.write_text(_claude_local_settings(aipass_home), encoding="utf-8")
|
||||
created.append(str(local_settings_path))
|
||||
|
||||
# 9c. .claude/commands/prep.md — /prep session wrap-up slash command
|
||||
# Only prep.md here — memo.md belongs at provider level (~/.claude/commands/)
|
||||
commands_dir = claude_dir / "commands"
|
||||
commands_dir.mkdir(exist_ok=True)
|
||||
@@ -474,6 +460,7 @@ def update_project(target: Path) -> dict:
|
||||
updated: list[str] = []
|
||||
already_current: list[str] = []
|
||||
skipped: list[str] = []
|
||||
removed: list[str] = []
|
||||
aipass_home: str | None = None
|
||||
|
||||
# Managed directories — create if missing (graceful recovery).
|
||||
@@ -501,11 +488,11 @@ def update_project(target: Path) -> dict:
|
||||
elif tier_dest.exists():
|
||||
already_current.append(str(tier_dest))
|
||||
|
||||
# settings.json — smart merge: preserve user hooks + env, update AIPass hooks
|
||||
# settings.json — smart merge: preserve user hooks, update AIPass permissions.
|
||||
# AIPASS_HOME never lives here — machine-local paths go in settings.local.json.
|
||||
settings_path = claude_dir / "settings.json"
|
||||
if not settings_path.exists():
|
||||
aipass_home = _detect_aipass_home()
|
||||
settings_path.write_text(_claude_settings(aipass_home), encoding="utf-8")
|
||||
settings_path.write_text(_claude_settings(), encoding="utf-8")
|
||||
updated.append(str(settings_path))
|
||||
else:
|
||||
existing_content = settings_path.read_text(encoding="utf-8")
|
||||
@@ -514,9 +501,7 @@ def update_project(target: Path) -> dict:
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.info("settings.json parse failed, rebuilding: %s", exc)
|
||||
existing = {}
|
||||
existing_env = existing.get("env", {})
|
||||
aipass_home = existing_env.get("AIPASS_HOME") or _detect_aipass_home()
|
||||
generated = json.loads(_claude_settings(aipass_home))
|
||||
generated = json.loads(_claude_settings())
|
||||
merged = _merge_settings(existing, generated)
|
||||
merged_content = json.dumps(merged, indent=2, ensure_ascii=False) + "\n"
|
||||
if existing != merged:
|
||||
@@ -525,6 +510,15 @@ def update_project(target: Path) -> dict:
|
||||
else:
|
||||
already_current.append(str(settings_path))
|
||||
|
||||
# settings.local.json — machine-local AIPASS_HOME (gitignored, create if missing)
|
||||
if aipass_home and not is_throwaway_path(aipass_home):
|
||||
local_settings_path = claude_dir / "settings.local.json"
|
||||
if not local_settings_path.exists():
|
||||
local_settings_path.write_text(_claude_local_settings(aipass_home), encoding="utf-8")
|
||||
updated.append(str(local_settings_path))
|
||||
else:
|
||||
already_current.append(str(local_settings_path))
|
||||
|
||||
# hooks.json — union-merge: preserve user enabled, add new hooks from template
|
||||
hooks_json_path = aipass_dir / "hooks.json"
|
||||
hook_home = aipass_home or _detect_aipass_home()
|
||||
@@ -542,6 +536,7 @@ def update_project(target: Path) -> dict:
|
||||
if existing_hooks != merged_hooks:
|
||||
hooks_json_path.write_text(merged_hooks_content, encoding="utf-8")
|
||||
updated.append(str(hooks_json_path))
|
||||
_enroll_project(target)
|
||||
else:
|
||||
already_current.append(str(hooks_json_path))
|
||||
else:
|
||||
@@ -550,6 +545,7 @@ def update_project(target: Path) -> dict:
|
||||
encoding="utf-8",
|
||||
)
|
||||
updated.append(str(hooks_json_path))
|
||||
_enroll_project(target)
|
||||
elif hooks_json_path.exists():
|
||||
already_current.append(str(hooks_json_path))
|
||||
|
||||
@@ -595,11 +591,20 @@ def update_project(target: Path) -> dict:
|
||||
venv_link.symlink_to(aipass_venv)
|
||||
updated.append(f".venv (symlink to AIPass runtime: {aipass_venv})")
|
||||
|
||||
# --- Cruft cleanup: remove known-stale AIPass-managed artifacts ---
|
||||
for rel in _STALE_MANAGED_FILES:
|
||||
stale_path = target / rel
|
||||
if stale_path.is_file():
|
||||
stale_path.unlink()
|
||||
removed.append(str(stale_path))
|
||||
logger.info("Removed stale managed file: %s", stale_path)
|
||||
|
||||
return {
|
||||
"project_name": name,
|
||||
"target": str(target),
|
||||
"updated_files": updated,
|
||||
"already_current": already_current,
|
||||
"skipped_files": skipped,
|
||||
"removed_files": removed,
|
||||
"aipass_home": aipass_home,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: __init__.py
|
||||
# Description: New project handler — create projects inside AIPass
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-17
|
||||
# Modified: 2026-07-17
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
New Project Handler — creates projects inside AIPass installations.
|
||||
|
||||
Business logic for `aipass new`. Creates a project at <host>/projects/<name>
|
||||
with its own git repo, registry, and optional AIPass agent.
|
||||
|
||||
Flow: find host -> validate -> mkdir -> mint registry (FIRST) ->
|
||||
write template -> scaffold AIPass files -> git init -> optional agent.
|
||||
|
||||
RULES:
|
||||
- Registry MUST be minted before any spawn call
|
||||
- git init via subprocess (hooks git gate only intercepts agent Bash)
|
||||
- Cleanup on failure: partial project is worse than no project
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.aipass.shared import scaffold_content as sc
|
||||
from aipass.prax import logger
|
||||
from aipass.spawn import spawn_agent
|
||||
|
||||
TEMPLATES = ("empty", "python")
|
||||
|
||||
|
||||
def find_host_root(start: Path) -> Path | None:
|
||||
"""Walk up from *start* to find the AIPass host installation root.
|
||||
|
||||
Returns the directory containing ``*_REGISTRY.json``, or ``None``.
|
||||
"""
|
||||
for p in [start, *start.parents]:
|
||||
try:
|
||||
entries = list(p.iterdir())
|
||||
except OSError:
|
||||
continue
|
||||
for f in entries:
|
||||
try:
|
||||
if f.is_file() and f.name.endswith("_REGISTRY.json"):
|
||||
return p
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _validate_name(name: str) -> str:
|
||||
if not name:
|
||||
raise ValueError("Project name cannot be empty")
|
||||
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_-]*$", name):
|
||||
raise ValueError(
|
||||
f"Invalid project name '{name}'. "
|
||||
"Must start with a letter, contain only letters, digits, hyphens, underscores."
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def _registry_name(name: str) -> str:
|
||||
return re.sub(r"[^A-Z0-9_-]", "_", name.upper()).strip("_")
|
||||
|
||||
|
||||
def _git(args: list[str], cwd: Path) -> str:
|
||||
r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"git {' '.join(args)}: {r.stderr.strip()}")
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
# ── registry ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _write_registry(target: Path, name: str) -> tuple[str, str]:
|
||||
"""Mint the project registry. Returns ``(registry_id, filename)``."""
|
||||
registry_id = str(uuid.uuid4())
|
||||
today = date.today().isoformat()
|
||||
reg = _registry_name(name)
|
||||
filename = f"{reg}_REGISTRY.json"
|
||||
|
||||
data = {
|
||||
"metadata": {
|
||||
"id": registry_id,
|
||||
"name": reg,
|
||||
"version": "1.0.0",
|
||||
"created": today,
|
||||
"last_updated": today,
|
||||
"total_branches": 0,
|
||||
},
|
||||
"branches": [],
|
||||
}
|
||||
(target / filename).write_text(
|
||||
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return registry_id, filename
|
||||
|
||||
|
||||
# ── templates ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _write_template(target: Path, name: str, template: str) -> list[str]:
|
||||
"""Write template-specific files. Returns relative paths created."""
|
||||
created: list[str] = []
|
||||
|
||||
(target / "README.md").write_text(
|
||||
f"# {name}\n\nCreated with `aipass new`. Template: {template}.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
created.append("README.md")
|
||||
|
||||
(target / ".gitignore").write_text(sc.gitignore(), encoding="utf-8")
|
||||
created.append(".gitignore")
|
||||
|
||||
if template == "python":
|
||||
pkg = name.replace("-", "_").lower()
|
||||
(target / "pyproject.toml").write_text(
|
||||
"[build-system]\n"
|
||||
'requires = ["setuptools>=61.0"]\n'
|
||||
'build-backend = "setuptools.build_meta"\n\n'
|
||||
"[project]\n"
|
||||
f'name = "{name}"\n'
|
||||
'version = "0.1.0"\n'
|
||||
f'description = "{name} — born deployable"\n'
|
||||
'requires-python = ">=3.10"\n\n'
|
||||
"[tool.setuptools.packages.find]\n"
|
||||
'where = ["src"]\n\n'
|
||||
"[tool.pytest.ini_options]\n"
|
||||
'testpaths = ["src"]\n'
|
||||
'pythonpath = ["src"]\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
created.append("pyproject.toml")
|
||||
src = target / "src" / pkg
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text(
|
||||
f'"""{name} — born deployable."""\n\n__version__ = "0.1.0"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
created.append(f"src/{pkg}/__init__.py")
|
||||
|
||||
return created
|
||||
|
||||
|
||||
# ── AIPass scaffold ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _scaffold_aipass(target: Path, name: str) -> list[str]:
|
||||
"""Write AIPass scaffold files (tiers, hooks, CLAUDE.md, settings, .venv)."""
|
||||
from aipass.aipass.shared.project_home import (
|
||||
_claude_local_settings,
|
||||
_claude_settings,
|
||||
_detect_aipass_home,
|
||||
_enroll_project,
|
||||
is_throwaway_path,
|
||||
)
|
||||
|
||||
created: list[str] = []
|
||||
aipass_home = _detect_aipass_home()
|
||||
reg = _registry_name(name)
|
||||
|
||||
# .aipass/
|
||||
aipass_dir = target / ".aipass"
|
||||
aipass_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Tier files
|
||||
if aipass_home:
|
||||
for tier_file in ("tier0_kernel.md", "tier1_navmap.md"):
|
||||
dest = aipass_dir / tier_file
|
||||
src_path = Path(aipass_home) / ".aipass" / tier_file
|
||||
if src_path.is_file():
|
||||
shutil.copy2(str(src_path), str(dest))
|
||||
created.append(f".aipass/{tier_file}")
|
||||
|
||||
# hooks.json + trust enrollment
|
||||
if aipass_home:
|
||||
template = Path(aipass_home) / ".aipass" / "project_hooks.json"
|
||||
if template.is_file():
|
||||
shutil.copy2(str(template), str(aipass_dir / "hooks.json"))
|
||||
created.append(".aipass/hooks.json")
|
||||
_enroll_project(target)
|
||||
|
||||
# CLAUDE.md, AGENTS.md
|
||||
for md_name in ("CLAUDE.md", "AGENTS.md"):
|
||||
dest = target / md_name
|
||||
if dest.exists():
|
||||
continue
|
||||
if aipass_home:
|
||||
tmpl = Path(aipass_home) / ".aipass" / f"project_{md_name}"
|
||||
if tmpl.is_file():
|
||||
content = tmpl.read_text(encoding="utf-8").replace("{name}", reg)
|
||||
dest.write_text(content, encoding="utf-8")
|
||||
created.append(md_name)
|
||||
continue
|
||||
if md_name == "AGENTS.md":
|
||||
dest.write_text(sc.agents_md(reg), encoding="utf-8")
|
||||
created.append(md_name)
|
||||
|
||||
# .claude/settings.json — tracked, permissions only (no machine-local paths)
|
||||
claude_dir = target / ".claude"
|
||||
claude_dir.mkdir(exist_ok=True)
|
||||
(claude_dir / "settings.json").write_text(
|
||||
_claude_settings(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
created.append(".claude/settings.json")
|
||||
|
||||
# .claude/settings.local.json — machine-local AIPASS_HOME (gitignored)
|
||||
if aipass_home and not is_throwaway_path(aipass_home):
|
||||
(claude_dir / "settings.local.json").write_text(
|
||||
_claude_local_settings(aipass_home),
|
||||
encoding="utf-8",
|
||||
)
|
||||
created.append(".claude/settings.local.json")
|
||||
|
||||
# .claude/commands/prep.md
|
||||
commands_dir = claude_dir / "commands"
|
||||
commands_dir.mkdir(exist_ok=True)
|
||||
(commands_dir / "prep.md").write_text(sc.prep_md(), encoding="utf-8")
|
||||
created.append(".claude/commands/prep.md")
|
||||
|
||||
# .venv symlink
|
||||
if aipass_home:
|
||||
venv = Path(aipass_home) / ".venv"
|
||||
if venv.is_dir():
|
||||
link = target / ".venv"
|
||||
link.symlink_to(venv)
|
||||
created.append(".venv")
|
||||
|
||||
return created
|
||||
|
||||
|
||||
# ── git ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _git_init(target: Path, name: str, template: str) -> None:
|
||||
"""Initialize git repo with birth commit. Guards against re-init."""
|
||||
if (target / ".git").exists():
|
||||
raise RuntimeError(f"'{target}' already has a .git directory")
|
||||
_git(["init", "-b", "main"], target)
|
||||
_git(["add", "-A"], target)
|
||||
_git(
|
||||
["commit", "-m", f"birth: {name} ({template} template) via aipass new"],
|
||||
target,
|
||||
)
|
||||
|
||||
|
||||
# ── agent (via @spawn) ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _agent_home(project_root: Path, name: str) -> Path:
|
||||
"""Compute the agent home directory: src/<pkg>/<pkg>/."""
|
||||
pkg = name.replace("-", "_").lower()
|
||||
return project_root / "src" / pkg / pkg
|
||||
|
||||
|
||||
def _spawn_project_agent(project_root: Path, name: str) -> dict:
|
||||
"""Create the project agent via spawn_agent().
|
||||
|
||||
Agent lives at src/<pkg>/<pkg>/ inside the project. Spawn discovers
|
||||
the project-local registry (minted earlier by _write_registry) by
|
||||
walking up from the agent home to the project root.
|
||||
"""
|
||||
home = _agent_home(project_root, name)
|
||||
result = spawn_agent(
|
||||
target_path=str(home),
|
||||
role="project_agent",
|
||||
purpose=f"Resident agent of the {name} project.",
|
||||
citizen_class="project_agent",
|
||||
)
|
||||
if not result.get("success"):
|
||||
raise RuntimeError(f"spawn_agent failed: {result.get('error', 'unknown')}")
|
||||
logger.info(
|
||||
"[aipass new] agent spawned via @spawn: %s (%d files)",
|
||||
result["branch_name"],
|
||||
result["files_copied"],
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ── public API ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def create_project(
|
||||
name: str,
|
||||
template: str = "empty",
|
||||
no_agent: bool = False,
|
||||
) -> dict:
|
||||
"""Create a new project inside the AIPass host installation.
|
||||
|
||||
Returns:
|
||||
dict with name, template, target, host, registry_id, registry_file,
|
||||
files, agent_spawned.
|
||||
|
||||
Raises:
|
||||
ValueError: Invalid name or template.
|
||||
RuntimeError: Not inside AIPass, target exists, git failure.
|
||||
"""
|
||||
_validate_name(name)
|
||||
if template not in TEMPLATES:
|
||||
raise ValueError(f"Unknown template '{template}'. Choose from: {', '.join(TEMPLATES)}")
|
||||
|
||||
host = find_host_root(Path.cwd())
|
||||
if host is None:
|
||||
raise RuntimeError(
|
||||
"Not inside an AIPass installation (no *_REGISTRY.json found). "
|
||||
"`aipass new` creates projects inside the AIPass environment."
|
||||
)
|
||||
|
||||
target = host / "projects" / name
|
||||
if target.exists():
|
||||
raise RuntimeError(f"Project already exists: {target}")
|
||||
|
||||
target.mkdir(parents=True)
|
||||
|
||||
try:
|
||||
registry_id, registry_file = _write_registry(target, name)
|
||||
logger.info("[aipass new] registry minted: %s (%s)", registry_file, registry_id)
|
||||
|
||||
template_files = _write_template(target, name, template)
|
||||
scaffold_files = _scaffold_aipass(target, name)
|
||||
|
||||
spawn_result = None
|
||||
if not no_agent:
|
||||
spawn_result = _spawn_project_agent(target, name)
|
||||
|
||||
_git_init(target, name, template)
|
||||
logger.info("[aipass new] git repo initialized with birth commit")
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"template": template,
|
||||
"target": str(target),
|
||||
"host": str(host),
|
||||
"registry_id": registry_id,
|
||||
"registry_file": registry_file,
|
||||
"files": template_files + scaffold_files,
|
||||
"agent_created": spawn_result is not None,
|
||||
"agent_home": str(_agent_home(target, name)) if spawn_result else None,
|
||||
"spawn_result": spawn_result,
|
||||
}
|
||||
except Exception:
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
raise
|
||||
@@ -0,0 +1,224 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: adopt.py
|
||||
# Description: aipass adopt — bring an existing projects/ directory into AIPass
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-20
|
||||
# Modified: 2026-07-20
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Adopt Handler — PRIVATE implementation
|
||||
|
||||
Business logic for `aipass adopt`. Turns an EXISTING directory at
|
||||
<host>/projects/<name> into a full AIPass project: sealed registry,
|
||||
resident agent, .aipass/.claude scaffold.
|
||||
|
||||
Unlike `aipass new` (which births a brand-new directory), adopt starts
|
||||
from a directory that already has its own content — possibly its own
|
||||
git repo, possibly public. Every write is existence-guarded; nothing
|
||||
already present is ever overwritten. .gitignore is the one file that's
|
||||
patched rather than skipped, since AIPass local state (.trinity/,
|
||||
mailbox, *.local.json) must never leak into a tracked commit.
|
||||
|
||||
RULES:
|
||||
- Additive only — never overwrite a file that already exists
|
||||
- Never touch the target's git history (no git init/add/commit)
|
||||
- gitignore safety runs BEFORE any other AIPass file is written
|
||||
- Registry-first: mint the project registry before spawning the agent
|
||||
- dry_run performs zero filesystem writes and never calls spawn_agent
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.aipass.apps.handlers.new_project import (
|
||||
_agent_home,
|
||||
_registry_name,
|
||||
_spawn_project_agent,
|
||||
_write_registry,
|
||||
)
|
||||
from aipass.aipass.shared import scaffold_content as sc
|
||||
from aipass.aipass.shared.project_home import (
|
||||
_claude_local_settings,
|
||||
_claude_settings,
|
||||
_detect_aipass_home,
|
||||
_enroll_project,
|
||||
is_projects_child,
|
||||
is_throwaway_path,
|
||||
)
|
||||
|
||||
GITIGNORE_MARKER = "# AIPass local state"
|
||||
|
||||
|
||||
def _gitignore_safety(target: Path, *, dry_run: bool) -> str:
|
||||
"""Ensure AIPass-managed paths are gitignored. Returns 'created', 'appended', or 'already-safe'."""
|
||||
gitignore_path = target / ".gitignore"
|
||||
if gitignore_path.exists():
|
||||
existing = gitignore_path.read_text(encoding="utf-8")
|
||||
if GITIGNORE_MARKER in existing:
|
||||
return "already-safe"
|
||||
if not dry_run:
|
||||
separator = "" if existing.endswith("\n") else "\n"
|
||||
gitignore_path.write_text(existing + separator + "\n" + sc.gitignore(), encoding="utf-8")
|
||||
return "appended"
|
||||
if not dry_run:
|
||||
gitignore_path.write_text(sc.gitignore(), encoding="utf-8")
|
||||
return "created"
|
||||
|
||||
|
||||
def _write_if_missing(path: Path, content: str, *, dry_run: bool, planned: list[str]) -> None:
|
||||
"""Record and (unless dry_run) write *content* to *path*, but only if it doesn't already exist."""
|
||||
if path.exists():
|
||||
return
|
||||
if not dry_run:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
planned.append(str(path))
|
||||
|
||||
|
||||
def adopt_project(target: Path, *, no_agent: bool = False, dry_run: bool = False) -> dict:
|
||||
"""Adopt an existing directory at <host>/projects/<name> as a full AIPass project.
|
||||
|
||||
Args:
|
||||
target: Existing directory to adopt — must be <host>/projects/<name>.
|
||||
no_agent: Skip resident-agent creation.
|
||||
dry_run: Report what WOULD happen; performs zero filesystem writes and
|
||||
never calls spawn_agent.
|
||||
|
||||
Returns:
|
||||
dict with name, target, host, dry_run, registry_id, registry_file,
|
||||
files, gitignore_action, agent_created, agent_home, spawn_result.
|
||||
|
||||
Raises:
|
||||
RuntimeError: target missing, not a projects/ child, already adopted,
|
||||
or a resident-agent home path collision.
|
||||
"""
|
||||
target = target.resolve()
|
||||
if not target.is_dir():
|
||||
raise RuntimeError(f"'{target}' does not exist. `aipass adopt` brings in an EXISTING directory.")
|
||||
|
||||
if not is_projects_child(target):
|
||||
raise RuntimeError(
|
||||
f"'{target}' is not <host>/projects/<name>. `aipass adopt` only works inside projects/ "
|
||||
"— use `aipass new` to create a fresh project instead."
|
||||
)
|
||||
|
||||
existing_registry = [f for f in target.iterdir() if f.is_file() and f.name.endswith("_REGISTRY.json")]
|
||||
if existing_registry:
|
||||
raise RuntimeError(f"'{target}' is already adopted (has {existing_registry[0].name}).")
|
||||
|
||||
host = target.parent.parent
|
||||
name = target.name
|
||||
reg = _registry_name(name)
|
||||
|
||||
agent_home = _agent_home(target, name)
|
||||
if not no_agent and agent_home.exists() and any(agent_home.iterdir()):
|
||||
raise RuntimeError(
|
||||
f"Name collision: '{agent_home}' already exists and is non-empty — "
|
||||
"cannot seat a resident agent there. Retry with --no-agent."
|
||||
)
|
||||
|
||||
aipass_home = _detect_aipass_home()
|
||||
files: list[str] = []
|
||||
|
||||
# gitignore safety FIRST — nothing AIPass-managed gets written before the
|
||||
# target is confirmed to ignore it (target may be a public repo).
|
||||
gitignore_action = _gitignore_safety(target, dry_run=dry_run)
|
||||
|
||||
# Registry — sealed, minted BEFORE spawn (registry-first rule)
|
||||
registry_id = None
|
||||
registry_filename = f"{reg}_REGISTRY.json"
|
||||
if not dry_run:
|
||||
registry_id, registry_filename = _write_registry(target, name)
|
||||
files.append(registry_filename)
|
||||
|
||||
# .aipass/ — tier files, hooks.json, CLAUDE.md/AGENTS.md
|
||||
aipass_dir = target / ".aipass"
|
||||
if aipass_home:
|
||||
for tier_file in ("tier0_kernel.md", "tier1_navmap.md"):
|
||||
src_path = Path(aipass_home) / ".aipass" / tier_file
|
||||
if src_path.is_file():
|
||||
_write_if_missing(
|
||||
aipass_dir / tier_file,
|
||||
src_path.read_text(encoding="utf-8"),
|
||||
dry_run=dry_run,
|
||||
planned=files,
|
||||
)
|
||||
|
||||
hooks_template = Path(aipass_home) / ".aipass" / "project_hooks.json"
|
||||
if hooks_template.is_file():
|
||||
hooks_dest = aipass_dir / "hooks.json"
|
||||
already_had_hooks = hooks_dest.exists()
|
||||
_write_if_missing(hooks_dest, hooks_template.read_text(encoding="utf-8"), dry_run=dry_run, planned=files)
|
||||
if not already_had_hooks and not dry_run:
|
||||
_enroll_project(target)
|
||||
|
||||
for md_name in ("CLAUDE.md", "AGENTS.md"):
|
||||
dest = target / md_name
|
||||
if dest.exists():
|
||||
continue
|
||||
if aipass_home:
|
||||
tmpl = Path(aipass_home) / ".aipass" / f"project_{md_name}"
|
||||
if tmpl.is_file():
|
||||
content = tmpl.read_text(encoding="utf-8").replace("{name}", reg)
|
||||
_write_if_missing(dest, content, dry_run=dry_run, planned=files)
|
||||
continue
|
||||
if md_name == "AGENTS.md":
|
||||
_write_if_missing(dest, sc.agents_md(reg), dry_run=dry_run, planned=files)
|
||||
|
||||
# .claude/settings.json — tracked, permissions only
|
||||
claude_dir = target / ".claude"
|
||||
_write_if_missing(claude_dir / "settings.json", _claude_settings(), dry_run=dry_run, planned=files)
|
||||
|
||||
# .claude/settings.local.json — machine-local AIPASS_HOME (gitignored)
|
||||
if aipass_home and not is_throwaway_path(aipass_home):
|
||||
_write_if_missing(
|
||||
claude_dir / "settings.local.json",
|
||||
_claude_local_settings(aipass_home),
|
||||
dry_run=dry_run,
|
||||
planned=files,
|
||||
)
|
||||
|
||||
# .claude/commands/prep.md
|
||||
_write_if_missing(claude_dir / "commands" / "prep.md", sc.prep_md(), dry_run=dry_run, planned=files)
|
||||
|
||||
# .venv symlink → AIPass shared runtime
|
||||
if aipass_home:
|
||||
venv = Path(aipass_home) / ".venv"
|
||||
venv_link = target / ".venv"
|
||||
if venv.is_dir() and not venv_link.exists():
|
||||
if not dry_run:
|
||||
venv_link.symlink_to(venv)
|
||||
files.append(str(venv_link))
|
||||
|
||||
# Resident agent — registry MUST exist first (dry_run never spawns)
|
||||
spawn_result = None
|
||||
agent_created = False
|
||||
will_have_agent = not no_agent
|
||||
if will_have_agent and not dry_run:
|
||||
spawn_result = _spawn_project_agent(target, name)
|
||||
agent_created = True
|
||||
elif will_have_agent and dry_run:
|
||||
files.append(f"{agent_home} (resident agent — planned)")
|
||||
|
||||
json_handler.log_operation(
|
||||
"adopt_project",
|
||||
{"name": name, "target": str(target), "dry_run": dry_run},
|
||||
"adopt",
|
||||
)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"target": str(target),
|
||||
"host": str(host),
|
||||
"dry_run": dry_run,
|
||||
"registry_id": registry_id,
|
||||
"registry_file": registry_filename,
|
||||
"files": files,
|
||||
"gitignore_action": gitignore_action,
|
||||
"agent_created": agent_created,
|
||||
"agent_home": str(agent_home) if will_have_agent else None,
|
||||
"spawn_result": spawn_result,
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: provider_wire.py
|
||||
# Description: Auto-wire provider settings from manifest into user config
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-11
|
||||
# Modified: 2026-07-11
|
||||
# =============================================
|
||||
|
||||
"""provider_wire — auto-wire provider settings.
|
||||
|
||||
Implements the additive merge of manifest into ~/.claude/settings.json.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
# =============================================================================
|
||||
# HOOK & ENV DESCRIPTIONS
|
||||
# =============================================================================
|
||||
|
||||
HOOK_DESCRIPTIONS: Dict[str, str] = {
|
||||
"pre_edit_gate.py": "blocks edits outside agent's branch",
|
||||
"subagent_stop_gate.py": "validates agent output on exit",
|
||||
"auto_fix_diagnostics.py": "auto-fixes lint issues after edits",
|
||||
"global_prompt_loader.py": "injects branch context on each turn",
|
||||
"identity_injector.py": "injects agent identity on each turn",
|
||||
"email_notification.py": "notifies on incoming agent mail",
|
||||
"branch_prompt_loader.py": "loads branch-specific prompts",
|
||||
"pre_compact.py": "saves state before context compaction",
|
||||
}
|
||||
|
||||
ENV_DESCRIPTIONS: Dict[str, str] = {
|
||||
"AIPASS_HOME": "tells agents where AIPass lives",
|
||||
"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "prevents conflict with .trinity/ memory system",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AUTO-WIRE
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def auto_wire_provider(manifest_path: Path, interactive: bool = True) -> List[str]:
|
||||
"""Auto-wire provider settings from manifest into ~/.claude/settings.json.
|
||||
|
||||
Additive merge only — never removes or overwrites existing keys/values.
|
||||
Returns list of action descriptions (for logging/display).
|
||||
"""
|
||||
actions: List[str] = []
|
||||
|
||||
manifest = json_handler.load_path(manifest_path)
|
||||
if manifest is None:
|
||||
return actions
|
||||
claude_section = manifest.get("cli", {}).get("claude", {})
|
||||
if not claude_section:
|
||||
return actions
|
||||
|
||||
settings_path = Path.home() / ".claude" / "settings.json"
|
||||
if settings_path.exists():
|
||||
settings = json_handler.load_path(settings_path) or {}
|
||||
else:
|
||||
settings = {}
|
||||
|
||||
if settings_path.exists():
|
||||
date_stamp = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
backup_path = settings_path.with_suffix(f".json.bak.{date_stamp}")
|
||||
shutil.copy2(settings_path, backup_path)
|
||||
actions.append(f"Backed up settings to {backup_path.name}")
|
||||
|
||||
manifest_hooks = claude_section.get("hooks", [])
|
||||
|
||||
for hook in manifest_hooks:
|
||||
command = hook.get("command", "")
|
||||
event = hook.get("event", "")
|
||||
if not command or not event:
|
||||
continue
|
||||
|
||||
if "hooks" not in settings:
|
||||
settings["hooks"] = {}
|
||||
if event not in settings["hooks"]:
|
||||
settings["hooks"][event] = []
|
||||
event_hooks = settings["hooks"][event]
|
||||
if not isinstance(event_hooks, list):
|
||||
event_hooks = [event_hooks]
|
||||
settings["hooks"][event] = event_hooks
|
||||
|
||||
hook_matcher = hook.get("matcher", "")
|
||||
already_wired = any(
|
||||
isinstance(h, dict) and command in json.dumps(h) and h.get("matcher", "") == hook_matcher
|
||||
for h in event_hooks
|
||||
)
|
||||
if not already_wired:
|
||||
cmd_entry: Dict[str, object] = {
|
||||
"type": "command",
|
||||
"command": command,
|
||||
}
|
||||
if hook.get("timeout"):
|
||||
cmd_entry["timeout"] = hook["timeout"]
|
||||
wrapper: Dict[str, object] = {}
|
||||
if hook.get("matcher"):
|
||||
wrapper["matcher"] = hook["matcher"]
|
||||
wrapper["hooks"] = [cmd_entry]
|
||||
event_hooks.append(wrapper)
|
||||
label = command.rsplit(" ", 1)[-1] if " " in command else command
|
||||
actions.append(f"Wired hook {label} -> {event}")
|
||||
|
||||
manifest_env = claude_section.get("env", {})
|
||||
if manifest_env:
|
||||
if "env" not in settings:
|
||||
settings["env"] = {}
|
||||
repo_root = str(manifest_path.parent.parent)
|
||||
project_root = str(Path.cwd())
|
||||
for key, value in manifest_env.items():
|
||||
if key not in settings["env"]:
|
||||
resolved = value.replace("{{REPO_ROOT}}", repo_root)
|
||||
resolved = resolved.replace("{{PROJECT_ROOT}}", project_root)
|
||||
settings["env"][key] = resolved
|
||||
actions.append(f"Set env {key}={resolved}")
|
||||
|
||||
manifest_perms = claude_section.get("permissions", {})
|
||||
manifest_deny = manifest_perms.get("deny", [])
|
||||
manifest_ask = manifest_perms.get("ask", [])
|
||||
|
||||
if manifest_deny or manifest_ask:
|
||||
if "permissions" not in settings:
|
||||
settings["permissions"] = {}
|
||||
if "deny" not in settings["permissions"]:
|
||||
settings["permissions"]["deny"] = []
|
||||
if "ask" not in settings["permissions"]:
|
||||
settings["permissions"]["ask"] = []
|
||||
|
||||
existing_deny = set(settings["permissions"]["deny"])
|
||||
for rule in manifest_deny:
|
||||
if rule not in existing_deny:
|
||||
settings["permissions"]["deny"].append(rule)
|
||||
actions.append(f"Added deny rule: {rule}")
|
||||
|
||||
existing_ask = set(settings["permissions"]["ask"])
|
||||
for rule in manifest_ask:
|
||||
if rule not in existing_ask:
|
||||
settings["permissions"]["ask"].append(rule)
|
||||
actions.append(f"Added ask rule: {rule}")
|
||||
|
||||
json_handler.save_path(settings_path, settings)
|
||||
actions.append("Updated ~/.claude/settings.json")
|
||||
|
||||
json_handler.log_operation("auto_wire_provider", {"actions": len(actions)})
|
||||
return actions
|
||||
@@ -93,7 +93,7 @@ def find_project_root(start: Path) -> Optional[Path]:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
_SCAN_SKIP_DIRS = {".archive", ".venv", ".git", "__pycache__", "node_modules", ".chroma"}
|
||||
_SCAN_SKIP_DIRS = {".archive", ".backup", ".venv", ".git", "__pycache__", "node_modules", ".chroma", "templates"}
|
||||
|
||||
|
||||
def scan_agents(project_root: Path) -> List[AgentInfo]:
|
||||
@@ -286,12 +286,15 @@ def detect_pollution(agents: List[AgentInfo]) -> List[PollutionHit]:
|
||||
def check_registry_consistency(
|
||||
registry_path: Path,
|
||||
agents: List[AgentInfo],
|
||||
project_root: Optional[Path] = None,
|
||||
) -> List[RegistryIssue]:
|
||||
"""Validate that registry branches[].path entries match actual filesystem.
|
||||
|
||||
Returns:
|
||||
List of RegistryIssue for each problem found.
|
||||
"""
|
||||
anchor = (project_root or registry_path.parent).resolve()
|
||||
|
||||
try:
|
||||
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
@@ -309,7 +312,8 @@ def check_registry_consistency(
|
||||
issues.append(RegistryIssue(name, "", "missing"))
|
||||
continue
|
||||
|
||||
reg_path = Path(path_str).resolve()
|
||||
p = Path(path_str)
|
||||
reg_path = p.resolve() if p.is_absolute() else (anchor / p).resolve()
|
||||
if not reg_path.exists():
|
||||
issues.append(RegistryIssue(name, path_str, "missing"))
|
||||
elif str(reg_path) not in agent_paths:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: progress.py
|
||||
# Description: Rich progress and glyph helpers for aipass doctor
|
||||
# Version: 1.0.0
|
||||
# Description: Rich progress and glyph helpers for aipass doctor + init
|
||||
# Version: 1.1.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-16
|
||||
# Modified: 2026-07-05
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
@@ -15,6 +15,9 @@ No bare print() — all output via logger or caller's console.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
@@ -49,6 +52,26 @@ def make_doctor_progress() -> Progress:
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def activity_spinner(description: str) -> Iterator[Progress]:
|
||||
"""Transient spinner for a blocking, non-streaming action.
|
||||
|
||||
Wrap Python work that would otherwise look frozen — scaffold build, ping
|
||||
sweep — so the user sees it is alive. Do NOT wrap a subprocess that streams
|
||||
its own output to the terminal (installer, spawn): the two renderers fight.
|
||||
Yields the Progress so a caller can retitle the task mid-flight if needed.
|
||||
|
||||
Args:
|
||||
description: What is happening, e.g. "Building project scaffold…".
|
||||
"""
|
||||
logger.info("[progress] activity spinner: %s", description)
|
||||
json_handler.log_operation("activity_spinner", {"description": description})
|
||||
prog = Progress(SpinnerColumn(), TextColumn("{task.description}"), transient=True)
|
||||
with prog:
|
||||
prog.add_task(description, total=None)
|
||||
yield prog
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CHECK FORMATTER
|
||||
# =============================================================================
|
||||
@@ -81,3 +104,26 @@ def format_check(
|
||||
line = f"{line}\n{indent}[dim yellow]{remediation}[/dim yellow]"
|
||||
|
||||
return line
|
||||
|
||||
|
||||
def render_step_header(current: int, total: int, label: str, width: int = 18) -> str:
|
||||
"""Render a one-line stage header with an inline progress bar.
|
||||
|
||||
Example: 'Step 3/10 [██████░░░░░░░░░░░░] — User profile'. Pure formatting —
|
||||
a static string, so it composes with the interactive prompts between stages
|
||||
(a live Rich bar would have to stop/start around every input()).
|
||||
|
||||
Args:
|
||||
current: 1-based index of the stage now starting.
|
||||
total: Total number of stages (clamps to >= 1).
|
||||
label: Human label for the stage.
|
||||
width: Character width of the bar.
|
||||
|
||||
Returns:
|
||||
Rich markup string ready for console.print().
|
||||
"""
|
||||
total = max(total, 1)
|
||||
current = max(0, min(current, total))
|
||||
filled = round(width * current / total)
|
||||
bar = "█" * filled + "░" * (width - filled)
|
||||
return f"[bold cyan]Step {current}/{total}[/bold cyan] [green]{bar}[/green] — [bold]{label}[/bold]"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: doctor_fix.py
|
||||
# Name: _doctor_fix.py
|
||||
# Description: Structure remediation report for aipass doctor --fix
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-05-15
|
||||
+68
-150
@@ -1,5 +1,5 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: doctor_wire.py
|
||||
# Name: _doctor_wire.py
|
||||
# Description: Auto-wire provider settings from manifest into user config
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-05-08
|
||||
@@ -18,36 +18,20 @@ Provides:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
from typing import List, NamedTuple
|
||||
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, success
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
# =============================================================================
|
||||
# HOOK & ENV DESCRIPTIONS (for interactive "no" warning)
|
||||
# =============================================================================
|
||||
|
||||
HOOK_DESCRIPTIONS: Dict[str, str] = {
|
||||
"pre_edit_gate.py": "blocks edits outside agent's branch",
|
||||
"subagent_stop_gate.py": "validates agent output on exit",
|
||||
"auto_fix_diagnostics.py": "auto-fixes lint issues after edits",
|
||||
"global_prompt_loader.py": "injects branch context on each turn",
|
||||
"identity_injector.py": "injects agent identity on each turn",
|
||||
"email_notification.py": "notifies on incoming agent mail",
|
||||
"branch_prompt_loader.py": "loads branch-specific prompts",
|
||||
"pre_compact.py": "saves state before context compaction",
|
||||
}
|
||||
|
||||
ENV_DESCRIPTIONS: Dict[str, str] = {
|
||||
"AIPASS_HOME": "tells agents where AIPass lives",
|
||||
"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "prevents conflict with .trinity/ memory system",
|
||||
}
|
||||
from aipass.aipass.apps.handlers.provider_wire import ( # noqa: F401
|
||||
HOOK_DESCRIPTIONS,
|
||||
ENV_DESCRIPTIONS,
|
||||
auto_wire_provider as _auto_wire_provider,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -57,130 +41,12 @@ ENV_DESCRIPTIONS: Dict[str, str] = {
|
||||
from aipass.aipass.apps.handlers.provider_reconcile import reconcile_stale_deny # noqa: E402, F401
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AUTO-WIRE
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _auto_wire_provider(manifest_path: Path, interactive: bool = True) -> List[str]:
|
||||
"""Auto-wire provider settings from manifest into ~/.claude/settings.json.
|
||||
|
||||
Additive merge only — never removes or overwrites existing keys/values.
|
||||
Returns list of action descriptions (for logging/display).
|
||||
"""
|
||||
actions: List[str] = []
|
||||
|
||||
manifest = json_handler.load_path(manifest_path)
|
||||
if manifest is None:
|
||||
return actions
|
||||
claude_section = manifest.get("cli", {}).get("claude", {})
|
||||
if not claude_section:
|
||||
return actions
|
||||
|
||||
# Read existing settings
|
||||
settings_path = Path.home() / ".claude" / "settings.json"
|
||||
if settings_path.exists():
|
||||
settings = json_handler.load_path(settings_path) or {}
|
||||
else:
|
||||
settings = {}
|
||||
|
||||
# Backup
|
||||
if settings_path.exists():
|
||||
date_stamp = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
backup_path = settings_path.with_suffix(f".json.bak.{date_stamp}")
|
||||
shutil.copy2(settings_path, backup_path)
|
||||
actions.append(f"Backed up settings to {backup_path.name}")
|
||||
|
||||
# Hooks — add bridge entries to provider settings
|
||||
manifest_hooks = claude_section.get("hooks", [])
|
||||
|
||||
for hook in manifest_hooks:
|
||||
command = hook.get("command", "")
|
||||
event = hook.get("event", "")
|
||||
if not command or not event:
|
||||
continue
|
||||
|
||||
if "hooks" not in settings:
|
||||
settings["hooks"] = {}
|
||||
if event not in settings["hooks"]:
|
||||
settings["hooks"][event] = []
|
||||
event_hooks = settings["hooks"][event]
|
||||
if not isinstance(event_hooks, list):
|
||||
event_hooks = [event_hooks]
|
||||
settings["hooks"][event] = event_hooks
|
||||
|
||||
hook_matcher = hook.get("matcher", "")
|
||||
already_wired = any(
|
||||
isinstance(h, dict) and command in json.dumps(h) and h.get("matcher", "") == hook_matcher
|
||||
for h in event_hooks
|
||||
)
|
||||
if not already_wired:
|
||||
cmd_entry: Dict[str, object] = {
|
||||
"type": "command",
|
||||
"command": command,
|
||||
}
|
||||
if hook.get("timeout"):
|
||||
cmd_entry["timeout"] = hook["timeout"]
|
||||
wrapper: Dict[str, object] = {}
|
||||
if hook.get("matcher"):
|
||||
wrapper["matcher"] = hook["matcher"]
|
||||
wrapper["hooks"] = [cmd_entry]
|
||||
event_hooks.append(wrapper)
|
||||
label = command.rsplit(" ", 1)[-1] if " " in command else command
|
||||
actions.append(f"Wired hook {label} -> {event}")
|
||||
|
||||
# Env vars
|
||||
manifest_env = claude_section.get("env", {})
|
||||
if manifest_env:
|
||||
if "env" not in settings:
|
||||
settings["env"] = {}
|
||||
repo_root = str(manifest_path.parent.parent)
|
||||
project_root = str(Path.cwd())
|
||||
for key, value in manifest_env.items():
|
||||
if key not in settings["env"]:
|
||||
resolved = value.replace("{{REPO_ROOT}}", repo_root)
|
||||
resolved = resolved.replace("{{PROJECT_ROOT}}", project_root)
|
||||
settings["env"][key] = resolved
|
||||
actions.append(f"Set env {key}={resolved}")
|
||||
|
||||
# Permissions
|
||||
manifest_perms = claude_section.get("permissions", {})
|
||||
manifest_deny = manifest_perms.get("deny", [])
|
||||
manifest_ask = manifest_perms.get("ask", [])
|
||||
|
||||
if manifest_deny or manifest_ask:
|
||||
if "permissions" not in settings:
|
||||
settings["permissions"] = {}
|
||||
if "deny" not in settings["permissions"]:
|
||||
settings["permissions"]["deny"] = []
|
||||
if "ask" not in settings["permissions"]:
|
||||
settings["permissions"]["ask"] = []
|
||||
|
||||
existing_deny = set(settings["permissions"]["deny"])
|
||||
for rule in manifest_deny:
|
||||
if rule not in existing_deny:
|
||||
settings["permissions"]["deny"].append(rule)
|
||||
actions.append(f"Added deny rule: {rule}")
|
||||
|
||||
existing_ask = set(settings["permissions"]["ask"])
|
||||
for rule in manifest_ask:
|
||||
if rule not in existing_ask:
|
||||
settings["permissions"]["ask"].append(rule)
|
||||
actions.append(f"Added ask rule: {rule}")
|
||||
|
||||
# Write settings back
|
||||
json_handler.save_path(settings_path, settings)
|
||||
actions.append("Updated ~/.claude/settings.json")
|
||||
|
||||
return actions
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INTERACTIVE WIRE PROMPTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def prompt_auto_wire(
|
||||
def _prompt_auto_wire(
|
||||
manifest_path: Path,
|
||||
missing_hooks: List[str],
|
||||
missing_env: List[str],
|
||||
@@ -205,16 +71,20 @@ def prompt_auto_wire(
|
||||
console.print(f"\n[bold]{', '.join(parts)} missing[/bold]")
|
||||
console.print("[dim]Review details: .claude/hooks/README.md[/dim]")
|
||||
|
||||
try:
|
||||
answer = input("Auto-wire provider settings? [y/N]: ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt) as exc:
|
||||
logger.info("[doctor] auto-wire prompt interrupted: %s", type(exc).__name__)
|
||||
if not sys.stdin.isatty():
|
||||
logger.info("[doctor] non-interactive stdin — auto-wire prompt skipped, treating as decline")
|
||||
answer = "n"
|
||||
else:
|
||||
try:
|
||||
answer = input("Auto-wire provider settings? [y/N]: ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt) as exc:
|
||||
logger.info("[doctor] auto-wire prompt interrupted: %s", type(exc).__name__)
|
||||
answer = "n"
|
||||
|
||||
if answer in ("y", "yes"):
|
||||
actions = _auto_wire_provider(manifest_path, interactive=True)
|
||||
for action in actions:
|
||||
console.print(f"[green]✓[/green] {action}")
|
||||
success(action)
|
||||
return bool(actions)
|
||||
|
||||
_print_manual_wire_warning(missing_hooks, missing_env, missing_deny, missing_ask)
|
||||
@@ -304,3 +174,51 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
|
||||
json_handler.log_operation("doctor_wire_noop", {"command": command})
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WIRE VERIFY GUARD (doctor check row)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class WireCheckResult(NamedTuple):
|
||||
"""Single doctor check result (mirrors doctor.CheckResult without importing it)."""
|
||||
|
||||
label: str
|
||||
glyph: str
|
||||
detail: str
|
||||
remediation: str
|
||||
|
||||
|
||||
_GLYPH_PASS = "[green]✓[/green]"
|
||||
_GLYPH_FAIL = "[red]✗[/red]"
|
||||
_GLYPH_WARN = "[yellow]![/yellow]"
|
||||
|
||||
|
||||
def check_wire_verify() -> list[WireCheckResult]:
|
||||
"""Run the hooks wire_verify guard — catch empty/orphaned/duplicate provider entries."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["drone", "@hooks", "verify"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return [WireCheckResult("wire verify", _GLYPH_PASS, "provider hooks wired correctly", "")]
|
||||
lines = [ln.strip() for ln in proc.stdout.splitlines() if ln.strip()]
|
||||
detail = lines[-1] if lines else "errors detected"
|
||||
return [
|
||||
WireCheckResult(
|
||||
"wire verify",
|
||||
_GLYPH_FAIL,
|
||||
detail,
|
||||
"Run 'aipass doctor --fix' to re-wire, then re-run doctor to confirm",
|
||||
)
|
||||
]
|
||||
except FileNotFoundError as exc:
|
||||
logger.warning("[doctor] drone not found for wire_verify: %s", exc)
|
||||
return [WireCheckResult("wire verify", _GLYPH_WARN, "drone not found", "")]
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
logger.warning("[doctor] wire_verify timed out: %s", exc)
|
||||
return [WireCheckResult("wire verify", _GLYPH_WARN, "timed out", "")]
|
||||
@@ -0,0 +1,127 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: adopt.py
|
||||
# Description: aipass adopt — bring an existing projects/ directory into AIPass
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-20
|
||||
# Modified: 2026-07-20
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
aipass adopt — turn an existing directory under projects/ into a full
|
||||
AIPass project (registry, resident agent, .aipass/.claude scaffold).
|
||||
|
||||
Unlike `aipass new`, adopt starts from a directory that already has its
|
||||
own content and git history — every write is additive, nothing existing
|
||||
is ever overwritten.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.cli.apps.modules import console, error, success
|
||||
from aipass.prax import logger
|
||||
|
||||
COMMAND = "adopt"
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Bare invocation — usage pointer."""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass adopt[/bold cyan] — bring an existing directory into projects/")
|
||||
console.print()
|
||||
console.print("[dim]Usage: aipass adopt <name-or-path> [--no-agent] [--dry-run][/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print usage help for the adopt command."""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass adopt[/bold cyan] — adopt an existing directory as an AIPass project")
|
||||
console.print()
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [green]aipass adopt <name>[/green] [dim]# Adopt <host>/projects/<name>[/dim]")
|
||||
console.print(" [green]aipass adopt <path>[/green] [dim]# Adopt by relative/absolute path[/dim]")
|
||||
console.print(" [green]aipass adopt <name> --no-agent[/green] [dim]# Skip resident agent[/dim]")
|
||||
console.print(" [green]aipass adopt <name> --dry-run[/green] [dim]# Preview, write nothing[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]WHAT IT DOES:[/yellow]")
|
||||
console.print(" Seats a sealed registry, scaffolds .aipass/.claude, and (unless")
|
||||
console.print(" --no-agent) creates a resident agent — all ADDITIVE. Never touches")
|
||||
console.print(" the target's existing git history or tracked files. If the target")
|
||||
console.print(" has no .gitignore covering AIPass local state, one is created or")
|
||||
console.print(" appended to first — the target may be a public repo.")
|
||||
console.print()
|
||||
console.print("[yellow]REQUIRES:[/yellow]")
|
||||
console.print(" Target must be an existing directory at <host>/projects/<name>.")
|
||||
console.print(" Use 'aipass new' instead to create a brand-new project.")
|
||||
console.print()
|
||||
|
||||
|
||||
def handle_command(command: str, args: list[str]) -> bool:
|
||||
"""Route the 'adopt' command. Returns True if handled."""
|
||||
if command != COMMAND:
|
||||
return False
|
||||
|
||||
if not args:
|
||||
json_handler.log_operation("adopt_usage", {"command": command})
|
||||
print_introspection()
|
||||
return True
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
json_handler.log_operation("adopt_help", {"command": command})
|
||||
print_help()
|
||||
return True
|
||||
|
||||
name_or_path = args[0]
|
||||
known = {"--no-agent", "--dry-run"}
|
||||
unknown = [a for a in args[1:] if a not in known]
|
||||
if unknown:
|
||||
error(f"Unknown option: {unknown[0]}")
|
||||
print_help()
|
||||
return True
|
||||
no_agent = "--no-agent" in args[1:]
|
||||
dry_run = "--dry-run" in args[1:]
|
||||
|
||||
from aipass.aipass.apps.handlers.new_project import find_host_root
|
||||
from aipass.aipass.apps.handlers.new_project.adopt import adopt_project
|
||||
|
||||
target_path = Path(name_or_path)
|
||||
if not target_path.exists():
|
||||
host = find_host_root(Path.cwd())
|
||||
if host is None:
|
||||
error("Not inside an AIPass installation (no *_REGISTRY.json found).")
|
||||
sys.exit(1)
|
||||
target_path = host / "projects" / name_or_path
|
||||
|
||||
try:
|
||||
result = adopt_project(target_path, no_agent=no_agent, dry_run=dry_run)
|
||||
except RuntimeError as e:
|
||||
logger.warning("[AIPASS] adopt failed: %s", e)
|
||||
error(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
console.print()
|
||||
verb = "Would adopt" if dry_run else "Adopted"
|
||||
success(f"{verb} '{result['name']}' at {result['target']}")
|
||||
console.print()
|
||||
console.print(f" [dim]Registry:[/dim] {result['registry_file']}")
|
||||
console.print(f" [dim]Gitignore:[/dim] {result['gitignore_action']}")
|
||||
if result["agent_home"]:
|
||||
state = "created" if result["agent_created"] else "planned"
|
||||
console.print(f" [dim]Agent:[/dim] {state} ({result['agent_home']})")
|
||||
else:
|
||||
console.print(" [dim]Agent:[/dim] skipped (--no-agent)")
|
||||
console.print()
|
||||
console.print("[dim]Files:[/dim]")
|
||||
for f in result["files"]:
|
||||
console.print(f" [dim]{f}[/dim]")
|
||||
console.print()
|
||||
|
||||
json_handler.log_operation(
|
||||
"adopt_project",
|
||||
{"name": result["name"], "target": result["target"], "dry_run": dry_run},
|
||||
)
|
||||
logger.info("[AIPASS] adopt: %s at %s (dry_run=%s)", result["name"], result["target"], dry_run)
|
||||
return True
|
||||
@@ -17,11 +17,23 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, NamedTuple
|
||||
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error as cli_error, success
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.shared.registry_discovery import find_registry as _discover_registry
|
||||
|
||||
from aipass.aipass.apps.handlers.cross_os import (
|
||||
CrossOsGapError,
|
||||
PreflightResult,
|
||||
RunRecordError,
|
||||
check_hookstatus,
|
||||
check_routing,
|
||||
check_versions,
|
||||
gaps_for_platform,
|
||||
generate_run_record,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.cross_os import run_e2e as run_e2e_preflight
|
||||
from aipass.aipass.apps.handlers.cross_os.preflight import E2E_UNRUNNABLE_PREFIX
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.aipass.apps.handlers.sandbox_check.sandbox_checker import (
|
||||
check_broker_alive,
|
||||
@@ -42,13 +54,14 @@ from aipass.aipass.apps.handlers.structure_scan.structure_scanner import (
|
||||
find_project_root,
|
||||
scan_agents,
|
||||
)
|
||||
from aipass.aipass.apps.modules.doctor_fix import (
|
||||
from aipass.aipass.apps.modules._doctor_fix import (
|
||||
print_json_report,
|
||||
print_remediation_report,
|
||||
)
|
||||
from aipass.aipass.apps.modules.doctor_wire import (
|
||||
from aipass.aipass.apps.modules._doctor_wire import (
|
||||
_auto_wire_provider,
|
||||
prompt_auto_wire,
|
||||
_prompt_auto_wire as prompt_auto_wire,
|
||||
check_wire_verify,
|
||||
reconcile_stale_deny,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.system_detect.system_detector import (
|
||||
@@ -68,6 +81,8 @@ from aipass.aipass.apps.handlers.ui.progress import (
|
||||
make_doctor_progress,
|
||||
)
|
||||
|
||||
COMMAND = "doctor"
|
||||
|
||||
_BRANCH_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
@@ -139,6 +154,118 @@ def _check_system() -> List[CheckResult]:
|
||||
return results
|
||||
|
||||
|
||||
def _check_global_aipass_home() -> List[CheckResult]:
|
||||
"""Check ~/.claude/settings.json env.AIPASS_HOME for stale or temp paths."""
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import is_throwaway_path
|
||||
|
||||
results: List[CheckResult] = []
|
||||
settings_path = Path.home() / ".claude" / "settings.json"
|
||||
if not settings_path.exists():
|
||||
return results
|
||||
try:
|
||||
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.info("[doctor] global settings.json unreadable: %s", exc)
|
||||
return results
|
||||
home_val = data.get("env", {}).get("AIPASS_HOME", "")
|
||||
if not home_val:
|
||||
return results
|
||||
home_path = Path(home_val)
|
||||
if not home_path.exists():
|
||||
results.append(
|
||||
CheckResult(
|
||||
"global AIPASS_HOME",
|
||||
GLYPH_FAIL,
|
||||
f"path does not exist: {home_val}",
|
||||
"Fix: edit ~/.claude/settings.json env.AIPASS_HOME to the real repo root",
|
||||
)
|
||||
)
|
||||
elif is_throwaway_path(home_val):
|
||||
results.append(
|
||||
CheckResult(
|
||||
"global AIPASS_HOME",
|
||||
GLYPH_FAIL,
|
||||
f"points to throwaway path: {home_val}",
|
||||
"Fix: edit ~/.claude/settings.json env.AIPASS_HOME to the real repo root",
|
||||
)
|
||||
)
|
||||
else:
|
||||
results.append(CheckResult("global AIPASS_HOME", GLYPH_PASS, home_val, ""))
|
||||
return results
|
||||
|
||||
|
||||
def _check_owner_seating() -> List[CheckResult]:
|
||||
"""Check owner/identity health via the frozen sync-registry --check contract."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["drone", "@spawn", "sync-registry", "--check", "--json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.info("[doctor] drone not on PATH — skipping owner seating check")
|
||||
return [CheckResult("owner", GLYPH_WARN, "drone not found", "Install drone to check owner seating")]
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("[doctor] sync-registry --check timed out")
|
||||
return [CheckResult("owner", GLYPH_WARN, "check timed out", "")]
|
||||
|
||||
stdout = proc.stdout.strip()
|
||||
if not stdout:
|
||||
if proc.returncode == 0:
|
||||
return [CheckResult("owner", GLYPH_PASS, "clean (no details)", "")]
|
||||
return [CheckResult("owner", GLYPH_WARN, "no output from check", "")]
|
||||
|
||||
try:
|
||||
data = json.loads(stdout)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("[doctor] sync-registry --check returned non-JSON: %s", stdout[:200])
|
||||
return [CheckResult("owner", GLYPH_WARN, "unparseable check output", "")]
|
||||
|
||||
issues = data.get("issues", [])
|
||||
owner_name = data.get("owner")
|
||||
owner_uid = data.get("owner_uid", "")
|
||||
uid_short = owner_uid[:8] if owner_uid else ""
|
||||
|
||||
if data.get("clean", False) and not issues:
|
||||
detail = f"@{owner_name} OK (seated, uid {uid_short})" if owner_name else "OK"
|
||||
return [CheckResult("owner", GLYPH_PASS, detail, "")]
|
||||
|
||||
results: List[CheckResult] = []
|
||||
for issue in issues:
|
||||
flag = issue.get("flag", "unknown")
|
||||
detail = issue.get("detail", flag)
|
||||
results.append(CheckResult(f"owner/{flag}", GLYPH_FAIL, detail, "Run 'aipass doctor --fix'"))
|
||||
|
||||
if not results:
|
||||
label = f"@{owner_name} ISSUES" if owner_name else "UNSEATED"
|
||||
results.append(CheckResult("owner", GLYPH_FAIL, label, "Run 'aipass doctor --fix'"))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _fix_owner_seating() -> List[CheckResult]:
|
||||
"""Delegate owner/identity repair to spawn's sync-registry --fix."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["drone", "@spawn", "sync-registry", "--fix"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.info("[doctor] drone not on PATH — skipping owner fix")
|
||||
return [CheckResult("owner fix", GLYPH_WARN, "drone not found", "")]
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("[doctor] sync-registry --fix timed out")
|
||||
return [CheckResult("owner fix", GLYPH_WARN, "fix timed out", "")]
|
||||
|
||||
if proc.returncode == 0:
|
||||
return [CheckResult("owner fix", GLYPH_PASS, "registry reconciled", "")]
|
||||
detail = proc.stderr.strip()[:120] if proc.stderr else "non-zero exit"
|
||||
return [CheckResult("owner fix", GLYPH_FAIL, detail, "")]
|
||||
|
||||
|
||||
def _check_identity() -> List[CheckResult]:
|
||||
"""Run Identity group checks."""
|
||||
results: List[CheckResult] = []
|
||||
@@ -161,6 +288,8 @@ def _check_identity() -> List[CheckResult]:
|
||||
)
|
||||
)
|
||||
|
||||
results.extend(_check_global_aipass_home())
|
||||
|
||||
if reg_path is None:
|
||||
results.append(CheckResult("registry", GLYPH_FAIL, "not found", "Run 'aipass init' to create registry"))
|
||||
return results
|
||||
@@ -210,6 +339,8 @@ def _check_identity() -> List[CheckResult]:
|
||||
else:
|
||||
results.append(CheckResult("passport", GLYPH_WARN, "not found", ""))
|
||||
|
||||
results.extend(_check_owner_seating())
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@@ -369,7 +500,7 @@ def _check_provider_manifest(interactive: bool = False, fix: bool = False) -> Li
|
||||
if fix:
|
||||
actions = _auto_wire_provider(manifest_path, interactive=False)
|
||||
for action in actions:
|
||||
console.print(f"[green]✓[/green] {action}")
|
||||
success(action)
|
||||
wired = bool(actions)
|
||||
else:
|
||||
wired = prompt_auto_wire(manifest_path, missing_hooks, missing_env, missing_deny, missing_ask)
|
||||
@@ -456,6 +587,9 @@ def _check_services(verbose: bool = False) -> List[CheckResult]:
|
||||
manifest_checks = _check_provider_manifest()
|
||||
results.extend(manifest_checks)
|
||||
|
||||
# wire_verify guard — catch empty/orphaned/duplicate provider hook entries
|
||||
results.extend(CheckResult(*r) for r in check_wire_verify())
|
||||
|
||||
# stale rm deny rules — detect only (fix runs in run_doctor when --fix)
|
||||
for tup in reconcile_stale_deny(fix=False):
|
||||
results.append(CheckResult(*tup))
|
||||
@@ -536,7 +670,7 @@ def _check_structure() -> List[CheckResult]:
|
||||
# Registry consistency
|
||||
reg_path = _discover_registry(start_path=project_root)
|
||||
if reg_path and reg_path.exists():
|
||||
reg_issues = check_registry_consistency(reg_path, agents)
|
||||
reg_issues = check_registry_consistency(reg_path, agents, project_root=project_root)
|
||||
if reg_issues:
|
||||
for issue in reg_issues:
|
||||
glyph = GLYPH_FAIL if issue.problem == "missing" else GLYPH_WARN
|
||||
@@ -552,7 +686,7 @@ def _check_structure() -> List[CheckResult]:
|
||||
root_hits = check_root_artifacts(project_root)
|
||||
if root_hits:
|
||||
for hit in root_hits:
|
||||
glyph = GLYPH_WARN if hit.severity == "warn" else GLYPH_PASS
|
||||
glyph = GLYPH_PASS if hit.severity == "pass" else GLYPH_WARN
|
||||
results.append(CheckResult(f"root: {hit.name}", glyph, hit.description, ""))
|
||||
else:
|
||||
results.append(CheckResult("root artifacts", GLYPH_PASS, "none misplaced", ""))
|
||||
@@ -661,6 +795,197 @@ def _check_sandbox() -> List[CheckResult]:
|
||||
return results
|
||||
|
||||
|
||||
# --- Cross-OS pre-flight group ---
|
||||
|
||||
|
||||
def _cross_os_gap_rows() -> List[CheckResult]:
|
||||
"""OS-gap cross-reference rows (slice 1): tracked gaps for this platform.
|
||||
|
||||
Machine pre-flight — surfaces OS-specific gaps from tests/CROSS_OS_TESTING.md
|
||||
for this box. Never claims the checklist's human green. WARN per gap, a single
|
||||
PASS when none apply, a single WARN when the registry can't be read (never
|
||||
silent).
|
||||
"""
|
||||
platform_name = sys.platform
|
||||
try:
|
||||
gaps = gaps_for_platform(platform_name)
|
||||
except CrossOsGapError as exc:
|
||||
logger.warning("[doctor] cross-OS gap registry unavailable: %s", exc)
|
||||
return [
|
||||
CheckResult(
|
||||
"cross-os registry (pre-flight)",
|
||||
GLYPH_WARN,
|
||||
f"pre-flight: gap registry unavailable — {exc}",
|
||||
"Ensure tests/CROSS_OS_TESTING.md has a 'Known cross-OS gap registry' table",
|
||||
)
|
||||
]
|
||||
|
||||
if not gaps:
|
||||
return [
|
||||
CheckResult(
|
||||
"cross-os (pre-flight)",
|
||||
GLYPH_PASS,
|
||||
f"pre-flight: no tracked cross-OS gaps for {platform_name}",
|
||||
"",
|
||||
)
|
||||
]
|
||||
|
||||
return [
|
||||
CheckResult(
|
||||
f"cross-os gap #{gap.number} (pre-flight)",
|
||||
GLYPH_WARN,
|
||||
f"pre-flight: {gap.symptom}",
|
||||
f"tracked gap [{gap.status}] — owner {gap.owner}; human Layer-3 pass still required",
|
||||
)
|
||||
for gap in gaps
|
||||
]
|
||||
|
||||
|
||||
def _preflight_row(label: str, result: PreflightResult, remediation: str) -> CheckResult:
|
||||
"""Map a non-mutating PreflightResult to a labelled pre-flight CheckResult.
|
||||
|
||||
ok -> PASS, else FAIL. The detail is always prefixed 'pre-flight:' so a row
|
||||
can never be mistaken for the checklist's human acceptance green.
|
||||
"""
|
||||
glyph = GLYPH_PASS if result.ok else GLYPH_FAIL
|
||||
return CheckResult(f"{label} (pre-flight)", glyph, f"pre-flight: {result.detail}", "" if result.ok else remediation)
|
||||
|
||||
|
||||
def _e2e_row(result: PreflightResult) -> CheckResult:
|
||||
"""Map the heavy e2e PreflightResult to a CheckResult (PASS/FAIL/WARN).
|
||||
|
||||
ok -> PASS. Un-runnable infra cases (dir missing, no pytest, timeout) -> WARN.
|
||||
Real test failures -> FAIL.
|
||||
"""
|
||||
if result.ok:
|
||||
glyph, remediation = GLYPH_PASS, ""
|
||||
elif result.detail.startswith(E2E_UNRUNNABLE_PREFIX):
|
||||
glyph = GLYPH_WARN
|
||||
remediation = "Ensure a project .venv with pytest (or system pytest) and tests/e2e are present"
|
||||
else:
|
||||
glyph, remediation = GLYPH_FAIL, "Run 'pytest tests/e2e -q' from the repo root to inspect the failures"
|
||||
return CheckResult("e2e suite (pre-flight)", glyph, f"pre-flight: {result.detail}", remediation)
|
||||
|
||||
|
||||
def _check_cross_os(run_e2e: bool = False) -> List[CheckResult]:
|
||||
"""Cross-OS pre-flight group (Layer-3-lite): gap cross-reference + machine routes.
|
||||
|
||||
Combines the slice-1 OS-gap rows with the non-mutating routing / --version /
|
||||
hookstatus probes (Phase 4 / 1.3 / 6.3). None of these wake a citizen. When
|
||||
``run_e2e`` is set, also runs the heavy Phase-2 e2e suite. Every row is
|
||||
labelled pre-flight and still needs the human Layer-3 pass.
|
||||
"""
|
||||
results = _cross_os_gap_rows()
|
||||
|
||||
results.append(
|
||||
_preflight_row(
|
||||
"routing", check_routing(), "Ensure aipass is installed (setup.sh) so 'drone systems' and routes resolve"
|
||||
)
|
||||
)
|
||||
results.append(
|
||||
_preflight_row(
|
||||
"versions", check_versions(), "Ensure 'drone' and 'aipass' are on PATH (clone the repo, run setup.sh)"
|
||||
)
|
||||
)
|
||||
results.append(
|
||||
_preflight_row("hookstatus", check_hookstatus(), "Check @hooks routing: 'drone @hooks status' should exit 0")
|
||||
)
|
||||
|
||||
if run_e2e:
|
||||
results.append(_e2e_row(run_e2e_preflight()))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def run_cross_os(run_e2e: bool = False) -> int:
|
||||
"""Render only the cross-OS pre-flight group (`aipass doctor --cross-os`).
|
||||
|
||||
Returns the error (FAIL) count; warnings do not fail, matching run_doctor.
|
||||
When ``run_e2e`` is set (``--cross-os --e2e``), the heavy e2e suite runs too.
|
||||
"""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass doctor --cross-os[/bold cyan]")
|
||||
console.print("[dim]machine pre-flight (Layer-3-lite) — augments, never replaces, the human acceptance pass[/dim]")
|
||||
if run_e2e:
|
||||
console.print("[dim]--e2e: running the heavy Phase-2 e2e wiring suite (builds a wheel + fresh venv)…[/dim]")
|
||||
console.print()
|
||||
|
||||
checks = _check_cross_os(run_e2e=run_e2e)
|
||||
pass_count = 0
|
||||
warn_count = 0
|
||||
error_count = 0
|
||||
|
||||
console.print(" [bold]Cross-OS[/bold]")
|
||||
for check in checks:
|
||||
line = format_check(check.label, check.glyph, check.detail, check.remediation)
|
||||
console.print(line)
|
||||
if check.glyph == GLYPH_PASS:
|
||||
pass_count += 1
|
||||
elif check.glyph == GLYPH_WARN:
|
||||
warn_count += 1
|
||||
else:
|
||||
error_count += 1
|
||||
console.print()
|
||||
|
||||
console.print("[dim]─────────────────────────────────[/dim]")
|
||||
console.print(
|
||||
f" [green]✓ pass: {pass_count}[/green] "
|
||||
f"[yellow]! warnings: {warn_count}[/yellow] "
|
||||
f"[red]✗ errors: {error_count}[/red]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
logger.info("[doctor] cross-os run — pass=%d warn=%d error=%d", pass_count, warn_count, error_count)
|
||||
return error_count
|
||||
|
||||
|
||||
def _record_path_arg(args: list[str]) -> str | None:
|
||||
"""Extract the optional PATH value following ``--record`` (None if absent).
|
||||
|
||||
``--record`` may stand alone (default path) or be followed by a path; a
|
||||
following token that starts with ``-`` is another flag, not the path.
|
||||
"""
|
||||
if "--record" not in args:
|
||||
return None
|
||||
idx = args.index("--record")
|
||||
if idx + 1 < len(args):
|
||||
candidate = args[idx + 1]
|
||||
if not candidate.startswith("-"):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def run_cross_os_record(path: str | None = None, run_e2e: bool = False) -> int:
|
||||
"""Generate a machine pre-flight Run Record (`aipass doctor --cross-os --record`).
|
||||
|
||||
Thin console wrapper: generation (env capture + machine-provable rows, human
|
||||
rows left blank/marked) lives in the cross_os handler. Returns 0 on success,
|
||||
1 if the file could not be written (never crashes).
|
||||
"""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass doctor --cross-os --record[/bold cyan]")
|
||||
console.print(
|
||||
"[dim]machine pre-flight DRAFT — auto-fills what the machine can prove; "
|
||||
"a human still runs the real Layer-3 pass[/dim]"
|
||||
)
|
||||
if run_e2e:
|
||||
console.print("[dim]--e2e: running the heavy Phase-2 e2e suite (builds a wheel + fresh venv)…[/dim]")
|
||||
console.print()
|
||||
|
||||
try:
|
||||
written = generate_run_record(path, run_heavy_e2e=run_e2e)
|
||||
except RunRecordError as exc:
|
||||
cli_error(str(exc))
|
||||
logger.error("[doctor] cross-os run record failed: %s", exc)
|
||||
return 1
|
||||
|
||||
success(f"Run Record written: {written}")
|
||||
console.print("[dim]Complete the '— human' rows and run the real Layer-3 acceptance pass before it counts.[/dim]")
|
||||
console.print()
|
||||
logger.info("[doctor] cross-os run record written to %s", written)
|
||||
return 0
|
||||
|
||||
|
||||
# --- Main doctor run ---
|
||||
|
||||
|
||||
@@ -698,6 +1023,14 @@ def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = Fal
|
||||
services = groups.get("Services", [])
|
||||
groups["Services"] = [r for r in services if r.label != "rm deny migration"] + stale_results
|
||||
|
||||
wire_recheck = [CheckResult(*r) for r in check_wire_verify()]
|
||||
services = groups.get("Services", [])
|
||||
groups["Services"] = [r for r in services if r.label != "wire verify"] + wire_recheck
|
||||
|
||||
owner_fix = _fix_owner_seating()
|
||||
identity = groups.get("Identity", [])
|
||||
groups["Identity"] = [r for r in identity if not r.label.startswith("owner")] + owner_fix
|
||||
|
||||
pass_count = 0
|
||||
warn_count = 0
|
||||
error_count = 0
|
||||
@@ -750,10 +1083,16 @@ def print_help() -> None:
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [green]aipass doctor[/green] [dim]# Run all checks[/dim]")
|
||||
console.print(" [green]aipass doctor --verbose[/green] [dim]# Show sub-check detail[/dim]")
|
||||
console.print(" [green]aipass doctor --fix[/green] [dim]# Auto-wire + remediation report[/dim]")
|
||||
console.print(" [green]aipass doctor --fix[/green] [dim]# Auto-wire, owner seat repair + remediation[/dim]")
|
||||
console.print(" [green]aipass doctor --fix --json[/green][dim]# Remediation as JSON (for spawn)[/dim]")
|
||||
console.print(" [green]aipass doctor --cross-os[/green][dim]# OS-gap + routing/version/hooks pre-flight[/dim]")
|
||||
console.print(" [green]aipass doctor --cross-os --e2e[/green][dim]# …also run the heavy e2e suite[/dim]")
|
||||
console.print(
|
||||
" [green]aipass doctor --cross-os --record [PATH][/green]"
|
||||
"[dim]# write a machine pre-flight Run Record draft (human completes it)[/dim]"
|
||||
)
|
||||
console.print()
|
||||
console.print("[yellow]OUTPUT:[/yellow] [green]✓[/green] pass [yellow]![/yellow] warn [red]✗[/red] error")
|
||||
console.print("[yellow]OUTPUT:[/yellow] pass / warn / error (color-coded)")
|
||||
console.print("[yellow]EXIT:[/yellow] 0 = pass/warn | 1 = errors found")
|
||||
console.print()
|
||||
|
||||
@@ -782,6 +1121,21 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if "--cross-os" in args:
|
||||
e2e = "--e2e" in args
|
||||
if "--record" in args:
|
||||
record_path = _record_path_arg(args)
|
||||
rc = run_cross_os_record(record_path, run_e2e=e2e)
|
||||
json_handler.log_operation("doctor_cross_os_record", {"path": record_path, "e2e": e2e, "rc": rc})
|
||||
if rc != 0:
|
||||
raise SystemExit(1)
|
||||
return True
|
||||
error_count = run_cross_os(run_e2e=e2e)
|
||||
json_handler.log_operation("doctor_cross_os", {"error_count": error_count, "e2e": e2e})
|
||||
if error_count > 0:
|
||||
raise SystemExit(1)
|
||||
return True
|
||||
|
||||
verbose = "--verbose" in args or "-v" in args
|
||||
fix_mode = "--fix" in args
|
||||
json_mode = "--json" in args
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: feedback.py
|
||||
# Description: aipass feedback — toggle the feedback reminder pulse on/off
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-18
|
||||
# Modified: 2026-07-18
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
aipass feedback — user-facing alias for the @hooks feedback toggle.
|
||||
|
||||
Usage:
|
||||
aipass feedback # show current state
|
||||
aipass feedback on # enable feedback reminders
|
||||
aipass feedback off # disable feedback reminders
|
||||
aipass feedback --help
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from aipass.cli.apps.modules import console, error, warning
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
COMMAND = "feedback"
|
||||
|
||||
_DRONE_TIMEOUT = 15
|
||||
|
||||
|
||||
def _run_hooks_feedback(action: str | None) -> int:
|
||||
"""Delegate to drone @hooks feedback. Returns the subprocess exit code."""
|
||||
cmd = ["drone", "@hooks", "feedback"]
|
||||
if action:
|
||||
cmd.append(action)
|
||||
try:
|
||||
proc = subprocess.run(cmd, timeout=_DRONE_TIMEOUT)
|
||||
return proc.returncode
|
||||
except FileNotFoundError:
|
||||
logger.warning("[feedback] drone not found on PATH")
|
||||
warning("drone not found on PATH — cannot reach @hooks.")
|
||||
return 1
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("[feedback] drone @hooks feedback timed out")
|
||||
warning("drone @hooks feedback timed out.")
|
||||
return 1
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print usage help for the feedback command."""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass feedback[/bold cyan] — toggle the feedback reminder")
|
||||
console.print()
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [green]aipass feedback[/green] [dim]# show current state[/dim]")
|
||||
console.print(" [green]aipass feedback on[/green] [dim]# enable feedback reminders[/dim]")
|
||||
console.print(" [green]aipass feedback off[/green] [dim]# disable feedback reminders[/dim]")
|
||||
console.print()
|
||||
console.print("[dim]Delegates to: drone @hooks feedback[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Show module info for feedback."""
|
||||
console.print()
|
||||
console.print("[bold cyan]feedback Module[/bold cyan]")
|
||||
console.print("User-facing alias for the @hooks feedback pulse toggle.")
|
||||
console.print()
|
||||
console.print("[dim]Delegates to: drone @hooks feedback on/off[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def handle_command(command: str, args: list[str]) -> bool:
|
||||
"""Route the feedback command. Returns True if handled."""
|
||||
if command != COMMAND:
|
||||
return False
|
||||
|
||||
if args and args[0] in ("--help", "-h", "help"):
|
||||
json_handler.log_operation("feedback_help", {"command": command})
|
||||
print_help()
|
||||
return True
|
||||
if args and args[0] in ("--info", "info"):
|
||||
json_handler.log_operation("feedback_info", {"command": command})
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if not args:
|
||||
json_handler.log_operation("feedback_usage", {"command": command})
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
action = args[0] if args[0] in ("on", "off") else None
|
||||
if action is None:
|
||||
error(f"Unknown option: {args[0]}. Use 'on' or 'off'.")
|
||||
print_help()
|
||||
return True
|
||||
|
||||
rc = _run_hooks_feedback(action)
|
||||
json_handler.log_operation(
|
||||
"feedback_toggle",
|
||||
{"action": action or "status", "exit": rc},
|
||||
)
|
||||
if rc != 0:
|
||||
logger.warning("[feedback] drone @hooks feedback exited %d", rc)
|
||||
return True
|
||||
@@ -21,14 +21,14 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aipass.cli.apps.modules import console, warning
|
||||
from aipass.cli.apps.modules import console, success, warning
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
COMMAND = "handoff"
|
||||
|
||||
_INIT_PROMPT = "I just completed aipass init and am ready to start. What should I do first?"
|
||||
INIT_PROMPT = "I just completed aipass init and am ready to start. What should I do first?"
|
||||
|
||||
CLI_CHOICES = ["claude", "codex"]
|
||||
FLAG_CHOICES = ["default", "skip-permissions"]
|
||||
@@ -48,7 +48,7 @@ def _get_stored_profile() -> dict:
|
||||
|
||||
def do_handoff(
|
||||
cli: str = "claude",
|
||||
prompt: str = _INIT_PROMPT,
|
||||
prompt: str = INIT_PROMPT,
|
||||
cwd: str = ".",
|
||||
flag_variant: str = "default",
|
||||
) -> bool:
|
||||
@@ -64,7 +64,7 @@ def do_handoff(
|
||||
|
||||
if launched:
|
||||
console.print()
|
||||
console.print(f"[green]✓[/green] Session started via tmux/wt — CLI: [cyan]{cli}[/cyan]")
|
||||
success(f"Session started via tmux/wt — CLI: {cli}")
|
||||
console.print(f"[dim]Session name: aipass-handoff | cwd: {cwd}[/dim]")
|
||||
console.print()
|
||||
else:
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: init_flow.py
|
||||
# Description: 12-stage guided first-run setup — aipass init command
|
||||
# Version: 1.0.0
|
||||
# Description: 10-stage guided first-run setup — aipass init command
|
||||
# Version: 1.2.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-16
|
||||
# Modified: 2026-07-04
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
aipass init — guided first-run setup
|
||||
|
||||
12 resumable stages. State persists to .aipass/init_progress.json.
|
||||
10 resumable stages. State persists to .aipass/init_progress.json.
|
||||
Ctrl-C at any stage resumes next time from that stage.
|
||||
|
||||
Usage:
|
||||
@@ -17,9 +17,9 @@ Usage:
|
||||
aipass init run # interactive
|
||||
aipass init run --non-interactive # CI/headless, all defaults
|
||||
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)
|
||||
aipass init run --dry-run # walk all 10 stages, no destructive ops
|
||||
# - skips drone @spawn create (stage 6)
|
||||
# - skips tmux/wt handoff (stage 9)
|
||||
# - does NOT write .aipass/init_progress.json
|
||||
"""
|
||||
|
||||
@@ -35,13 +35,13 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from aipass.cli.apps.modules import console, warning
|
||||
from aipass.cli.apps.modules import console, error as cli_error, success, warning
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.aipass.apps.handlers.ui.progress import activity_spinner, render_step_header
|
||||
from aipass.aipass.apps.handlers.system_detect.system_detector import (
|
||||
detect_cpu,
|
||||
detect_docker,
|
||||
detect_git,
|
||||
detect_install_method,
|
||||
detect_os,
|
||||
@@ -62,7 +62,7 @@ except ImportError as _qe:
|
||||
HAS_QUESTIONARY = False
|
||||
|
||||
COMMAND = "init"
|
||||
TOTAL_STAGES = 12
|
||||
TOTAL_STAGES = 10
|
||||
|
||||
_BRANCH_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -95,7 +95,8 @@ STYLE_CHOICES = ["building-my-own-project", "improving-aipass", "just-exploring"
|
||||
TEMPLATE_EMPTY = "empty project"
|
||||
TEMPLATE_AIPASS = "aipass_framework"
|
||||
TEMPLATE_CHOICES = [TEMPLATE_EMPTY, TEMPLATE_AIPASS]
|
||||
AIPASS_SPECIFIC_STAGES = {8, 9, 11, 12}
|
||||
# first_agent, ping_sweep, handoff, done — skipped for empty (non-framework) projects
|
||||
AIPASS_SPECIFIC_STAGES = {6, 7}
|
||||
|
||||
|
||||
# --- LOCAL JSON HELPERS ---
|
||||
@@ -209,7 +210,7 @@ def _choose(msg: str, choices: List[str], default: str | None = None) -> str:
|
||||
return choices[idx]
|
||||
except ValueError as exc:
|
||||
logger.info("[init_flow] invalid menu input %r: %s", raw, exc)
|
||||
console.print("[red]Invalid choice.[/red]")
|
||||
cli_error("Invalid choice.")
|
||||
|
||||
|
||||
# --- STAGE FUNCTIONS ---
|
||||
@@ -233,15 +234,47 @@ def stage_1_welcome(dry_run: bool = False) -> Dict[str, Any]:
|
||||
if dry_run:
|
||||
console.print("[yellow]\\[dry-run][/yellow] No state will be written, no subprocesses launched.")
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 1/12[/bold cyan] — Welcome")
|
||||
console.print(render_step_header(1, TOTAL_STAGES, "Welcome"))
|
||||
_save_stage(1, dry_run=dry_run)
|
||||
return {}
|
||||
|
||||
|
||||
def _print_os_gap_heads_up() -> None:
|
||||
"""Print tracked cross-OS gaps that apply to this OS (machine pre-flight, not a guarantee).
|
||||
|
||||
Lightweight: one doc read + parse. Failures are non-fatal — init must not
|
||||
crash here. This is NOT the human acceptance pass; it just surfaces tracked
|
||||
gaps at the moment the user is on the box.
|
||||
"""
|
||||
try:
|
||||
from aipass.aipass.apps.handlers.cross_os import CrossOsGapError, gaps_for_platform
|
||||
except ImportError as exc:
|
||||
logger.warning("[init_flow] cross_os handler unavailable: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
gaps = gaps_for_platform(sys.platform)
|
||||
except CrossOsGapError as exc:
|
||||
logger.warning("[init_flow] cross-OS gap registry unavailable: %s", exc)
|
||||
warning("cross-OS gap registry unavailable — skipping OS heads-up.")
|
||||
return
|
||||
|
||||
if not gaps:
|
||||
return
|
||||
|
||||
console.print()
|
||||
console.print(
|
||||
f"[dim]Heads-up — {len(gaps)} tracked cross-OS gap(s) may apply on this OS "
|
||||
"(machine pre-flight, not a guarantee):[/dim]"
|
||||
)
|
||||
for gap in gaps:
|
||||
console.print(f" [yellow]![/yellow] [dim]gap #{gap.number}: {gap.symptom} [{gap.status}][/dim]")
|
||||
|
||||
|
||||
def stage_2_system_detect(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Detect OS, Python, shell, RAM, CPU, install method, and optional tools."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 2/12[/bold cyan] — System detection")
|
||||
console.print(render_step_header(2, TOTAL_STAGES, "System detection"))
|
||||
|
||||
from rich.table import Table
|
||||
|
||||
@@ -254,7 +287,6 @@ def stage_2_system_detect(non_interactive: bool = False, dry_run: bool = False)
|
||||
install = detect_install_method()
|
||||
has_tmux = detect_tmux()
|
||||
has_wt = detect_wt()
|
||||
has_docker = detect_docker()
|
||||
|
||||
table = Table(show_header=False, box=None)
|
||||
table.add_column("key", style="cyan")
|
||||
@@ -269,7 +301,6 @@ def stage_2_system_detect(non_interactive: bool = False, dry_run: bool = False)
|
||||
table.add_row("tmux", "yes" if has_tmux else "no")
|
||||
if sys.platform == "win32":
|
||||
table.add_row("wt.exe", "yes" if has_wt else "no")
|
||||
table.add_row("docker", "yes" if has_docker else "no")
|
||||
console.print(table)
|
||||
|
||||
console.print()
|
||||
@@ -277,50 +308,21 @@ def stage_2_system_detect(non_interactive: bool = False, dry_run: bool = False)
|
||||
install_labels = {"dev": "development (editable source)", "pip": "pip", "clone": "git clone", "unknown": "unknown"}
|
||||
console.print(f"Install type: [cyan]{install_labels.get(install, install)}[/cyan]")
|
||||
|
||||
_print_os_gap_heads_up()
|
||||
|
||||
system_data: Dict[str, Any] = {
|
||||
"os": os_info["os_name"],
|
||||
"python": py["version"],
|
||||
"shell": sh["name"],
|
||||
"ram_gb": ram["total_gb"],
|
||||
"install": install,
|
||||
"has_docker": has_docker,
|
||||
"has_tmux": has_tmux,
|
||||
}
|
||||
_save_stage(2, system_data, dry_run=dry_run)
|
||||
return system_data
|
||||
|
||||
|
||||
def stage_3_doctor(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Run aipass doctor health checks inline."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 3/12[/bold cyan] — System health check")
|
||||
|
||||
error_count = 0
|
||||
provider_gaps: Dict[str, Any] = {}
|
||||
try:
|
||||
from aipass.aipass.apps.modules import doctor
|
||||
|
||||
error_count = doctor.run_doctor(interactive=not non_interactive)
|
||||
try:
|
||||
for r in doctor._check_provider_manifest():
|
||||
if r.glyph != doctor.GLYPH_PASS:
|
||||
provider_gaps[r.label] = r.detail
|
||||
except Exception as exc:
|
||||
logger.warning("[init_flow] provider manifest check failed: %s", exc)
|
||||
except Exception as exc:
|
||||
logger.warning("[init_flow] doctor run failed: %s", exc)
|
||||
warning(f"Doctor check skipped: {exc}")
|
||||
|
||||
if error_count > 0:
|
||||
warning(f"{error_count} issue(s) found above — review when convenient.")
|
||||
else:
|
||||
console.print("[green]✓[/green] Health check passed.")
|
||||
|
||||
_save_stage(3, {"doctor_errors": error_count}, dry_run=dry_run)
|
||||
return {"doctor_errors": error_count, "provider_gaps": provider_gaps}
|
||||
|
||||
|
||||
def stage_4_user_profile(
|
||||
def stage_3_user_profile(
|
||||
non_interactive: bool = False,
|
||||
name_override: str | None = None,
|
||||
system_data: dict | None = None,
|
||||
@@ -328,7 +330,7 @@ def stage_4_user_profile(
|
||||
) -> Dict[str, Any]:
|
||||
"""Collect user name and OS, save to profile."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 4/12[/bold cyan] — User profile")
|
||||
console.print(render_step_header(3, TOTAL_STAGES, "User profile"))
|
||||
|
||||
from aipass.aipass.apps.modules import profile as profile_mod
|
||||
|
||||
@@ -356,19 +358,19 @@ def stage_4_user_profile(
|
||||
else:
|
||||
profile_mod.save_profile(existing)
|
||||
|
||||
console.print(f"[green]✓[/green] Hello, {name}!")
|
||||
_save_stage(4, {"name": name}, dry_run=dry_run)
|
||||
success(f"Hello, {name}!")
|
||||
_save_stage(3, {"name": name}, dry_run=dry_run)
|
||||
return {"name": name}
|
||||
|
||||
|
||||
def stage_5_style_questions(
|
||||
def stage_4_style_questions(
|
||||
non_interactive: bool = False,
|
||||
style_override: str | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Ask what the user wants to do — routes tone of later stages."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 5/12[/bold cyan] — What brings you here?")
|
||||
console.print(render_step_header(4, TOTAL_STAGES, "What brings you here?"))
|
||||
|
||||
if style_override and style_override in STYLE_CHOICES:
|
||||
style = style_override
|
||||
@@ -377,8 +379,8 @@ def stage_5_style_questions(
|
||||
else:
|
||||
style = _choose("What are you looking to do?", STYLE_CHOICES, default=STYLE_CHOICES[0])
|
||||
|
||||
console.print(f"[green]✓[/green] Got it: {style}")
|
||||
_save_stage(5, {"style": style}, dry_run=dry_run)
|
||||
success(f"Got it: {style}")
|
||||
_save_stage(4, {"style": style}, dry_run=dry_run)
|
||||
return {"style": style}
|
||||
|
||||
|
||||
@@ -415,29 +417,29 @@ def _handle_missing_claude(non_interactive: bool) -> None:
|
||||
"""Prompt to install Claude Code when missing, or warn in non-interactive mode."""
|
||||
if non_interactive:
|
||||
warning("[bold yellow]Claude Code ('claude') is not installed.[/bold yellow]")
|
||||
console.print(" Stage 11 handoff requires it. Install manually before then.")
|
||||
console.print(" Stage 9 handoff requires it. Install manually before then.")
|
||||
return
|
||||
|
||||
raw = _prompt("Claude Code ('claude') not found. Install now? [Y/n]", "Y")
|
||||
if raw.lower() in ("y", "yes", ""):
|
||||
console.print("[dim]Installing Claude Code...[/dim]")
|
||||
console.print("[cyan]Installing Claude Code[/cyan] [dim](this can take a minute)…[/dim]")
|
||||
if _install_claude_code():
|
||||
console.print("[green]✓[/green] Claude Code installed successfully.")
|
||||
success("Claude Code installed successfully.")
|
||||
else:
|
||||
warning("[bold yellow]Installation failed.[/bold yellow]")
|
||||
console.print(" Install manually: https://claude.ai/download")
|
||||
else:
|
||||
console.print("[dim]Skipped. Stage 11 handoff will need 'claude' on PATH.[/dim]")
|
||||
console.print("[dim]Skipped. Stage 9 handoff will need 'claude' on PATH.[/dim]")
|
||||
|
||||
|
||||
def stage_6_tool_choice(
|
||||
def stage_5_tool_choice(
|
||||
non_interactive: bool = False,
|
||||
cli_override: str | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Choose CLI tool and launch flag variant."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 6/12[/bold cyan] — CLI tool choice")
|
||||
console.print(render_step_header(5, TOTAL_STAGES, "CLI tool choice"))
|
||||
|
||||
if cli_override and cli_override in CLI_CHOICES:
|
||||
cli_choice = cli_override
|
||||
@@ -458,8 +460,8 @@ def stage_6_tool_choice(
|
||||
default="default",
|
||||
)
|
||||
|
||||
console.print(f"[green]✓[/green] {cli_choice} ({flag_variant})")
|
||||
_save_stage(6, {"cli": cli_choice, "flag_variant": flag_variant}, dry_run=dry_run)
|
||||
success(f"{cli_choice} ({flag_variant})")
|
||||
_save_stage(5, {"cli": cli_choice, "flag_variant": flag_variant}, dry_run=dry_run)
|
||||
|
||||
if dry_run:
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would save preferred_cli={cli_choice} to profile")
|
||||
@@ -476,37 +478,10 @@ def stage_6_tool_choice(
|
||||
return {"cli": cli_choice, "flag_variant": flag_variant}
|
||||
|
||||
|
||||
def stage_7_docker_offer(
|
||||
non_interactive: bool = False,
|
||||
no_docker: bool = False,
|
||||
has_docker: bool | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Offer Docker sandbox test if Docker is detected."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 7/12[/bold cyan] — Docker")
|
||||
|
||||
if has_docker is None:
|
||||
has_docker = detect_docker()
|
||||
|
||||
if not has_docker or no_docker or non_interactive:
|
||||
reason = "not detected" if not has_docker else ("--no-docker" if no_docker else "non-interactive")
|
||||
console.print(f"[dim]Docker offer skipped ({reason}).[/dim]")
|
||||
_save_stage(7, {"docker": "skipped"}, dry_run=dry_run)
|
||||
return {"docker": "skipped"}
|
||||
|
||||
raw = _prompt("Test in a Docker sandbox? [y/N]", "N")
|
||||
use_docker = raw.lower() in ("y", "yes")
|
||||
result = "yes" if use_docker else "no"
|
||||
console.print(f"[green]✓[/green] Docker: {result}")
|
||||
_save_stage(7, {"docker": result}, dry_run=dry_run)
|
||||
return {"docker": result}
|
||||
|
||||
|
||||
def stage_8_first_agent(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
def stage_6_first_agent(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Create the user's first AI agent via drone @spawn."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 8/12[/bold cyan] — Create your first agent")
|
||||
console.print(render_step_header(6, TOTAL_STAGES, "Create your first agent"))
|
||||
console.print("Let's create your first AI agent (citizen).")
|
||||
|
||||
if non_interactive:
|
||||
@@ -519,41 +494,41 @@ def stage_8_first_agent(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
agent_path = f"{package_dir}/{agent_name}"
|
||||
else:
|
||||
agent_path = f"src/{agent_name}"
|
||||
console.print(f"Running: [cyan]drone @spawn create {agent_path}[/cyan]")
|
||||
console.print(f"[cyan]Creating your first agent[/cyan] [dim](drone @spawn create {agent_path})…[/dim]")
|
||||
|
||||
success = False
|
||||
spawned = False
|
||||
if dry_run:
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would run: drone @spawn create {agent_path}")
|
||||
success = True
|
||||
spawned = True
|
||||
else:
|
||||
try:
|
||||
proc = subprocess.run(["drone", "@spawn", "create", agent_path], timeout=60)
|
||||
success = proc.returncode == 0
|
||||
spawned = proc.returncode == 0
|
||||
except FileNotFoundError as exc:
|
||||
logger.warning("[init_flow] drone not found in stage 8: %s", exc)
|
||||
logger.warning("[init_flow] drone not found in stage 6: %s", exc)
|
||||
warning("drone not found — skipping agent creation.")
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
logger.warning("[init_flow] spawn timed out in stage 8: %s", exc)
|
||||
logger.warning("[init_flow] spawn timed out in stage 6: %s", exc)
|
||||
warning("spawn timed out — agent may still be created.")
|
||||
|
||||
if success:
|
||||
console.print(f"[green]✓[/green] Agent created at {agent_path}")
|
||||
if spawned:
|
||||
success(f"Agent created at {agent_path}")
|
||||
|
||||
_save_stage(8, {"agent_name": agent_name, "agent_path": agent_path, "success": success}, dry_run=dry_run)
|
||||
_save_stage(6, {"agent_name": agent_name, "agent_path": agent_path, "success": spawned}, dry_run=dry_run)
|
||||
return {"agent_name": agent_name, "agent_path": agent_path}
|
||||
|
||||
|
||||
def stage_9_ping_sweep(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
def stage_7_ping_sweep(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Ping all registered branches via test-convention emails."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 9/12[/bold cyan] — Pinging agents")
|
||||
console.print(render_step_header(7, TOTAL_STAGES, "Pinging agents"))
|
||||
|
||||
from aipass.aipass.apps.handlers import ping_sweep
|
||||
|
||||
branches = ping_sweep._discover_branches()
|
||||
if not branches:
|
||||
console.print("[dim] No branches registered yet — skipping ping sweep.[/dim]")
|
||||
_save_stage(9, {"results": {}, "skipped": True}, dry_run=dry_run)
|
||||
_save_stage(7, {"results": {}, "skipped": True}, dry_run=dry_run)
|
||||
return {"ping_results": {}}
|
||||
|
||||
# Standalone projects can't ping agents via drone (drone only knows AIPass's registry).
|
||||
@@ -565,7 +540,7 @@ def stage_9_ping_sweep(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
else:
|
||||
console.print(f"[dim] Found {len(branches)} agent(s) in this project.[/dim]")
|
||||
console.print("[dim] Ping skipped — agents will be reachable after handoff (next step).[/dim]")
|
||||
_save_stage(9, {"results": {}, "skipped_standalone": True}, dry_run=dry_run)
|
||||
_save_stage(7, {"results": {}, "skipped_standalone": True}, dry_run=dry_run)
|
||||
return {"ping_results": {}}
|
||||
|
||||
console.print(f"[dim] Found {len(branches)} agent(s). Checking reachability...[/dim]")
|
||||
@@ -573,7 +548,8 @@ def stage_9_ping_sweep(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
"[dim] (Agents with a running session will auto-ack; new agents will time out — that's normal.)[/dim]"
|
||||
)
|
||||
|
||||
results = ping_sweep.sweep_all_branches(timeout=10)
|
||||
with activity_spinner("Pinging agents…"):
|
||||
results = ping_sweep.sweep_all_branches(timeout=10)
|
||||
|
||||
for branch, status in results.items():
|
||||
if status == "ack":
|
||||
@@ -587,33 +563,35 @@ def stage_9_ping_sweep(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
|
||||
summary = ping_sweep.sweep_summary(results)
|
||||
console.print(f" {summary}")
|
||||
_save_stage(9, {"results": results}, dry_run=dry_run)
|
||||
_save_stage(7, {"results": results}, dry_run=dry_run)
|
||||
return {"ping_results": results}
|
||||
|
||||
|
||||
def stage_10_smoke_test(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
def stage_8_smoke_test(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Verify drone and aipass binaries are on PATH."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 10/12[/bold cyan] — Smoke test")
|
||||
console.print(render_step_header(8, TOTAL_STAGES, "Smoke test"))
|
||||
|
||||
drone_bin = shutil.which("drone")
|
||||
aipass_bin = shutil.which("aipass")
|
||||
|
||||
if drone_bin:
|
||||
console.print(f"[green]✓[/green] drone: {drone_bin}")
|
||||
success(f"drone: {drone_bin}")
|
||||
else:
|
||||
warning("drone not on PATH — clone the repo and run setup.sh")
|
||||
|
||||
if aipass_bin:
|
||||
console.print(f"[green]✓[/green] aipass: {aipass_bin}")
|
||||
success(f"aipass: {aipass_bin}")
|
||||
else:
|
||||
warning("aipass not on PATH — clone the repo and run setup.sh")
|
||||
|
||||
_save_stage(10, {"drone": drone_bin, "aipass": aipass_bin}, dry_run=dry_run)
|
||||
console.print("[dim]Full cross-OS pre-flight: aipass doctor --cross-os[/dim]")
|
||||
|
||||
_save_stage(8, {"drone": drone_bin, "aipass": aipass_bin}, dry_run=dry_run)
|
||||
return {"drone": drone_bin, "aipass": aipass_bin}
|
||||
|
||||
|
||||
def stage_11_handoff(
|
||||
def stage_9_handoff(
|
||||
cli_choice: str = "claude",
|
||||
flag_variant: str = "default",
|
||||
agent_path: str = "src/my_agent",
|
||||
@@ -623,31 +601,37 @@ def stage_11_handoff(
|
||||
) -> Dict[str, Any]:
|
||||
"""Launch user's chosen CLI — inline (same terminal) or new window."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 11/12[/bold cyan] — Handoff")
|
||||
console.print(render_step_header(9, TOTAL_STAGES, "Handoff"))
|
||||
|
||||
init_prompt = "I just completed aipass init. I am ready to start. What should I do first?"
|
||||
from aipass.aipass.apps.modules.handoff import INIT_PROMPT
|
||||
|
||||
init_prompt = INIT_PROMPT
|
||||
|
||||
_template = (accumulated or {}).get("template", TEMPLATE_AIPASS)
|
||||
console.print()
|
||||
console.print(" Your agent is ready.")
|
||||
console.print(f" [dim]CLI: {cli_choice} | Agent: {agent_path}[/dim]")
|
||||
if _template == TEMPLATE_AIPASS:
|
||||
console.print(" Your agent is ready.")
|
||||
console.print(f" [dim]CLI: {cli_choice} | Agent: {agent_path}[/dim]")
|
||||
else:
|
||||
_display = str(Path(agent_path).resolve()) if agent_path == "." else agent_path
|
||||
console.print(" Your project is ready — launching your CLI.")
|
||||
console.print(f" [dim]CLI: {cli_choice} | Project: {_display}[/dim]")
|
||||
|
||||
from aipass.aipass.apps.handlers.handoff_platform import build_manual_command
|
||||
|
||||
command = build_manual_command(cli_choice, init_prompt, agent_path, flag_variant)
|
||||
display_path = str(Path(agent_path).resolve()) if agent_path == "." else agent_path
|
||||
command = build_manual_command(cli_choice, init_prompt, display_path, flag_variant)
|
||||
inline = False
|
||||
|
||||
if dry_run:
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would launch handoff: {command}")
|
||||
launched = False
|
||||
elif non_interactive:
|
||||
from aipass.aipass.apps.modules import handoff as handoff_mod
|
||||
|
||||
launched = handoff_mod.do_handoff(
|
||||
cli=cli_choice,
|
||||
prompt=init_prompt,
|
||||
cwd=agent_path,
|
||||
flag_variant=flag_variant,
|
||||
)
|
||||
console.print()
|
||||
console.print(" [dim]Next step (run manually):[/dim]")
|
||||
console.print(f" [cyan]{command}[/cyan]")
|
||||
console.print()
|
||||
launched = False
|
||||
else:
|
||||
console.print()
|
||||
console.print(" [bold]1.[/bold] Stay here — launch agent in this terminal")
|
||||
@@ -657,12 +641,12 @@ def stage_11_handoff(
|
||||
inline = choice != "2"
|
||||
|
||||
if inline:
|
||||
_save_stage(11, {"command": command, "launched": True, "inline": True}, dry_run=dry_run)
|
||||
_save_stage(12, dry_run=dry_run)
|
||||
_save_stage(9, {"command": command, "launched": True, "inline": True}, dry_run=dry_run)
|
||||
_save_stage(10, dry_run=dry_run)
|
||||
if accumulated:
|
||||
_write_init_report(accumulated.get("agent_path", agent_path), accumulated, dry_run=dry_run)
|
||||
console.print()
|
||||
console.print("[bold green]✓ Setup complete![/bold green]")
|
||||
success("Setup complete!")
|
||||
console.print()
|
||||
from aipass.aipass.apps.handlers.handoff_platform import launch_inline
|
||||
|
||||
@@ -679,10 +663,24 @@ def stage_11_handoff(
|
||||
)
|
||||
|
||||
if not inline:
|
||||
_save_stage(11, {"command": command, "launched": launched}, dry_run=dry_run)
|
||||
_save_stage(9, {"command": command, "launched": launched}, dry_run=dry_run)
|
||||
return {"handoff_command": command, "launched": launched}
|
||||
|
||||
|
||||
def _collect_provider_gaps() -> Dict[str, Any]:
|
||||
"""Gather provider-manifest gaps for the init report — standalone, no full doctor run."""
|
||||
gaps: Dict[str, Any] = {}
|
||||
try:
|
||||
from aipass.aipass.apps.modules import doctor
|
||||
|
||||
for r in doctor._check_provider_manifest():
|
||||
if r.glyph != doctor.GLYPH_PASS:
|
||||
gaps[r.label] = r.detail
|
||||
except Exception as exc:
|
||||
logger.warning("[init_flow] provider manifest check failed: %s", exc)
|
||||
return gaps
|
||||
|
||||
|
||||
def _write_init_report(agent_path: str, accumulated: Dict[str, Any], dry_run: bool = False) -> None:
|
||||
"""Drop init_report.json into the agent's dropbox."""
|
||||
if dry_run or not agent_path:
|
||||
@@ -707,7 +705,7 @@ def _write_init_report(agent_path: str, accumulated: Dict[str, Any], dry_run: bo
|
||||
" drone @devpulse watchdog agent @target"
|
||||
),
|
||||
}
|
||||
provider_gaps = accumulated.get("provider_gaps", {})
|
||||
provider_gaps = _collect_provider_gaps()
|
||||
if provider_gaps:
|
||||
report["provider_gaps"] = provider_gaps
|
||||
report["provider_action"] = (
|
||||
@@ -719,12 +717,12 @@ def _write_init_report(agent_path: str, accumulated: Dict[str, Any], dry_run: bo
|
||||
logger.info("[init_flow] init report written to %s", report_path)
|
||||
|
||||
|
||||
def stage_12_done(accumulated: Dict[str, Any] | None = None, dry_run: bool = False) -> Dict[str, Any]:
|
||||
def stage_10_done(accumulated: Dict[str, Any] | None = None, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Print completion summary and drop init report."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 12/12[/bold cyan] — Done!")
|
||||
console.print(render_step_header(10, TOTAL_STAGES, "Done!"))
|
||||
console.print()
|
||||
console.print("[bold green]✓ Setup complete![/bold green]")
|
||||
success("Setup complete!")
|
||||
console.print()
|
||||
console.print(" [cyan]aipass help[/cyan] [dim]# Ask any question[/dim]")
|
||||
console.print(" [cyan]aipass doctor[/cyan] [dim]# Check system health[/dim]")
|
||||
@@ -732,13 +730,17 @@ def stage_12_done(accumulated: Dict[str, Any] | None = None, dry_run: bool = Fal
|
||||
console.print()
|
||||
if accumulated:
|
||||
_write_init_report(accumulated.get("agent_path", ""), accumulated, dry_run=dry_run)
|
||||
_save_stage(12, dry_run=dry_run)
|
||||
_save_stage(10, dry_run=dry_run)
|
||||
return {}
|
||||
|
||||
|
||||
# --- MAIN RUNNER ---
|
||||
def _preflight_check() -> str | None:
|
||||
"""Return an error message if CWD is unsafe for init, else None."""
|
||||
def _preflight_check(*, allow_projects_child: bool = False) -> str | None:
|
||||
"""Return an error message if CWD is unsafe for init, else None.
|
||||
|
||||
When *allow_projects_child* is True, the nested-project check is skipped
|
||||
if CWD is ``<host>/projects/<name>``.
|
||||
"""
|
||||
cwd = Path.cwd()
|
||||
# Block if inside an agent directory
|
||||
if (cwd / ".trinity" / "passport.json").is_file():
|
||||
@@ -746,6 +748,12 @@ def _preflight_check() -> str | None:
|
||||
"This directory is an agent branch (has .trinity/passport.json).\n"
|
||||
"Agents are managed by 'drone @spawn', not 'aipass init'."
|
||||
)
|
||||
# Early exit: if CWD is a valid <host>/projects/<name>, skip nesting check
|
||||
if allow_projects_child:
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import is_projects_child
|
||||
|
||||
if is_projects_child(cwd):
|
||||
return None
|
||||
# Block if inside an existing AIPass project (registry above us).
|
||||
# Walking up to the filesystem root can hit ancestors that can't be
|
||||
# enumerated or stat'd — e.g. locked Windows system entries at the drive
|
||||
@@ -778,15 +786,17 @@ def run_init(
|
||||
name: str | None = None,
|
||||
cli: str | None = None,
|
||||
style: str | None = None,
|
||||
no_docker: bool = False,
|
||||
dry_run: bool = False,
|
||||
template: str | None = None,
|
||||
) -> int:
|
||||
"""Run the 12-stage init flow. Returns 0 on success."""
|
||||
"""Run the 10-stage init flow. Returns 0 on success."""
|
||||
if not sys.stdin.isatty():
|
||||
non_interactive = True
|
||||
|
||||
# Pre-flight: refuse to run inside existing projects or agent dirs
|
||||
err = _preflight_check()
|
||||
if err:
|
||||
console.print(f"[red]✗[/red] {err}")
|
||||
cli_error(str(err))
|
||||
return 1
|
||||
|
||||
# Template selection — before scaffold
|
||||
@@ -806,7 +816,9 @@ def run_init(
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import init_project
|
||||
|
||||
if not dry_run:
|
||||
init_project(cwd)
|
||||
with activity_spinner("Building project scaffold…"):
|
||||
init_project(cwd)
|
||||
success("Project scaffold ready")
|
||||
else:
|
||||
console.print("[yellow]\\[dry-run][/yellow] would create project scaffold")
|
||||
|
||||
@@ -814,7 +826,7 @@ def run_init(
|
||||
last_done = 0 if dry_run else _get_last_completed_stage()
|
||||
|
||||
if last_done >= TOTAL_STAGES:
|
||||
console.print("[green]✓[/green] Setup already complete.")
|
||||
success("Setup already complete.")
|
||||
console.print("[dim]Run 'aipass doctor' to check status.[/dim]")
|
||||
return 0
|
||||
|
||||
@@ -822,21 +834,21 @@ def run_init(
|
||||
warning(f"Resuming from stage {last_done + 1}...")
|
||||
|
||||
accumulated: Dict[str, Any] = {"template": template}
|
||||
if template != TEMPLATE_AIPASS:
|
||||
accumulated["agent_path"] = "."
|
||||
|
||||
stage_fns = [
|
||||
(1, lambda: stage_1_welcome(dry_run=dry_run)),
|
||||
(2, lambda: stage_2_system_detect(non_interactive, dry_run=dry_run)),
|
||||
(3, lambda: stage_3_doctor(non_interactive, dry_run=dry_run)),
|
||||
(4, lambda: stage_4_user_profile(non_interactive, name, accumulated, dry_run=dry_run)),
|
||||
(5, lambda: stage_5_style_questions(non_interactive, style, dry_run=dry_run)),
|
||||
(6, lambda: stage_6_tool_choice(non_interactive, cli, dry_run=dry_run)),
|
||||
(7, lambda: stage_7_docker_offer(non_interactive, no_docker, accumulated.get("has_docker"), dry_run=dry_run)),
|
||||
(8, lambda: stage_8_first_agent(non_interactive, dry_run=dry_run)),
|
||||
(9, lambda: stage_9_ping_sweep(non_interactive, dry_run=dry_run)),
|
||||
(10, lambda: stage_10_smoke_test(non_interactive, dry_run=dry_run)),
|
||||
(3, lambda: stage_3_user_profile(non_interactive, name, accumulated, dry_run=dry_run)),
|
||||
(4, lambda: stage_4_style_questions(non_interactive, style, dry_run=dry_run)),
|
||||
(5, lambda: stage_5_tool_choice(non_interactive, cli, dry_run=dry_run)),
|
||||
(6, lambda: stage_6_first_agent(non_interactive, dry_run=dry_run)),
|
||||
(7, lambda: stage_7_ping_sweep(non_interactive, dry_run=dry_run)),
|
||||
(8, lambda: stage_8_smoke_test(non_interactive, dry_run=dry_run)),
|
||||
(
|
||||
11,
|
||||
lambda: stage_11_handoff(
|
||||
9,
|
||||
lambda: stage_9_handoff(
|
||||
accumulated.get("cli", "claude"),
|
||||
accumulated.get("flag_variant", "default"),
|
||||
accumulated.get("agent_path", "src/my_agent"),
|
||||
@@ -845,7 +857,7 @@ def run_init(
|
||||
accumulated=accumulated,
|
||||
),
|
||||
),
|
||||
(12, lambda: stage_12_done(accumulated=accumulated, dry_run=dry_run)),
|
||||
(10, lambda: stage_10_done(accumulated=accumulated, dry_run=dry_run)),
|
||||
]
|
||||
|
||||
for stage_num, fn in stage_fns:
|
||||
@@ -853,6 +865,8 @@ def run_init(
|
||||
continue
|
||||
if stage_num in AIPASS_SPECIFIC_STAGES and template != TEMPLATE_AIPASS:
|
||||
logger.info("[init_flow] skipping stage %d (not aipass_framework)", stage_num)
|
||||
if not non_interactive:
|
||||
console.print(f"\n[dim] (skipping step {stage_num} — framework-only)[/dim]")
|
||||
continue
|
||||
try:
|
||||
result = fn() or {}
|
||||
@@ -866,11 +880,6 @@ def run_init(
|
||||
warning(f"Stage {stage_num} error: {exc} — continuing.")
|
||||
_save_stage(stage_num, {"error": str(exc)}, dry_run=dry_run)
|
||||
|
||||
if template != TEMPLATE_AIPASS:
|
||||
console.print()
|
||||
console.print("[green]✓[/green] Project initialized.")
|
||||
console.print("[dim]Run 'aipass init agent <name>' to add an agent.[/dim]")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
@@ -881,12 +890,12 @@ def print_introspection() -> None:
|
||||
last = progress.get("last_completed_stage", 0)
|
||||
console.print()
|
||||
console.print("[bold cyan]init_flow Module[/bold cyan]")
|
||||
console.print("12-stage guided first-run setup, resumable")
|
||||
console.print("10-stage guided first-run setup, resumable")
|
||||
console.print()
|
||||
if last == 0:
|
||||
console.print("[dim]Setup not started. Run: aipass init run[/dim]")
|
||||
elif last >= TOTAL_STAGES:
|
||||
console.print("[green]✓[/green] Setup complete.")
|
||||
success("Setup complete.")
|
||||
else:
|
||||
console.print(f"[yellow]In progress:[/yellow] stage {last}/{TOTAL_STAGES} completed.")
|
||||
console.print(f"[dim]Run 'aipass init run' to resume from stage {last + 1}.[/dim]")
|
||||
@@ -904,11 +913,10 @@ def print_help() -> None:
|
||||
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 --template <name>[/green] [dim]# select template[/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]")
|
||||
console.print(" [green]aipass init --list[/green] [dim]# list available templates[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]STAGES:[/yellow] 12 stages, each saved — resume on ctrl-C")
|
||||
console.print("[yellow]STAGES:[/yellow] 10 stages, each saved — resume on ctrl-C")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -921,7 +929,7 @@ def _handle_init_scaffold(args: list[str]) -> int:
|
||||
project_name = args[1] if len(args) > 1 else None
|
||||
try:
|
||||
result = init_project(target, project_name)
|
||||
console.print(f"\n[green]✓[/green] Project initialized at [bold]{target}[/bold]")
|
||||
success(f"Project initialized at {target}")
|
||||
console.print()
|
||||
|
||||
# Find the package directory to show in guidance
|
||||
@@ -948,7 +956,7 @@ def _handle_init_scaffold(args: list[str]) -> int:
|
||||
return 0
|
||||
except Exception as exc:
|
||||
logger.warning("[init_flow] scaffold failed: %s", exc)
|
||||
console.print(f"[red]✗[/red] Init failed: {exc}")
|
||||
cli_error(f"Init failed: {exc}")
|
||||
return 1
|
||||
|
||||
|
||||
@@ -962,23 +970,39 @@ def _handle_init_update(args: list[str]) -> int:
|
||||
updated = result.get("updated_files", [])
|
||||
current = result.get("already_current", [])
|
||||
if updated:
|
||||
console.print(f"[green]✓[/green] Updated {len(updated)} file(s):")
|
||||
success(f"Updated {len(updated)} file(s):")
|
||||
for f in updated:
|
||||
console.print(f" [green]+[/green] {f}")
|
||||
console.print(f" + {f}")
|
||||
else:
|
||||
console.print("[green]✓[/green] All files already current.")
|
||||
success("All files already current.")
|
||||
if current:
|
||||
console.print(f" ({len(current)} already up to date)")
|
||||
# Heal registry: prune stale entries (e.g. cross-project ../paths)
|
||||
# Owner/identity check + heal via the frozen sync-registry contract
|
||||
try:
|
||||
sync_proc = subprocess.run(
|
||||
["drone", "@spawn", "sync-registry", "--fix"],
|
||||
check_proc = subprocess.run(
|
||||
["drone", "@spawn", "sync-registry", "--check"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if sync_proc.returncode == 0:
|
||||
console.print(" [green]Registry synced.[/green]")
|
||||
if check_proc.returncode != 0:
|
||||
warning("Owner/identity issues detected — auto-repairing…")
|
||||
fix_proc = subprocess.run(
|
||||
["drone", "@spawn", "sync-registry", "--fix"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if fix_proc.returncode == 0:
|
||||
success("Registry owner/identity reconciled.")
|
||||
else:
|
||||
logger.warning("[init_flow] sync-registry --fix exit %s", fix_proc.returncode)
|
||||
else:
|
||||
success("Owner/identity OK.")
|
||||
except FileNotFoundError:
|
||||
logger.info("[init_flow] drone not on PATH — skipping owner check")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("[init_flow] sync-registry timed out during update")
|
||||
except Exception as sync_exc:
|
||||
logger.warning("[init_flow] registry sync during update skipped: %s", sync_exc)
|
||||
|
||||
@@ -986,14 +1010,14 @@ def _handle_init_update(args: list[str]) -> int:
|
||||
return 0
|
||||
except Exception as exc:
|
||||
logger.warning("[init_flow] update failed: %s", exc)
|
||||
console.print(f"[red]✗[/red] Update failed: {exc}")
|
||||
cli_error(f"Update failed: {exc}")
|
||||
return 1
|
||||
|
||||
|
||||
def _handle_init_agent(args: list[str]) -> int:
|
||||
"""Handle `aipass init agent <name>` — create a new agent via spawn."""
|
||||
if not args:
|
||||
console.print("[red]✗[/red] Usage: aipass init agent <name>")
|
||||
cli_error("Usage: aipass init agent <name>")
|
||||
return 1
|
||||
agent_name = args[0]
|
||||
import subprocess as _sp
|
||||
@@ -1059,7 +1083,6 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
cli = _flag_value("--cli")
|
||||
style = _flag_value("--style")
|
||||
template = _flag_value("--template")
|
||||
no_docker = "--no-docker" in run_args
|
||||
dry_run = "--dry-run" in run_args
|
||||
|
||||
result = run_init(
|
||||
@@ -1067,7 +1090,6 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
name=name,
|
||||
cli=cli,
|
||||
style=style,
|
||||
no_docker=no_docker,
|
||||
dry_run=dry_run,
|
||||
template=template,
|
||||
)
|
||||
@@ -1087,11 +1109,21 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
# Positional args = target path and/or project name for scaffold
|
||||
err = _preflight_check()
|
||||
if err:
|
||||
console.print(f"[red]✗[/red] {err}")
|
||||
cli_error(str(err))
|
||||
sys.exit(1)
|
||||
sys.exit(_handle_init_scaffold(args))
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Windows terminals/pipes default to cp1252, which can't encode the Unicode
|
||||
# Rich emits (✓/✗, box-drawing). Reconfigure live streams to UTF-8 in place
|
||||
# so a direct `python -m ...init_flow` run doesn't crash printing its banner.
|
||||
# Guarded to win32 — mirrors apps/aipass.py (S190 / gap #1).
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(_stream, "reconfigure"):
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
handle_command("init", sys.argv[1:])
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: install.py
|
||||
# Description: aipass install — one-command PyPI bootstrap (clone + setup + handoff)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-05
|
||||
# Modified: 2026-07-05
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
aipass install — one-command bootstrap of the whole framework
|
||||
|
||||
The missing half of `pip install aipass`. pip lands the *code* in site-packages;
|
||||
this command materializes a working, writable AIPass home and wires it up:
|
||||
|
||||
1. Resolve where AIPass should live (default ~/AIPass; --here / --path to steer).
|
||||
2. Fetch the framework there (git clone of the public repo) if not already present.
|
||||
3. Run the canonical setup.sh (venv, editable install, provider-hook wiring, binaries).
|
||||
4. Verify drone/aipass are on PATH, then hand off into `aipass init run` to
|
||||
scaffold a first project (interactive default; --no-init to skip, --with-init
|
||||
to force even headless). Init targets a sibling dir, never the engine tree.
|
||||
|
||||
Each step prints a Step k/N progress header. Streaming subprocesses (git, setup.sh)
|
||||
show a header + their own output + a result line — no spinner (the two renderers
|
||||
fight, per ui/progress.activity_spinner).
|
||||
|
||||
Usage:
|
||||
aipass install # interactive, then launches init
|
||||
aipass install --non-interactive # CI/headless (~/AIPass), stops before init
|
||||
aipass install --with-init # headless AND chain into init --non-interactive
|
||||
aipass install --no-init # install the engine only, skip the handoff
|
||||
aipass install --path ~/tools/aipass # explicit home
|
||||
aipass install --project ~/proj # where the first project scaffolds
|
||||
aipass install --here # install into the current directory
|
||||
aipass install --dry-run # walk all steps, no clone/setup/launch
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from aipass.cli.apps.modules import console, success, warning
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import is_throwaway_path
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.aipass.apps.handlers.ui.progress import render_step_header
|
||||
|
||||
COMMAND = "install"
|
||||
TOTAL_STEPS = 4
|
||||
REPO_URL = "https://github.com/AIOSAI/AIPass.git"
|
||||
DEFAULT_HOME = Path.home() / "AIPass"
|
||||
# `aipass init` refuses to run inside the engine tree (its pre-flight blocks on a
|
||||
# parent registry), so the auto-handoff scaffolds the first project in a sibling.
|
||||
DEFAULT_PROJECT = Path.home() / "aipass-project"
|
||||
|
||||
# Clone can be slow on a cold network; setup.sh compiles a venv + installs deps.
|
||||
_CLONE_TIMEOUT = 600
|
||||
_SETUP_TIMEOUT = 1800
|
||||
|
||||
|
||||
def _prompt(msg: str, default: str = "") -> str:
|
||||
"""Simple input prompt with optional default (raises on Ctrl-C/EOF)."""
|
||||
display = f"{msg} [{default}]: " if default else f"{msg}: "
|
||||
try:
|
||||
val = input(display).strip()
|
||||
return val if val else default
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
|
||||
def _looks_like_aipass_tree(home: Path) -> bool:
|
||||
"""True if `home` already holds an AIPass source tree (idempotent re-install)."""
|
||||
if not home.is_dir():
|
||||
return False
|
||||
if (home / "setup.sh").is_file():
|
||||
return True
|
||||
return bool(list(home.glob("*_REGISTRY.json")))
|
||||
|
||||
|
||||
def _resolve_home(path: str | None, here: bool, non_interactive: bool) -> Path:
|
||||
"""Decide where AIPass lives — --here / --path / $AIPASS_HOME / prompt / default."""
|
||||
if here:
|
||||
return Path.cwd().resolve()
|
||||
if path:
|
||||
return Path(path).expanduser().resolve()
|
||||
env_home = os.environ.get("AIPASS_HOME", "").strip()
|
||||
if env_home and _looks_like_aipass_tree(Path(env_home).expanduser()):
|
||||
return Path(env_home).expanduser().resolve()
|
||||
if non_interactive:
|
||||
return DEFAULT_HOME.resolve()
|
||||
raw = _prompt("Where should AIPass live?", str(DEFAULT_HOME))
|
||||
return Path(raw).expanduser().resolve()
|
||||
|
||||
|
||||
def _clone_repo(home: Path, dry_run: bool) -> bool:
|
||||
"""git clone the public AIPass repo into `home`. Returns True on success."""
|
||||
if dry_run:
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would run: git clone --depth 1 {REPO_URL} {home}")
|
||||
return True
|
||||
if home.exists() and any(home.iterdir()):
|
||||
warning(f"{home} exists and is not empty — pass an empty --path, or remove it first.")
|
||||
return False
|
||||
if shutil.which("git") is None:
|
||||
warning("git not found — the installer needs git to fetch AIPass. Install git and retry.")
|
||||
return False
|
||||
home.parent.mkdir(parents=True, exist_ok=True)
|
||||
console.print("[cyan]Downloading AIPass[/cyan] [dim](git clone — this can take a minute)…[/dim]")
|
||||
try:
|
||||
proc = subprocess.run(["git", "clone", "--depth", "1", REPO_URL, str(home)], timeout=_CLONE_TIMEOUT)
|
||||
if proc.returncode == 0:
|
||||
return True
|
||||
logger.warning("[install] git clone exited %s", proc.returncode)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
logger.warning("[install] git clone timed out: %s", exc)
|
||||
warning("git clone timed out.")
|
||||
return False
|
||||
|
||||
|
||||
def _run_setup(home: Path, dry_run: bool, no_symlink: bool = False, force_symlink: bool = False) -> bool:
|
||||
"""Run the repo's setup.sh (venv + editable install + hook wiring + binaries)."""
|
||||
setup = home / "setup.sh"
|
||||
# --no-init: install owns the init handoff (_handoff_to_init) — without it,
|
||||
# setup.sh's own init chain (DPLAN-0234) would scaffold the project twice.
|
||||
# --no-symlink / --force-symlink (#660) pass through to setup.sh's CLI-symlink guard.
|
||||
setup_args = ["bash", str(setup), "--no-init"]
|
||||
if no_symlink:
|
||||
setup_args.append("--no-symlink")
|
||||
if force_symlink:
|
||||
setup_args.append("--force-symlink")
|
||||
if dry_run:
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would run: {' '.join(setup_args)}")
|
||||
return True
|
||||
if not setup.is_file():
|
||||
warning(f"setup.sh not found at {setup} — cannot build the environment.")
|
||||
return False
|
||||
console.print("[cyan]Building environment[/cyan] [dim](venv, dependencies, hook wiring)…[/dim]")
|
||||
try:
|
||||
proc = subprocess.run(setup_args, cwd=str(home), timeout=_SETUP_TIMEOUT)
|
||||
if proc.returncode == 0:
|
||||
return True
|
||||
logger.warning("[install] setup.sh exited %s", proc.returncode)
|
||||
warning("setup.sh reported errors — see output above.")
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
|
||||
logger.warning("[install] setup.sh failed: %s", exc)
|
||||
warning(f"setup failed: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_aipass_bin(home: Path) -> str | None:
|
||||
"""Locate the aipass binary post-setup — PATH, then home/.venv/bin, then ~/.local/bin."""
|
||||
found = shutil.which("aipass")
|
||||
if found:
|
||||
return found
|
||||
for candidate in (home / ".venv" / "bin" / "aipass", Path.home() / ".local" / "bin" / "aipass"):
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
return None
|
||||
|
||||
|
||||
def _verify_binaries(home: Path) -> Dict[str, str | None]:
|
||||
"""Report drone/aipass resolution after setup (PATH may lag in the live shell)."""
|
||||
drone = shutil.which("drone") or (
|
||||
str(home / ".venv" / "bin" / "drone") if (home / ".venv" / "bin" / "drone").is_file() else None
|
||||
)
|
||||
aipass = _resolve_aipass_bin(home)
|
||||
if drone:
|
||||
success(f"drone: {drone}")
|
||||
else:
|
||||
warning("drone not found after setup — check the setup output above.")
|
||||
if aipass:
|
||||
success(f"aipass: {aipass}")
|
||||
else:
|
||||
warning("aipass not found after setup — check the setup output above.")
|
||||
return {"drone": drone, "aipass": aipass}
|
||||
|
||||
|
||||
def _build_install_prompt(home: Path, bins: dict) -> str:
|
||||
"""Compose the authored first prompt for the post-install @aipass chat."""
|
||||
parts = [f"Fresh AIPass install completed at {home}."]
|
||||
for name, path in bins.items():
|
||||
if path:
|
||||
parts.append(f"{name}: {path}.")
|
||||
parts.append(
|
||||
"This is my first time here — what can I do with AIPass? Show me a few things to try, with the exact commands."
|
||||
)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _should_run_init(no_init: bool) -> bool:
|
||||
"""Decide whether to auto-launch init. --no-init skips; default = always chain."""
|
||||
return not no_init
|
||||
|
||||
|
||||
def _handoff_to_init(
|
||||
home: Path,
|
||||
aipass_bin: str | None,
|
||||
non_interactive: bool,
|
||||
dry_run: bool,
|
||||
project: str | None,
|
||||
run_it: bool,
|
||||
) -> None:
|
||||
"""Print the installed banner, then (optionally) launch init for a first project.
|
||||
|
||||
`aipass init` scaffolds a *new* project and refuses to run inside the engine
|
||||
tree (pre-flight blocks on a parent registry), so init targets a sibling
|
||||
directory (``--project`` or DEFAULT_PROJECT), never ``home``. When install ran
|
||||
headless, init is launched headless too so the whole chain stays non-blocking.
|
||||
"""
|
||||
console.print()
|
||||
success(f"AIPass is installed at {home}")
|
||||
console.print()
|
||||
console.print(" [cyan]drone systems[/cyan] [dim]# list every agent[/dim]")
|
||||
console.print(" [cyan]aipass doctor[/cyan] [dim]# check system health[/dim]")
|
||||
console.print(" [cyan]aipass init run[/cyan] [dim]# scaffold your first project on AIPass[/dim]")
|
||||
console.print()
|
||||
|
||||
if not run_it:
|
||||
console.print("[dim]Run 'aipass init run' in a fresh directory to start your first project.[/dim]")
|
||||
return
|
||||
|
||||
project_dir = _resolve_project_dir(project, non_interactive)
|
||||
if project_dir is None:
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would launch: aipass init run in {project_dir}")
|
||||
return
|
||||
if not aipass_bin:
|
||||
warning("Can't find the aipass binary yet — open a new terminal and run 'aipass init run'.")
|
||||
return
|
||||
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
console.print(f"[cyan]Launching guided setup[/cyan] [dim]in {project_dir}…[/dim]")
|
||||
cmd = [aipass_bin, "init", "run"]
|
||||
if non_interactive:
|
||||
cmd.append("--non-interactive")
|
||||
try:
|
||||
subprocess.run(cmd, cwd=str(project_dir))
|
||||
except (FileNotFoundError, OSError) as exc:
|
||||
logger.warning("[install] could not launch init: %s", exc)
|
||||
warning(f"Could not launch init: {exc}. Run 'aipass init run' in {project_dir} yourself.")
|
||||
|
||||
|
||||
def _check_and_fix_owner(home: Path) -> None:
|
||||
"""Run sync-registry --check; if issues found, auto-heal with --fix."""
|
||||
try:
|
||||
check_proc = subprocess.run(
|
||||
["drone", "@spawn", "sync-registry", "--check"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd=str(home),
|
||||
)
|
||||
if check_proc.returncode != 0:
|
||||
warning("Owner/identity issues detected — auto-repairing…")
|
||||
fix_proc = subprocess.run(
|
||||
["drone", "@spawn", "sync-registry", "--fix"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
cwd=str(home),
|
||||
)
|
||||
if fix_proc.returncode == 0:
|
||||
success("Registry owner/identity reconciled.")
|
||||
else:
|
||||
logger.warning("[install] sync-registry --fix exit %s", fix_proc.returncode)
|
||||
else:
|
||||
success("Owner/identity OK.")
|
||||
except FileNotFoundError:
|
||||
logger.info("[install] drone not on PATH — skipping owner check")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("[install] sync-registry timed out during install")
|
||||
except Exception as exc:
|
||||
logger.warning("[install] owner check skipped: %s", exc)
|
||||
|
||||
|
||||
def _resolve_project_dir(project: str | None, non_interactive: bool) -> Path | None:
|
||||
"""Resolve the first-project directory — --project / prompt / DEFAULT_PROJECT."""
|
||||
if project:
|
||||
return Path(project).expanduser().resolve()
|
||||
if non_interactive:
|
||||
return DEFAULT_PROJECT.resolve()
|
||||
try:
|
||||
raw = _prompt("Project directory for your first project", str(DEFAULT_PROJECT))
|
||||
except KeyboardInterrupt:
|
||||
logger.info("[install] init handoff cancelled by user")
|
||||
console.print()
|
||||
return None
|
||||
return Path(raw).expanduser().resolve()
|
||||
|
||||
|
||||
def run_install(
|
||||
non_interactive: bool = False,
|
||||
path: str | None = None,
|
||||
here: bool = False,
|
||||
dry_run: bool = False,
|
||||
with_init: bool = False,
|
||||
no_init: bool = False,
|
||||
project: str | None = None,
|
||||
no_symlink: bool = False,
|
||||
force_symlink: bool = False,
|
||||
) -> int:
|
||||
"""Run the 4-step one-command install. Returns 0 on success, 1 on failure."""
|
||||
console.print()
|
||||
console.print("[bold cyan]AIPass — one-command install[/bold cyan]")
|
||||
if dry_run:
|
||||
console.print("[yellow]\\[dry-run][/yellow] No clone, no setup, no launch — walking the steps only.")
|
||||
|
||||
# Step 1 — resolve + fetch the framework home
|
||||
console.print()
|
||||
console.print(render_step_header(1, TOTAL_STEPS, "Preparing AIPass home"))
|
||||
try:
|
||||
home = _resolve_home(path, here, non_interactive)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("[install] cancelled at home resolution by user")
|
||||
console.print()
|
||||
warning("Cancelled.")
|
||||
return 1
|
||||
console.print(f" Home: [cyan]{home}[/cyan]")
|
||||
|
||||
if is_throwaway_path(home):
|
||||
warning(
|
||||
f"REFUSED: '{home}' is a temporary/scratchpad path. "
|
||||
"Installing here would hijack the machine-wide AIPASS_HOME. "
|
||||
"Use a permanent directory, or pass --force-global-home to override."
|
||||
)
|
||||
if "--force-global-home" not in sys.argv:
|
||||
return 1
|
||||
logger.warning("[install] --force-global-home override: proceeding with throwaway home %s", home)
|
||||
|
||||
if _looks_like_aipass_tree(home):
|
||||
success(f"AIPass already present at {home} — skipping download")
|
||||
elif not _clone_repo(home, dry_run):
|
||||
warning("Could not fetch AIPass — aborting install.")
|
||||
return 1
|
||||
else:
|
||||
success(f"AIPass downloaded to {home}")
|
||||
|
||||
# Step 2 — build the environment via setup.sh
|
||||
console.print()
|
||||
console.print(render_step_header(2, TOTAL_STEPS, "Building environment"))
|
||||
if not _run_setup(home, dry_run, no_symlink=no_symlink, force_symlink=force_symlink):
|
||||
warning("Environment build failed — aborting install.")
|
||||
return 1
|
||||
success("Environment ready")
|
||||
|
||||
# Step 3 — verify the binaries landed
|
||||
console.print()
|
||||
console.print(render_step_header(3, TOTAL_STEPS, "Verifying install"))
|
||||
bins = _verify_binaries(home) if not dry_run else {"drone": "dry-run", "aipass": "dry-run"}
|
||||
|
||||
# Owner/identity retro-trigger — check and self-heal via spawn
|
||||
if not dry_run:
|
||||
_check_and_fix_owner(home)
|
||||
|
||||
# Step 4 — hand off into init (or print next steps)
|
||||
console.print()
|
||||
console.print(render_step_header(4, TOTAL_STEPS, "First project"))
|
||||
run_it = _should_run_init(no_init)
|
||||
_handoff_to_init(home, bins.get("aipass"), non_interactive, dry_run, project, run_it)
|
||||
|
||||
# Log BEFORE exec — launch_inline replaces the process and never returns
|
||||
json_handler.log_operation(
|
||||
"aipass_install",
|
||||
{"home": str(home), "non_interactive": non_interactive, "dry_run": dry_run, "init": run_it},
|
||||
)
|
||||
|
||||
# Install-to-chat handoff — launch @aipass concierge in same terminal
|
||||
if run_it and not dry_run and sys.stdin.isatty():
|
||||
prompt = _build_install_prompt(home, bins)
|
||||
aipass_branch = str(Path(__file__).resolve().parents[2])
|
||||
console.print()
|
||||
console.print("[dim]Launching the AIPass concierge — Ctrl-C to stay in the shell[/dim]")
|
||||
console.print()
|
||||
from aipass.aipass.apps.handlers.handoff_platform import launch_inline
|
||||
|
||||
launch_inline("claude", prompt, aipass_branch)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print usage help for the install command."""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass install[/bold cyan] — one-command bootstrap of AIPass")
|
||||
console.print()
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [green]aipass install[/green] [dim]# interactive, then launches init[/dim]")
|
||||
console.print(" [green]aipass install --non-interactive[/green] [dim]# CI/headless (~/AIPass), no init[/dim]")
|
||||
console.print(" [green]aipass install --path DIR[/green] [dim]# explicit home[/dim]")
|
||||
console.print(" [green]aipass install --here[/green] [dim]# install into current dir[/dim]")
|
||||
console.print(" [green]aipass install --no-init[/green] [dim]# install only, skip init[/dim]")
|
||||
console.print(" [green]aipass install --with-init[/green] [dim]# force init even when headless[/dim]")
|
||||
console.print(" [green]aipass install --no-symlink[/green] [dim]# skip global CLI symlinks[/dim]")
|
||||
console.print(" [green]aipass install --force-symlink[/green] [dim]# repoint from another install[/dim]")
|
||||
console.print(" [green]aipass install --project DIR[/green] [dim]# where the first project scaffolds[/dim]")
|
||||
console.print(" [green]aipass install --force-global-home[/green] [dim]# allow install into /tmp (unsafe)[/dim]")
|
||||
console.print(" [green]aipass install --dry-run[/green] [dim]# walk steps, no side effects[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]STEPS:[/yellow] resolve home -> fetch -> setup.sh -> verify -> launch init")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Show module info for install."""
|
||||
console.print()
|
||||
console.print("[bold cyan]install Module[/bold cyan]")
|
||||
console.print("One-command bootstrap: clone + setup.sh + verify + handoff")
|
||||
console.print()
|
||||
console.print(f"[dim]Default home: {DEFAULT_HOME}[/dim]")
|
||||
console.print(f"[dim]Source: {REPO_URL}[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def handle_command(command: str, args: list[str]) -> bool:
|
||||
"""Route install subcommands. Returns True if handled, False otherwise."""
|
||||
if command != COMMAND:
|
||||
return False
|
||||
|
||||
if args and args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
if args and args[0] in ("--info", "info"):
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
# `aipass install` runs directly; `run` is accepted as an optional verb.
|
||||
run_args = args[1:] if args and args[0] == "run" else args
|
||||
|
||||
def _flag_value(flag: str) -> str | None:
|
||||
"""Extract the value after a named flag, or None if absent."""
|
||||
if flag not in run_args:
|
||||
return None
|
||||
idx = run_args.index(flag)
|
||||
return run_args[idx + 1] if idx + 1 < len(run_args) else None
|
||||
|
||||
non_interactive = "--non-interactive" in run_args
|
||||
dry_run = "--dry-run" in run_args
|
||||
here = "--here" in run_args
|
||||
with_init = "--with-init" in run_args
|
||||
no_init = "--no-init" in run_args
|
||||
no_symlink = "--no-symlink" in run_args
|
||||
force_symlink = "--force-symlink" in run_args
|
||||
path = _flag_value("--path")
|
||||
project = _flag_value("--project")
|
||||
|
||||
result = run_install(
|
||||
non_interactive=non_interactive,
|
||||
path=path,
|
||||
here=here,
|
||||
dry_run=dry_run,
|
||||
with_init=with_init,
|
||||
no_init=no_init,
|
||||
project=project,
|
||||
no_symlink=no_symlink,
|
||||
force_symlink=force_symlink,
|
||||
)
|
||||
json_handler.log_operation(
|
||||
"install_run",
|
||||
{"non_interactive": non_interactive, "dry_run": dry_run, "with_init": with_init, "exit": result},
|
||||
)
|
||||
sys.exit(result)
|
||||
@@ -0,0 +1,223 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: new_project.py
|
||||
# Description: aipass new — create projects inside the AIPass installation
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-17
|
||||
# Modified: 2026-07-17
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
aipass new — create projects inside the AIPass installation (DPLAN-0247)
|
||||
|
||||
Creates a project at <host>/projects/<name> with its own git repo,
|
||||
AIPass scaffold, and optional resident agent. Born deployable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.cli.apps.modules import console, error, success
|
||||
from aipass.prax import logger
|
||||
|
||||
COMMAND = "new"
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""List existing projects in the installation."""
|
||||
from aipass.aipass.apps.handlers.new_project import find_host_root
|
||||
|
||||
host = find_host_root(Path.cwd())
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass new[/bold cyan] — project creator")
|
||||
console.print()
|
||||
|
||||
if host is None:
|
||||
console.print("[dim]Not inside an AIPass installation.[/dim]")
|
||||
console.print()
|
||||
return
|
||||
|
||||
projects_dir = host / "projects"
|
||||
if not projects_dir.is_dir():
|
||||
console.print(f"[dim]No projects/ directory at {host}[/dim]")
|
||||
console.print()
|
||||
return
|
||||
|
||||
projects = [d for d in sorted(projects_dir.iterdir()) if d.is_dir() and not d.name.startswith(".")]
|
||||
if not projects:
|
||||
console.print("[dim]No projects yet. Create one:[/dim]")
|
||||
else:
|
||||
console.print(f"[yellow]{len(projects)} project(s):[/yellow]")
|
||||
for p in projects:
|
||||
has_git = (p / ".git").is_dir()
|
||||
has_reg = any(f.name.endswith("_REGISTRY.json") for f in p.iterdir() if f.is_file())
|
||||
markers = []
|
||||
if has_git:
|
||||
markers.append("git")
|
||||
if has_reg:
|
||||
markers.append("registry")
|
||||
info = f" [dim]({', '.join(markers)})[/dim]" if markers else ""
|
||||
console.print(f" [cyan]{p.name}[/cyan]{info}")
|
||||
|
||||
console.print()
|
||||
console.print("[dim]Create: aipass new <name> [--template python] [--no-agent][/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print usage help for the new command."""
|
||||
from aipass.aipass.apps.handlers.new_project import TEMPLATES
|
||||
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass new[/bold cyan] — create a project inside AIPass")
|
||||
console.print()
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [green]aipass new <name>[/green] [dim]# Create with empty template[/dim]")
|
||||
console.print(" [green]aipass new <name> --template python[/green] [dim]# Create with Python template[/dim]")
|
||||
console.print(" [green]aipass new <name> --no-agent[/green] [dim]# Skip agent creation[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]TEMPLATES:[/yellow]")
|
||||
console.print(f" [dim]{', '.join(TEMPLATES)}[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]WHAT IT DOES:[/yellow]")
|
||||
console.print(" Creates projects/<name> with its own git repo, AIPass scaffold,")
|
||||
console.print(" and optional resident agent. Born deployable — repo + packaging")
|
||||
console.print(" from minute one.")
|
||||
console.print()
|
||||
|
||||
|
||||
def _prompt_template(templates: list[str]) -> str:
|
||||
"""Prompt user to choose a template interactively."""
|
||||
console.print()
|
||||
console.print("[yellow]Choose a template:[/yellow]")
|
||||
for idx, t in enumerate(templates, 1):
|
||||
console.print(f" [green]{idx}[/green]. {t}")
|
||||
console.print()
|
||||
while True:
|
||||
try:
|
||||
choice = input("Template [1]: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
logger.info("template prompt interrupted, defaulting to %s", templates[0])
|
||||
return templates[0]
|
||||
if not choice:
|
||||
return templates[0]
|
||||
if choice.isdigit() and 1 <= int(choice) <= len(templates):
|
||||
return templates[int(choice) - 1]
|
||||
if choice in templates:
|
||||
return choice
|
||||
error(f"Invalid choice. Enter 1-{len(templates)} or a template name.")
|
||||
|
||||
|
||||
def _prompt_agent() -> bool:
|
||||
"""Prompt user whether to skip agent creation. Returns no_agent flag."""
|
||||
console.print()
|
||||
while True:
|
||||
try:
|
||||
choice = input("Create resident agent? [Y/n]: ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
logger.info("agent prompt interrupted, defaulting to create agent")
|
||||
return False
|
||||
if choice in ("", "y", "yes"):
|
||||
return False
|
||||
if choice in ("n", "no"):
|
||||
return True
|
||||
error("Enter y or n.")
|
||||
|
||||
|
||||
def handle_command(command: str, args: list[str]) -> bool:
|
||||
"""Route the 'new' command. Returns True if handled."""
|
||||
if command != COMMAND:
|
||||
return False
|
||||
|
||||
if not args:
|
||||
json_handler.log_operation("new_project_usage", {"command": command})
|
||||
print_introspection()
|
||||
return True
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
json_handler.log_operation("new_project_help", {"command": command})
|
||||
print_help()
|
||||
return True
|
||||
if args[0] == "--info":
|
||||
json_handler.log_operation("new_project_info", {"command": command})
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
name = args[0]
|
||||
template = None
|
||||
no_agent = None
|
||||
has_template_flag = False
|
||||
has_agent_flag = False
|
||||
|
||||
i = 1
|
||||
while i < len(args):
|
||||
if args[i] == "--template" and i + 1 < len(args):
|
||||
template = args[i + 1]
|
||||
has_template_flag = True
|
||||
i += 2
|
||||
elif args[i] == "--no-agent":
|
||||
no_agent = True
|
||||
has_agent_flag = True
|
||||
i += 1
|
||||
else:
|
||||
error(f"Unknown option: {args[i]}")
|
||||
print_help()
|
||||
return True
|
||||
|
||||
from aipass.aipass.apps.handlers.new_project import TEMPLATES, create_project
|
||||
|
||||
if not has_template_flag:
|
||||
template = _prompt_template(list(TEMPLATES))
|
||||
elif template is None:
|
||||
template = "empty"
|
||||
if not has_agent_flag:
|
||||
no_agent = _prompt_agent()
|
||||
elif no_agent is None:
|
||||
no_agent = False
|
||||
|
||||
try:
|
||||
result = create_project(name, template, no_agent)
|
||||
except (RuntimeError, ValueError) as e:
|
||||
logger.warning("[AIPASS] new project failed: %s", e)
|
||||
error(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
console.print()
|
||||
success(f"Project '{name}' created at {result['target']}")
|
||||
console.print()
|
||||
console.print(f" [dim]Registry:[/dim] {result['registry_file']}")
|
||||
console.print(f" [dim]Template:[/dim] {result['template']}")
|
||||
if result["agent_created"]:
|
||||
console.print(" [dim]Agent:[/dim] created (full framework agent)")
|
||||
else:
|
||||
console.print(" [dim]Agent:[/dim] skipped (--no-agent)")
|
||||
|
||||
json_handler.log_operation(
|
||||
"new_project_create",
|
||||
{"name": name, "template": template, "target": result["target"]},
|
||||
)
|
||||
logger.info("[AIPASS] new project: %s (%s) at %s", name, template, result["target"])
|
||||
|
||||
if result["agent_created"] and sys.stdin.isatty():
|
||||
console.print()
|
||||
console.print("[dim]Launching your manager agent — Ctrl-C to stay in the shell[/dim]")
|
||||
console.print()
|
||||
from aipass.aipass.apps.handlers.handoff_platform import launch_inline
|
||||
|
||||
launch_inline(
|
||||
"claude",
|
||||
"You are the new resident agent of this project."
|
||||
" Read your passport and README, then tell me what you can do.",
|
||||
result["agent_home"],
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print("[yellow]Next steps:[/yellow]")
|
||||
if result["agent_created"]:
|
||||
console.print(f" [cyan]cd {result['agent_home']}[/cyan]")
|
||||
console.print(" [cyan]claude[/cyan] [dim]# meet your project agent[/dim]")
|
||||
else:
|
||||
console.print(f" [cyan]cd {result['target']}[/cyan]")
|
||||
console.print()
|
||||
return True
|
||||
@@ -24,7 +24,7 @@ import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.cli.apps.modules import console, error, warning
|
||||
from aipass.cli.apps.modules import console, error, success, warning
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
@@ -151,13 +151,13 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
return True
|
||||
field, value = args[1], args[2]
|
||||
if field not in USER_FIELDS:
|
||||
console.print(f"[red]Unknown field: {field}[/red]")
|
||||
error(f"Unknown field: {field}")
|
||||
console.print("[dim]Valid fields: " + ", ".join(USER_FIELDS) + "[/dim]")
|
||||
return True
|
||||
profile = get_user_profile()
|
||||
profile[field] = value
|
||||
save_profile(profile)
|
||||
console.print(f"[green]✓[/green] {field} = {value}")
|
||||
success(f"{field} = {value}")
|
||||
return True
|
||||
|
||||
if args[0] == "clear":
|
||||
@@ -166,7 +166,7 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
skip_confirm = any(a in ("--yes", "-y") for a in args[1:])
|
||||
if skip_confirm:
|
||||
save_profile({f: None for f in USER_FIELDS})
|
||||
console.print("[green]✓[/green] Profile cleared.")
|
||||
success("Profile cleared.")
|
||||
return True
|
||||
warning("Type 'aipass' to confirm clearing your profile (ctrl-C to cancel):")
|
||||
try:
|
||||
@@ -177,7 +177,7 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
return True
|
||||
if confirm == "aipass":
|
||||
save_profile({f: None for f in USER_FIELDS})
|
||||
console.print("[green]✓[/green] Profile cleared.")
|
||||
success("Profile cleared.")
|
||||
else:
|
||||
console.print("[yellow]Cancelled.[/yellow]")
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: trust.py
|
||||
# Description: Trust management — aipass trust / aipass revoke commands
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-15
|
||||
# Modified: 2026-07-15
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
aipass trust / revoke — manage the trusted-project registry (DPLAN-0244)
|
||||
|
||||
Enrollment controls which projects have their .aipass/hooks.json loaded
|
||||
by the hook engine. Projects created via `aipass init` auto-enroll;
|
||||
these commands handle manual enrollment and revocation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.cli.apps.modules import console, error, success
|
||||
from aipass.hooks.apps.handlers.config.trust_registry import (
|
||||
enroll,
|
||||
read_registry,
|
||||
revoke,
|
||||
)
|
||||
from aipass.prax import logger
|
||||
|
||||
COMMAND = "trust"
|
||||
_COMMAND_REVOKE = "revoke"
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Display the current trusted-project registry."""
|
||||
from rich.table import Table
|
||||
|
||||
registry = read_registry()
|
||||
projects = registry.get("projects", {})
|
||||
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass trust[/bold cyan] — trusted-project registry")
|
||||
console.print()
|
||||
|
||||
if not projects:
|
||||
console.print("[dim]No projects enrolled.[/dim]")
|
||||
else:
|
||||
table = Table(show_header=True, header_style="bold yellow")
|
||||
table.add_column("Project", style="cyan")
|
||||
table.add_column("Hash", style="dim", max_width=24)
|
||||
table.add_column("Enrolled")
|
||||
for path, entry in projects.items():
|
||||
short_hash = entry.get("config_hash", "")[:18] + "..."
|
||||
table.add_row(path, short_hash, entry.get("enrolled", ""))
|
||||
console.print(table)
|
||||
|
||||
console.print()
|
||||
console.print("[dim]Use 'aipass trust <path>' to enroll or 'aipass revoke <path>' to remove.[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print usage help for the trust/revoke commands."""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass trust / revoke[/bold cyan] — trusted-project registry")
|
||||
console.print()
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [green]aipass trust[/green] [dim]# Show enrolled projects[/dim]")
|
||||
console.print(
|
||||
" [green]aipass trust <path>[/green] [dim]# Enroll a project (requires .aipass/hooks.json)[/dim]"
|
||||
)
|
||||
console.print(" [green]aipass revoke <path>[/green] [dim]# Remove a project from the registry[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def _do_trust(args: list[str]) -> bool:
|
||||
"""Execute the trust enrollment for a given path."""
|
||||
target = Path(args[0]).resolve()
|
||||
if not target.is_dir():
|
||||
error(f"Not a directory: {target}")
|
||||
return True
|
||||
hooks_path = target / ".aipass" / "hooks.json"
|
||||
if not hooks_path.is_file():
|
||||
error(f"No .aipass/hooks.json found in {target}")
|
||||
return True
|
||||
if enroll(str(target)):
|
||||
success(f"Enrolled {target}")
|
||||
logger.info("[AIPASS] trust: enrolled %s", target)
|
||||
else:
|
||||
error(f"Failed to enroll {target}")
|
||||
return True
|
||||
|
||||
|
||||
def _do_revoke(args: list[str]) -> bool:
|
||||
"""Execute the revocation for a given path."""
|
||||
target = Path(args[0]).resolve()
|
||||
if revoke(str(target)):
|
||||
success(f"Revoked {target}")
|
||||
logger.info("[AIPASS] revoke: removed %s", target)
|
||||
else:
|
||||
console.print(f"[dim]{target} was not in the registry.[/dim]")
|
||||
return True
|
||||
|
||||
|
||||
def handle_command(command: str, args: list[str]) -> bool:
|
||||
"""Route trust/revoke subcommands. Returns True if handled."""
|
||||
if command == COMMAND:
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
if args[0] == "--info":
|
||||
print_introspection()
|
||||
return True
|
||||
return _do_trust(args)
|
||||
if command == _COMMAND_REVOKE:
|
||||
if not args or args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
return _do_revoke(args)
|
||||
return False
|
||||
@@ -0,0 +1,42 @@
|
||||
# Probe Hygiene SOP
|
||||
|
||||
Standard operating procedure for throwaway test installs of AIPass.
|
||||
|
||||
## Principle
|
||||
|
||||
Temporary environments are used, then deleted, gone. Nothing permanent may ever point at a temp path.
|
||||
|
||||
## Rules
|
||||
|
||||
- Install probes and throwaway test installs live ONLY in throwaway directories (system temp dir, `/tmp`, Claude Code scratchpad dirs).
|
||||
- Used = deleted = GONE. Delete the probe directory immediately after the test completes.
|
||||
- NOTHING permanent may ever point at a temporary path: no global settings (`~/.claude/settings.json` `env.AIPASS_HOME`), no symlinks, no registry entries.
|
||||
- `aipass install` now refuses throwaway homes automatically. The `--force-global-home` flag is the explicit unsafe override, for probe use only.
|
||||
- `aipass doctor` now detects a hijacked global `AIPASS_HOME` (nonexistent or temp path) and flags it as an error with fix guidance.
|
||||
|
||||
## What the defenses do
|
||||
|
||||
1. **`is_throwaway_path()`** (bootstrap.py) — detects paths under `tempfile.gettempdir()`, `/tmp` (POSIX), or containing `scratchpad`. Shared gate used by both install and bootstrap.
|
||||
2. **`run_install()` gate** (install.py) — refuses to proceed when the resolved home is throwaway. Prints a loud `REFUSED` message with guidance. `--force-global-home` overrides.
|
||||
3. **`_claude_settings()` gate** (bootstrap.py) — refuses to write `env.AIPASS_HOME` into project settings when the detected home is throwaway. Defense-in-depth behind the install gate.
|
||||
4. **`_check_global_aipass_home()`** (doctor.py) — reads `~/.claude/settings.json` and flags `env.AIPASS_HOME` pointing at a nonexistent or throwaway path as an error.
|
||||
|
||||
## Correct probe workflow
|
||||
|
||||
```bash
|
||||
# 1. Create throwaway dir
|
||||
cd /tmp && mkdir aipass_probe && cd aipass_probe
|
||||
|
||||
# 2. Run the probe (install will refuse — this is correct)
|
||||
aipass install --here
|
||||
# → REFUSED: '/tmp/aipass_probe' is a temporary/scratchpad path.
|
||||
|
||||
# 3. If you genuinely need a temp install (testing only):
|
||||
aipass install --here --force-global-home
|
||||
|
||||
# 4. IMMEDIATELY after testing, delete the probe
|
||||
rm -rf /tmp/aipass_probe
|
||||
|
||||
# 5. Verify global settings are clean
|
||||
aipass doctor
|
||||
```
|
||||
@@ -0,0 +1,125 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: project_home.py
|
||||
# Description: Shared AIPass-home detection, project-path validation, and settings content
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-21
|
||||
# Modified: 2026-07-21
|
||||
# =============================================
|
||||
|
||||
"""Project home — AIPASS_HOME detection, path validation, settings content.
|
||||
|
||||
Shared across every command that scaffolds or inspects an AIPass project
|
||||
(`aipass init`, `aipass new`, `aipass adopt`), so there is exactly one
|
||||
source of truth per helper — no per-command copies to drift apart.
|
||||
|
||||
Dependency-free: uses only stdlib. Importable before drone/prax exist.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_throwaway_path(path: str | Path) -> bool:
|
||||
"""True if path is under a temp dir or Claude Code scratchpad."""
|
||||
resolved = str(Path(path).resolve())
|
||||
tmp_roots = [tempfile.gettempdir()]
|
||||
if os.name == "posix":
|
||||
tmp_roots.append("/tmp")
|
||||
for root in tmp_roots:
|
||||
try:
|
||||
r = str(Path(root).resolve())
|
||||
except OSError:
|
||||
logger.info("is_throwaway_path: could not resolve %s", root)
|
||||
continue
|
||||
if resolved == r or resolved.startswith(r + os.sep):
|
||||
return True
|
||||
if "scratchpad" in resolved.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _detect_aipass_home() -> str | None:
|
||||
"""Detect the AIPass installation root from the aipass package location.
|
||||
|
||||
Returns the parent of the src/ directory (the repo root).
|
||||
Returns None if detection fails.
|
||||
"""
|
||||
try:
|
||||
spec = importlib.util.find_spec("aipass")
|
||||
if spec and spec.origin:
|
||||
# aipass/__init__.py lives at src/aipass/__init__.py
|
||||
# parent = src/aipass/, parent.parent = src/, parent.parent.parent = AIPass root
|
||||
return str(Path(spec.origin).resolve().parent.parent.parent)
|
||||
except Exception as exc:
|
||||
logger.info("AIPASS_HOME detection skipped: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _claude_settings() -> str:
|
||||
"""Generate .claude/settings.json — permissions only, no machine-local paths.
|
||||
|
||||
Hooks are NOT wired at the project level. All AIPass hooks
|
||||
(prompt injection, identity, email, pre-compact, edit gates) fire
|
||||
from provider settings (~/.claude/settings.json), installed by
|
||||
setup.sh. Provider hooks use CWD-walking patterns that work from
|
||||
any directory in any project.
|
||||
|
||||
AIPASS_HOME is a machine-local absolute path — it never belongs in this
|
||||
tracked file. It goes in .claude/settings.local.json instead, which is
|
||||
gitignored and merged over tracked settings by Claude Code natively.
|
||||
See _claude_local_settings().
|
||||
"""
|
||||
data: dict = {
|
||||
"permissions": {
|
||||
"deny": [
|
||||
"Bash(git push --force*)",
|
||||
"Bash(git reset --hard*)",
|
||||
"EnterPlanMode",
|
||||
],
|
||||
},
|
||||
}
|
||||
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def _claude_local_settings(aipass_home: str) -> str:
|
||||
"""Generate .claude/settings.local.json — machine-local env (gitignored)."""
|
||||
return json.dumps({"env": {"AIPASS_HOME": aipass_home}}, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def _enroll_project(target: Path) -> None:
|
||||
"""Enroll a project in the trusted-project registry (DPLAN-0244).
|
||||
|
||||
Lazy import to keep this module free of prax/module-level deps.
|
||||
"""
|
||||
try:
|
||||
from aipass.hooks.apps.handlers.config.trust_registry import enroll
|
||||
|
||||
if enroll(str(target)):
|
||||
logger.info("Enrolled project in trust registry: %s", target)
|
||||
else:
|
||||
logger.warning("Trust enrollment failed for %s", target)
|
||||
except ImportError as exc:
|
||||
logger.info("Trust registry unavailable, skipping enrollment: %s", exc)
|
||||
|
||||
|
||||
def is_projects_child(target: Path) -> bool:
|
||||
"""True if *target* is ``<host>/projects/<name>`` — a valid nested project path.
|
||||
|
||||
The host is identified by having a ``*_REGISTRY.json`` in the grandparent
|
||||
of target (i.e. target's parent is named ``projects``).
|
||||
"""
|
||||
resolved = target.resolve()
|
||||
if resolved.parent.name != "projects":
|
||||
return False
|
||||
host = resolved.parent.parent
|
||||
try:
|
||||
return any(f.is_file() and f.name.endswith("_REGISTRY.json") for f in host.iterdir())
|
||||
except OSError as exc:
|
||||
logger.info("is_projects_child: could not read host dir %s: %s", host, exc)
|
||||
return False
|
||||
+12
-6
@@ -1,17 +1,20 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: scaffold_content.py
|
||||
# Description: Template content generators for aipass init scaffold
|
||||
# Description: Shared template content generators for project scaffolding
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-22
|
||||
# Modified: 2026-04-22
|
||||
# Modified: 2026-07-21
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Scaffold Content — template generators for `aipass init`
|
||||
Scaffold Content — template generators for `aipass init`, `aipass new`, `aipass adopt`
|
||||
|
||||
Pure string-returning functions that produce the content for each scaffold
|
||||
file (CLAUDE.md, AGENTS.md, etc.). Extracted from bootstrap.py to keep
|
||||
the handler under 700 lines.
|
||||
file (CLAUDE.md, AGENTS.md, .gitignore, etc.). Shared across every command
|
||||
that mints AIPass project files, so there is exactly one source of truth
|
||||
per template — no per-command copies to drift apart.
|
||||
|
||||
Dependency-free: uses only stdlib. Importable before drone/prax exist.
|
||||
|
||||
RULES:
|
||||
- Pure Python only (no module/prax/cli imports)
|
||||
@@ -251,7 +254,7 @@ def gitignore() -> str:
|
||||
"*.egg-info/\n"
|
||||
"dist/\n"
|
||||
"build/\n"
|
||||
".venv/\n"
|
||||
".venv\n"
|
||||
"venv/\n"
|
||||
"\n"
|
||||
"# IDE\n"
|
||||
@@ -267,6 +270,9 @@ def gitignore() -> str:
|
||||
"\n"
|
||||
"# Disabled files\n"
|
||||
"*(disabled)*\n"
|
||||
"\n"
|
||||
"# Registry lock\n"
|
||||
".*_REGISTRY.lock\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_adopt.py
|
||||
# Description: Tests for aipass adopt — project adoption handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-20
|
||||
# Modified: 2026-07-20
|
||||
# =============================================
|
||||
|
||||
"""Tests for the adopt handler and module.
|
||||
|
||||
All file operations use tmp_path to stay fully isolated from the live
|
||||
filesystem. dry_run tests assert zero filesystem mutation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest # pyright: ignore[reportMissingImports]
|
||||
|
||||
from aipass.aipass.apps.handlers.new_project.adopt import (
|
||||
GITIGNORE_MARKER,
|
||||
adopt_project,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def host_env(tmp_path):
|
||||
"""Minimal AIPass host installation with an existing (pre-populated) project dir."""
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text(json.dumps({"metadata": {"id": "host-id"}, "branches": []}))
|
||||
projects = tmp_path / "projects"
|
||||
projects.mkdir()
|
||||
target = projects / "existing-site"
|
||||
target.mkdir()
|
||||
(target / "index.html").write_text("<html></html>\n", encoding="utf-8")
|
||||
(target / "README.md").write_text("# existing-site\n\nReal content.\n", encoding="utf-8")
|
||||
return tmp_path, target
|
||||
|
||||
|
||||
def _no_home():
|
||||
return patch(
|
||||
"aipass.aipass.apps.handlers.new_project.adopt._detect_aipass_home",
|
||||
return_value=None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# adopt_project — guards / refusals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_adopt_rejects_missing_target(tmp_path):
|
||||
with pytest.raises(RuntimeError, match="does not exist"):
|
||||
adopt_project(tmp_path / "nope")
|
||||
|
||||
|
||||
def test_adopt_rejects_non_projects_child(tmp_path):
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||
outside = tmp_path / "elsewhere" / "myapp"
|
||||
outside.mkdir(parents=True)
|
||||
with pytest.raises(RuntimeError, match="not <host>/projects/<name>"):
|
||||
adopt_project(outside)
|
||||
|
||||
|
||||
def test_adopt_rejects_already_adopted(host_env):
|
||||
_, target = host_env
|
||||
(target / "EXISTING_SITE_REGISTRY.json").write_text("{}")
|
||||
with pytest.raises(RuntimeError, match="already adopted"):
|
||||
adopt_project(target, no_agent=True)
|
||||
|
||||
|
||||
def test_adopt_rejects_agent_home_collision(host_env):
|
||||
_, target = host_env
|
||||
home = target / "src" / "existing_site" / "existing_site"
|
||||
home.mkdir(parents=True)
|
||||
(home / "stray.txt").write_text("junk\n", encoding="utf-8")
|
||||
with _no_home():
|
||||
with pytest.raises(RuntimeError, match="Name collision"):
|
||||
adopt_project(target, no_agent=False)
|
||||
|
||||
|
||||
def test_adopt_allows_collision_with_no_agent(host_env):
|
||||
"""An occupied agent-home path is fine when --no-agent skips the agent entirely."""
|
||||
_, target = host_env
|
||||
home = target / "src" / "existing_site" / "existing_site"
|
||||
home.mkdir(parents=True)
|
||||
(home / "stray.txt").write_text("junk\n", encoding="utf-8")
|
||||
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
||||
result = adopt_project(target, no_agent=True)
|
||||
assert result["agent_created"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# adopt_project — gitignore safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_adopt_creates_gitignore_when_absent(host_env):
|
||||
_, target = host_env
|
||||
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
||||
result = adopt_project(target, no_agent=True)
|
||||
assert result["gitignore_action"] == "created"
|
||||
content = (target / ".gitignore").read_text(encoding="utf-8")
|
||||
assert GITIGNORE_MARKER in content
|
||||
assert ".trinity/" in content
|
||||
|
||||
|
||||
def test_adopt_appends_gitignore_when_marker_absent(host_env):
|
||||
_, target = host_env
|
||||
(target / ".gitignore").write_text("node_modules/\ndist/\n", encoding="utf-8")
|
||||
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
||||
result = adopt_project(target, no_agent=True)
|
||||
assert result["gitignore_action"] == "appended"
|
||||
content = (target / ".gitignore").read_text(encoding="utf-8")
|
||||
assert "node_modules/" in content
|
||||
assert GITIGNORE_MARKER in content
|
||||
assert ".trinity/" in content
|
||||
|
||||
|
||||
def test_adopt_skips_gitignore_when_marker_present(host_env):
|
||||
_, target = host_env
|
||||
(target / ".gitignore").write_text(f"{GITIGNORE_MARKER}\n.trinity/\n", encoding="utf-8")
|
||||
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
||||
result = adopt_project(target, no_agent=True)
|
||||
assert result["gitignore_action"] == "already-safe"
|
||||
content = (target / ".gitignore").read_text(encoding="utf-8")
|
||||
assert content.count(GITIGNORE_MARKER) == 1
|
||||
|
||||
|
||||
def test_adopt_gitignore_covers_symlinked_venv_and_registry_lock(host_env, tmp_path):
|
||||
"""Live-verification regression: a symlinked .venv (not a real dir) and a
|
||||
registry lock file must both be actually ignored by real git, not just
|
||||
present as a string in the .gitignore content."""
|
||||
host, target = host_env
|
||||
aipass_home = tmp_path / "fake_aipass_home"
|
||||
(aipass_home / ".venv").mkdir(parents=True)
|
||||
subprocess.run(["git", "init"], cwd=target, capture_output=True, text=True, check=True)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.new_project.adopt._detect_aipass_home",
|
||||
return_value=str(aipass_home),
|
||||
),
|
||||
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
|
||||
):
|
||||
adopt_project(target, no_agent=True)
|
||||
|
||||
venv_link = target / ".venv"
|
||||
assert venv_link.is_symlink()
|
||||
lock_file = target / ".EXISTING-SITE_REGISTRY.lock"
|
||||
lock_file.write_text("", encoding="utf-8")
|
||||
|
||||
result = subprocess.run(
|
||||
["git", "check-ignore", ".venv", ".EXISTING-SITE_REGISTRY.lock"],
|
||||
cwd=target,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert ".venv" in result.stdout.split()
|
||||
assert ".EXISTING-SITE_REGISTRY.lock" in result.stdout.split()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# adopt_project — additive scaffold, existing files untouched
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_adopt_never_overwrites_existing_readme(host_env):
|
||||
_, target = host_env
|
||||
original = (target / "README.md").read_text(encoding="utf-8")
|
||||
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
||||
adopt_project(target, no_agent=True)
|
||||
assert (target / "README.md").read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
def test_adopt_never_touches_existing_tracked_files(host_env):
|
||||
_, target = host_env
|
||||
original = (target / "index.html").read_text(encoding="utf-8")
|
||||
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
||||
adopt_project(target, no_agent=True)
|
||||
assert (target / "index.html").read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
def test_adopt_writes_registry_and_settings(host_env):
|
||||
_, target = host_env
|
||||
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
||||
result = adopt_project(target, no_agent=True)
|
||||
assert result["registry_file"] == "EXISTING-SITE_REGISTRY.json"
|
||||
assert (target / "EXISTING-SITE_REGISTRY.json").exists()
|
||||
reg = json.loads((target / "EXISTING-SITE_REGISTRY.json").read_text())
|
||||
assert reg["metadata"]["name"] == "EXISTING-SITE"
|
||||
assert (target / ".claude" / "settings.json").exists()
|
||||
settings = json.loads((target / ".claude" / "settings.json").read_text())
|
||||
assert "env" not in settings
|
||||
|
||||
|
||||
def test_adopt_no_agent_skips_spawn(host_env):
|
||||
_, target = host_env
|
||||
with (
|
||||
_no_home(),
|
||||
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
|
||||
patch("aipass.aipass.apps.handlers.new_project.spawn_agent") as mock_spawn,
|
||||
):
|
||||
result = adopt_project(target, no_agent=True)
|
||||
mock_spawn.assert_not_called()
|
||||
assert result["agent_created"] is False
|
||||
assert result["agent_home"] is None
|
||||
|
||||
|
||||
def test_adopt_with_agent_spawns(host_env):
|
||||
_, target = host_env
|
||||
spawn_ok = {
|
||||
"success": True,
|
||||
"branch_name": "EXISTING_SITE",
|
||||
"path": str(target / "src" / "existing_site" / "existing_site"),
|
||||
"files_copied": 12,
|
||||
"registry_updated": True,
|
||||
"validation_issues": [],
|
||||
}
|
||||
with (
|
||||
_no_home(),
|
||||
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||
return_value=spawn_ok,
|
||||
) as mock_spawn,
|
||||
):
|
||||
result = adopt_project(target, no_agent=False)
|
||||
mock_spawn.assert_called_once()
|
||||
assert result["agent_created"] is True
|
||||
assert result["agent_home"] == str(target / "src" / "existing_site" / "existing_site")
|
||||
|
||||
|
||||
def test_adopt_registry_minted_before_spawn(host_env):
|
||||
"""Registry-first invariant: registry file exists on disk before spawn_agent is called."""
|
||||
_, target = host_env
|
||||
seen = {}
|
||||
|
||||
def _check_registry_exists(**kwargs):
|
||||
seen["registry_present"] = (target / "EXISTING-SITE_REGISTRY.json").exists()
|
||||
return {
|
||||
"success": True,
|
||||
"branch_name": "EXISTING_SITE",
|
||||
"path": kwargs["target_path"],
|
||||
"files_copied": 1,
|
||||
"registry_updated": True,
|
||||
"validation_issues": [],
|
||||
}
|
||||
|
||||
with (
|
||||
_no_home(),
|
||||
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||
side_effect=_check_registry_exists,
|
||||
),
|
||||
):
|
||||
adopt_project(target, no_agent=False)
|
||||
assert seen["registry_present"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# adopt_project — dry_run performs zero writes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_adopt_dry_run_writes_nothing(host_env):
|
||||
_, target = host_env
|
||||
before = sorted(p.relative_to(target) for p in target.rglob("*"))
|
||||
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.spawn_agent") as mock_spawn:
|
||||
result = adopt_project(target, no_agent=False, dry_run=True)
|
||||
after = sorted(p.relative_to(target) for p in target.rglob("*"))
|
||||
assert before == after
|
||||
mock_spawn.assert_not_called()
|
||||
assert result["dry_run"] is True
|
||||
assert result["registry_id"] is None
|
||||
assert result["agent_created"] is False
|
||||
|
||||
|
||||
def test_adopt_dry_run_reports_planned_registry_and_agent(host_env):
|
||||
_, target = host_env
|
||||
with _no_home():
|
||||
result = adopt_project(target, no_agent=False, dry_run=True)
|
||||
assert result["registry_file"] == "EXISTING-SITE_REGISTRY.json"
|
||||
assert "EXISTING-SITE_REGISTRY.json" in result["files"]
|
||||
assert result["agent_home"] == str(target / "src" / "existing_site" / "existing_site")
|
||||
assert any("resident agent" in f for f in result["files"])
|
||||
|
||||
|
||||
def test_adopt_dry_run_no_agent_reports_no_home(host_env):
|
||||
_, target = host_env
|
||||
with _no_home():
|
||||
result = adopt_project(target, no_agent=True, dry_run=True)
|
||||
assert result["agent_home"] is None
|
||||
|
||||
|
||||
def test_adopt_dry_run_still_reports_gitignore_action(host_env):
|
||||
_, target = host_env
|
||||
with _no_home():
|
||||
result = adopt_project(target, no_agent=True, dry_run=True)
|
||||
assert result["gitignore_action"] == "created"
|
||||
assert not (target / ".gitignore").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module handle_command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_module_handles_not_mine():
|
||||
from aipass.aipass.apps.modules.adopt import handle_command
|
||||
|
||||
assert handle_command("notmine", []) is False
|
||||
|
||||
|
||||
def test_module_handles_help():
|
||||
from aipass.aipass.apps.modules.adopt import handle_command
|
||||
|
||||
assert handle_command("adopt", ["--help"]) is True
|
||||
|
||||
|
||||
def test_module_handles_no_args():
|
||||
from aipass.aipass.apps.modules.adopt import handle_command
|
||||
|
||||
assert handle_command("adopt", []) is True
|
||||
|
||||
|
||||
def test_module_rejects_unknown_option(host_env):
|
||||
from aipass.aipass.apps.modules.adopt import handle_command
|
||||
|
||||
_, target = host_env
|
||||
with patch("aipass.aipass.apps.modules.adopt.error") as mock_error:
|
||||
handle_command("adopt", [str(target), "--bogus"])
|
||||
mock_error.assert_called_once()
|
||||
assert "Unknown option" in mock_error.call_args[0][0]
|
||||
|
||||
|
||||
def test_module_adopt_by_absolute_path(host_env, monkeypatch):
|
||||
from aipass.aipass.apps.modules.adopt import handle_command
|
||||
|
||||
host, target = host_env
|
||||
monkeypatch.chdir(host)
|
||||
with (
|
||||
_no_home(),
|
||||
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
|
||||
patch("aipass.aipass.apps.modules.adopt.console") as mock_con,
|
||||
):
|
||||
handle_command("adopt", [str(target), "--no-agent"])
|
||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||
assert "Registry:" in printed
|
||||
assert "EXISTING-SITE_REGISTRY.json" in printed
|
||||
|
||||
|
||||
def test_module_adopt_by_bare_name(host_env, monkeypatch):
|
||||
from aipass.aipass.apps.modules.adopt import handle_command
|
||||
|
||||
host, target = host_env
|
||||
monkeypatch.chdir(host)
|
||||
with (
|
||||
_no_home(),
|
||||
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
|
||||
patch("aipass.aipass.apps.modules.adopt.console") as mock_con,
|
||||
):
|
||||
handle_command("adopt", ["existing-site", "--no-agent"])
|
||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||
assert "Registry:" in printed
|
||||
assert "EXISTING-SITE_REGISTRY.json" in printed
|
||||
|
||||
|
||||
def test_module_adopt_dry_run_reports_would_adopt(host_env, monkeypatch, capsys):
|
||||
from aipass.aipass.apps.modules.adopt import handle_command
|
||||
|
||||
host, target = host_env
|
||||
monkeypatch.chdir(host)
|
||||
with _no_home():
|
||||
handle_command("adopt", ["existing-site", "--no-agent", "--dry-run"])
|
||||
out = capsys.readouterr().out
|
||||
assert "Would adopt" in out
|
||||
assert not (target / "EXISTING-SITE_REGISTRY.json").exists()
|
||||
|
||||
|
||||
def test_module_adopt_missing_target_no_host(tmp_path, monkeypatch):
|
||||
from aipass.aipass.apps.modules.adopt import handle_command
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with (
|
||||
patch("aipass.aipass.apps.modules.adopt.error") as mock_error,
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
handle_command("adopt", ["nope"])
|
||||
mock_error.assert_called_once()
|
||||
assert "Not inside an AIPass installation" in mock_error.call_args[0][0]
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
def test_module_adopt_reports_refusal(host_env, monkeypatch):
|
||||
from aipass.aipass.apps.modules.adopt import handle_command
|
||||
|
||||
host, target = host_env
|
||||
monkeypatch.chdir(host)
|
||||
(target / "EXISTING-SITE_REGISTRY.json").write_text("{}")
|
||||
with (
|
||||
patch("aipass.aipass.apps.modules.adopt.error") as mock_error,
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
handle_command("adopt", ["existing-site", "--no-agent"])
|
||||
mock_error.assert_called_once()
|
||||
assert "already adopted" in mock_error.call_args[0][0]
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# aipass.py wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_aipass_public_commands_includes_adopt():
|
||||
from aipass.aipass.apps.aipass import _PUBLIC_COMMANDS
|
||||
|
||||
assert "adopt" in _PUBLIC_COMMANDS
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import types
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -129,23 +130,15 @@ class TestRouteCommand:
|
||||
mod2.handle_command.assert_called_once_with("cmd", ["arg1"])
|
||||
mod3.handle_command.assert_not_called()
|
||||
|
||||
def test_handles_module_exception(self) -> None:
|
||||
"""Exception in a module is caught; returns False if no other handles."""
|
||||
def test_module_exception_re_raises(self) -> None:
|
||||
"""Exception in a handler is re-raised so callers see the real error."""
|
||||
mod = MagicMock()
|
||||
mod.handle_command.side_effect = RuntimeError("crash")
|
||||
mod.__name__ = "broken_mod"
|
||||
assert route_command("cmd", [], [mod]) is False
|
||||
import pytest
|
||||
|
||||
def test_exception_in_first_tries_second(self) -> None:
|
||||
"""Exception in first module does not prevent second from handling."""
|
||||
mod1 = MagicMock()
|
||||
mod1.handle_command.side_effect = RuntimeError("crash")
|
||||
mod1.__name__ = "mod1"
|
||||
mod2 = MagicMock()
|
||||
mod2.handle_command.return_value = True
|
||||
|
||||
assert route_command("cmd", [], [mod1, mod2]) is True
|
||||
mod2.handle_command.assert_called_once()
|
||||
with pytest.raises(RuntimeError, match="crash"):
|
||||
route_command("cmd", [], [mod])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -157,88 +150,110 @@ class TestMain:
|
||||
"""Tests for the main() entry point."""
|
||||
|
||||
def test_version_flag(self) -> None:
|
||||
"""--version prints version and returns 0."""
|
||||
"""--version prints real package version and returns 0."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "--version"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
result = main()
|
||||
assert result == 0
|
||||
mock_print.assert_called_once_with("aipass 0.1.0")
|
||||
printed = mock_con.print.call_args[0][0]
|
||||
assert printed.startswith("aipass ")
|
||||
assert printed != "aipass 0.1.0"
|
||||
|
||||
def test_version_flag_short(self) -> None:
|
||||
"""-V prints version and returns 0."""
|
||||
"""-V prints real package version and returns 0."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "-V"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
result = main()
|
||||
assert result == 0
|
||||
mock_print.assert_called_once_with("aipass 0.1.0")
|
||||
printed = mock_con.print.call_args[0][0]
|
||||
assert printed.startswith("aipass ")
|
||||
|
||||
def test_version_flag_fallback(self) -> None:
|
||||
"""--version prints 'unknown' when package metadata unavailable."""
|
||||
_not_found = importlib.metadata.PackageNotFoundError
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "--version"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch(
|
||||
"aipass.aipass.apps.aipass.importlib.metadata.version",
|
||||
side_effect=_not_found,
|
||||
):
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
result = main()
|
||||
assert result == 0
|
||||
mock_con.print.assert_called_once_with("aipass unknown")
|
||||
|
||||
def test_help_flag_shows_help(self) -> None:
|
||||
"""--help shows module list and returns 0."""
|
||||
"""--help calls print_help and returns 0."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "--help"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
result = main()
|
||||
assert result == 0
|
||||
mock_print.assert_called()
|
||||
mock_con.print.assert_called()
|
||||
|
||||
def test_h_flag_shows_help(self) -> None:
|
||||
"""-h shows module list and returns 0."""
|
||||
"""-h shows help and returns 0."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "-h"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print"):
|
||||
with patch("aipass.aipass.apps.aipass.console"):
|
||||
result = main()
|
||||
assert result == 0
|
||||
|
||||
def test_no_args_shows_help(self) -> None:
|
||||
"""No arguments shows module list and returns 0."""
|
||||
"""No arguments shows introspection and returns 0."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print"):
|
||||
with patch("aipass.aipass.apps.aipass.console"):
|
||||
result = main()
|
||||
assert result == 0
|
||||
|
||||
def test_help_word_shows_help(self) -> None:
|
||||
"""'help' as only arg shows module list and returns 0."""
|
||||
def test_help_word_routes_to_module(self) -> None:
|
||||
"""'help' as only arg routes to help_chat module, not root help."""
|
||||
mod = MagicMock()
|
||||
mod.handle_command.return_value = True
|
||||
mod.__name__ = "aipass.aipass.apps.modules.help_chat"
|
||||
mod.COMMAND = "help"
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "help"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print"):
|
||||
result = main()
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[mod]):
|
||||
result = main()
|
||||
assert result == 0
|
||||
mod.handle_command.assert_called_once_with("help", [])
|
||||
|
||||
def test_help_shows_module_count(self) -> None:
|
||||
"""Help output includes discovered module count."""
|
||||
mod = types.ModuleType("test_mod")
|
||||
mod.__doc__ = "Test module doc"
|
||||
def test_introspection_shows_public_commands(self) -> None:
|
||||
"""Introspection lists modules with COMMAND in _PUBLIC_COMMANDS."""
|
||||
mod = types.ModuleType("aipass.aipass.apps.modules.help_chat")
|
||||
mod.__doc__ = "Help chatbot"
|
||||
mod.COMMAND = "help" # type: ignore[attr-defined]
|
||||
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[mod]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
main()
|
||||
first_call_args = mock_print.call_args_list[0][0][0]
|
||||
assert "1 modules" in first_call_args
|
||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||
assert "help" in printed
|
||||
|
||||
def test_help_shows_module_with_no_doc(self) -> None:
|
||||
"""Module without docstring shows 'No description'."""
|
||||
mod = types.ModuleType("nodoc_mod")
|
||||
mod.__doc__ = None
|
||||
def test_introspection_hides_non_public(self) -> None:
|
||||
"""Modules without COMMAND in _PUBLIC_COMMANDS are hidden."""
|
||||
mod = types.ModuleType("aipass.aipass.apps.modules.internal")
|
||||
mod.__doc__ = "Internal module"
|
||||
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[mod]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
main()
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
assert "No description" in printed
|
||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||
assert "internal" not in printed
|
||||
|
||||
def test_unknown_command_returns_1(self) -> None:
|
||||
"""Unknown command prints error and returns 1."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "xyzzy"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
result = main()
|
||||
assert result == 1
|
||||
mock_print.assert_called_with("Unknown command: xyzzy")
|
||||
mock_con.print.assert_called_with("Unknown command: xyzzy")
|
||||
|
||||
def test_known_command_routes_and_returns_0(self) -> None:
|
||||
"""Known command that gets handled returns 0."""
|
||||
@@ -252,6 +267,38 @@ class TestMain:
|
||||
assert result == 0
|
||||
mod.handle_command.assert_called_once_with("doctor", [])
|
||||
|
||||
def test_at_prefix_shows_drone_guidance(self) -> None:
|
||||
"""@drone prints guidance pointing to drone, not 'Unknown command'."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@drone"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
result = main()
|
||||
assert result == 1
|
||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||
assert "@drone" in printed
|
||||
assert "drone routing target" in printed
|
||||
assert "Unknown command" not in printed
|
||||
|
||||
def test_at_prefix_uses_actual_name(self) -> None:
|
||||
"""@memory prints guidance with the actual @name the user typed."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@memory"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
result = main()
|
||||
assert result == 1
|
||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||
assert "@memory" in printed
|
||||
assert "drone @memory" in printed
|
||||
|
||||
def test_plain_bad_command_still_unknown(self) -> None:
|
||||
"""Non-@ bad command still prints 'Unknown command', not drone guidance."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "frobnicate"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
result = main()
|
||||
assert result == 1
|
||||
mock_con.print.assert_called_with("Unknown command: frobnicate")
|
||||
|
||||
def test_command_with_remaining_args(self) -> None:
|
||||
"""Remaining args are passed to route_command."""
|
||||
mod = MagicMock()
|
||||
@@ -262,3 +309,76 @@ class TestMain:
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[mod]):
|
||||
main()
|
||||
mod.handle_command.assert_called_once_with("doctor", ["--verbose", "--fix"])
|
||||
|
||||
def test_help_shows_command_constant(self) -> None:
|
||||
"""Introspection uses module COMMAND constant, not file stem."""
|
||||
mod = types.ModuleType("aipass.aipass.apps.modules.help_chat")
|
||||
mod.__doc__ = "Help chatbot"
|
||||
mod.COMMAND = "help" # type: ignore[attr-defined]
|
||||
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
||||
with patch(
|
||||
"aipass.aipass.apps.aipass.discover_modules",
|
||||
return_value=[mod],
|
||||
):
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
main()
|
||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||
assert "help" in printed
|
||||
assert "help_chat" not in printed
|
||||
|
||||
def test_introspection_skips_no_command_module(self) -> None:
|
||||
"""Modules without COMMAND in _PUBLIC_COMMANDS are hidden from introspection."""
|
||||
mod = types.ModuleType("aipass.aipass.apps.modules.doctor")
|
||||
mod.__doc__ = "Doctor module"
|
||||
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
||||
with patch(
|
||||
"aipass.aipass.apps.aipass.discover_modules",
|
||||
return_value=[mod],
|
||||
):
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
main()
|
||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||
assert (
|
||||
"Commands:" not in printed or "doctor" not in printed.split("Commands:")[1]
|
||||
if "Commands:" in printed
|
||||
else True
|
||||
)
|
||||
|
||||
def test_handler_crash_surfaces_error(self) -> None:
|
||||
"""Handler crash prints real error, not 'Unknown command'."""
|
||||
mod = MagicMock()
|
||||
mod.handle_command.side_effect = RuntimeError("db connection failed")
|
||||
mod.__name__ = "aipass.aipass.apps.modules.doctor"
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "doctor"]):
|
||||
with patch(
|
||||
"aipass.aipass.apps.aipass.discover_modules",
|
||||
return_value=[mod],
|
||||
):
|
||||
with patch("aipass.aipass.apps.aipass.console"):
|
||||
with patch("aipass.aipass.apps.aipass.error") as mock_err:
|
||||
result = main()
|
||||
assert result == 1
|
||||
err_text = " ".join(str(a) for call in mock_err.call_args_list for a in call[0])
|
||||
assert "db connection failed" in err_text
|
||||
|
||||
def test_import_failure_surfaces_on_command(self) -> None:
|
||||
"""Failed module import surfaces when user types that command."""
|
||||
import aipass.aipass.apps.aipass as aipass_mod
|
||||
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "broken"]):
|
||||
with patch(
|
||||
"aipass.aipass.apps.aipass.discover_modules",
|
||||
return_value=[],
|
||||
):
|
||||
aipass_mod._import_failures.clear()
|
||||
aipass_mod._import_failures["broken"] = ImportError("no module")
|
||||
with patch("aipass.aipass.apps.aipass.console"):
|
||||
with patch("aipass.aipass.apps.aipass.error") as mock_err:
|
||||
result = main()
|
||||
assert result == 1
|
||||
err_text = " ".join(str(a) for call in mock_err.call_args_list for a in call[0])
|
||||
assert "failed to load" in err_text
|
||||
assert "no module" in err_text
|
||||
aipass_mod._import_failures.clear()
|
||||
|
||||
@@ -20,13 +20,14 @@ from pathlib import Path
|
||||
|
||||
import pytest # pyright: ignore[reportMissingImports]
|
||||
|
||||
from aipass.aipass.apps.handlers.init import scaffold_content as sc
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import (
|
||||
_merge_hooks_json,
|
||||
_sanitize_name,
|
||||
is_throwaway_path,
|
||||
init_project,
|
||||
update_project,
|
||||
)
|
||||
from aipass.aipass.shared import scaffold_content as sc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -277,8 +278,12 @@ def test_init_project_claude_settings_content(tmp_path):
|
||||
assert "deny" in data["permissions"]
|
||||
|
||||
|
||||
def test_init_project_settings_no_hooks(tmp_path):
|
||||
def test_init_project_settings_no_hooks(tmp_path, monkeypatch):
|
||||
""".claude/settings.json has no hooks — all hooks fire from provider level."""
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||
lambda _: False,
|
||||
)
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
@@ -288,7 +293,7 @@ def test_init_project_settings_no_hooks(tmp_path):
|
||||
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert "hooks" not in data, "Project settings should not contain hooks"
|
||||
assert "env" in data
|
||||
assert "env" not in data, "AIPASS_HOME is machine-local — belongs in settings.local.json"
|
||||
assert "permissions" in data
|
||||
|
||||
|
||||
@@ -440,6 +445,7 @@ def test_update_project_return_dict_structure(tmp_path):
|
||||
"updated_files",
|
||||
"already_current",
|
||||
"skipped_files",
|
||||
"removed_files",
|
||||
"aipass_home",
|
||||
}
|
||||
assert result["project_name"] == "UPD"
|
||||
@@ -449,8 +455,12 @@ def test_update_project_return_dict_structure(tmp_path):
|
||||
assert isinstance(result["skipped_files"], list)
|
||||
|
||||
|
||||
def test_update_project_already_current_after_init(tmp_path):
|
||||
def test_update_project_already_current_after_init(tmp_path, monkeypatch):
|
||||
"""Running update immediately after init reports all managed files as already current."""
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||
lambda _: False,
|
||||
)
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="fresh")
|
||||
@@ -461,8 +471,12 @@ def test_update_project_already_current_after_init(tmp_path):
|
||||
assert len(result["already_current"]) >= 5
|
||||
|
||||
|
||||
def test_update_project_idempotent(tmp_path):
|
||||
def test_update_project_idempotent(tmp_path, monkeypatch):
|
||||
"""Running update twice in a row produces no changes on second run."""
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||
lambda _: False,
|
||||
)
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="idem")
|
||||
@@ -533,9 +547,10 @@ def test_update_project_creates_missing_managed_dirs(tmp_path):
|
||||
result = update_project(target)
|
||||
|
||||
assert (target / ".claude" / "settings.json").exists()
|
||||
# Managed files in deleted dirs re-written (tier0_kernel, tier1_navmap, hooks.json, settings, prep)
|
||||
# Managed files in deleted dirs re-written (tier0_kernel, tier1_navmap,
|
||||
# hooks.json, settings.json, settings.local.json, prep)
|
||||
if result["aipass_home"]:
|
||||
assert len(result["updated_files"]) == 5
|
||||
assert len(result["updated_files"]) == 6
|
||||
else:
|
||||
assert len(result["updated_files"]) == 2
|
||||
assert len(result["already_current"]) >= 2
|
||||
@@ -569,8 +584,12 @@ def test_init_project_returns_aipass_home(tmp_path):
|
||||
assert result["aipass_home"] is None or isinstance(result["aipass_home"], str)
|
||||
|
||||
|
||||
def test_init_project_settings_has_aipass_home_when_detected(tmp_path):
|
||||
"""When AIPASS_HOME is detected, settings.json includes env.AIPASS_HOME."""
|
||||
def test_init_project_settings_has_aipass_home_when_detected(tmp_path, monkeypatch):
|
||||
"""When AIPASS_HOME is detected, settings.local.json (not settings.json) includes env.AIPASS_HOME."""
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||
lambda _: False,
|
||||
)
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
@@ -580,8 +599,10 @@ def test_init_project_settings_has_aipass_home_when_detected(tmp_path):
|
||||
pytest.skip("AIPASS_HOME not detectable in this environment")
|
||||
|
||||
settings = json.loads((target / ".claude" / "settings.json").read_text(encoding="utf-8"))
|
||||
assert "env" in settings
|
||||
assert settings["env"]["AIPASS_HOME"] == result["aipass_home"]
|
||||
assert "env" not in settings
|
||||
|
||||
local_settings = json.loads((target / ".claude" / "settings.local.json").read_text(encoding="utf-8"))
|
||||
assert local_settings["env"]["AIPASS_HOME"] == result["aipass_home"]
|
||||
|
||||
|
||||
def test_update_project_returns_aipass_home(tmp_path):
|
||||
@@ -596,23 +617,25 @@ def test_update_project_returns_aipass_home(tmp_path):
|
||||
assert result["aipass_home"] is None or isinstance(result["aipass_home"], str)
|
||||
|
||||
|
||||
def test_update_project_adds_aipass_home_if_missing(tmp_path):
|
||||
"""update_project injects AIPASS_HOME into settings.json if env section is absent."""
|
||||
def test_update_project_adds_aipass_home_if_missing(tmp_path, monkeypatch):
|
||||
"""update_project recreates settings.local.json with AIPASS_HOME if missing."""
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||
lambda _: False,
|
||||
)
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="addenv")
|
||||
|
||||
settings_path = target / ".claude" / "settings.json"
|
||||
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
data.pop("env", None)
|
||||
settings_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
local_settings_path = target / ".claude" / "settings.local.json"
|
||||
local_settings_path.unlink(missing_ok=True)
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
if result["aipass_home"] is not None:
|
||||
new_data = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
new_data = json.loads(local_settings_path.read_text(encoding="utf-8"))
|
||||
assert new_data.get("env", {}).get("AIPASS_HOME") == result["aipass_home"]
|
||||
assert str(settings_path) in result["updated_files"]
|
||||
assert str(local_settings_path) in result["updated_files"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1097,3 +1120,186 @@ def test_with_source_header_is_first_line():
|
||||
lines = result.split("\n")
|
||||
assert lines[0] == "<!-- Source: /test.md -->"
|
||||
assert lines[1] == "content"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GAP 1: AGENTS.md sync on update (#676)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_update_project_syncs_agents_md(tmp_path):
|
||||
"""update restores AGENTS.md when its content has been altered."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="sync")
|
||||
|
||||
agents_md = target / "AGENTS.md"
|
||||
agents_md.write_text("# Corrupted\n", encoding="utf-8")
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
assert str(agents_md.resolve()) in result["updated_files"]
|
||||
restored = agents_md.read_text(encoding="utf-8")
|
||||
assert "# SYNC" in restored
|
||||
assert "Startup protocol" in restored
|
||||
|
||||
|
||||
def test_update_project_creates_missing_agents_md(tmp_path):
|
||||
"""update creates AGENTS.md if it was deleted from the project."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="miss")
|
||||
|
||||
agents_md = target / "AGENTS.md"
|
||||
agents_md.unlink()
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
assert agents_md.exists()
|
||||
assert str(agents_md.resolve()) in result["updated_files"]
|
||||
content = agents_md.read_text(encoding="utf-8")
|
||||
assert "# MISS" in content
|
||||
|
||||
|
||||
def test_update_project_agents_md_already_current(tmp_path):
|
||||
"""update reports AGENTS.md as already_current when unchanged."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="cur")
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
assert any("AGENTS.md" in f for f in result["already_current"])
|
||||
assert not any("AGENTS.md" in f for f in result["updated_files"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GAP 2: Cruft cleanup on update (#676)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_update_project_removes_stale_global_prompt(tmp_path):
|
||||
"""update removes retired .aipass/aipass_global_prompt.md."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="cruft")
|
||||
|
||||
stale = target / ".aipass" / "aipass_global_prompt.md"
|
||||
stale.write_text("# old\n", encoding="utf-8")
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
assert not stale.exists()
|
||||
assert str(stale) in result["removed_files"]
|
||||
|
||||
|
||||
def test_update_project_cleanup_does_not_touch_user_files(tmp_path):
|
||||
"""Cruft cleanup never removes user-owned files."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="safe")
|
||||
|
||||
readme = target / "README.md"
|
||||
registry = target / "SAFE_REGISTRY.json"
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
assert readme.exists()
|
||||
assert registry.exists()
|
||||
assert len(result["removed_files"]) == 0
|
||||
|
||||
|
||||
def test_update_project_removed_files_in_result(tmp_path):
|
||||
"""Return dict always contains the removed_files key."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="rkey")
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
assert "removed_files" in result
|
||||
assert isinstance(result["removed_files"], list)
|
||||
|
||||
|
||||
def test_update_project_cleanup_no_stale_is_noop(tmp_path):
|
||||
"""When no stale files exist, removed_files is empty."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="clean")
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
assert result["removed_files"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_throwaway_path tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_throwaway_path_detects_tmp(tmp_path):
|
||||
"""Paths under the system temp dir are throwaway."""
|
||||
assert is_throwaway_path(str(tmp_path))
|
||||
|
||||
|
||||
def test_throwaway_path_detects_scratchpad():
|
||||
"""Paths containing 'scratchpad' are throwaway."""
|
||||
assert is_throwaway_path(str(Path.home() / ".claude" / "scratchpad" / "probe_1"))
|
||||
|
||||
|
||||
def test_throwaway_path_allows_normal():
|
||||
"""Normal home-directory paths are not throwaway."""
|
||||
assert not is_throwaway_path(str(Path.home() / "AIPass"))
|
||||
|
||||
|
||||
def test_throwaway_path_allows_project():
|
||||
"""A typical project path is not throwaway."""
|
||||
assert not is_throwaway_path(str(Path.home() / "Projects" / "myapp"))
|
||||
|
||||
|
||||
def test_settings_omits_throwaway_aipass_home(tmp_path, monkeypatch):
|
||||
"""init_project skips settings.local.json when detected AIPASS_HOME is a throwaway path."""
|
||||
from aipass.aipass.apps.handlers.init import bootstrap
|
||||
|
||||
monkeypatch.setattr(bootstrap, "_detect_aipass_home", lambda: str(tmp_path))
|
||||
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
bootstrap.init_project(target, project_name="alpha")
|
||||
|
||||
assert not (target / ".claude" / "settings.local.json").exists()
|
||||
|
||||
|
||||
# Directory/file-name fragments that .gitignore excludes from git tracking —
|
||||
# mirrors scaffold_content.gitignore(). Tracked-file scans must skip these.
|
||||
_GITIGNORED_PARTS = (".trinity", ".ai_mail.local", "logs", ".venv", "venv", ".git")
|
||||
|
||||
|
||||
def test_minted_tracked_files_have_no_absolute_paths(tmp_path, monkeypatch):
|
||||
"""No file init_project writes into a TRACKED path may contain the machine-local AIPASS_HOME.
|
||||
|
||||
Regression guard for the settings.json bug: AIPASS_HOME is an absolute,
|
||||
machine-local path and must only ever land in gitignored *.local.json
|
||||
files. This walks every minted file that would actually be committed
|
||||
and greps it for the (fake) AIPASS_HOME value.
|
||||
"""
|
||||
from aipass.aipass.apps.handlers.init import bootstrap
|
||||
|
||||
fake_home = str(tmp_path / f"fake_aipass_home_{uuid.uuid4().hex}")
|
||||
monkeypatch.setattr(bootstrap, "_detect_aipass_home", lambda: fake_home)
|
||||
monkeypatch.setattr(bootstrap, "is_throwaway_path", lambda _: False)
|
||||
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
bootstrap.init_project(target, project_name="pathcheck")
|
||||
|
||||
for path in target.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(target)
|
||||
if any(part in _GITIGNORED_PARTS for part in rel.parts):
|
||||
continue
|
||||
if ".local." in rel.name:
|
||||
continue
|
||||
content = path.read_text(encoding="utf-8")
|
||||
assert fake_home not in content, f"{rel} leaks machine-local AIPASS_HOME"
|
||||
|
||||
@@ -0,0 +1,839 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_cross_os.py
|
||||
# Description: Tests for cross-OS gap registry parser + doctor/init integration
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-02
|
||||
# Modified: 2026-07-02
|
||||
# =============================================
|
||||
|
||||
"""Tests for the cross-OS gap registry (TDPLAN-0011 slice 1).
|
||||
|
||||
Covers: parser happy path, platform filtering (win32/darwin/linux),
|
||||
fail-to-error (missing/malformed doc), _check_cross_os() row shape, the
|
||||
doctor --cross-os subcommand, and the init stage-2 heads-up wiring.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest # pyright: ignore[reportMissingImports]
|
||||
|
||||
from aipass.aipass.apps.handlers.cross_os import (
|
||||
CrossOsGap,
|
||||
CrossOsGapError,
|
||||
PreflightResult,
|
||||
RunRecordError,
|
||||
build_run_record,
|
||||
check_hookstatus,
|
||||
check_routing,
|
||||
check_versions,
|
||||
default_record_path,
|
||||
find_e2e_dir,
|
||||
find_gap_doc,
|
||||
gaps_for_platform,
|
||||
generate_run_record,
|
||||
load_gaps,
|
||||
os_matches,
|
||||
parse_gap_registry,
|
||||
run_e2e,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.cross_os.preflight import E2E_UNRUNNABLE_PREFIX
|
||||
from aipass.aipass.apps.handlers.ui.progress import GLYPH_FAIL, GLYPH_PASS, GLYPH_WARN
|
||||
from aipass.aipass.apps.modules.doctor import (
|
||||
_check_cross_os,
|
||||
_cross_os_gap_rows,
|
||||
run_cross_os,
|
||||
run_cross_os_record,
|
||||
)
|
||||
|
||||
_HANDLER_MOD = "aipass.aipass.apps.handlers.cross_os.gap_registry"
|
||||
_PREFLIGHT_MOD = "aipass.aipass.apps.handlers.cross_os.preflight"
|
||||
_RECORD_MOD = "aipass.aipass.apps.handlers.cross_os.run_record"
|
||||
_DOCTOR_MOD = "aipass.aipass.apps.modules.doctor"
|
||||
_INIT_MOD = "aipass.aipass.apps.modules.init_flow"
|
||||
|
||||
|
||||
def _completed(returncode: int = 0, stdout: str = "", stderr: str = "") -> MagicMock:
|
||||
"""Build a fake subprocess.CompletedProcess for patching subprocess.run."""
|
||||
proc = MagicMock()
|
||||
proc.returncode = returncode
|
||||
proc.stdout = stdout
|
||||
proc.stderr = stderr
|
||||
return proc
|
||||
|
||||
|
||||
# A minimal but structurally-faithful copy of the registry section.
|
||||
SAMPLE_DOC = """# Some doc
|
||||
|
||||
## Known cross-OS gap registry (living — update as fixed)
|
||||
|
||||
Source of truth: DPLAN-0194.
|
||||
|
||||
| # | Gap | OS | Symptom | Owner | Status |
|
||||
|---|-----|----|---------| ------|--------|
|
||||
| 1 | cp1252 stdout | Win | UnicodeEncodeError on banner | aipass | fixed |
|
||||
| 7 | linux-only audio player | Win/mac | hook sound silent | hooks | suspected |
|
||||
| 9 | route masks errors | all | printed as Unknown command | aipass | recommended |
|
||||
|
||||
> a trailing footnote that is not a table row
|
||||
|
||||
## Run Record
|
||||
|
||||
should not be parsed
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_handler_json():
|
||||
"""Suppress json_handler.log_operation side effects in the handler."""
|
||||
with patch(f"{_HANDLER_MOD}.json_handler") as mock:
|
||||
mock.log_operation = MagicMock()
|
||||
yield mock
|
||||
|
||||
|
||||
def _write_doc(root: Path, text: str = SAMPLE_DOC) -> Path:
|
||||
"""Create root/tests/CROSS_OS_TESTING.md with the given text."""
|
||||
doc = root / "tests" / "CROSS_OS_TESTING.md"
|
||||
doc.parent.mkdir(parents=True, exist_ok=True)
|
||||
doc.write_text(text, encoding="utf-8")
|
||||
return doc
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Parser
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestParseGapRegistry:
|
||||
def test_happy_path_row_count_and_fields(self) -> None:
|
||||
gaps = parse_gap_registry(SAMPLE_DOC)
|
||||
assert len(gaps) == 3
|
||||
first = gaps[0]
|
||||
assert isinstance(first, CrossOsGap)
|
||||
assert first.number == "1"
|
||||
assert first.gap == "cp1252 stdout"
|
||||
assert first.os == "Win"
|
||||
assert first.symptom == "UnicodeEncodeError on banner"
|
||||
assert first.owner == "aipass"
|
||||
assert first.status == "fixed"
|
||||
|
||||
def test_skips_header_separator_and_footnote(self) -> None:
|
||||
"""Only digit-led data rows are captured; the Run Record section is excluded."""
|
||||
gaps = parse_gap_registry(SAMPLE_DOC)
|
||||
numbers = [g.number for g in gaps]
|
||||
assert numbers == ["1", "7", "9"]
|
||||
|
||||
def test_missing_section_raises(self) -> None:
|
||||
with pytest.raises(CrossOsGapError):
|
||||
parse_gap_registry("# No registry here\n\njust prose\n")
|
||||
|
||||
def test_section_without_data_rows_raises(self) -> None:
|
||||
text = (
|
||||
"## Known cross-OS gap registry\n\n| # | Gap | OS | Symptom | Owner | Status |\n|---|---|---|---|---|---|\n"
|
||||
)
|
||||
with pytest.raises(CrossOsGapError):
|
||||
parse_gap_registry(text)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# os_matches — platform mapping
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestOsMatches:
|
||||
def test_all_matches_every_platform(self) -> None:
|
||||
for plat in ("win32", "darwin", "linux", "freebsd"):
|
||||
assert os_matches("all", plat) is True
|
||||
|
||||
def test_win_only(self) -> None:
|
||||
assert os_matches("Win", "win32") is True
|
||||
assert os_matches("Win", "darwin") is False
|
||||
assert os_matches("Win", "linux") is False
|
||||
|
||||
def test_win_mac_matches_both(self) -> None:
|
||||
assert os_matches("Win/mac", "win32") is True
|
||||
assert os_matches("Win/mac", "darwin") is True
|
||||
assert os_matches("Win/mac", "linux") is False
|
||||
|
||||
def test_case_insensitive(self) -> None:
|
||||
assert os_matches("WIN", "win32") is True
|
||||
assert os_matches("ALL", "linux") is True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# find_gap_doc + load/filter (end-to-end via a temp doc)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestFindAndFilter:
|
||||
def test_find_gap_doc_walks_up(self, tmp_path) -> None:
|
||||
doc = _write_doc(tmp_path)
|
||||
nested = tmp_path / "a" / "b" / "c"
|
||||
nested.mkdir(parents=True)
|
||||
found = find_gap_doc(nested)
|
||||
assert found == doc
|
||||
|
||||
def test_find_gap_doc_uses_live_repo_by_default(self) -> None:
|
||||
"""With no start arg, walks up from the handler file to the real repo doc."""
|
||||
doc = find_gap_doc()
|
||||
assert doc.name == "CROSS_OS_TESTING.md"
|
||||
assert doc.is_file()
|
||||
|
||||
def test_load_gaps_from_temp_doc(self, tmp_path) -> None:
|
||||
_write_doc(tmp_path)
|
||||
gaps = load_gaps(start=tmp_path)
|
||||
assert [g.number for g in gaps] == ["1", "7", "9"]
|
||||
|
||||
def test_filter_linux_only_all_rows(self, tmp_path) -> None:
|
||||
_write_doc(tmp_path)
|
||||
gaps = gaps_for_platform("linux", start=tmp_path)
|
||||
assert [g.number for g in gaps] == ["9"]
|
||||
|
||||
def test_filter_win32_gets_win_and_all(self, tmp_path) -> None:
|
||||
_write_doc(tmp_path)
|
||||
gaps = gaps_for_platform("win32", start=tmp_path)
|
||||
assert [g.number for g in gaps] == ["1", "7", "9"]
|
||||
|
||||
def test_filter_darwin_gets_mac_and_all(self, tmp_path) -> None:
|
||||
_write_doc(tmp_path)
|
||||
gaps = gaps_for_platform("darwin", start=tmp_path)
|
||||
assert [g.number for g in gaps] == ["7", "9"]
|
||||
|
||||
def test_default_platform_uses_sys_platform(self, tmp_path) -> None:
|
||||
_write_doc(tmp_path)
|
||||
with patch(f"{_HANDLER_MOD}.sys") as mock_sys:
|
||||
mock_sys.platform = "win32"
|
||||
gaps = gaps_for_platform(start=tmp_path)
|
||||
assert [g.number for g in gaps] == ["1", "7", "9"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fail-to-error (never silently empty)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestFailToError:
|
||||
def test_missing_doc_raises(self, tmp_path) -> None:
|
||||
with pytest.raises(CrossOsGapError):
|
||||
gaps_for_platform("linux", start=tmp_path)
|
||||
|
||||
def test_malformed_doc_raises(self, tmp_path) -> None:
|
||||
_write_doc(tmp_path, text="# no registry section at all\n")
|
||||
with pytest.raises(CrossOsGapError):
|
||||
load_gaps(start=tmp_path)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _cross_os_gap_rows — OS-gap cross-reference row shape (slice 1 logic)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCrossOsGapRows:
|
||||
def test_gaps_become_warn_preflight_rows(self) -> None:
|
||||
fake = [
|
||||
CrossOsGap("2", ".venv symlink", "Win", "WinError 1314", "aipass", "untested"),
|
||||
]
|
||||
with patch(f"{_DOCTOR_MOD}.gaps_for_platform", return_value=fake):
|
||||
results = _cross_os_gap_rows()
|
||||
assert len(results) == 1
|
||||
row = results[0]
|
||||
assert row.glyph == GLYPH_WARN
|
||||
assert "gap #2" in row.label
|
||||
assert "pre-flight" in row.label
|
||||
assert row.detail.startswith("pre-flight:")
|
||||
assert "WinError 1314" in row.detail
|
||||
assert "aipass" in row.remediation
|
||||
|
||||
def test_no_gaps_emits_single_pass(self) -> None:
|
||||
with patch(f"{_DOCTOR_MOD}.gaps_for_platform", return_value=[]):
|
||||
results = _cross_os_gap_rows()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_PASS
|
||||
assert "no tracked cross-OS gaps" in results[0].detail
|
||||
|
||||
def test_registry_error_emits_warn_not_silent(self) -> None:
|
||||
with patch(f"{_DOCTOR_MOD}.gaps_for_platform", side_effect=CrossOsGapError("doc gone")):
|
||||
results = _cross_os_gap_rows()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_WARN
|
||||
assert "unavailable" in results[0].detail
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pre-flight runners — routing / versions / hookstatus (mocked subprocess)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestPreflightRunners:
|
||||
def test_routing_ok_both_routes_exit_zero(self) -> None:
|
||||
with patch(f"{_PREFLIGHT_MOD}.subprocess.run", return_value=_completed(0, "systems ok")):
|
||||
result = check_routing()
|
||||
assert isinstance(result, PreflightResult)
|
||||
assert result.ok is True
|
||||
assert "exit 0" in result.detail
|
||||
|
||||
def test_routing_fails_when_route_nonzero(self) -> None:
|
||||
# drone systems exits 0, @ai_mail route exits 1.
|
||||
with patch(f"{_PREFLIGHT_MOD}.subprocess.run", side_effect=[_completed(0), _completed(1, stderr="boom")]):
|
||||
result = check_routing()
|
||||
assert result.ok is False
|
||||
assert "@ai_mail" in result.detail
|
||||
|
||||
def test_routing_fails_to_error_on_missing_binary(self) -> None:
|
||||
with patch(f"{_PREFLIGHT_MOD}.subprocess.run", side_effect=FileNotFoundError("no drone")):
|
||||
result = check_routing()
|
||||
assert result.ok is False # never crashes
|
||||
|
||||
def test_versions_ok_captures_strings(self) -> None:
|
||||
with patch(
|
||||
f"{_PREFLIGHT_MOD}.subprocess.run",
|
||||
side_effect=[_completed(0, "drone v1.1.0"), _completed(0, "aipass 0.1.0")],
|
||||
):
|
||||
result = check_versions()
|
||||
assert result.ok is True
|
||||
assert "drone v1.1.0" in result.detail
|
||||
assert "aipass 0.1.0" in result.detail
|
||||
|
||||
def test_versions_fail_nonzero(self) -> None:
|
||||
with patch(
|
||||
f"{_PREFLIGHT_MOD}.subprocess.run",
|
||||
side_effect=[_completed(0, "drone v1.1.0"), _completed(1, stderr="nope")],
|
||||
):
|
||||
result = check_versions()
|
||||
assert result.ok is False
|
||||
|
||||
def test_hookstatus_ok(self) -> None:
|
||||
with patch(f"{_PREFLIGHT_MOD}.subprocess.run", return_value=_completed(0, "hook config viewer")):
|
||||
result = check_hookstatus()
|
||||
assert result.ok is True
|
||||
assert "hook config" in result.detail
|
||||
|
||||
def test_hookstatus_fail_to_error_on_timeout(self) -> None:
|
||||
import subprocess as _sp
|
||||
|
||||
with patch(f"{_PREFLIGHT_MOD}.subprocess.run", side_effect=_sp.TimeoutExpired(cmd="drone", timeout=1)):
|
||||
result = check_hookstatus()
|
||||
assert result.ok is False
|
||||
assert "timed out" in result.detail
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# run_e2e — heavy suite runner (mocked subprocess); dir/pytest resolution
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _stub_preflight_json():
|
||||
"""Suppress json_handler.log_operation side effects in the preflight module."""
|
||||
with patch(f"{_PREFLIGHT_MOD}.json_handler") as mock:
|
||||
mock.log_operation = MagicMock()
|
||||
yield mock
|
||||
|
||||
|
||||
class TestRunE2e:
|
||||
def test_find_e2e_dir_locates_live_repo(self) -> None:
|
||||
found = find_e2e_dir()
|
||||
assert found is not None
|
||||
assert found.name == "e2e"
|
||||
assert found.is_dir()
|
||||
|
||||
def test_missing_dir_returns_unrunnable_warn(self, _stub_preflight_json) -> None:
|
||||
with patch(f"{_PREFLIGHT_MOD}.find_e2e_dir", return_value=None):
|
||||
result = run_e2e()
|
||||
assert result.ok is False
|
||||
assert result.detail.startswith(E2E_UNRUNNABLE_PREFIX)
|
||||
|
||||
def test_passing_suite_ok(self, tmp_path, _stub_preflight_json) -> None:
|
||||
e2e_dir = tmp_path / "tests" / "e2e"
|
||||
e2e_dir.mkdir(parents=True)
|
||||
with (
|
||||
patch(f"{_PREFLIGHT_MOD}.find_e2e_dir", return_value=e2e_dir),
|
||||
patch(f"{_PREFLIGHT_MOD}.subprocess.run", return_value=_completed(0, "14 passed in 18.15s")),
|
||||
):
|
||||
result = run_e2e()
|
||||
assert result.ok is True
|
||||
assert "14 passed" in result.detail
|
||||
|
||||
def test_failing_suite_not_ok_and_not_unrunnable(self, tmp_path, _stub_preflight_json) -> None:
|
||||
e2e_dir = tmp_path / "tests" / "e2e"
|
||||
e2e_dir.mkdir(parents=True)
|
||||
with (
|
||||
patch(f"{_PREFLIGHT_MOD}.find_e2e_dir", return_value=e2e_dir),
|
||||
patch(f"{_PREFLIGHT_MOD}.subprocess.run", return_value=_completed(1, "2 failed, 12 passed in 3s")),
|
||||
):
|
||||
result = run_e2e()
|
||||
assert result.ok is False
|
||||
assert not result.detail.startswith(E2E_UNRUNNABLE_PREFIX)
|
||||
assert "failed" in result.detail
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _check_cross_os — composed group (gap rows + pre-flight rows + optional e2e)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCheckCrossOsComposed:
|
||||
def _patch_preflight(self, routing=None, versions=None, hookstatus=None):
|
||||
"""Patch the three light pre-flight runners with given PreflightResults."""
|
||||
routing = routing or PreflightResult("routing", True, "ok")
|
||||
versions = versions or PreflightResult("versions", True, "drone v1; aipass 0.1")
|
||||
hookstatus = hookstatus or PreflightResult("hookstatus", True, "config ok")
|
||||
return (
|
||||
patch(f"{_DOCTOR_MOD}.gaps_for_platform", return_value=[]),
|
||||
patch(f"{_DOCTOR_MOD}.check_routing", return_value=routing),
|
||||
patch(f"{_DOCTOR_MOD}.check_versions", return_value=versions),
|
||||
patch(f"{_DOCTOR_MOD}.check_hookstatus", return_value=hookstatus),
|
||||
)
|
||||
|
||||
def test_preflight_rows_pass_when_ok(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in self._patch_preflight():
|
||||
stack.enter_context(p)
|
||||
results = _check_cross_os()
|
||||
labels = {r.label: r for r in results}
|
||||
assert labels["routing (pre-flight)"].glyph == GLYPH_PASS
|
||||
assert labels["versions (pre-flight)"].glyph == GLYPH_PASS
|
||||
assert labels["hookstatus (pre-flight)"].glyph == GLYPH_PASS
|
||||
assert labels["routing (pre-flight)"].detail.startswith("pre-flight:")
|
||||
|
||||
def test_preflight_fail_maps_to_fail_glyph(self) -> None:
|
||||
bad = PreflightResult("routing", False, "drone systems -> 1")
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in self._patch_preflight(routing=bad):
|
||||
stack.enter_context(p)
|
||||
results = _check_cross_os()
|
||||
row = next(r for r in results if r.label == "routing (pre-flight)")
|
||||
assert row.glyph == GLYPH_FAIL
|
||||
assert row.remediation # fail rows carry remediation
|
||||
|
||||
def test_e2e_not_run_by_default(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in self._patch_preflight():
|
||||
stack.enter_context(p)
|
||||
mock_e2e = stack.enter_context(patch(f"{_DOCTOR_MOD}.run_e2e_preflight"))
|
||||
results = _check_cross_os()
|
||||
mock_e2e.assert_not_called()
|
||||
assert not any("e2e" in r.label for r in results)
|
||||
|
||||
def test_e2e_runs_when_flag_set_and_pass_maps_pass(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in self._patch_preflight():
|
||||
stack.enter_context(p)
|
||||
mock_e2e = stack.enter_context(
|
||||
patch(f"{_DOCTOR_MOD}.run_e2e_preflight", return_value=PreflightResult("e2e", True, "14 passed"))
|
||||
)
|
||||
results = _check_cross_os(run_e2e=True)
|
||||
mock_e2e.assert_called_once()
|
||||
row = next(r for r in results if r.label == "e2e suite (pre-flight)")
|
||||
assert row.glyph == GLYPH_PASS
|
||||
|
||||
def test_e2e_real_failure_maps_fail(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in self._patch_preflight():
|
||||
stack.enter_context(p)
|
||||
stack.enter_context(
|
||||
patch(
|
||||
f"{_DOCTOR_MOD}.run_e2e_preflight",
|
||||
return_value=PreflightResult("e2e", False, "2 failed, 12 passed"),
|
||||
)
|
||||
)
|
||||
results = _check_cross_os(run_e2e=True)
|
||||
row = next(r for r in results if r.label == "e2e suite (pre-flight)")
|
||||
assert row.glyph == GLYPH_FAIL
|
||||
|
||||
def test_e2e_unrunnable_maps_warn(self) -> None:
|
||||
unrunnable = PreflightResult("e2e", False, f"{E2E_UNRUNNABLE_PREFIX}: e2e dir not found")
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in self._patch_preflight():
|
||||
stack.enter_context(p)
|
||||
stack.enter_context(patch(f"{_DOCTOR_MOD}.run_e2e_preflight", return_value=unrunnable))
|
||||
results = _check_cross_os(run_e2e=True)
|
||||
row = next(r for r in results if r.label == "e2e suite (pre-flight)")
|
||||
assert row.glyph == GLYPH_WARN
|
||||
|
||||
def test_run_cross_os_returns_int_no_errors(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in self._patch_preflight():
|
||||
stack.enter_context(p)
|
||||
stack.enter_context(patch(f"{_DOCTOR_MOD}.console"))
|
||||
rc = run_cross_os()
|
||||
assert rc == 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# doctor --cross-os subcommand routing
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestDoctorCrossOsCommand:
|
||||
def test_cross_os_flag_routes_to_run_cross_os(self) -> None:
|
||||
from aipass.aipass.apps.modules.doctor import handle_command
|
||||
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.run_cross_os", return_value=0) as mock_run,
|
||||
patch(f"{_DOCTOR_MOD}.json_handler"),
|
||||
):
|
||||
handled = handle_command("doctor", ["--cross-os"])
|
||||
assert handled is True
|
||||
mock_run.assert_called_once()
|
||||
|
||||
def test_cross_os_does_not_run_full_doctor(self) -> None:
|
||||
"""The subcommand must not invoke the default full run."""
|
||||
from aipass.aipass.apps.modules.doctor import handle_command
|
||||
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.run_cross_os", return_value=0),
|
||||
patch(f"{_DOCTOR_MOD}.run_doctor") as mock_full,
|
||||
patch(f"{_DOCTOR_MOD}.json_handler"),
|
||||
):
|
||||
handle_command("doctor", ["--cross-os"])
|
||||
mock_full.assert_not_called()
|
||||
|
||||
def test_cross_os_alone_stays_light_no_e2e(self) -> None:
|
||||
"""`--cross-os` without `--e2e` threads run_e2e=False."""
|
||||
from aipass.aipass.apps.modules.doctor import handle_command
|
||||
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.run_cross_os", return_value=0) as mock_run,
|
||||
patch(f"{_DOCTOR_MOD}.json_handler"),
|
||||
):
|
||||
handle_command("doctor", ["--cross-os"])
|
||||
mock_run.assert_called_once_with(run_e2e=False)
|
||||
|
||||
def test_cross_os_with_e2e_flag_threads_run_e2e_true(self) -> None:
|
||||
"""Both `--cross-os` and `--e2e` present -> run_cross_os(run_e2e=True)."""
|
||||
from aipass.aipass.apps.modules.doctor import handle_command
|
||||
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.run_cross_os", return_value=0) as mock_run,
|
||||
patch(f"{_DOCTOR_MOD}.json_handler"),
|
||||
):
|
||||
handle_command("doctor", ["--cross-os", "--e2e"])
|
||||
mock_run.assert_called_once_with(run_e2e=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# init stage-2 heads-up wiring
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestInitStage2HeadsUp:
|
||||
def test_heads_up_prints_gaps(self) -> None:
|
||||
from aipass.aipass.apps.modules.init_flow import _print_os_gap_heads_up
|
||||
|
||||
fake = [CrossOsGap("9", "route masks", "all", "printed as Unknown command", "aipass", "rec")]
|
||||
with (
|
||||
patch("aipass.aipass.apps.handlers.cross_os.gaps_for_platform", return_value=fake),
|
||||
patch(f"{_INIT_MOD}.console") as mock_console,
|
||||
):
|
||||
_print_os_gap_heads_up()
|
||||
printed = " ".join(str(c.args[0]) for c in mock_console.print.call_args_list if c.args)
|
||||
assert "gap #9" in printed
|
||||
assert "Unknown command" in printed
|
||||
|
||||
def test_heads_up_no_gaps_prints_nothing(self) -> None:
|
||||
from aipass.aipass.apps.modules.init_flow import _print_os_gap_heads_up
|
||||
|
||||
with (
|
||||
patch("aipass.aipass.apps.handlers.cross_os.gaps_for_platform", return_value=[]),
|
||||
patch(f"{_INIT_MOD}.console") as mock_console,
|
||||
):
|
||||
_print_os_gap_heads_up()
|
||||
mock_console.print.assert_not_called()
|
||||
|
||||
def test_heads_up_error_warns_and_does_not_crash(self) -> None:
|
||||
from aipass.aipass.apps.modules.init_flow import _print_os_gap_heads_up
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.cross_os.gaps_for_platform",
|
||||
side_effect=CrossOsGapError("doc missing"),
|
||||
),
|
||||
patch(f"{_INIT_MOD}.console"),
|
||||
patch(f"{_INIT_MOD}.warning") as mock_warning,
|
||||
):
|
||||
_print_os_gap_heads_up() # must not raise
|
||||
mock_warning.assert_called_once()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Run Record generator (slice 3) — build_run_record
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _line_starting(text: str, prefix: str) -> str:
|
||||
"""Return the first line in ``text`` starting with ``prefix`` (or "")."""
|
||||
for line in text.splitlines():
|
||||
if line.startswith(prefix):
|
||||
return line
|
||||
return ""
|
||||
|
||||
|
||||
class TestBuildRunRecord:
|
||||
def _patch(self, stack, gaps=None, routing=None, hooks=None):
|
||||
"""Patch the record's live inputs (gaps + light pre-flight runners)."""
|
||||
gaps = gaps if gaps is not None else []
|
||||
routing = routing or PreflightResult("routing", True, "drone systems exit 0; @ai_mail route exit 0")
|
||||
hooks = hooks or PreflightResult("hookstatus", True, "hook config viewer")
|
||||
stack.enter_context(patch(f"{_RECORD_MOD}.gaps_for_platform", return_value=gaps))
|
||||
stack.enter_context(patch(f"{_RECORD_MOD}.check_routing", return_value=routing))
|
||||
stack.enter_context(patch(f"{_RECORD_MOD}.check_hookstatus", return_value=hooks))
|
||||
|
||||
def test_env_fields_present_and_filled(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
text = build_run_record(platform_name="linux")
|
||||
for label in ("Machine/VM", "OS + version", "Arch", "Python", "Shell / term", "AIPASS_HOME", "Date :"):
|
||||
assert label in text
|
||||
# A real, machine-knowable fact is actually filled in (not left blank).
|
||||
assert platform.python_version() in text
|
||||
assert (platform.machine() or "unknown") in text
|
||||
|
||||
def test_header_marks_machine_preflight_draft(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
text = build_run_record(platform_name="linux")
|
||||
assert "pre-flight DRAFT" in text
|
||||
assert "human must complete" in text.lower()
|
||||
|
||||
def test_machine_rows_auto_ticked_pass(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
text = build_run_record(platform_name="linux")
|
||||
phase0 = _line_starting(text, "Phase 0")
|
||||
phase4 = _line_starting(text, "Phase 4")
|
||||
assert "✅" in phase0 and "machine" in phase0
|
||||
assert "✅" in phase4
|
||||
assert "drone systems exit 0" in phase4
|
||||
|
||||
def test_routing_fail_marks_fail_glyph(self) -> None:
|
||||
bad = PreflightResult("routing", False, "drone systems -> 1")
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack, routing=bad)
|
||||
text = build_run_record(platform_name="linux")
|
||||
phase4 = _line_starting(text, "Phase 4")
|
||||
assert "❌" in phase4
|
||||
assert "✅" not in phase4
|
||||
|
||||
def test_human_rows_marked_and_never_auto_ticked(self) -> None:
|
||||
"""Human-only rows must carry the human marker and NEVER the machine ✅."""
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
text = build_run_record(platform_name="linux")
|
||||
for prefix in (
|
||||
"Phase 1 clean install",
|
||||
"Phase 3 aipass init",
|
||||
"Phase 5 daemons",
|
||||
"Phase 7 interactive",
|
||||
"Per-branch matrix",
|
||||
):
|
||||
line = _line_starting(text, prefix)
|
||||
assert line, f"missing row: {prefix}"
|
||||
assert "— human" in line
|
||||
assert "✅" not in line
|
||||
# Overall verdict stays human, never a machine tick.
|
||||
verdict = _line_starting(text, "Overall verdict")
|
||||
assert "— human" in verdict
|
||||
assert "✅" not in verdict
|
||||
|
||||
def test_commit_and_tester_left_blank_with_hint(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
text = build_run_record(platform_name="linux")
|
||||
commit = _line_starting(text, "Commit")
|
||||
# No value filled — just the hint on how a human fills it.
|
||||
assert "drone @git log -1" in commit
|
||||
tester = _line_starting(text, "Tester")
|
||||
# Tester side is blank; the Date side is machine-filled.
|
||||
assert tester.split("Date", 1)[0].replace("Tester", "").strip(" :") == ""
|
||||
|
||||
def test_phase6_hookstatus_machine_sound_human(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
text = build_run_record(platform_name="linux")
|
||||
phase6 = _line_starting(text, "Phase 6")
|
||||
assert "✅" in phase6 # hookstatus machine-proved
|
||||
assert "sound" in phase6 and "— human" in phase6 # audible cue stays human
|
||||
|
||||
def test_e2e_not_run_marked_by_default(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
mock_e2e = stack.enter_context(patch(f"{_RECORD_MOD}.run_e2e"))
|
||||
text = build_run_record(platform_name="linux", run_heavy_e2e=False)
|
||||
mock_e2e.assert_not_called()
|
||||
phase2 = _line_starting(text, "Phase 2")
|
||||
assert "not run" in phase2
|
||||
assert "— human" in phase2
|
||||
assert "✅" not in phase2
|
||||
|
||||
def test_e2e_recorded_when_flag_set(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
mock_e2e = stack.enter_context(
|
||||
patch(f"{_RECORD_MOD}.run_e2e", return_value=PreflightResult("e2e", True, "14 passed in 18.15s"))
|
||||
)
|
||||
text = build_run_record(platform_name="linux", run_heavy_e2e=True)
|
||||
mock_e2e.assert_called_once()
|
||||
phase2 = _line_starting(text, "Phase 2")
|
||||
assert "✅" in phase2
|
||||
assert "14 passed" in phase2
|
||||
|
||||
def test_e2e_unrunnable_marked_could_not_run(self) -> None:
|
||||
unrunnable = PreflightResult("e2e", False, f"{E2E_UNRUNNABLE_PREFIX}: e2e dir not found")
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
stack.enter_context(patch(f"{_RECORD_MOD}.run_e2e", return_value=unrunnable))
|
||||
text = build_run_record(platform_name="linux", run_heavy_e2e=True)
|
||||
phase2 = _line_starting(text, "Phase 2")
|
||||
assert "could not run" in phase2
|
||||
assert "✅" not in phase2
|
||||
|
||||
def test_watch_items_list_platform_gaps(self) -> None:
|
||||
fake = [CrossOsGap("9", "route masks", "all", "printed as Unknown command", "aipass", "rec")]
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack, gaps=fake)
|
||||
text = build_run_record(platform_name="linux")
|
||||
assert "Watch items" in text
|
||||
assert "gap #9" in text
|
||||
assert "Unknown command" in text
|
||||
|
||||
def test_watch_items_none_tracked_note(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack, gaps=[])
|
||||
text = build_run_record(platform_name="linux")
|
||||
assert "none tracked for linux" in text
|
||||
|
||||
def test_watch_items_registry_error_degrades_not_crash(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(patch(f"{_RECORD_MOD}.gaps_for_platform", side_effect=CrossOsGapError("doc gone")))
|
||||
stack.enter_context(
|
||||
patch(f"{_RECORD_MOD}.check_routing", return_value=PreflightResult("routing", True, "ok"))
|
||||
)
|
||||
stack.enter_context(
|
||||
patch(f"{_RECORD_MOD}.check_hookstatus", return_value=PreflightResult("hookstatus", True, "ok"))
|
||||
)
|
||||
text = build_run_record(platform_name="linux") # must not raise
|
||||
assert "registry unavailable" in text
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Run Record generator (slice 3) — generate_run_record (file writing)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGenerateRunRecord:
|
||||
def _patch(self, stack):
|
||||
"""Patch live inputs + json_handler side effect for the writing path."""
|
||||
stack.enter_context(patch(f"{_RECORD_MOD}.gaps_for_platform", return_value=[]))
|
||||
stack.enter_context(patch(f"{_RECORD_MOD}.check_routing", return_value=PreflightResult("routing", True, "ok")))
|
||||
stack.enter_context(
|
||||
patch(f"{_RECORD_MOD}.check_hookstatus", return_value=PreflightResult("hookstatus", True, "ok"))
|
||||
)
|
||||
stack.enter_context(patch(f"{_RECORD_MOD}.json_handler"))
|
||||
|
||||
def test_writes_to_given_path(self, tmp_path) -> None:
|
||||
target = tmp_path / "rr.txt"
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
written = generate_run_record(str(target))
|
||||
assert written == target
|
||||
assert target.is_file()
|
||||
assert "AIPass Cross-OS Run Record" in target.read_text(encoding="utf-8")
|
||||
|
||||
def test_creates_missing_parent_dirs(self, tmp_path) -> None:
|
||||
target = tmp_path / "nested" / "deep" / "rr.txt"
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
written = generate_run_record(str(target))
|
||||
assert written.is_file()
|
||||
|
||||
def test_default_path_in_cwd_when_none(self, tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
written = generate_run_record(None)
|
||||
assert written.parent == tmp_path
|
||||
assert written.name.startswith("aipass-crossos-record-")
|
||||
assert written.is_file()
|
||||
|
||||
def test_default_record_path_helper_uses_cwd(self, tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
path = default_record_path()
|
||||
assert path.parent == tmp_path
|
||||
assert path.name.startswith("aipass-crossos-record-")
|
||||
|
||||
def test_write_error_raises_run_record_error(self, tmp_path) -> None:
|
||||
# Target an existing directory → write_text raises OSError → RunRecordError.
|
||||
target = tmp_path / "adir"
|
||||
target.mkdir()
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
with pytest.raises(RunRecordError):
|
||||
generate_run_record(str(target))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# doctor --cross-os --record subcommand routing + thin wrapper
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestDoctorCrossOsRecordCommand:
|
||||
def test_record_flag_routes_to_run_cross_os_record(self) -> None:
|
||||
from aipass.aipass.apps.modules.doctor import handle_command
|
||||
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.run_cross_os_record", return_value=0) as mock_rec,
|
||||
patch(f"{_DOCTOR_MOD}.run_cross_os") as mock_plain,
|
||||
patch(f"{_DOCTOR_MOD}.json_handler"),
|
||||
):
|
||||
handled = handle_command("doctor", ["--cross-os", "--record", "record.txt"])
|
||||
assert handled is True
|
||||
mock_rec.assert_called_once_with("record.txt", run_e2e=False)
|
||||
mock_plain.assert_not_called() # record path does not also run the plain group
|
||||
|
||||
def test_record_default_path_when_no_value(self) -> None:
|
||||
from aipass.aipass.apps.modules.doctor import handle_command
|
||||
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.run_cross_os_record", return_value=0) as mock_rec,
|
||||
patch(f"{_DOCTOR_MOD}.json_handler"),
|
||||
):
|
||||
handle_command("doctor", ["--cross-os", "--record"])
|
||||
mock_rec.assert_called_once_with(None, run_e2e=False)
|
||||
|
||||
def test_record_with_e2e_threads_run_e2e_true(self) -> None:
|
||||
from aipass.aipass.apps.modules.doctor import handle_command
|
||||
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.run_cross_os_record", return_value=0) as mock_rec,
|
||||
patch(f"{_DOCTOR_MOD}.json_handler"),
|
||||
):
|
||||
handle_command("doctor", ["--cross-os", "--record", "--e2e"])
|
||||
# --e2e after --record is a flag, not the path -> path None, e2e threaded True.
|
||||
mock_rec.assert_called_once_with(None, run_e2e=True)
|
||||
|
||||
def test_record_write_failure_exits_nonzero(self) -> None:
|
||||
from aipass.aipass.apps.modules.doctor import handle_command
|
||||
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.run_cross_os_record", return_value=1),
|
||||
patch(f"{_DOCTOR_MOD}.json_handler"),
|
||||
):
|
||||
with pytest.raises(SystemExit):
|
||||
handle_command("doctor", ["--cross-os", "--record", "record.txt"])
|
||||
|
||||
def test_run_cross_os_record_returns_zero_on_success(self) -> None:
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.generate_run_record", return_value=Path("record.txt")),
|
||||
patch(f"{_DOCTOR_MOD}.console"),
|
||||
):
|
||||
assert run_cross_os_record("record.txt") == 0
|
||||
|
||||
def test_run_cross_os_record_returns_one_on_write_error(self) -> None:
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.generate_run_record", side_effect=RunRecordError("disk full")),
|
||||
patch(f"{_DOCTOR_MOD}.console"),
|
||||
):
|
||||
assert run_cross_os_record("record.txt") == 1
|
||||
@@ -28,8 +28,10 @@ from aipass.aipass.apps.handlers.ui.progress import (
|
||||
GLYPH_FAIL,
|
||||
GLYPH_PASS,
|
||||
GLYPH_WARN,
|
||||
activity_spinner,
|
||||
format_check,
|
||||
make_doctor_progress,
|
||||
render_step_header,
|
||||
)
|
||||
from aipass.aipass.apps.modules.doctor import handle_command, run_doctor
|
||||
|
||||
@@ -273,6 +275,36 @@ class TestProgressHelpers:
|
||||
prog = make_doctor_progress()
|
||||
assert isinstance(prog, Progress)
|
||||
|
||||
def test_render_step_header_shows_step_and_label(self) -> None:
|
||||
"""Header carries the step counter and the stage label."""
|
||||
line = render_step_header(3, 10, "User profile")
|
||||
assert "Step 3/10" in line
|
||||
assert "User profile" in line
|
||||
|
||||
def test_render_step_header_bar_fills_with_progress(self) -> None:
|
||||
"""The bar has more filled cells later in the flow, full at the end."""
|
||||
early = render_step_header(1, 10, "x", width=18)
|
||||
late = render_step_header(10, 10, "x", width=18)
|
||||
assert late.count("█") > early.count("█")
|
||||
assert late.count("█") == 18
|
||||
|
||||
def test_render_step_header_clamps_out_of_range(self) -> None:
|
||||
"""current > total and total <= 0 are clamped — no overflow, no div-by-zero."""
|
||||
over = render_step_header(15, 10, "x", width=18)
|
||||
assert over.count("█") == 18
|
||||
zero = render_step_header(0, 0, "x", width=18)
|
||||
assert "Step 0/1" in zero
|
||||
|
||||
def test_activity_spinner_yields_progress_and_runs_block(self) -> None:
|
||||
"""activity_spinner is a context manager yielding a Progress; block runs."""
|
||||
from rich.progress import Progress
|
||||
|
||||
ran = []
|
||||
with activity_spinner("doing a thing…") as prog:
|
||||
assert isinstance(prog, Progress)
|
||||
ran.append(True)
|
||||
assert ran == [True]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TestDoctorHandleCommand
|
||||
@@ -624,15 +656,15 @@ class TestReconcileStaleDeny:
|
||||
|
||||
def test_no_settings_file_returns_empty(self, tmp_path) -> None:
|
||||
"""Missing settings.json returns no results."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=False)
|
||||
assert results == []
|
||||
|
||||
def test_no_stale_rules_returns_pass(self, tmp_path) -> None:
|
||||
"""Settings with no stale rm rules returns PASS."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
@@ -640,7 +672,7 @@ class TestReconcileStaleDeny:
|
||||
json.dumps({"permissions": {"deny": ["Bash(git push --force*)", "Bash(git reset --hard*)"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=False)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == GLYPH_PASS
|
||||
@@ -648,7 +680,7 @@ class TestReconcileStaleDeny:
|
||||
|
||||
def test_stale_rules_detected_without_fix(self, tmp_path) -> None:
|
||||
"""Stale rm rules present returns WARN when fix=False."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
@@ -656,7 +688,7 @@ class TestReconcileStaleDeny:
|
||||
json.dumps({"permissions": {"deny": ["Bash(rm -rf*)", "Bash(git push --force*)", "Bash(rm -r *)"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=False)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == GLYPH_WARN
|
||||
@@ -665,7 +697,7 @@ class TestReconcileStaleDeny:
|
||||
|
||||
def test_fix_removes_stale_rules(self, tmp_path) -> None:
|
||||
"""fix=True removes stale rules and preserves others."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
@@ -674,7 +706,7 @@ class TestReconcileStaleDeny:
|
||||
"env": {"AIPASS_HOME": "/test"},
|
||||
}
|
||||
settings.write_text(json.dumps(original), encoding="utf-8")
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=True)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == GLYPH_PASS
|
||||
@@ -687,7 +719,7 @@ class TestReconcileStaleDeny:
|
||||
|
||||
def test_fix_single_stale_rule(self, tmp_path) -> None:
|
||||
"""fix=True works when only one of two stale rules is present."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
@@ -695,7 +727,7 @@ class TestReconcileStaleDeny:
|
||||
json.dumps({"permissions": {"deny": ["Bash(rm -rf*)", "Bash(git reset --hard*)"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=True)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == GLYPH_PASS
|
||||
@@ -704,7 +736,7 @@ class TestReconcileStaleDeny:
|
||||
|
||||
def test_fix_idempotent(self, tmp_path) -> None:
|
||||
"""Running fix twice is safe — second run returns PASS with no stale rules."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
@@ -712,7 +744,7 @@ class TestReconcileStaleDeny:
|
||||
json.dumps({"permissions": {"deny": ["Bash(rm -rf*)", "Bash(rm -r *)"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||
reconcile_stale_deny(fix=True)
|
||||
results = reconcile_stale_deny(fix=True)
|
||||
assert len(results) == 1
|
||||
@@ -721,24 +753,363 @@ class TestReconcileStaleDeny:
|
||||
|
||||
def test_empty_deny_list_returns_pass(self, tmp_path) -> None:
|
||||
"""Empty deny list returns PASS."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
settings.write_text(json.dumps({"permissions": {"deny": []}}), encoding="utf-8")
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=False)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == GLYPH_PASS
|
||||
|
||||
def test_no_permissions_key_returns_pass(self, tmp_path) -> None:
|
||||
"""Settings without permissions key returns PASS."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
settings.write_text(json.dumps({"env": {"FOO": "bar"}}), encoding="utf-8")
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=False)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == GLYPH_PASS
|
||||
|
||||
|
||||
class TestCheckWireVerify:
|
||||
"""Tests for check_wire_verify() — hooks wire_verify guard."""
|
||||
|
||||
def test_pass_on_zero_exit(self) -> None:
|
||||
"""Exit 0 from drone @hooks verify produces a PASS row."""
|
||||
from aipass.aipass.apps.modules._doctor_wire import check_wire_verify
|
||||
|
||||
fake = MagicMock(returncode=0, stdout="✓ Wire check passed\n\n0 errors, 0 warnings\n")
|
||||
with patch("aipass.aipass.apps.modules._doctor_wire.subprocess.run", return_value=fake):
|
||||
results = check_wire_verify()
|
||||
assert len(results) == 1
|
||||
assert results[0].label == "wire verify"
|
||||
assert results[0].glyph == "[green]✓[/green]"
|
||||
|
||||
def test_fail_on_nonzero_exit(self) -> None:
|
||||
"""Non-zero exit from drone @hooks verify produces a FAIL row."""
|
||||
from aipass.aipass.apps.modules._doctor_wire import check_wire_verify
|
||||
|
||||
fake = MagicMock(returncode=1, stdout="ERROR empty array\n2 errors, 0 warnings\n")
|
||||
with patch("aipass.aipass.apps.modules._doctor_wire.subprocess.run", return_value=fake):
|
||||
results = check_wire_verify()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == "[red]✗[/red]"
|
||||
assert "errors" in results[0].detail
|
||||
|
||||
def test_warn_on_drone_not_found(self) -> None:
|
||||
"""FileNotFoundError (drone missing) produces a WARN row."""
|
||||
from aipass.aipass.apps.modules._doctor_wire import check_wire_verify
|
||||
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules._doctor_wire.subprocess.run",
|
||||
side_effect=FileNotFoundError("drone"),
|
||||
):
|
||||
results = check_wire_verify()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == "[yellow]![/yellow]"
|
||||
|
||||
def test_warn_on_timeout(self) -> None:
|
||||
"""TimeoutExpired produces a WARN row."""
|
||||
import subprocess as sp
|
||||
|
||||
from aipass.aipass.apps.modules._doctor_wire import check_wire_verify
|
||||
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules._doctor_wire.subprocess.run",
|
||||
side_effect=sp.TimeoutExpired(cmd="drone", timeout=10),
|
||||
):
|
||||
results = check_wire_verify()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == "[yellow]![/yellow]"
|
||||
assert "timed out" in results[0].detail
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# prompt_auto_wire — non-interactive stdin guard (issue #663)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestPromptAutoWireIsatty:
|
||||
"""Guard: non-tty stdin must not block on input() (#663)."""
|
||||
|
||||
@staticmethod
|
||||
def _args() -> dict:
|
||||
return {
|
||||
"manifest_path": MagicMock(),
|
||||
"missing_hooks": ["some_hook"],
|
||||
"missing_env": [],
|
||||
"missing_deny": [],
|
||||
"missing_ask": [],
|
||||
}
|
||||
|
||||
def test_non_tty_stdin_skips_prompt_and_declines(self) -> None:
|
||||
"""Non-tty stdin must NOT call input() — it declines and warns instead."""
|
||||
from aipass.aipass.apps.modules import _doctor_wire
|
||||
|
||||
with (
|
||||
patch.object(_doctor_wire.sys, "stdin") as mock_stdin,
|
||||
patch("builtins.input") as mock_input,
|
||||
patch.object(_doctor_wire, "_print_manual_wire_warning") as mock_warn,
|
||||
):
|
||||
mock_stdin.isatty.return_value = False
|
||||
result = _doctor_wire._prompt_auto_wire(**self._args())
|
||||
|
||||
assert result is False
|
||||
mock_input.assert_not_called()
|
||||
mock_warn.assert_called_once()
|
||||
|
||||
def test_tty_stdin_prompts_and_respects_decline(self) -> None:
|
||||
"""Tty stdin still prompts; a 'n' answer declines."""
|
||||
from aipass.aipass.apps.modules import _doctor_wire
|
||||
|
||||
with (
|
||||
patch.object(_doctor_wire.sys, "stdin") as mock_stdin,
|
||||
patch("builtins.input", return_value="n") as mock_input,
|
||||
patch.object(_doctor_wire, "_print_manual_wire_warning"),
|
||||
):
|
||||
mock_stdin.isatty.return_value = True
|
||||
result = _doctor_wire._prompt_auto_wire(**self._args())
|
||||
|
||||
assert result is False
|
||||
mock_input.assert_called_once()
|
||||
|
||||
def test_tty_stdin_accepts_and_wires(self) -> None:
|
||||
"""Tty stdin with a 'y' answer runs the wire and returns True."""
|
||||
from aipass.aipass.apps.modules import _doctor_wire
|
||||
|
||||
with (
|
||||
patch.object(_doctor_wire.sys, "stdin") as mock_stdin,
|
||||
patch("builtins.input", return_value="y"),
|
||||
patch.object(_doctor_wire, "_auto_wire_provider", return_value=["wired hook"]) as mock_wire,
|
||||
):
|
||||
mock_stdin.isatty.return_value = True
|
||||
result = _doctor_wire._prompt_auto_wire(**self._args())
|
||||
|
||||
assert result is True
|
||||
mock_wire.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _check_global_aipass_home tests (#688)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCheckGlobalAipassHome:
|
||||
"""Tests for _check_global_aipass_home doctor check."""
|
||||
|
||||
def test_nonexistent_path_is_error(self, tmp_path):
|
||||
"""AIPASS_HOME pointing to a nonexistent path is flagged as error."""
|
||||
from aipass.aipass.apps.modules.doctor import _check_global_aipass_home
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
settings.write_text(
|
||||
json.dumps({"env": {"AIPASS_HOME": str(tmp_path / "gone")}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path):
|
||||
results = _check_global_aipass_home()
|
||||
fails = [r for r in results if "does not exist" in r.detail]
|
||||
assert len(fails) == 1
|
||||
|
||||
def test_throwaway_path_is_error(self, tmp_path):
|
||||
"""AIPASS_HOME pointing to a temp path is flagged as error."""
|
||||
from aipass.aipass.apps.modules.doctor import _check_global_aipass_home
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
settings.write_text(
|
||||
json.dumps({"env": {"AIPASS_HOME": str(tmp_path)}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path):
|
||||
results = _check_global_aipass_home()
|
||||
fails = [r for r in results if "throwaway" in r.detail]
|
||||
assert len(fails) == 1
|
||||
|
||||
def test_valid_path_passes(self, tmp_path):
|
||||
"""AIPASS_HOME pointing to a real, non-temp path passes."""
|
||||
from aipass.aipass.apps.modules.doctor import _check_global_aipass_home, GLYPH_PASS
|
||||
|
||||
real_home = tmp_path / "AIPass"
|
||||
real_home.mkdir()
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
settings.write_text(
|
||||
json.dumps({"env": {"AIPASS_HOME": str(real_home)}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with (
|
||||
patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
results = _check_global_aipass_home()
|
||||
assert any(r.glyph == GLYPH_PASS for r in results)
|
||||
|
||||
def test_no_settings_file_is_noop(self, tmp_path):
|
||||
"""Missing ~/.claude/settings.json produces no results."""
|
||||
from aipass.aipass.apps.modules.doctor import _check_global_aipass_home
|
||||
|
||||
with patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path):
|
||||
results = _check_global_aipass_home()
|
||||
assert results == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _check_owner_seating / _fix_owner_seating tests (DPLAN-0239 P3+P5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCheckOwnerSeating:
|
||||
"""Tests for owner/identity detection via sync-registry --check."""
|
||||
|
||||
def test_clean_owner_returns_pass(self):
|
||||
from aipass.aipass.apps.modules.doctor import _check_owner_seating
|
||||
|
||||
check_json = json.dumps({"clean": True, "owner": "vera", "owner_uid": "8fb38c96-abcd", "issues": []})
|
||||
mock_proc = MagicMock(returncode=0, stdout=check_json, stderr="")
|
||||
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
|
||||
results = _check_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_PASS
|
||||
assert "@vera" in results[0].detail
|
||||
assert "8fb38c96" in results[0].detail
|
||||
|
||||
def test_unseated_owner_returns_errors(self):
|
||||
from aipass.aipass.apps.modules.doctor import _check_owner_seating
|
||||
|
||||
check_json = json.dumps(
|
||||
{
|
||||
"clean": False,
|
||||
"owner": None,
|
||||
"owner_uid": "",
|
||||
"issues": [
|
||||
{"flag": "no_owner", "detail": "No owner:true in registry"},
|
||||
{"flag": "metadata_id_missing", "detail": "metadata.id absent"},
|
||||
],
|
||||
}
|
||||
)
|
||||
mock_proc = MagicMock(returncode=1, stdout=check_json, stderr="")
|
||||
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
|
||||
results = _check_owner_seating()
|
||||
assert len(results) == 2
|
||||
assert all(r.glyph == GLYPH_FAIL for r in results)
|
||||
assert results[0].label == "owner/no_owner"
|
||||
|
||||
def test_issue_with_branch_field(self):
|
||||
from aipass.aipass.apps.modules.doctor import _check_owner_seating
|
||||
|
||||
check_json = json.dumps(
|
||||
{
|
||||
"clean": False,
|
||||
"owner": "vera",
|
||||
"owner_uid": "8fb38c96",
|
||||
"issues": [
|
||||
{"flag": "entry_rid_stale", "detail": "stale rid", "branch": "vera"},
|
||||
],
|
||||
}
|
||||
)
|
||||
mock_proc = MagicMock(returncode=1, stdout=check_json, stderr="")
|
||||
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
|
||||
results = _check_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_FAIL
|
||||
assert results[0].label == "owner/entry_rid_stale"
|
||||
|
||||
def test_drone_not_found_returns_warn(self):
|
||||
from aipass.aipass.apps.modules.doctor import _check_owner_seating
|
||||
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.doctor.subprocess.run",
|
||||
side_effect=FileNotFoundError("drone"),
|
||||
):
|
||||
results = _check_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_WARN
|
||||
assert "drone" in results[0].detail
|
||||
|
||||
def test_timeout_returns_warn(self):
|
||||
import subprocess as _sp
|
||||
|
||||
from aipass.aipass.apps.modules.doctor import _check_owner_seating
|
||||
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.doctor.subprocess.run",
|
||||
side_effect=_sp.TimeoutExpired("drone", 30),
|
||||
):
|
||||
results = _check_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_WARN
|
||||
|
||||
def test_non_json_output_returns_warn(self):
|
||||
from aipass.aipass.apps.modules.doctor import _check_owner_seating
|
||||
|
||||
mock_proc = MagicMock(returncode=1, stdout="not json at all", stderr="")
|
||||
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
|
||||
results = _check_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_WARN
|
||||
|
||||
def test_empty_stdout_exit_zero(self):
|
||||
from aipass.aipass.apps.modules.doctor import _check_owner_seating
|
||||
|
||||
mock_proc = MagicMock(returncode=0, stdout="", stderr="")
|
||||
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
|
||||
results = _check_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_PASS
|
||||
|
||||
|
||||
class TestFixOwnerSeating:
|
||||
"""Tests for owner/identity repair via sync-registry --fix."""
|
||||
|
||||
def test_fix_success_returns_pass(self):
|
||||
from aipass.aipass.apps.modules.doctor import _fix_owner_seating
|
||||
|
||||
mock_proc = MagicMock(returncode=0, stdout="", stderr="")
|
||||
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
|
||||
results = _fix_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_PASS
|
||||
assert "reconciled" in results[0].detail
|
||||
|
||||
def test_fix_failure_returns_fail(self):
|
||||
from aipass.aipass.apps.modules.doctor import _fix_owner_seating
|
||||
|
||||
mock_proc = MagicMock(returncode=1, stdout="", stderr="owner conflict")
|
||||
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
|
||||
results = _fix_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_FAIL
|
||||
|
||||
def test_fix_drone_not_found(self):
|
||||
from aipass.aipass.apps.modules.doctor import _fix_owner_seating
|
||||
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.doctor.subprocess.run",
|
||||
side_effect=FileNotFoundError("drone"),
|
||||
):
|
||||
results = _fix_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_WARN
|
||||
|
||||
def test_fix_timeout(self):
|
||||
import subprocess as _sp
|
||||
|
||||
from aipass.aipass.apps.modules.doctor import _fix_owner_seating
|
||||
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.doctor.subprocess.run",
|
||||
side_effect=_sp.TimeoutExpired("drone", 60),
|
||||
):
|
||||
results = _fix_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_WARN
|
||||
|
||||
@@ -12,7 +12,7 @@ import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from aipass.aipass.apps.modules.doctor_fix import (
|
||||
from aipass.aipass.apps.modules._doctor_fix import (
|
||||
RemediationItem,
|
||||
detect_project_name,
|
||||
format_json_report,
|
||||
@@ -68,7 +68,7 @@ class TestDetectProjectName:
|
||||
no_reg = tmp_path / "empty_project"
|
||||
no_reg.mkdir()
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.doctor_fix._discover_registry",
|
||||
"aipass.aipass.apps.modules._doctor_fix._discover_registry",
|
||||
return_value=no_reg / "MISSING_REGISTRY.json",
|
||||
):
|
||||
result = detect_project_name(no_reg)
|
||||
@@ -368,16 +368,16 @@ class TestPrintFunctions:
|
||||
class TestDoctorFixHandleCommand:
|
||||
def test_wrong_command(self) -> None:
|
||||
"""Non-doctor_fix commands are not handled."""
|
||||
from aipass.aipass.apps.modules.doctor_fix import handle_command
|
||||
from aipass.aipass.apps.modules._doctor_fix import handle_command
|
||||
|
||||
assert handle_command("doctor", []) is False
|
||||
assert handle_command("help", []) is False
|
||||
|
||||
def test_no_args_shows_usage(self) -> None:
|
||||
"""No args shows usage message (not introspection banner)."""
|
||||
from aipass.aipass.apps.modules.doctor_fix import handle_command
|
||||
from aipass.aipass.apps.modules._doctor_fix import handle_command
|
||||
|
||||
with patch("aipass.aipass.apps.modules.doctor_fix.console") as mock_console:
|
||||
with patch("aipass.aipass.apps.modules._doctor_fix.console") as mock_console:
|
||||
result = handle_command("doctor_fix", [])
|
||||
assert result is True
|
||||
printed = " ".join(str(c) for c in mock_console.print.call_args_list)
|
||||
@@ -385,9 +385,9 @@ class TestDoctorFixHandleCommand:
|
||||
|
||||
def test_info_flag(self) -> None:
|
||||
"""--info triggers print_introspection."""
|
||||
from aipass.aipass.apps.modules.doctor_fix import handle_command
|
||||
from aipass.aipass.apps.modules._doctor_fix import handle_command
|
||||
|
||||
with patch("aipass.aipass.apps.modules.doctor_fix.print_introspection") as mock:
|
||||
with patch("aipass.aipass.apps.modules._doctor_fix.print_introspection") as mock:
|
||||
result = handle_command("doctor_fix", ["--info"])
|
||||
assert result is True
|
||||
mock.assert_called_once()
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_feedback.py
|
||||
# Description: Tests for aipass feedback — toggle alias for @hooks feedback pulse
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-18
|
||||
# Modified: 2026-07-18
|
||||
# =============================================
|
||||
|
||||
"""Tests for the aipass feedback module."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from aipass.aipass.apps.modules.feedback import handle_command, print_help, print_introspection
|
||||
|
||||
_MOD = "aipass.aipass.apps.modules.feedback"
|
||||
|
||||
|
||||
class TestHandleCommand:
|
||||
"""Command routing for aipass feedback."""
|
||||
|
||||
def test_ignores_other_commands(self) -> None:
|
||||
"""A non-feedback command is not handled."""
|
||||
assert handle_command("doctor", []) is False
|
||||
|
||||
def test_help(self) -> None:
|
||||
"""--help is handled."""
|
||||
assert handle_command("feedback", ["--help"]) is True
|
||||
|
||||
def test_info(self) -> None:
|
||||
"""--info is handled."""
|
||||
assert handle_command("feedback", ["--info"]) is True
|
||||
|
||||
def test_unknown_arg_shows_error(self) -> None:
|
||||
"""An unknown argument shows an error and help."""
|
||||
with patch(f"{_MOD}.error") as mock_err:
|
||||
assert handle_command("feedback", ["banana"]) is True
|
||||
mock_err.assert_called_once()
|
||||
|
||||
def test_on_delegates_to_hooks(self) -> None:
|
||||
"""'on' delegates to drone @hooks feedback on."""
|
||||
with patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as run:
|
||||
handle_command("feedback", ["on"])
|
||||
cmd = run.call_args[0][0]
|
||||
assert cmd == ["drone", "@hooks", "feedback", "on"]
|
||||
|
||||
def test_off_delegates_to_hooks(self) -> None:
|
||||
"""'off' delegates to drone @hooks feedback off."""
|
||||
with patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as run:
|
||||
handle_command("feedback", ["off"])
|
||||
cmd = run.call_args[0][0]
|
||||
assert cmd == ["drone", "@hooks", "feedback", "off"]
|
||||
|
||||
def test_no_args_shows_introspection(self) -> None:
|
||||
"""No args shows module introspection."""
|
||||
with patch(f"{_MOD}.subprocess.run") as run:
|
||||
assert handle_command("feedback", []) is True
|
||||
run.assert_not_called()
|
||||
|
||||
def test_drone_not_found(self) -> None:
|
||||
"""Missing drone warns cleanly, no crash."""
|
||||
with (
|
||||
patch(f"{_MOD}.subprocess.run", side_effect=FileNotFoundError("drone")),
|
||||
patch(f"{_MOD}.warning") as warn,
|
||||
):
|
||||
handle_command("feedback", ["on"])
|
||||
warn.assert_called_once()
|
||||
|
||||
|
||||
class TestSmoke:
|
||||
"""Help/introspection render without error."""
|
||||
|
||||
def test_print_help_runs(self) -> None:
|
||||
print_help()
|
||||
|
||||
def test_print_introspection_runs(self) -> None:
|
||||
print_introspection()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user