Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df1e9a2e2b | ||
|
|
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 |
+22
-7
@@ -6,8 +6,12 @@
|
||||
"presence_gate": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.security.presence_gate.handle",
|
||||
"matcher": "",
|
||||
"provider_wired": false
|
||||
"matcher": ""
|
||||
},
|
||||
"persistent_alert": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.persistent_alert.handle",
|
||||
"matcher": ""
|
||||
},
|
||||
"identity_injector": {
|
||||
"enabled": true,
|
||||
@@ -34,11 +38,27 @@
|
||||
"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
|
||||
},
|
||||
"user_message_relay": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.skills.lib.telegram.apps.handlers.user_message_relay.handle",
|
||||
"matcher": ""
|
||||
}
|
||||
},
|
||||
|
||||
@@ -67,11 +87,6 @@
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.security.registry_gate.handle",
|
||||
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"
|
||||
},
|
||||
"engine_test_sound": {
|
||||
"enabled": false,
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/engine_test_sound.py",
|
||||
"matcher": "WebSearch"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
+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's front-door concierge and its OWN CLI, NOT drone-routed: run `aipass` / `aipass --help` directly, never `drone @aipass` (drone can't resolve it). The human's best friend — onboarding (`aipass init`/`install`), `doctor` health, help chat, and OS/system questions ("why's my wifi dropping", "why's CC hogging CPU", "what is drone", "how do I make a project"). Serves humans, not agents — 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 (live, per-file mirror — slow on huge file counts, respect `.backupignore`). `.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.
|
||||
|
||||
@@ -55,7 +55,12 @@ 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
|
||||
|
||||
## 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 +76,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,17 @@
|
||||
"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: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"},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.16.0
|
||||
@@ -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.16.0 \
|
||||
--hash=sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17 \
|
||||
--hash=sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d \
|
||||
--hash=sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af \
|
||||
--hash=sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09 \
|
||||
--hash=sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522 \
|
||||
--hash=sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472 \
|
||||
--hash=sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0 \
|
||||
--hash=sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b \
|
||||
--hash=sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9 \
|
||||
--hash=sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213 \
|
||||
--hash=sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb \
|
||||
--hash=sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed \
|
||||
--hash=sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717 \
|
||||
--hash=sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a \
|
||||
--hash=sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81 \
|
||||
--hash=sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982 \
|
||||
--hash=sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e \
|
||||
--hash=sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4
|
||||
# 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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
- uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
with:
|
||||
languages: python
|
||||
- uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
- uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
|
||||
@@ -124,6 +124,12 @@ src/aipass/*/apps/integrations/**
|
||||
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/
|
||||
|
||||
|
||||
+663
@@ -9,6 +9,669 @@ PyPI version — not the changelog header.
|
||||
|
||||
---
|
||||
|
||||
## [2026-07-18]
|
||||
|
||||
**docs** — root README v3 restructure (DPLAN-0249): single-funnel story with
|
||||
zero duplicated commands (install, `aipass new`/`init run`, trees, drone
|
||||
examples each taught exactly once), hero link line to aipass.ai/PyPI/r/AIPass,
|
||||
three reserved gif slots. Positioning ruling: the README tells only the
|
||||
Claude Code on Linux/WSL story — Codex/macOS/Windows mentions and the Roadmap
|
||||
section removed (code support unchanged; Docker distribution will serve those
|
||||
users later). Earlier same day: stale demo.gif embed dropped (#701) and
|
||||
aipass.ai realigned to the v2.7.3 front door.
|
||||
|
||||
**v2.7.3** — the onboarding chain: from `git clone` to a conversation with an
|
||||
agent that remembers you. Install's three dead-ends are gone — the default
|
||||
`init` path, headless runs, and `aipass new` all now end where they should:
|
||||
`install` chains through the guided init and **opens a live conversation with
|
||||
the AIPass concierge**, first prompt authored with the install report in its
|
||||
context. The concierge's Welcome Mode (research-backed opener, one name-ask,
|
||||
deferred setup triage, hooks-first health check via a real @hooks dispatch,
|
||||
every suggestion with its exact command) was proven in a live multi-turn
|
||||
door-test — including the second-session payoff: relaunch, and it picks up
|
||||
mid-task where you left off. Plus `aipass new` and the front-door overhaul below.
|
||||
|
||||
### Added (onboarding chain — TDPLAN-0014)
|
||||
|
||||
- **Install→chat handoff**: after init returns, install `launch_inline`s the
|
||||
concierge with an authored first prompt (fresh-install recognition + binary
|
||||
report). TTY-only; headless returns cleanly.
|
||||
- **Welcome Mode** in the concierge branch prompt: capability opener with 3–5
|
||||
concrete starters, single graceful name-ask, ~turn-5 setup push ("every
|
||||
machine is different"), hooks-first verification incl. trust-registry
|
||||
enrollment, setup plan seeded from the cross-OS checklist, Windows→WSL
|
||||
recommendation, prax-monitor + hooksound tips, exact copy-paste command with
|
||||
every suggestion.
|
||||
- **Feedback pulse** (@hooks): one ignorable line every ~10 turns with the repo
|
||||
feedback link — `aipass feedback on/off` (alias for `drone @hooks feedback`)
|
||||
turns it off. Registered disabled for the AIPass host itself. 25 tests.
|
||||
- **Dead-end kills**: empty-template init now runs handoff + report stages
|
||||
(default path ends in the conversation); non-interactive installs complete
|
||||
with defaults and exit 0 (headless stage 9 prints the launch command instead
|
||||
of spawning); `aipass new` auto-launches into the new project's manager agent
|
||||
on a TTY with a printed fallback and Ctrl-C escape line.
|
||||
- **Unified handoff prompt**: one `INIT_PROMPT` constant (was two drifting
|
||||
strings in init_flow vs handoff).
|
||||
|
||||
### Fixed (onboarding chain)
|
||||
|
||||
- Non-TTY `aipass init run` crashed with EOFError at the first prompt (caught
|
||||
in a live door-test after unit suites ran green — the prompt layer now
|
||||
auto-detects non-TTY and takes defaults).
|
||||
- `aipass new` outside an AIPass environment now exits 1 instead of 0.
|
||||
- Empty-template handoff messaging no longer claims an agent exists
|
||||
("Your project is ready", resolved absolute path instead of `cd .`).
|
||||
- Stage numbering shows a skip notice instead of silently jumping 5→8.
|
||||
|
||||
## [2026-07-17]
|
||||
|
||||
**v2.7.3 (first pass)** — `aipass new` and the front-door overhaul. The `projects/`
|
||||
directory is now a first-class playground: `aipass new <name>` creates a fully
|
||||
isolated project — own registry, own git repo with a birth commit, born
|
||||
deployable — with a full framework resident agent that answers
|
||||
`drone @<name>` from inside the project while staying invisible to the host
|
||||
roster. ai_mail enforces the project boundary (cross-project mail is refused
|
||||
with a pointer to the feedback channel). A live door-test of the aipass CLI
|
||||
exposed a blind spot in the audit — perfect structural scores on an unusable
|
||||
front door — so seedgo grew two user-facing-quality standards (`cli_ux`,
|
||||
`readme_quality`) and a fleet-100 campaign brought every branch's help output
|
||||
and README to the house pattern: 17/17 branches at 100%.
|
||||
|
||||
### Added
|
||||
|
||||
- **`aipass new <name>`** (module + handler): creates `projects/<name>` with
|
||||
registry-first credential linkage (`registry.metadata.id ==
|
||||
passport.citizenship.registry_id`), empty/python templates, interactive
|
||||
template + agent prompts (flags for scripted use), a full framework agent
|
||||
(entry point, modules/handlers skeleton, trinity set, mailbox, tier files),
|
||||
git init + birth commit, and next-step output. 49 tests.
|
||||
- **seedgo standards 41–42**: `cli_ux` (8 AST checks — two-tier help, Rich
|
||||
console, styled title, purpose line, --help pointer, Usage, Examples, no
|
||||
exposed internal plumbing) and `readme_quality` (Quick Start with runnable
|
||||
code block, stranger accessibility, invoke/entry-point match, early
|
||||
what-description). 36 tests + case-resolution regression tests.
|
||||
- **ai_mail cross-project boundary**: sender and recipient project roots
|
||||
compared on delivery; cross-project sends refused with a feedback-channel
|
||||
pointer. Fail-open for internal/unregistered sends. 13 tests.
|
||||
- **Root `.gitignore`**: `projects/*` ignored (each project is its own repo);
|
||||
only the future catalog README stays trackable.
|
||||
|
||||
### Added (second pass — the agent becomes a real citizen)
|
||||
|
||||
- **`aipass new` agents are now spawn-issued full citizens** (FPLAN-0334): the
|
||||
hand-rolled scaffold in the new_project handler is retired for a
|
||||
`spawn_agent()` call against @spawn's new `project_agent` template — branch
|
||||
prompt, structured mailbox, birth certificate, trinity trio, dashboard,
|
||||
house-pattern entry point, and a branch-style README. One authority issues
|
||||
citizens; project agents inherit template evolution for free.
|
||||
- **Agent home = `src/<project>/<agent>/`**, mirroring the host's
|
||||
`src/aipass/<branch>` layout (door-test ruling: the project root is never an
|
||||
agent home). Seat paths are relative like host seats; the registry walk stops
|
||||
at the first project registry. The first agent is the project's **manager**
|
||||
(`citizen_class: manager` — its devpulse), named after the project.
|
||||
- **Birth-commit hygiene**: the `.venv` symlink (absolute host path) and the
|
||||
registry lock file are no longer tracked in new projects' birth commits.
|
||||
- **Boundary verified live in all four directions**: host↔project email and
|
||||
dispatch all refused — project→host lands on the ai_mail cross-project check
|
||||
with its feedback-channel pointer, closing the leak found in the S319
|
||||
prototype probes.
|
||||
|
||||
### Changed
|
||||
|
||||
- **aipass front door rebuilt**: `--help` now follows the house pattern with a
|
||||
curated command list, usage, and examples (internal plumbing hidden —
|
||||
`doctor_fix`/`doctor_wire` renamed underscore-private); `aipass help` shows
|
||||
the Q&A screen instead of falling through to the module dump; README
|
||||
rewritten to pass the stranger test with a Quick Start and the correct
|
||||
invocation.
|
||||
- **Fleet-100 sweep**: 15 branches gained Quick Start READMEs and/or
|
||||
Usage/Examples help sections — each owner fixed their own front against the
|
||||
new gate.
|
||||
- **Debug_Print detector hardened**: the regex-based checker matched `print(`
|
||||
inside string literals (flagging cli_ux_check's own error messages — the
|
||||
auditor was the last branch under 100%). String content is now stripped
|
||||
before matching, with a regression test; plus a depth-5 nesting refactor in
|
||||
the same file.
|
||||
|
||||
**v2.7.2** — everything merged since v2.7.1, headlined by the compass decision
|
||||
engine v2: curation with supersedes links + write-time conflict advisories
|
||||
(Track 1), and ambient recall — rated past decisions now surface verbatim into
|
||||
live sessions on matching prompts, governed by session caps and spacing
|
||||
(Track 2). Also in this release: the plan close pipeline completes itself
|
||||
(auto-vectorization + crash-safe registry writes), drone's 3-layer subprocess
|
||||
timeout policy with a collision-free `--drone-timeout` flag, plan-number memory
|
||||
search that pins the exact plan, a fleet-wide seedgo 100% restoration, and
|
||||
SSH-signed commits now verifying on GitHub. Details in the sections below
|
||||
(2026-07-16 carries the full stories).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Release cadence ruling: every dev→main merge ships a PATCH bump + tag by
|
||||
default.** PyPI tracks main, always current; version numbers carry no
|
||||
significance during beta — the big jump is reserved for beta exit.
|
||||
|
||||
- **Merge playbook SOP refined from live run PPLAN-0010.** The raw
|
||||
`git fetch origin main:main` step (now blocked by the git gate) is replaced
|
||||
with `drone @git sync` in both places it appeared, and the template opens
|
||||
with the exact create command (`drone @flow create . "Merge summary" merge
|
||||
pplan` — template name before type), closing the trap where the wrong arg
|
||||
order silently stamps the default template.
|
||||
|
||||
## [2026-07-16]
|
||||
|
||||
### Added
|
||||
|
||||
- **Compass ambient recall — Track 2 (DPLAN-0246/FPLAN-0332): rated decisions
|
||||
surface unprompted.** On every user prompt, a new hooks handler
|
||||
(`compass_recall`, registered in `.aipass/hooks.json` only) queries compass
|
||||
FTS with the prompt text and injects matching rulings VERBATIM —
|
||||
`[BAD] #56: <decision text>` — tidbits, never vibes. Three branches, one
|
||||
pipeline, each piece behind a modules/-boundary API: devpulse's
|
||||
`recall_decisions()` (side-effect-free scored candidates; rare-token
|
||||
evidence scoring + a query-side stopword filter so greeting/filler words
|
||||
can't fake relevance) + `mark_surfaced()` (counts only real injections);
|
||||
@memory's pure `should_surface()` governance (promoted from the dormant
|
||||
symbolic engine: threshold, 5/session cap, 10-message spacing — first
|
||||
surface exempt, 300s cooldown, dedup; state-in/state-out, caller persists);
|
||||
@hooks' 90-line handler + engine per-handler budget (errors never block a
|
||||
prompt — `compass_recall_unreachable` log signature for @trigger's watcher).
|
||||
Live acceptance matrix through the real bridge: topic-with-history prompts
|
||||
recall the right ruling (a CI prompt surfaced the red-CI-never-parked
|
||||
ruling), small talk and greetings stay silent, repeat prompts gate on
|
||||
spacing. Review caught and fixed pre-ship: wrong payload key (`userInput` →
|
||||
`prompt`), phantom `CLAUDE_CODE_SESSION_ID` env (session id is
|
||||
stdin-payload-only), spacing gate blocking the first surface, and a trust
|
||||
registry re-enrollment gap that silently disabled ALL project hooks for 20
|
||||
minutes after the hooks.json edit. 446 devpulse + 1011 memory + 1129 hooks
|
||||
tests green; seedgo 31/31 on every touched module.
|
||||
|
||||
- **Compass curation v2 Track 1 (DPLAN-0246/FPLAN-0331): supersedes links +
|
||||
write-time conflict check.** A correcting compass entry now archives and
|
||||
links what it replaces in one transaction (`compass add --supersedes N`);
|
||||
query renders both directions ("supersedes #N" / "ARCHIVED — superseded by
|
||||
#M") so a retracted decision can never masquerade as current truth. Every
|
||||
`compass add` FTS-checks the new text against active entries and prints a
|
||||
non-blocking "possible conflict with #X" advisory — flag-and-ask, no LLM, no
|
||||
auto-resolve (boardroom ruling). New `compass note <id>` command (FTS
|
||||
re-index proven by test), `--include-archived` query flag (the avoid-list is
|
||||
finally searchable), dead `score` column removed from all code surfaces
|
||||
(kept inert on disk — zero migration risk). Idempotent PRAGMA-checked
|
||||
migration ran clean on the production store (128 rows, no loss); the four
|
||||
fresh-eyes-audit archive pairs got their links backfilled. /prep now runs
|
||||
one `compass review` per session — curation living in a path that already
|
||||
runs, the lesson of all three compass eras. 435 devpulse tests green,
|
||||
seedgo 31/31 on both touched modules.
|
||||
|
||||
- **Close pipeline completes itself (DPLAN-0245): auto-vectorization +
|
||||
crash-safe registry writes + drone timeout policy.** Closing a plan now
|
||||
produces all side effects from one command — `post_close_runner` invokes
|
||||
@memory's plan intake directly after archival (detached, loud on failure,
|
||||
drains any backlog it finds), so plans can no longer silently pile up
|
||||
unvectorized. Plan registry saves (@flow `save_registry` + mbank
|
||||
`save_flow_registry`) now use the O_EXCL lockfile + atomic
|
||||
tempfile-and-replace pattern, closing the same lost-update race class fixed
|
||||
earlier in CLOSED_PLANS. @drone gained a 3-layer timeout policy: per-command
|
||||
overrides (`@memory process-plans` 120s, `@flow close` 90s), a `--timeout N`
|
||||
flag, 30s default — replacing the flat 30s guillotine that killed legitimate
|
||||
long commands mid-pipeline; the timeout error now says how to override.
|
||||
Proven end-to-end live: one `drone @flow close` on a throwaway plan yielded
|
||||
archive + vectors + ledger + registry with zero manual steps, and the
|
||||
auto-trigger swept a pre-existing backlog file on its first run. 730 flow +
|
||||
878 drone tests green, seedgo 100%.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CI seedgo gate back to 100% across all 17 branches.** The Track 2 compass
|
||||
recall code left three branches at 99%: @hooks' compass_recall handler was
|
||||
missing json_handler operation logging and had two silent catches (now
|
||||
logged); @memory's governance module held its implementation in modules/
|
||||
(moved to handlers/governance/engine.py with modules/governance.py as the
|
||||
thin re-export — the cross-branch import path is unchanged and live-E2E
|
||||
verified through the real bridge); devpulse's README test count had drifted
|
||||
(309 → 348). Audits re-run per branch: 100% overall, all suites green.
|
||||
|
||||
- **Plan-number memory search hits the exact plan.** Searching a plan ID
|
||||
('DPLAN-0244', 'fplan 0332' — any case, dash or space) now pins the exact
|
||||
plan as the top result at 100%, via a metadata lookup on the vector store's
|
||||
source-file field instead of embedding similarity (which treats all plan IDs
|
||||
as near-identical strings and never surfaced the target). Patrick ruling:
|
||||
searching a plan number must return that plan first. Semantic search quality
|
||||
for normal queries is unchanged. Also purged 193 junk vectors — throwaway
|
||||
probe/flaky test plans from scratchpad sessions (dv4 batch, probe_test_plan,
|
||||
throwaway_e2e_proof) that had leaked into the store. 1011 memory tests green.
|
||||
|
||||
- **drone --timeout collision: router flag swallowed module flags.** The
|
||||
DPLAN-0245 subprocess-timeout flag consumed the first `--timeout` token
|
||||
anywhere in argv, so module-level flags silently vanished — watchdog's
|
||||
`--timeout 1800` never arrived and long watches died at the 600s default
|
||||
(live repro x2). Drone's flag is now namespaced `--drone-timeout`; plain
|
||||
`--timeout` passes through untouched to the target module, with a regression
|
||||
test pinning the passthrough. Per-command overrides intact. 879 drone tests
|
||||
green, seedgo 100%.
|
||||
|
||||
- **@memory command routing eaten by the new governance module.** The
|
||||
governance module shipped in Track 2 had the wrong `handle_command`
|
||||
signature (`args: list` instead of `command: str, args: list`) and always
|
||||
returned True, so auto-discovery routed EVERY @memory command through it
|
||||
first — `drone @memory search` answered "governance: unknown command 's'".
|
||||
Fixed to the standard signature returning False for commands not its own;
|
||||
search verified live (135 results). Library modules must decline commands
|
||||
they don't own or they silently hijack the whole CLI. 1011 memory tests
|
||||
green, seedgo 31/31.
|
||||
|
||||
## [2026-07-15]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Plan vectorization pipeline unwedged (DPLAN-0245): 57 closed plans were
|
||||
silently missing from semantic memory since mid-June.** Vector IDs were pure
|
||||
content hashes, so identical template boilerplate across different plans
|
||||
produced duplicate IDs within one ChromaDB upsert — the store rejected the
|
||||
entire batch, and the all-or-nothing intake retried the same failing batch
|
||||
forever. Fixed in @memory: IDs are now salted with the source filename when
|
||||
present (rollover hashes unchanged — no re-vectorization churn), in-batch
|
||||
dedup as a safety net, and `process_plans()` now runs per-file with the
|
||||
manifest saved after each success so a poison file can never wedge the queue
|
||||
again. Backlog drained and verified: 229/229 archived plans vectorized, 1112
|
||||
chunks, formerly-lost plans answering semantic queries at 85%+ similarity.
|
||||
990 memory tests green.
|
||||
|
||||
- **CLOSED_PLANS ledger append race (@flow): concurrent plan closes lost
|
||||
entries.** `append_to_closed_plans` was an unlocked read-modify-write; the
|
||||
S314 bulk sweep lost 18 of 21 entries to it (reconciled by hand). Now guarded
|
||||
by an `O_CREAT|O_EXCL` lockfile with retry/backoff, and the previously
|
||||
silent append failure is surfaced in close output and logs. 730 flow tests
|
||||
green.
|
||||
|
||||
- **Telegram routine read-timeouts no longer logged as errors (@skills,
|
||||
Patrick ruling): ends the medic wake-loop.** A routine long-poll read
|
||||
timeout (`socket.timeout` — an `OSError` subclass) slipped past the earlier
|
||||
`URLError`-only guard into the network-outage path, logging ERROR once per
|
||||
episode (~576 lines/30h) and waking @trigger's medic each time. The
|
||||
`_is_routine_read_timeout` guard now covers the `OSError` handler too, and
|
||||
the genuine-outage episode-start line is demoted ERROR→WARNING (backoff
|
||||
self-heals; recovery already logs INFO; medic only fires on ERROR/CRITICAL).
|
||||
Real failures still log ERROR. 825 telegram tests green.
|
||||
|
||||
### Security
|
||||
|
||||
- **Hook config trust model hardening (DPLAN-0244): closes a zero-interaction
|
||||
RCE from untrusted `.aipass/hooks.json`.** The hook loader walked up from CWD
|
||||
and trusted any `.aipass/hooks.json` it found; since the bridge is wired
|
||||
globally in provider settings, a hostile repo shipping a `command`-type hook
|
||||
could execute arbitrary shell on `SessionStart` with no user interaction.
|
||||
Fixed with defense-in-depth. **Layer A (engine):** per-project configs may no
|
||||
longer run `command`-type hooks (refused via an unconditionally-stamped
|
||||
`_source` provenance flag), and handler paths are gated to the `aipass.*`
|
||||
namespace. **Layer B (loader + CLI):** a trusted-project registry
|
||||
(`~/.aipass/trusted_projects.json`, path + sha256) that the loader checks
|
||||
fail-closed; on upgrade it bootstraps **only** the `$AIPASS_HOME` install
|
||||
(never trust-on-first-use of an arbitrary directory); `aipass init`/`init
|
||||
update` auto-enroll, and new `aipass trust`/`revoke` commands manage
|
||||
enrollment. Both gates proven to block the attack independently via a live
|
||||
acceptance test driving the real bridge with a real payload. 1105 hooks +
|
||||
133 aipass tests green. Origin: external scan (false positive at
|
||||
`engine.py:37`) whose triage surfaced the real adjacent hole.
|
||||
|
||||
### Added
|
||||
|
||||
- **Supply-chain hardening pass (DPLAN-0243): commit signing + hash-pinned CI
|
||||
tooling + release provenance.** All commits are now SSH-signed via a
|
||||
dedicated repo-scoped signing key (first signed commit 9048666c, verified
|
||||
`Good "git" signature`). Every standalone pip tool install across the four
|
||||
CI workflows now installs `--require-hashes` from lock files in
|
||||
`.github/requirements/` (pip/ruff/build/pytest/pip-audit), generated with
|
||||
full multi-platform hash coverage — including the Windows `colorama` marker
|
||||
dependency that naive Linux-side pinning silently drops. `publish.yml`
|
||||
gained a SHA-pinned build-provenance attestation step (activates on the
|
||||
next release), and Dependabot now watches the new lock directory as a
|
||||
grouped `pip` ecosystem. Editable `-e .` installs untouched. Full 31-check
|
||||
CI matrix green on the change. Driven by the OpenSSF Scorecard gaps
|
||||
surfaced via hvtracker.net (HVTrust 82.0, #1 in Multi-Agent Systems);
|
||||
detector-gap correction filed upstream as YugantM/hvtracker#186 (Claude
|
||||
Code-native projects misread as "no Anthropic dependency").
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CI green pass on the runaway-log PR — every red was ours, every fix
|
||||
verified.** Morning-after triage of PR#696's failing checks: the test
|
||||
matrices' only failure was the known parked flake, but lint and the seedgo
|
||||
audit were genuinely red from the previous night's new code. One `ruff
|
||||
format` on trigger's runaway-handler tests fixed lint. The audit findings
|
||||
went back to their owners by dispatch: @hooks built the branch's missing
|
||||
json_handler and wired it into `persistent_alert`/`alert_dismiss`, added the
|
||||
introspection no-args gate, and flattened `_menu_live()`'s nesting
|
||||
(1071 tests green); @prax refactored `rate_tracker.py` to dependency
|
||||
injection — the module layer now injects `logs_dir` and `trigger.fire` via
|
||||
`configure()`, so the handler carries no cross-handler or handler→module
|
||||
imports (1028 tests green). Both branches re-audit at 100% across all 41
|
||||
standards, independently verified. Detection re-proven live post-refactor
|
||||
with a fresh planted log storm. Also trimmed the tier-1 navmap prompt
|
||||
(9.3k → 7.9k chars, under its ~8k injection cap) with the comms doctrine
|
||||
intact.
|
||||
|
||||
- **Windows flake pinned: `test_is_pid_alive_dead` escaped the ca096295
|
||||
sweep.** That commit's rule — tests mocking `os.kill` must pin
|
||||
`sys.platform="linux"` because Windows takes the ctypes OpenProcess path and
|
||||
never reaches the mock — was applied to every pid-liveness test except this
|
||||
one. It only failed when PID 1234 happened to be alive on the runner
|
||||
(environment lottery, first hit today). Pinned like its siblings. The
|
||||
remaining Windows session_boot reds and the relay mtime-cache flake predate
|
||||
this PR and stay parked.
|
||||
|
||||
- **The parked reds, unparked — Patrick's ruling: red CI is never parked.**
|
||||
"If CI is red, it's because you or I left it red." Both remaining reds fixed
|
||||
by their owners the same hour. @prax root-caused the relay mtime-cache flake:
|
||||
the test only passed when two writes landed in the same mtime-granularity
|
||||
window (true locally, false on CI runners) — fixed by pinning mtime with
|
||||
`os.utime` so the cache contract is tested deterministically, proven 50/50 +
|
||||
20/20 loops. @hooks root-caused the four Windows session_boot reds: the boot
|
||||
path's `_tmux_session_exists()` ran a real `subprocess.run(["tmux", ...])`
|
||||
that Windows runners can't satisfy (WinError 2) — mocked in all four tests
|
||||
per the ca096295 convention, leaving `execvp` (already mocked) as the only
|
||||
terminal call. Ruling recorded in compass; the "forget CI" era is over.
|
||||
|
||||
- **Burst-evasion closed: bursty runaways can no longer slip past the rate
|
||||
tracker.** Found live during the morning's chain verification: any single
|
||||
below-threshold 10-second scan window zero-reset the sustain counter, so a
|
||||
bursty writer (20 short lines every 6 seconds — 200 lines/min average, the
|
||||
exact retry-loop-with-sleep shape of the TG relay incident) ran 4 minutes
|
||||
undetected. @prax's fix (rate_tracker v1.2.0): severity now evaluates
|
||||
`max(instant_rate, 60s window average)` — continuous writers behave exactly
|
||||
as before (instant rate dominates), bursts sustain through their gap
|
||||
windows, and subsidence still clears as zeros fill the window. Four new
|
||||
burst tests; live-proven with the previously-evading storm pattern:
|
||||
`RUNAWAY WARNING: prax_burst_storm_test.log — 191 lines/min sustained 120s`
|
||||
in the tracker log, fired from the restarted running service. Detection
|
||||
evidence now spans all three storm shapes: continuous fast (332/min),
|
||||
continuous moderate (257/min), bursty (191/min).
|
||||
|
||||
### Added
|
||||
|
||||
- **TG streaming v2 polish (DPLAN-0229): the last two finalize paths now
|
||||
honor the streaming flag.** v1 shipped with a deliberate gap — when logs
|
||||
were active mid-turn or the final response exceeded 4096 chars, the Stop
|
||||
hook fell back to "Done." + a fresh message, orphaning the streamed bubble.
|
||||
@hooks threaded `streaming` through `_deliver_chunks`: logs-active now
|
||||
reconcile-edits the streamed message with the final formatted response, and
|
||||
multi-chunk edits chunk 1 in place then sends [2/N]+ as continuations.
|
||||
Batch mode is verified zero-change (regression tests for both paths), plus
|
||||
an edit-fail fallback. 6 new tests, 1077 green. Live streamed-turn proof
|
||||
pending Patrick's next streaming session — honestly flagged, not faked.
|
||||
|
||||
## [2026-07-14]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Prax TG relay gets the same offline backoff as the bots.** Found in
|
||||
Patrick's live plug-pull test: the bots went quiet correctly, but the
|
||||
monitor→Telegram relay kept logging `Send failed` every ~5 seconds (89 lines,
|
||||
no backoff) — and each failed-send error was re-ingested by the log watcher,
|
||||
feeding the relay more events to fail on. Now network-class send failures put
|
||||
the relay in offline mode: doubling backoff (1s→60s cap), flush gate skips
|
||||
sends while offline so the monitor loop never blocks and viewers keep
|
||||
rendering, log-once semantics (one enter line, one 5-minute summary, one
|
||||
recovery line with drop count), full reset on first successful send. 11 new
|
||||
tests; 1007 prax green.
|
||||
|
||||
- **TG bots no longer hot-spin when the internet drops.** Live find from
|
||||
Patrick's on-location tether outage: DNS failure makes `urlopen` fail
|
||||
instantly (no 30s long-poll wait), so the shared poll loop retried as fast as
|
||||
it could — up to 13 ERROR lines/second per bot, all 5 bots spinning for the
|
||||
whole offline window (rotation saved the disk; nothing saved the CPU, and the
|
||||
flood tripped the medic circuit breaker fleet-wide). Now network-class poll
|
||||
failures (DNS/connection/socket, classified via `_NetworkPollError`) back off
|
||||
exponentially 1s→60s cap and reset on the first successful poll, with
|
||||
log-once semantics: one "unreachable, backing off" line, one summary per 5
|
||||
minutes while offline, one recovery line with suppressed count. Routine
|
||||
long-poll read-timeouts (expected getUpdates behavior, ~863 medic-suppressed
|
||||
events/day) no longer log at all. Bots still self-recover the moment
|
||||
connectivity returns. 25 new tests; 822 TG + 252 skills green.
|
||||
|
||||
### Added
|
||||
|
||||
- **Runaway-log detection + escalation — designed and built by the agents
|
||||
themselves.** Patrick's mission brief went to @prax as lead ("I don't want it
|
||||
to be you" — devpulse relayed requirements, not a design): prax researched,
|
||||
collaborated with @hooks and @trigger by mail, wrote DPLAN-0242, and ran the
|
||||
build as TDPLAN-0013 across three branches. The system: @prax `rate_tracker`
|
||||
watches every log in `system_logs/` for volume (not content — orthogonal to
|
||||
medic), disk-persisted state, WARNING at >100 lines/min sustained 2 min /
|
||||
CRITICAL at >10 lines/sec 1 min, per-file suppression, runs as a 4th monitor
|
||||
thread, plus `drone @prax log-health` for an at-a-glance rate overview.
|
||||
@trigger registers the new `runaway_log_detected` event and dispatches to the
|
||||
responsible branch with a per-file 30-min cooldown deliberately independent
|
||||
of medic's circuit breaker (a storm can't silence both systems), UNKNOWN
|
||||
attribution falls back to @prax, and every alert is written to
|
||||
`.aipass/alerts.json`. @hooks `persistent_alert` injects an advisory banner
|
||||
into every agent's prompt until the alert is fixed or dismissed
|
||||
(`drone @hooks dismiss <id>`) — general-purpose, any agent can raise alerts.
|
||||
Devpulse verification found and fixed the last-mile gaps: both hooks pieces
|
||||
stopped at the first `.aipass/` dir walking up (every branch has one — the
|
||||
banner could never render), and hooks.json registration alone isn't
|
||||
deployment — the handler needed manual wiring into `~/.claude/settings.json`
|
||||
(agents can't edit it; documented for future handlers). Live-fire acceptance:
|
||||
a planted 240 lines/min storm was detected at 257 lines/min sustained 120s →
|
||||
event → dispatch → **@aipass woke autonomously, root-caused the test writer
|
||||
down to its PID and loop shape, triaged no-action** → alerts.json → banner
|
||||
renders → dismiss clears. ~77 new tests across four branches, suites green
|
||||
(prax 1028, trigger 619, hooks 1071), seedgo 98–100%.
|
||||
|
||||
- **Citizens wake each other freely — wake-back for everyone, devpulse
|
||||
unwakeable by design.** Patrick's ruling after two team-mission stalls in one
|
||||
evening (prax emailed sleeping collaborators; trigger replied instead of
|
||||
dispatching back — replies never wake, and wake-back was owner-gated so
|
||||
agent-to-agent dispatch never woke the sender). @ai_mail removed the
|
||||
owner-gate: any citizen sender is woken when its dispatched agent completes
|
||||
(proven live: "@trigger woken after @aipass completed" — first citizen
|
||||
wake-back ever). @devpulse is now structurally unwakeable via a
|
||||
`citizen_class: manager` check on every wake path — mail always lands, wake
|
||||
always skips, no longer dependent on an interactive session happening to be
|
||||
open. The gate removal exposed a self-wake loop within minutes (wake-back
|
||||
sessions were attributed to @ai_mail as sender, so ai_mail kept waking
|
||||
itself; the depth cap stopped it after one cycle) — fixed the same night:
|
||||
self-wake guard + wake-back sessions carry no sender, so chains terminate at
|
||||
the original dispatcher. 765 ai_mail tests green. The navmap gained a
|
||||
"Talking to other agents" section: dispatch vs email semantics, team-relay
|
||||
discipline, and the manager exception.
|
||||
|
||||
- **Medic is back on — and the loop is proven live.** Off since 2026-05-10 (a
|
||||
pytest fixture storm flooded the error registry; the off switch was pulled to
|
||||
stop the noise and forgotten for 65 days). Three fixes made re-enable safe:
|
||||
(1) @prax: pytest logging routes to a temp dir when `PYTEST_CURRENT_TEST` is
|
||||
set — test fixtures can never pollute production `logs/` again (the storm
|
||||
class that caused the shutdown); (2) @trigger: circuit breaker self-heals —
|
||||
open breakers half-open on read, close on a successful probe, cooldown decays
|
||||
to base (previously `half_open` was a terminal trap and only manual reset
|
||||
recovered); (3) @trigger: **TTL mutes** — `medic mute @branch` and `medic off`
|
||||
now auto-expire after 24h by default (`--for 48h/7d` custom, `--forever`
|
||||
explicit kill switch; temp `off` keeps detection running). Agents doing build
|
||||
work mute themselves and never have to remember to unmute — the permanent
|
||||
switch that got medic forgotten no longer exists. Breadcrumbs shipped: ai_mail
|
||||
footer + navmap tell every agent to mute before build work. Live-fire proof:
|
||||
a planted commons SQL bug was detected, dispatched, and fixed byte-identical
|
||||
by @commons in 105 seconds (15/15 tests green); a real TG poll error was
|
||||
correctly triaged NOT ACTIONABLE; organic instance-lock noise was correctly
|
||||
triaged LOW/expected. @skills/@api on 7-day mutes until the TG poll-level fix
|
||||
lands. 993 prax + 603 trigger tests green.
|
||||
|
||||
- **Prax monitor: concurrent viewers — laptop and Telegram mirror side by
|
||||
side.** Patrick's ruling after being locked out of his own monitor three
|
||||
times: *processes are not agents; display processes must never be
|
||||
single-instance.* The instance lock is gone from the display path — any
|
||||
number of `monitor run` viewers start and render concurrently. The lock is
|
||||
scoped to the one true single-writer responsibility: the Telegram relay
|
||||
(`relay.pid`, held by `prax-monitor.service`); extra instances run
|
||||
viewer-only, so no TG double-sends. The misleading "kill the existing
|
||||
process" error is dead. 998 prax tests green, 3 new concurrent-viewer tests;
|
||||
live-verified: interactive Mission Control rendering while the TG relay
|
||||
service runs untouched.
|
||||
|
||||
- **Telegram user-comment mirror: the TG chat now shows the whole conversation,
|
||||
whichever door you speak through.** Patrick's spec from the live cross-door
|
||||
drill: his own messages typed in the terminal or claude.ai remote never
|
||||
appeared in TG — only the replies did. New `user_message_relay` UserPromptSubmit
|
||||
handler (@skills-built, self-contained in the telegram skill, registered by
|
||||
@hooks as the last, crash-isolated entry) posts genuine user messages to the
|
||||
branch's TG chat with an origin tag, silently (`disable_notification`). Noise
|
||||
fences keep it human-only: system/task notifications, slash-command output,
|
||||
dispatch wake prompts, sub-agent prompts, TG-origin echoes, and consecutive
|
||||
dupes are all skipped (structural session-type detection was investigated and
|
||||
rejected — it's session-wide, would eat genuine mid-flight messages). Inbound
|
||||
hardening rides along: stale pending files cleaned before each write, and an
|
||||
undelivered-response overwrite now logs a warning instead of silently losing
|
||||
the reply. 47 new TG tests; registration execution-proven via engine.jsonl and
|
||||
the positive path live-verified — a terminal-door message delivered to the
|
||||
real TG chat. TG dormancy/proactive push deliberately untouched (design chat
|
||||
with Patrick pending).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **TG bot heartbeat race: delivered replies no longer flip back to
|
||||
"Processing…".** Patrick watched his answered bubble get overwritten live: a
|
||||
heartbeat thread stuck >5s in a slow Telegram edit call survived its stop
|
||||
(the join timed out), woke to a *shared* stop Event the next message had
|
||||
already cleared, and re-edited the old placeholder with "Processing…
|
||||
(elapsed)" over the delivered reply. Fixed structurally (@skills, devpulse
|
||||
root-cause brief): a generation counter captured per heartbeat thread —
|
||||
any stale thread breaks before every edit — plus a delivered re-check
|
||||
immediately before each edit call in both batch and streaming loops.
|
||||
Second bug in the same window: rapid-fire messages (photo + text in one
|
||||
turn) overwrite the bot's single pending slot, stranding the earlier
|
||||
placeholder frozen; superseded placeholders are now finalized to
|
||||
"⏭ Superseded by newer message" in both message and file paths. 6 new
|
||||
heartbeat tests; full TG suite 797 green (devpulse-verified). Deployment
|
||||
lesson from the same morning: bot fixes aren't live until the systemd
|
||||
units restart — commit ≠ deploy.
|
||||
|
||||
- **TG mirror live-test fixes: main-chat messages mirror, TG messages don't
|
||||
echo.** Patrick's first morning test caught what 47 green tests missed: the
|
||||
relay's sub-agent skip blocked ALL daemon-backed main chats (they run with
|
||||
`--agent claude`, so `agent_type="claude"` — and real sub-agents never fire
|
||||
UserPromptSubmit at all; the filter's premise was empirically wrong across the
|
||||
entire engine log). Skip is now agent_id-based (defensive, never observed).
|
||||
Second catch from tracing his test: TG messages inject into tmux as raw text —
|
||||
no `via Telegram:` marker — so the TG-origin filter never matched and every
|
||||
TG message would have echoed back once the first fix landed. New structural
|
||||
gate: the bot stores the injected prompt in its pending file; the relay skips
|
||||
a prompt that text-matches a fresh undelivered pending entry. Mirror proven
|
||||
live by Patrick across both directions ("success :)"). 791 TG tests green.
|
||||
|
||||
- **DPLAN-0241 round 4 (night shift): user flags survive every launch path, and
|
||||
every session is born with an honest name.** R6 — the bug behind Patrick's
|
||||
approve-everything chat: the boot menu suppressed its bypass defaults when the
|
||||
user passed `--permission-mode` himself, but only the fresh-launch path threaded
|
||||
the user's flags into the exec — resume, takeover, continue, and dead-window
|
||||
paths all launched flagless. `extra_args` now threads through ALL launch paths
|
||||
(headless `-p` included). R7 — auto-namer: every launch is stamped
|
||||
`--name <branch>-<short-session-id>` (flag live-verified on claude 2.1.209; a
|
||||
user-passed `-n/--name` wins), so made-up auto-names can no longer hide which
|
||||
chat is which. Plus four drill nits: new-over-all ABORTS if the daemon stop
|
||||
fails (one brain even in failure paths), close-all's failure hint no longer
|
||||
recommends the mechanism that just failed, `exit`/`q`/`quit` quietly leave every
|
||||
menu, session rows stay rich (PID, kind, name, age). Surgical-stop probe:
|
||||
`op:kill` exists in the daemon's Unix-socket control protocol (per-job bg stop,
|
||||
8-char sessionId prefix, no auth) — documented in DPLAN-0241, deliberately NOT
|
||||
shipped: undocumented internal protocol. 1048 hooks tests green (102
|
||||
session_boot, 11 real-binary CLI contract).
|
||||
|
||||
## [2026-07-13]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **DPLAN-0241 rounds 2-3: Enter IS the takeover — background chats reopen as
|
||||
normal terminal chats.** Live incident round two (Patrick's laptop, 23:00): the
|
||||
boot menu's resume for a background chat opened the `claude agents` viewer, which
|
||||
dispatched his typed message as a brand-new bg job WITHOUT bypass permissions —
|
||||
and the shipped stop path called `claude agents stop`, a subcommand that does not
|
||||
exist (987 mocked tests never noticed). All fixed by @hooks across two rounds,
|
||||
every CLI fact live-verified against claude 2.1.208: phantom stop removed
|
||||
(bg close is now honest — no per-job stop exists in the CLI; SIGTERM never used
|
||||
on bg, the daemon respawns it); Enter on a live bg session now takes the chat
|
||||
over — `claude daemon stop --any` (returncode-checked, blast-radius listing +
|
||||
y/N confirm when other branches' bg sessions would also stop) then `--resume
|
||||
<sessionId>` inside tmux with bypass; ALL interactive launches tmux-wrapped so a
|
||||
closed terminal is always recoverable; multi-session menu shows real session
|
||||
names, requires an explicit pick, and its new/close paths stop-first honestly;
|
||||
new real-binary CLI contract test tier (20 tests probing every claude
|
||||
flag/subcommand our code invokes — the phantom-subcommand class is now
|
||||
structurally unshippable). 1025 hooks tests green. North-star architecture
|
||||
recorded from Patrick's rulings: one conversation per branch; TG/claude.ai/
|
||||
terminal are views of it; agents bind to the machine, not the interface.
|
||||
|
||||
- **Session management overhaul (DPLAN-0241): one brain per branch, attach-first
|
||||
boot menu, honest session listings.** Born from a live incident — Patrick locked
|
||||
out of a running chat for an hour. Root causes, all fixed by @hooks: the bashrc
|
||||
boot shim hijacked EVERY `claude` invocation (so `claude agents`, the real
|
||||
attach path, never executed) — now intercepts only bare/`--permission-mode`
|
||||
launches; session_boot printed one PID from a list and advised `kill` for
|
||||
daemon-managed background sessions (which respawn — the unwinnable loop) — now
|
||||
a 3-option boot menu (resume / start-new-closes-old / close) with per-kind
|
||||
proper stops; presence_gate (single-session enforcement) had NEVER run in
|
||||
production (`provider_wired: false`, absent from settings.json, zero engine
|
||||
entries ever) and carried two latent bugs (self-PID resolver matched
|
||||
comm=="claude" but CC binaries are version-named; agent_type skip waved through
|
||||
daemon bg sessions) — both fixed, wired, shipped OBSERVE-ONLY for a soak period
|
||||
per prior-art recall (the gate false-blocked a real resume in the
|
||||
PRESENCE-file era); wire_verify no longer excludes unwired security hooks from
|
||||
its check (enabled-but-unwired = ERROR); new `drone @hooks sessions` +
|
||||
`sessions reclaim` one-command reset; session listings/names standardized to
|
||||
`PID · branch · short-id · kind · age`. Verified live: gate's first production
|
||||
run correctly logged a would-block for a real duplicate session without
|
||||
self-blocking. 987 hooks tests green (26 new/updated).
|
||||
|
||||
## [2026-07-12]
|
||||
|
||||
### Added
|
||||
|
||||
- **Telegram log-stream control: `/logs` on branch bots + interactive Prax
|
||||
Monitor chat.** The per-branch session LogStreamer auto-started on first
|
||||
message hardwired to full firehose with no off switch; the Prax Monitor
|
||||
relay chat was send-only — no command menu, and anything typed there was
|
||||
silently never read (nothing polled that token). @skills added `/logs
|
||||
on|errors|off|status` to all branch bots (preference persisted per chat,
|
||||
honored by the auto-start; 33 tests) and a new `PraxMonitorBot` receiver
|
||||
service (`telegram-bot@prax_monitor`) with `/pause /resume /errors /all
|
||||
/status` and a registered command menu (34 tests). @prax made the relay
|
||||
honor the shared control file (`~/.aipass/telegram_bots/
|
||||
prax_monitor_control.json`, frozen contract: paused + level) each 5s flush —
|
||||
paused discards, `errors` filters to WARNING/ERROR/CRITICAL (17 tests).
|
||||
Live-verified end-to-end from Telegram Web: `/errors` silenced INFO batches
|
||||
within one flush, `/all` restored them.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Legacy `builder` citizen_class migration + birth-certificate template
|
||||
(fixes #692).** `builder` was renamed to `aipass_framework` on 2026-07-01
|
||||
(13463c0c) as a pure rename, but passports minted pre-rename kept the retired
|
||||
name, and the seedgo Architecture checker requires
|
||||
`spawn/templates/<citizen_class>/` — hard-capping those citizens below 100%
|
||||
(Vera Studio's @vera/@writer stuck at 99%; same legacy class found in 6
|
||||
external projects). @spawn completed the rename instead of resurrecting a
|
||||
`builder` template: `sync-registry --fix` now migrates the exact value
|
||||
`builder` → `aipass_framework` in passports (idempotent, dry-run safe, 3 new
|
||||
tests), so external projects self-heal via `aipass doctor --fix`. Also fixed
|
||||
the template leftover that kept minting the retired name:
|
||||
`birth_certificate.json` now renders `{{CITIZEN_CLASS}}` like the passport
|
||||
does. Verified: dry-run against Vera Studio's live registry plans exactly the
|
||||
two migrations with zero writes; spawn 347 tests green. #695 closed won't-fix
|
||||
(armed Monitor-tool watchdog is the dispatch indicator; always-arm is the
|
||||
rule).
|
||||
- **Order-dependent `test_missing_file` + skills test litter (fixes #694).**
|
||||
Root cause was @prax's `json_handler_module` fixture popping EVERY branch's
|
||||
json_handler from `sys.modules` (never restored), orphaning the module object
|
||||
@skills' conftest had patched — `test_missing_file` then re-imported a fresh
|
||||
module pointed at the real `skills_json/`, planted `ghost_config.json`, and
|
||||
failed on it every later full-repo run (the only failure in an 11k-test
|
||||
sweep). @prax scoped the eviction to `aipass.prax.*` via
|
||||
`monkeypatch.delitem` (auto-restore). @skills made all 4 resilience tests
|
||||
hermetic (patch `SKILLS_JSON_DIR` → `tmp_path` inside the test body, immune
|
||||
to sys.modules state), fully-qualified the legacy bare `skills.`
|
||||
`BRANCH_MODULE` in 3 test files (the source of the remaining litter), and
|
||||
fixed a latent wrong-variable assert. Verified: original failing pair now
|
||||
passes both orders, prax+skills+spawn 1576 tests green, `skills_json/` stays
|
||||
clean after a full run.
|
||||
|
||||
## [2026-07-11]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,51 +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
|
||||
./aipass install # installs everything, then walks you into your first project
|
||||
```
|
||||
|
||||
One command does it all: builds the environment, puts `aipass` + `drone` on your PATH, then chains straight into a guided init that 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -82,31 +53,47 @@ aipass init agent my-agent # Full agent: apps, mail, memory, identity
|
||||
```bash
|
||||
git clone https://github.com/AIOSAI/AIPass.git
|
||||
cd AIPass
|
||||
./aipass install # Creates venv, installs, puts `aipass` + `drone` on your PATH, bootstraps 17 agents
|
||||
./aipass install
|
||||
```
|
||||
|
||||
On an interactive terminal, install ends by chaining into `aipass init run` — one command takes you from clone to a working first project. Pass `--no-init` to skip the chain, `--project <dir>` to pick where the project lands (CI and piped shells skip automatically). `./aipass` is a thin repo-root launcher over `setup.sh`; after setup it simply forwards to the installed `aipass` binary.
|
||||
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
|
||||
cd ~ && mkdir my-project && cd my-project
|
||||
aipass init run # Guided setup — project, first agent, terminal handoff
|
||||
aipass new my-project --template python # Project + resident manager agent + git birth commit
|
||||
```
|
||||
|
||||
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`.
|
||||
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, ends in the conversation
|
||||
```
|
||||
|
||||
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
|
||||
@@ -114,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 aipass # 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.
|
||||
 -->
|
||||
|
||||
---
|
||||
|
||||
@@ -165,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
|
||||
@@ -180,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>
|
||||
|
||||
@@ -203,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) |
|
||||
@@ -222,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 |
|
||||
|
||||
The installer (`./aipass install`, powered by setup.sh) auto-detects which CLIs are installed and configures hooks for each — merging with any hooks you've already wired, never overwriting them.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -242,32 +202,21 @@ The installer (`./aipass install`, powered by setup.sh) auto-detects which CLIs
|
||||
| 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>
|
||||
@@ -305,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
|
||||
|
||||
@@ -316,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).
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "aipass"
|
||||
version = "2.7.0"
|
||||
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"
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
git clone + ./setup.sh — https://github.com/AIOSAI/AIPass
|
||||
"""
|
||||
|
||||
__version__ = "2.7.0"
|
||||
__version__ = "2.7.3"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -86,28 +86,22 @@ 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.
|
||||
|
||||
Wake-back is owner-only: only the project owner (sealed registry)
|
||||
gets woken. Non-owners silently skipped.
|
||||
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_not_owner, failed
|
||||
skipped_sender, skipped_self, failed
|
||||
"""
|
||||
if not sender or not sender.strip():
|
||||
logger.info("[monitor] Wake-back skipped — no sender")
|
||||
return "skipped_sender"
|
||||
|
||||
normalized = f"@{sender.lstrip('@').lower()}"
|
||||
|
||||
try:
|
||||
from aipass.spawn.apps.handlers.registry import is_owner
|
||||
except ImportError:
|
||||
logger.warning("[monitor] Wake-back skipped — is_owner import failed")
|
||||
return "failed"
|
||||
|
||||
if not is_owner(normalized):
|
||||
logger.info("[monitor] Wake-back skipped — sender %s is not project owner", sender)
|
||||
return "skipped_not_owner"
|
||||
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:
|
||||
@@ -118,7 +112,7 @@ def _wake_sender(sender: str, branch_email: str, exit_code: int, lock_file: str)
|
||||
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="@ai_mail")
|
||||
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)
|
||||
|
||||
@@ -542,14 +542,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", "?")
|
||||
@@ -565,7 +578,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")
|
||||
@@ -574,7 +587,7 @@ 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)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ 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":
|
||||
@@ -213,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]:
|
||||
@@ -283,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
|
||||
|
||||
@@ -16,7 +16,9 @@ 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":
|
||||
@@ -36,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
|
||||
|
||||
|
||||
@@ -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 == ""
|
||||
|
||||
@@ -1840,15 +1840,7 @@ finally:
|
||||
|
||||
|
||||
class TestWakeSender:
|
||||
"""_wake_sender guards and owner-allowlist dispatch."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_is_owner(self, monkeypatch):
|
||||
"""Default: is_owner returns False (non-owner). Tests override as needed."""
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=False),
|
||||
)
|
||||
"""_wake_sender guards and wake-back dispatch."""
|
||||
|
||||
def test_skips_empty_sender(self, monkeypatch):
|
||||
"""Empty sender returns skipped_sender."""
|
||||
@@ -1862,44 +1854,22 @@ class TestWakeSender:
|
||||
result = _wake_sender(" ", "@target", 0, "/fake/lock")
|
||||
assert result == "skipped_sender"
|
||||
|
||||
def test_skips_non_owner_sender(self, monkeypatch):
|
||||
"""Non-owner sender returns skipped_not_owner."""
|
||||
def test_skips_self_wake(self, monkeypatch):
|
||||
"""Sender equal to completed agent returns skipped_self."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=False),
|
||||
)
|
||||
result = _wake_sender("@someagent", "@target", 0, "/fake/lock")
|
||||
assert result == "skipped_not_owner"
|
||||
result = _wake_sender("@trigger", "@trigger", 0, "/fake/lock")
|
||||
assert result == "skipped_self"
|
||||
|
||||
def test_skips_ai_mail_when_not_owner(self, monkeypatch):
|
||||
"""@ai_mail is not owner — skipped."""
|
||||
def test_skips_self_wake_case_insensitive(self, monkeypatch):
|
||||
"""Self-wake guard is case-insensitive."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=False),
|
||||
)
|
||||
result = _wake_sender("@ai_mail", "@target", 0, "/fake/lock")
|
||||
assert result == "skipped_not_owner"
|
||||
result = _wake_sender("Trigger", "@TRIGGER", 0, "/fake/lock")
|
||||
assert result == "skipped_self"
|
||||
|
||||
def test_skips_human_when_not_owner(self, monkeypatch):
|
||||
"""@human is not owner — skipped."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=False),
|
||||
)
|
||||
result = _wake_sender("@human", "@target", 0, "/fake/lock")
|
||||
assert result == "skipped_not_owner"
|
||||
|
||||
def test_owner_passes_guard(self, monkeypatch):
|
||||
"""Owner sender passes the is_owner guard and reaches wake_branch."""
|
||||
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)
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
mock_status = MagicMock()
|
||||
mock_status.summary = "ok"
|
||||
mock_wake = MagicMock(return_value=(mock_status, True))
|
||||
@@ -1907,67 +1877,42 @@ class TestWakeSender:
|
||||
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
|
||||
mock_wake,
|
||||
)
|
||||
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
_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_is_owner_called_with_normalized_sender(self, monkeypatch):
|
||||
"""is_owner receives normalized @-prefixed lowercase sender."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
mock_is_owner = MagicMock(return_value=False)
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
mock_is_owner,
|
||||
)
|
||||
_wake_sender("DevPulse", "@target", 0, "/fake/lock")
|
||||
mock_is_owner.assert_called_once_with("@devpulse")
|
||||
|
||||
def test_is_owner_import_failure(self, monkeypatch):
|
||||
"""ImportError from is_owner returns failed."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
import builtins
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fail_import(name, *args, **kwargs):
|
||||
if name == "aipass.spawn.apps.handlers.registry":
|
||||
raise ImportError("no spawn")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fail_import)
|
||||
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
assert result == "failed"
|
||||
|
||||
def test_depth_cap_blocks(self, monkeypatch):
|
||||
"""AIPASS_WAKE_DEPTH >= MAX_WAKE_DEPTH returns blocked_depth."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
monkeypatch.setenv("AIPASS_WAKE_DEPTH", str(MAX_WAKE_DEPTH))
|
||||
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
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.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
monkeypatch.setenv("AIPASS_WAKE_DEPTH", str(MAX_WAKE_DEPTH + 5))
|
||||
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
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)
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
mock_status = MagicMock()
|
||||
mock_status.summary = "ok"
|
||||
@@ -1977,18 +1922,14 @@ class TestWakeSender:
|
||||
mock_wake,
|
||||
)
|
||||
|
||||
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
result = _wake_sender("@trigger", "@target", 0, "/fake/lock")
|
||||
assert result == "success"
|
||||
mock_wake.assert_called_once_with("@devpulse", auto=True, sender="@ai_mail")
|
||||
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)
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
mock_status = MagicMock()
|
||||
mock_status.summary = "lock: Active agent (PID 1234)"
|
||||
@@ -1998,17 +1939,13 @@ class TestWakeSender:
|
||||
mock_wake,
|
||||
)
|
||||
|
||||
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
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)
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
mock_status = MagicMock()
|
||||
mock_status.summary = "blocked: Cannot spawn — interactive session running"
|
||||
@@ -2018,34 +1955,26 @@ class TestWakeSender:
|
||||
mock_wake,
|
||||
)
|
||||
|
||||
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
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.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
|
||||
MagicMock(side_effect=RuntimeError("broken")),
|
||||
)
|
||||
|
||||
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
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")
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
captured_depth = []
|
||||
|
||||
@@ -2060,17 +1989,13 @@ class TestWakeSender:
|
||||
capture_wake,
|
||||
)
|
||||
|
||||
_wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
_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)
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
mock_status = MagicMock()
|
||||
mock_status.summary = "ok"
|
||||
@@ -2080,24 +2005,10 @@ class TestWakeSender:
|
||||
mock_wake,
|
||||
)
|
||||
|
||||
result = _wake_sender("@devpulse", "@target", 1, "/fake/lock")
|
||||
result = _wake_sender("@prax", "@target", 1, "/fake/lock")
|
||||
assert result == "success"
|
||||
mock_wake.assert_called_once()
|
||||
|
||||
def test_sender_normalization_for_is_owner(self, monkeypatch):
|
||||
"""Sender with or without @ prefix is normalized before is_owner call."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
mock_is_owner = MagicMock(return_value=False)
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
mock_is_owner,
|
||||
)
|
||||
_wake_sender("devpulse", "@target", 0, "/fake/lock")
|
||||
_wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
assert mock_is_owner.call_count == 2
|
||||
for call in mock_is_owner.call_args_list:
|
||||
assert call[0][0] == "@devpulse"
|
||||
|
||||
|
||||
class TestLogWakeResult:
|
||||
"""_log_wake_result writes to dispatch_wake.log."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -985,6 +985,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)
|
||||
|
||||
@@ -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 (DPLAN-0136).** Don't PR / reveal this branch until Phase 8 — that's a *policy*, NOT a gitignore. Only the usual runtime/memory layer is ignored (`.trinity/`, plan files, `*.local`, logs) same as every branch; my code (init_flow, cross_os, tests, README) IS trackable. Committed-or-not = git's call, devpulse's lane.
|
||||
- **`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.
|
||||
|
||||
@@ -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",
|
||||
@@ -257,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",
|
||||
@@ -375,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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+35
-14
@@ -1,11 +1,22 @@
|
||||
# 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 init # Guided setup (10 stages, resumable)
|
||||
```
|
||||
|
||||
## Invoke
|
||||
|
||||
```
|
||||
drone @aipass <command>
|
||||
aipass <command> [options]
|
||||
aipass <command> --help
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -16,26 +27,30 @@ aipass/
|
||||
│ ├── aipass.py # Entry point — subcommand dispatch
|
||||
│ ├── modules/
|
||||
│ │ ├── doctor.py # System health aggregation + cross-OS pre-flight (--cross-os)
|
||||
│ │ ├── doctor_fix.py # Remediation report (--fix, --json)
|
||||
│ │ ├── doctor_wire.py # Auto-wire provider settings + stale-deny re-export
|
||||
│ │ ├── _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 # 10-stage guided setup
|
||||
│ │ ├── install.py # aipass install — one-command bootstrap (clone + setup + init)
|
||||
│ │ └── profile.py # User profile read/write
|
||||
│ │ ├── new_project.py # aipass new — create projects inside the installation
|
||||
│ │ ├── 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)
|
||||
│ │ ├── 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/ # 609 passing
|
||||
├── tests/ # 756 passing
|
||||
├── requirements.project.txt # Project-specific Python dependencies
|
||||
├── .trinity/ # Identity + session history + observations
|
||||
└── README.md
|
||||
@@ -45,17 +60,23 @@ 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 doctor --cross-os` | Cross-OS pre-flight (Layer-3-lite, machine) — OS-gap cross-ref + routing/versions/hookstatus |
|
||||
| `aipass doctor --cross-os --e2e` | ...also runs the real Layer-2 e2e wiring suite (heavy, opt-in) |
|
||||
| `aipass doctor --cross-os --record [PATH]` | Write a machine-filled Run Record for the human Layer-3 acceptance pass |
|
||||
| `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 (`--no-init`/`--with-init`/`--path`/`--here`) |
|
||||
| `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 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
|
||||
@@ -76,7 +97,7 @@ Humans only. Nothing in AIPass depends on this branch.
|
||||
|
||||
## Tests
|
||||
|
||||
609 passing — `pytest src/aipass/aipass/tests/`
|
||||
723 passing — `pytest src/aipass/aipass/tests/`
|
||||
|
||||
## Known Issues
|
||||
|
||||
@@ -84,4 +105,4 @@ Humans only. Nothing in AIPass depends on this branch.
|
||||
|
||||
## Last Updated
|
||||
|
||||
Last Updated: 2026-07-05
|
||||
Last Updated: 2026-07-17
|
||||
|
||||
@@ -35,8 +35,24 @@ 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 = {
|
||||
"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
|
||||
# =============================================================================
|
||||
@@ -72,6 +88,81 @@ def discover_modules() -> List[Any]:
|
||||
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]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 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.
|
||||
|
||||
@@ -105,17 +196,15 @@ def main():
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
logger.info("[AIPASS] Package metadata not found, version unknown")
|
||||
version = "unknown"
|
||||
print(f"aipass {version}")
|
||||
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:
|
||||
stem = module.__name__.split(".")[-1]
|
||||
name = getattr(module, "COMMAND", stem)
|
||||
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]
|
||||
@@ -126,31 +215,31 @@ def main():
|
||||
for module in modules:
|
||||
if module.handle_command(command, ["--help"]):
|
||||
return 0
|
||||
print(f"Unknown command: {command}")
|
||||
console.print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
try:
|
||||
if route_command(command, remaining, modules):
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error: '{command}' crashed: {e}")
|
||||
error(f"'{command}' crashed: {e}")
|
||||
logger.error(f"[AIPASS] '{command}' traceback", exc_info=True)
|
||||
return 1
|
||||
|
||||
if command.startswith("@"):
|
||||
print(f"{command} is a drone routing target, not an aipass command.")
|
||||
print("aipass is your front-door CLI; drone is the agent router — two separate tools.")
|
||||
print()
|
||||
print(f" Reach an agent: drone {command} ... · drone systems")
|
||||
print(" aipass commands: aipass --help")
|
||||
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("_", "")):
|
||||
print(f"Error: '{command}' failed to load: {err}")
|
||||
error(f"'{command}' failed to load: {err}")
|
||||
return 1
|
||||
|
||||
print(f"Unknown command: {command}")
|
||||
console.print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
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 (
|
||||
@@ -25,6 +26,7 @@ __all__ = [
|
||||
"global_prompt_md",
|
||||
"inbox_json",
|
||||
"init_project",
|
||||
"is_projects_child",
|
||||
"prep_md",
|
||||
"update_project",
|
||||
"with_source",
|
||||
|
||||
@@ -246,9 +246,46 @@ def _claude_settings(aipass_home: str | None = None) -> str:
|
||||
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def _guard_init(target: Path) -> None:
|
||||
def _enroll_project(target: Path) -> None:
|
||||
"""Enroll a project in the trusted-project registry (DPLAN-0244).
|
||||
|
||||
Lazy import to keep bootstrap.py 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
|
||||
|
||||
|
||||
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()
|
||||
@@ -270,6 +307,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."
|
||||
@@ -280,6 +319,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."
|
||||
@@ -288,12 +329,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
|
||||
@@ -303,7 +350,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)
|
||||
|
||||
@@ -362,6 +409,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)
|
||||
|
||||
@@ -573,6 +621,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:
|
||||
@@ -581,6 +630,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))
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
# =================== 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.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(
|
||||
"__pycache__/\n*.pyc\n.venv\n.trinity/\n.ai_mail.local/\n*.local.json\n*.local/\nlogs/\n.*_REGISTRY.lock\n",
|
||||
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.apps.handlers.init.bootstrap import (
|
||||
_claude_settings,
|
||||
_detect_aipass_home,
|
||||
_enroll_project,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.init import scaffold_content as sc
|
||||
|
||||
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
|
||||
claude_dir = target / ".claude"
|
||||
claude_dir.mkdir(exist_ok=True)
|
||||
(claude_dir / "settings.json").write_text(
|
||||
_claude_settings(aipass_home),
|
||||
encoding="utf-8",
|
||||
)
|
||||
created.append(".claude/settings.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
|
||||
+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
|
||||
+1
-1
@@ -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
|
||||
@@ -54,11 +54,11 @@ 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 as prompt_auto_wire,
|
||||
check_wire_verify,
|
||||
@@ -81,6 +81,8 @@ from aipass.aipass.apps.handlers.ui.progress import (
|
||||
make_doctor_progress,
|
||||
)
|
||||
|
||||
COMMAND = "doctor"
|
||||
|
||||
_BRANCH_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -28,7 +28,7 @@ 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:
|
||||
|
||||
@@ -96,7 +96,7 @@ TEMPLATE_EMPTY = "empty project"
|
||||
TEMPLATE_AIPASS = "aipass_framework"
|
||||
TEMPLATE_CHOICES = [TEMPLATE_EMPTY, TEMPLATE_AIPASS]
|
||||
# first_agent, ping_sweep, handoff, done — skipped for empty (non-framework) projects
|
||||
AIPASS_SPECIFIC_STAGES = {6, 7, 9, 10}
|
||||
AIPASS_SPECIFIC_STAGES = {6, 7}
|
||||
|
||||
|
||||
# --- LOCAL JSON HELPERS ---
|
||||
@@ -603,29 +603,35 @@ def stage_9_handoff(
|
||||
console.print()
|
||||
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")
|
||||
@@ -729,8 +735,12 @@ def stage_10_done(accumulated: Dict[str, Any] | None = None, dry_run: bool = Fal
|
||||
|
||||
|
||||
# --- 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():
|
||||
@@ -738,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
|
||||
@@ -774,6 +790,9 @@ def run_init(
|
||||
template: str | None = None,
|
||||
) -> int:
|
||||
"""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:
|
||||
@@ -815,6 +834,8 @@ 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)),
|
||||
@@ -844,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 {}
|
||||
@@ -857,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()
|
||||
success("Project initialized.")
|
||||
console.print("[dim]Run 'aipass init agent <name>' to add an agent.[/dim]")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -179,17 +179,21 @@ def _verify_binaries(home: Path) -> Dict[str, str | None]:
|
||||
return {"drone": drone, "aipass": aipass}
|
||||
|
||||
|
||||
def _should_run_init(non_interactive: bool, with_init: bool, no_init: bool) -> bool:
|
||||
"""Decide whether to auto-launch init. --no-init wins; --with-init forces on.
|
||||
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)
|
||||
|
||||
Default: interactive flows chain into init ("one command, done"); headless
|
||||
flows stop at a wired engine and print the next command (safe for CI/Docker).
|
||||
"""
|
||||
if no_init:
|
||||
return False
|
||||
if with_init:
|
||||
return True
|
||||
return not non_interactive
|
||||
|
||||
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(
|
||||
@@ -357,13 +361,26 @@ def run_install(
|
||||
# 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(non_interactive, with_init, no_init)
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -153,10 +153,10 @@ class TestMain:
|
||||
"""--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
|
||||
printed = mock_print.call_args[0][0]
|
||||
printed = mock_con.print.call_args[0][0]
|
||||
assert printed.startswith("aipass ")
|
||||
assert printed != "aipass 0.1.0"
|
||||
|
||||
@@ -164,10 +164,10 @@ class TestMain:
|
||||
"""-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
|
||||
printed = mock_print.call_args[0][0]
|
||||
printed = mock_con.print.call_args[0][0]
|
||||
assert printed.startswith("aipass ")
|
||||
|
||||
def test_version_flag_fallback(self) -> None:
|
||||
@@ -179,76 +179,81 @@ class TestMain:
|
||||
"aipass.aipass.apps.aipass.importlib.metadata.version",
|
||||
side_effect=_not_found,
|
||||
):
|
||||
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 unknown")
|
||||
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."""
|
||||
@@ -266,10 +271,10 @@ class TestMain:
|
||||
"""@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("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
result = main()
|
||||
assert result == 1
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
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
|
||||
@@ -278,10 +283,10 @@ class TestMain:
|
||||
"""@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("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
result = main()
|
||||
assert result == 1
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
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
|
||||
|
||||
@@ -289,10 +294,10 @@ class TestMain:
|
||||
"""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("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: frobnicate")
|
||||
mock_con.print.assert_called_with("Unknown command: frobnicate")
|
||||
|
||||
def test_command_with_remaining_args(self) -> None:
|
||||
"""Remaining args are passed to route_command."""
|
||||
@@ -306,7 +311,7 @@ class TestMain:
|
||||
mod.handle_command.assert_called_once_with("doctor", ["--verbose", "--fix"])
|
||||
|
||||
def test_help_shows_command_constant(self) -> None:
|
||||
"""Help listing uses module COMMAND constant, not file stem."""
|
||||
"""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]
|
||||
@@ -316,14 +321,14 @@ class TestMain:
|
||||
"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])
|
||||
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_help_falls_back_to_stem(self) -> None:
|
||||
"""Without COMMAND constant, help listing uses file stem."""
|
||||
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]
|
||||
@@ -332,10 +337,14 @@ class TestMain:
|
||||
"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 "doctor" in printed
|
||||
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'."""
|
||||
@@ -347,12 +356,12 @@ class TestMain:
|
||||
"aipass.aipass.apps.aipass.discover_modules",
|
||||
return_value=[mod],
|
||||
):
|
||||
with patch("builtins.print") as mock_print:
|
||||
result = main()
|
||||
with patch("aipass.aipass.apps.aipass.console"):
|
||||
with patch("aipass.aipass.apps.aipass.error") as mock_err:
|
||||
result = main()
|
||||
assert result == 1
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
assert "db connection failed" in printed
|
||||
assert "Unknown command" not in printed
|
||||
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."""
|
||||
@@ -365,11 +374,11 @@ class TestMain:
|
||||
):
|
||||
aipass_mod._import_failures.clear()
|
||||
aipass_mod._import_failures["broken"] = ImportError("no module")
|
||||
with patch("builtins.print") as mock_print:
|
||||
result = main()
|
||||
with patch("aipass.aipass.apps.aipass.console"):
|
||||
with patch("aipass.aipass.apps.aipass.error") as mock_err:
|
||||
result = main()
|
||||
assert result == 1
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
assert "failed to load" in printed
|
||||
assert "no module" in printed
|
||||
assert "Unknown command" not in printed
|
||||
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()
|
||||
|
||||
@@ -656,7 +656,7 @@ 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.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=False)
|
||||
@@ -664,7 +664,7 @@ class TestReconcileStaleDeny:
|
||||
|
||||
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)
|
||||
@@ -680,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)
|
||||
@@ -697,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)
|
||||
@@ -719,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)
|
||||
@@ -736,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)
|
||||
@@ -753,7 +753,7 @@ 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)
|
||||
@@ -765,7 +765,7 @@ class TestReconcileStaleDeny:
|
||||
|
||||
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)
|
||||
@@ -781,10 +781,10 @@ class TestCheckWireVerify:
|
||||
|
||||
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
|
||||
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):
|
||||
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"
|
||||
@@ -792,10 +792,10 @@ class TestCheckWireVerify:
|
||||
|
||||
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
|
||||
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):
|
||||
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]"
|
||||
@@ -803,10 +803,10 @@ class TestCheckWireVerify:
|
||||
|
||||
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
|
||||
from aipass.aipass.apps.modules._doctor_wire import check_wire_verify
|
||||
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.doctor_wire.subprocess.run",
|
||||
"aipass.aipass.apps.modules._doctor_wire.subprocess.run",
|
||||
side_effect=FileNotFoundError("drone"),
|
||||
):
|
||||
results = check_wire_verify()
|
||||
@@ -817,10 +817,10 @@ class TestCheckWireVerify:
|
||||
"""TimeoutExpired produces a WARN row."""
|
||||
import subprocess as sp
|
||||
|
||||
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
|
||||
from aipass.aipass.apps.modules._doctor_wire import check_wire_verify
|
||||
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.doctor_wire.subprocess.run",
|
||||
"aipass.aipass.apps.modules._doctor_wire.subprocess.run",
|
||||
side_effect=sp.TimeoutExpired(cmd="drone", timeout=10),
|
||||
):
|
||||
results = check_wire_verify()
|
||||
@@ -849,15 +849,15 @@ class TestPromptAutoWireIsatty:
|
||||
|
||||
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
|
||||
from aipass.aipass.apps.modules import _doctor_wire
|
||||
|
||||
with (
|
||||
patch.object(doctor_wire.sys, "stdin") as mock_stdin,
|
||||
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,
|
||||
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())
|
||||
result = _doctor_wire._prompt_auto_wire(**self._args())
|
||||
|
||||
assert result is False
|
||||
mock_input.assert_not_called()
|
||||
@@ -865,30 +865,30 @@ class TestPromptAutoWireIsatty:
|
||||
|
||||
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
|
||||
from aipass.aipass.apps.modules import _doctor_wire
|
||||
|
||||
with (
|
||||
patch.object(doctor_wire.sys, "stdin") as mock_stdin,
|
||||
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"),
|
||||
patch.object(_doctor_wire, "_print_manual_wire_warning"),
|
||||
):
|
||||
mock_stdin.isatty.return_value = True
|
||||
result = doctor_wire._prompt_auto_wire(**self._args())
|
||||
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
|
||||
from aipass.aipass.apps.modules import _doctor_wire
|
||||
|
||||
with (
|
||||
patch.object(doctor_wire.sys, "stdin") as mock_stdin,
|
||||
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,
|
||||
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())
|
||||
result = _doctor_wire._prompt_auto_wire(**self._args())
|
||||
|
||||
assert result is True
|
||||
mock_wire.assert_called_once()
|
||||
|
||||
@@ -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()
|
||||
@@ -586,6 +586,17 @@ class TestStages:
|
||||
result = stage_9_handoff(agent_path="src/mybot", non_interactive=True)
|
||||
assert "src/mybot" in result["handoff_command"]
|
||||
|
||||
def test_stage_9_non_interactive_no_spawn(self, tmp_local_json) -> None:
|
||||
"""Non-interactive stage 9 prints the command but never spawns a session."""
|
||||
with (
|
||||
patch(f"{_MOD}.console"),
|
||||
patch("aipass.aipass.apps.modules.handoff.do_handoff") as mock_handoff,
|
||||
):
|
||||
result = stage_9_handoff(non_interactive=True)
|
||||
mock_handoff.assert_not_called()
|
||||
assert result["launched"] is False
|
||||
assert result["handoff_command"]
|
||||
|
||||
def test_stage_10_done_returns_empty(self, tmp_local_json) -> None:
|
||||
"""stage_10_done returns {} and marks stage 10 complete."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
@@ -785,8 +796,8 @@ class TestTemplateSelector:
|
||||
]
|
||||
return {name: MagicMock(return_value={}) for name in stage_names}
|
||||
|
||||
def test_empty_project_default_skips_scaffold(self, tmp_local_json) -> None:
|
||||
"""empty project (default) = no scaffold; framework-only stages 6,7,9,10 skipped."""
|
||||
def test_empty_project_default_skips_agent_and_ping(self, tmp_local_json) -> None:
|
||||
"""empty project (default) = no scaffold; framework-only stages 6,7 skipped; 9,10 run."""
|
||||
mocks = self._stage_patches()
|
||||
with patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks):
|
||||
result = run_init(non_interactive=True, template=TEMPLATE_EMPTY)
|
||||
@@ -798,9 +809,11 @@ class TestTemplateSelector:
|
||||
"stage_4_style_questions",
|
||||
"stage_5_tool_choice",
|
||||
"stage_8_smoke_test",
|
||||
"stage_9_handoff",
|
||||
"stage_10_done",
|
||||
):
|
||||
assert mocks[name].called, f"{name} should have been called"
|
||||
for name in ("stage_6_first_agent", "stage_7_ping_sweep", "stage_9_handoff", "stage_10_done"):
|
||||
for name in ("stage_6_first_agent", "stage_7_ping_sweep"):
|
||||
assert not mocks[name].called, f"{name} should NOT have been called"
|
||||
|
||||
def test_aipass_framework_runs_full_scaffold(self, tmp_local_json) -> None:
|
||||
@@ -862,6 +875,27 @@ class TestTemplateSelector:
|
||||
assert "setup.sh" in msg
|
||||
assert "pip" not in msg
|
||||
|
||||
def test_empty_template_stage9_gets_cwd_as_agent_path(self, tmp_local_json) -> None:
|
||||
"""Empty template sets agent_path='.' so stage 9 hands off from CWD."""
|
||||
mocks = self._stage_patches()
|
||||
with patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks):
|
||||
run_init(non_interactive=True, template=TEMPLATE_EMPTY)
|
||||
stage_9_call = mocks["stage_9_handoff"].call_args
|
||||
assert stage_9_call is not None
|
||||
agent_path_arg = stage_9_call[0][2] if len(stage_9_call[0]) > 2 else stage_9_call[1].get("agent_path", "")
|
||||
assert agent_path_arg == "."
|
||||
|
||||
def test_non_tty_forces_non_interactive(self, tmp_local_json) -> None:
|
||||
"""When stdin is not a TTY, run_init auto-forces non_interactive (no crash)."""
|
||||
mocks = self._stage_patches()
|
||||
with (
|
||||
patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks),
|
||||
patch("sys.stdin") as mock_stdin,
|
||||
):
|
||||
mock_stdin.isatty.return_value = False
|
||||
rc = run_init(non_interactive=False, template=TEMPLATE_EMPTY)
|
||||
assert rc == 0
|
||||
|
||||
def test_aipass_specific_stages_constant(self) -> None:
|
||||
"""AIPASS_SPECIFIC_STAGES contains exactly {6, 7, 9, 10}."""
|
||||
assert AIPASS_SPECIFIC_STAGES == {6, 7, 9, 10}
|
||||
"""AIPASS_SPECIFIC_STAGES contains exactly {6, 7} — stages 9/10 run for ALL templates."""
|
||||
assert AIPASS_SPECIFIC_STAGES == {6, 7}
|
||||
|
||||
@@ -17,6 +17,7 @@ from aipass.aipass.apps.modules.install import (
|
||||
DEFAULT_HOME,
|
||||
DEFAULT_PROJECT,
|
||||
TOTAL_STEPS,
|
||||
_build_install_prompt,
|
||||
_clone_repo,
|
||||
_handoff_to_init,
|
||||
_looks_like_aipass_tree,
|
||||
@@ -215,21 +216,13 @@ class TestRunInstall:
|
||||
class TestShouldRunInit:
|
||||
"""Deciding whether the install chains into init."""
|
||||
|
||||
def test_no_init_wins(self) -> None:
|
||||
"""--no-init disables the handoff even alongside --with-init."""
|
||||
assert _should_run_init(non_interactive=False, with_init=True, no_init=True) is False
|
||||
def test_no_init_skips(self) -> None:
|
||||
"""--no-init disables the handoff."""
|
||||
assert _should_run_init(no_init=True) is False
|
||||
|
||||
def test_with_init_forces_headless(self) -> None:
|
||||
"""--with-init runs init even when the install was headless."""
|
||||
assert _should_run_init(non_interactive=True, with_init=True, no_init=False) is True
|
||||
|
||||
def test_headless_defaults_off(self) -> None:
|
||||
"""A plain headless install stops before init."""
|
||||
assert _should_run_init(non_interactive=True, with_init=False, no_init=False) is False
|
||||
|
||||
def test_interactive_defaults_on(self) -> None:
|
||||
"""A plain interactive install chains into init."""
|
||||
assert _should_run_init(non_interactive=False, with_init=False, no_init=False) is True
|
||||
def test_default_chains(self) -> None:
|
||||
"""Default: always chain into init."""
|
||||
assert _should_run_init(no_init=False) is True
|
||||
|
||||
|
||||
class TestResolveProjectDir:
|
||||
@@ -327,6 +320,75 @@ class TestHandleCommand:
|
||||
assert kwargs["project"] == "/x/proj"
|
||||
|
||||
|
||||
class TestBuildInstallPrompt:
|
||||
"""Authored first prompt for the post-install @aipass chat."""
|
||||
|
||||
def test_includes_home(self, tmp_path: Path) -> None:
|
||||
"""Prompt mentions the install home directory."""
|
||||
prompt = _build_install_prompt(tmp_path, {"drone": "/x/drone", "aipass": "/x/aipass"})
|
||||
assert str(tmp_path) in prompt
|
||||
|
||||
def test_includes_verified_bins(self) -> None:
|
||||
"""Verified binary paths appear in the prompt."""
|
||||
prompt = _build_install_prompt(Path("/h"), {"drone": "/x/drone", "aipass": "/x/aipass"})
|
||||
assert "/x/drone" in prompt
|
||||
assert "/x/aipass" in prompt
|
||||
|
||||
def test_omits_none_bins(self) -> None:
|
||||
"""Binaries that weren't found are omitted, not shown as None."""
|
||||
prompt = _build_install_prompt(Path("/h"), {"drone": None, "aipass": "/x/aipass"})
|
||||
assert "None" not in prompt
|
||||
assert "/x/aipass" in prompt
|
||||
|
||||
def test_ends_with_question(self) -> None:
|
||||
"""Prompt ends by asking what to explore."""
|
||||
prompt = _build_install_prompt(Path("/h"), {})
|
||||
assert "?" in prompt
|
||||
|
||||
|
||||
class TestInstallChatHandoff:
|
||||
"""Install-to-chat handoff launches @aipass after init on TTY."""
|
||||
|
||||
def test_tty_launches_inline(self) -> None:
|
||||
"""Interactive TTY install launches the @aipass concierge after init."""
|
||||
home = Path("/fake/AIPass")
|
||||
with (
|
||||
patch(f"{_MOD}._resolve_home", return_value=home),
|
||||
patch(f"{_MOD}.is_throwaway_path", return_value=False),
|
||||
patch(f"{_MOD}._clone_repo", return_value=True),
|
||||
patch(f"{_MOD}._run_setup", return_value=True),
|
||||
patch(f"{_MOD}._verify_binaries", return_value={"drone": "/x/drone", "aipass": "/x/aipass"}),
|
||||
patch(f"{_MOD}._check_and_fix_owner"),
|
||||
patch(f"{_MOD}._handoff_to_init"),
|
||||
patch(f"{_MOD}.sys.stdin") as mock_stdin,
|
||||
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||
):
|
||||
mock_stdin.isatty.return_value = True
|
||||
run_install(non_interactive=False, dry_run=False)
|
||||
mock_launch.assert_called_once()
|
||||
prompt_arg = mock_launch.call_args[0][1]
|
||||
assert "Fresh AIPass install" in prompt_arg
|
||||
|
||||
def test_no_tty_skips_launch(self) -> None:
|
||||
"""Non-TTY install skips the chat handoff."""
|
||||
home = Path("/fake/AIPass")
|
||||
with (
|
||||
patch(f"{_MOD}._resolve_home", return_value=home),
|
||||
patch(f"{_MOD}.is_throwaway_path", return_value=False),
|
||||
patch(f"{_MOD}._clone_repo", return_value=True),
|
||||
patch(f"{_MOD}._run_setup", return_value=True),
|
||||
patch(f"{_MOD}._verify_binaries", return_value={"drone": "/x/drone", "aipass": "/x/aipass"}),
|
||||
patch(f"{_MOD}._check_and_fix_owner"),
|
||||
patch(f"{_MOD}._handoff_to_init"),
|
||||
patch(f"{_MOD}.sys.stdin") as mock_stdin,
|
||||
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||
):
|
||||
mock_stdin.isatty.return_value = False
|
||||
rc = run_install(non_interactive=True, dry_run=False)
|
||||
mock_launch.assert_not_called()
|
||||
assert rc == 0
|
||||
|
||||
|
||||
class TestSmoke:
|
||||
"""Help/introspection render and constants hold."""
|
||||
|
||||
|
||||
@@ -330,7 +330,7 @@ class TestReturnTypeContracts:
|
||||
|
||||
def test_doctor_wire_handle_command_returns_bool(self):
|
||||
"""Doctor wire handle_command returns True for match, False otherwise."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import handle_command as wire_cmd
|
||||
from aipass.aipass.apps.modules._doctor_wire import handle_command as wire_cmd
|
||||
|
||||
assert wire_cmd("doctor_wire", []) is True
|
||||
assert wire_cmd("not_wire", []) is False
|
||||
|
||||
@@ -0,0 +1,738 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_new_project.py
|
||||
# Description: Tests for aipass new — project creation handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-17
|
||||
# Modified: 2026-07-17
|
||||
# =============================================
|
||||
|
||||
"""Tests for the new_project handler and module.
|
||||
|
||||
All file operations use tmp_path to stay fully isolated from the live
|
||||
filesystem. Tests mock subprocess calls to avoid real git/drone invocations.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest # pyright: ignore[reportMissingImports]
|
||||
|
||||
from aipass.aipass.apps.handlers.new_project import (
|
||||
_agent_home,
|
||||
_registry_name,
|
||||
_spawn_project_agent,
|
||||
_validate_name,
|
||||
_write_registry,
|
||||
_write_template,
|
||||
create_project,
|
||||
find_host_root,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_host_root
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_find_host_root_finds_registry(tmp_path):
|
||||
"""Finds directory containing *_REGISTRY.json."""
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||
sub = tmp_path / "projects" / "myapp"
|
||||
sub.mkdir(parents=True)
|
||||
assert find_host_root(sub) == tmp_path
|
||||
|
||||
|
||||
def test_find_host_root_returns_none_without_registry(tmp_path):
|
||||
"""Returns None when no registry exists above start."""
|
||||
assert find_host_root(tmp_path) is None
|
||||
|
||||
|
||||
def test_find_host_root_finds_closest_registry(tmp_path):
|
||||
"""Walks up and finds the closest *_REGISTRY.json."""
|
||||
(tmp_path / "HOST_REGISTRY.json").write_text("{}")
|
||||
sub = tmp_path / "a" / "b"
|
||||
sub.mkdir(parents=True)
|
||||
assert find_host_root(sub) == tmp_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validate_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_name_accepts_valid():
|
||||
assert _validate_name("myapp") == "myapp"
|
||||
assert _validate_name("My-App_2") == "My-App_2"
|
||||
|
||||
|
||||
def test_validate_name_rejects_empty():
|
||||
with pytest.raises(ValueError, match="cannot be empty"):
|
||||
_validate_name("")
|
||||
|
||||
|
||||
def test_validate_name_rejects_leading_digit():
|
||||
with pytest.raises(ValueError, match="Must start with a letter"):
|
||||
_validate_name("2fast")
|
||||
|
||||
|
||||
def test_validate_name_rejects_special_chars():
|
||||
with pytest.raises(ValueError, match="Must start with a letter"):
|
||||
_validate_name("my app!")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _registry_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registry_name_uppercases():
|
||||
assert _registry_name("myapp") == "MYAPP"
|
||||
|
||||
|
||||
def test_registry_name_replaces_special():
|
||||
assert _registry_name("my.app") == "MY_APP"
|
||||
|
||||
|
||||
def test_registry_name_preserves_hyphens():
|
||||
assert _registry_name("my-app") == "MY-APP"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _write_registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_write_registry_creates_file(tmp_path):
|
||||
rid, fname = _write_registry(tmp_path, "demo")
|
||||
path = tmp_path / fname
|
||||
assert path.exists()
|
||||
data = json.loads(path.read_text())
|
||||
assert data["metadata"]["id"] == rid
|
||||
assert data["metadata"]["name"] == "DEMO"
|
||||
assert fname == "DEMO_REGISTRY.json"
|
||||
assert data["branches"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _write_template — empty
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_write_template_empty(tmp_path):
|
||||
created = _write_template(tmp_path, "demo", "empty")
|
||||
assert "README.md" in created
|
||||
assert ".gitignore" in created
|
||||
assert (tmp_path / "README.md").exists()
|
||||
assert (tmp_path / ".gitignore").exists()
|
||||
assert not (tmp_path / "pyproject.toml").exists()
|
||||
assert not (tmp_path / "src").exists()
|
||||
gitignore = (tmp_path / ".gitignore").read_text()
|
||||
assert ".venv\n" in gitignore
|
||||
assert ".venv/\n" not in gitignore
|
||||
assert "*_REGISTRY.lock" in gitignore
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _write_template — python
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_write_template_python(tmp_path):
|
||||
created = _write_template(tmp_path, "demo", "python")
|
||||
assert "pyproject.toml" in created
|
||||
assert "src/demo/__init__.py" in created
|
||||
assert (tmp_path / "pyproject.toml").exists()
|
||||
assert (tmp_path / "src" / "demo" / "__init__.py").exists()
|
||||
pyproject = (tmp_path / "pyproject.toml").read_text()
|
||||
assert 'name = "demo"' in pyproject
|
||||
|
||||
|
||||
def test_write_template_python_hyphen_name(tmp_path):
|
||||
_write_template(tmp_path, "my-app", "python")
|
||||
assert (tmp_path / "src" / "my_app" / "__init__.py").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_project — integration (mocked subprocess)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def host_env(tmp_path):
|
||||
"""Set up a minimal AIPass host installation in tmp_path."""
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text(json.dumps({"metadata": {"id": "host-id"}, "branches": []}))
|
||||
(tmp_path / "projects").mkdir()
|
||||
(tmp_path / ".aipass").mkdir()
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _mock_git_run(args, **kwargs):
|
||||
"""Stub subprocess.run for git commands — always succeeds."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
result.stdout = ""
|
||||
result.stderr = ""
|
||||
return result
|
||||
|
||||
|
||||
def test_create_project_empty_template(host_env, monkeypatch):
|
||||
monkeypatch.chdir(host_env)
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._enroll_project",
|
||||
),
|
||||
):
|
||||
result = create_project("testproj", template="empty", no_agent=True)
|
||||
|
||||
target = Path(result["target"])
|
||||
assert target.exists()
|
||||
assert result["name"] == "testproj"
|
||||
assert result["template"] == "empty"
|
||||
assert result["registry_file"] == "TESTPROJ_REGISTRY.json"
|
||||
assert (target / "TESTPROJ_REGISTRY.json").exists()
|
||||
assert (target / "README.md").exists()
|
||||
assert (target / ".gitignore").exists()
|
||||
assert not (target / "pyproject.toml").exists()
|
||||
|
||||
|
||||
def test_create_project_python_template(host_env, monkeypatch):
|
||||
monkeypatch.chdir(host_env)
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._enroll_project",
|
||||
),
|
||||
):
|
||||
result = create_project("pyapp", template="python", no_agent=True)
|
||||
|
||||
target = Path(result["target"])
|
||||
assert (target / "pyproject.toml").exists()
|
||||
assert (target / "src" / "pyapp" / "__init__.py").exists()
|
||||
|
||||
|
||||
def test_create_project_rejects_existing(host_env, monkeypatch):
|
||||
monkeypatch.chdir(host_env)
|
||||
(host_env / "projects" / "taken").mkdir()
|
||||
with pytest.raises(RuntimeError, match="already exists"):
|
||||
create_project("taken", no_agent=True)
|
||||
|
||||
|
||||
def test_create_project_rejects_invalid_name(host_env, monkeypatch):
|
||||
monkeypatch.chdir(host_env)
|
||||
with pytest.raises(ValueError, match="Must start with a letter"):
|
||||
create_project("123bad", no_agent=True)
|
||||
|
||||
|
||||
def test_create_project_rejects_bad_template(host_env, monkeypatch):
|
||||
monkeypatch.chdir(host_env)
|
||||
with pytest.raises(ValueError, match="Unknown template"):
|
||||
create_project("foo", template="rust", no_agent=True)
|
||||
|
||||
|
||||
def test_create_project_no_host(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with pytest.raises(RuntimeError, match="Not inside an AIPass"):
|
||||
create_project("foo", no_agent=True)
|
||||
|
||||
|
||||
def test_create_project_cleans_up_on_failure(host_env, monkeypatch):
|
||||
monkeypatch.chdir(host_env)
|
||||
|
||||
def _fail_git(args, **kwargs):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
result = MagicMock()
|
||||
result.returncode = 1
|
||||
result.stderr = "simulated failure"
|
||||
return result
|
||||
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_fail_git),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||
pytest.raises(RuntimeError, match="simulated failure"),
|
||||
):
|
||||
create_project("failproj", no_agent=True)
|
||||
|
||||
assert not (host_env / "projects" / "failproj").exists()
|
||||
|
||||
|
||||
def test_create_project_registry_before_scaffold(host_env, monkeypatch):
|
||||
"""Registry file must exist before scaffold runs (order invariant)."""
|
||||
monkeypatch.chdir(host_env)
|
||||
creation_order = []
|
||||
|
||||
original_write_registry = _write_registry
|
||||
original_write_template = _write_template
|
||||
|
||||
def track_registry(target, name):
|
||||
creation_order.append("registry")
|
||||
return original_write_registry(target, name)
|
||||
|
||||
def track_template(target, name, template):
|
||||
creation_order.append("template")
|
||||
return original_write_template(target, name, template)
|
||||
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.new_project._write_registry",
|
||||
side_effect=track_registry,
|
||||
),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.new_project._write_template",
|
||||
side_effect=track_template,
|
||||
),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||
):
|
||||
create_project("ordertest", no_agent=True)
|
||||
|
||||
assert creation_order.index("registry") < creation_order.index("template")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _spawn_project_agent (delegates to spawn_agent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SPAWN_SUCCESS = {
|
||||
"success": True,
|
||||
"branch_name": "DEMO",
|
||||
"path": "/tmp/demo",
|
||||
"files_copied": 12,
|
||||
"registry_updated": True,
|
||||
"validation_issues": [],
|
||||
}
|
||||
|
||||
|
||||
def test_agent_home_simple():
|
||||
"""Agent home is src/<pkg>/<pkg>/."""
|
||||
from pathlib import Path
|
||||
|
||||
home = _agent_home(Path("/proj"), "demo")
|
||||
assert home == Path("/proj/src/demo/demo")
|
||||
|
||||
|
||||
def test_agent_home_hyphenated():
|
||||
"""Hyphens normalized to underscores, matching python template."""
|
||||
from pathlib import Path
|
||||
|
||||
home = _agent_home(Path("/proj"), "my-app")
|
||||
assert home == Path("/proj/src/my_app/my_app")
|
||||
|
||||
|
||||
def test_spawn_project_agent_calls_spawn(tmp_path):
|
||||
"""Calls spawn_agent with correct citizen_class, purpose, and agent_home path."""
|
||||
with patch(
|
||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||
return_value=_SPAWN_SUCCESS,
|
||||
) as mock_spawn:
|
||||
result = _spawn_project_agent(tmp_path, "demo")
|
||||
expected_home = str(tmp_path / "src" / "demo" / "demo")
|
||||
mock_spawn.assert_called_once_with(
|
||||
target_path=expected_home,
|
||||
role="project_agent",
|
||||
purpose="Resident agent of the demo project.",
|
||||
citizen_class="project_agent",
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["branch_name"] == "DEMO"
|
||||
|
||||
|
||||
def test_spawn_project_agent_raises_on_failure(tmp_path):
|
||||
"""Raises RuntimeError when spawn_agent returns success=False."""
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||
return_value={"success": False, "error": "template missing"},
|
||||
),
|
||||
pytest.raises(RuntimeError, match="spawn_agent failed.*template missing"),
|
||||
):
|
||||
_spawn_project_agent(tmp_path, "broken")
|
||||
|
||||
|
||||
def test_spawn_project_agent_returns_spawn_result(tmp_path):
|
||||
"""Returns the full result dict from spawn_agent."""
|
||||
with patch(
|
||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||
return_value={**_SPAWN_SUCCESS, "citizen_number": 1},
|
||||
):
|
||||
result = _spawn_project_agent(tmp_path, "demo")
|
||||
assert result["files_copied"] == 12
|
||||
assert result["citizen_number"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_project — WITH agent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_project_with_agent(host_env, monkeypatch):
|
||||
"""WITH-agent path: spawn_agent called, result propagated."""
|
||||
monkeypatch.chdir(host_env)
|
||||
spawn_ok = {
|
||||
"success": True,
|
||||
"branch_name": "WITHAGENT",
|
||||
"path": str(host_env / "projects" / "withagent"),
|
||||
"files_copied": 15,
|
||||
"registry_updated": True,
|
||||
"validation_issues": [],
|
||||
}
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||
return_value=spawn_ok,
|
||||
) as mock_spawn,
|
||||
):
|
||||
result = create_project("withagent", template="empty", no_agent=False)
|
||||
|
||||
assert result["agent_created"] is True
|
||||
assert result["spawn_result"] == spawn_ok
|
||||
expected_home = str(host_env / "projects" / "withagent" / "src" / "withagent" / "withagent")
|
||||
assert result["agent_home"] == expected_home
|
||||
mock_spawn.assert_called_once()
|
||||
call_kwargs = mock_spawn.call_args[1]
|
||||
assert call_kwargs["target_path"] == expected_home
|
||||
assert call_kwargs["citizen_class"] == "project_agent"
|
||||
|
||||
|
||||
def test_create_project_spawn_failure_cleans_up(host_env, monkeypatch):
|
||||
"""spawn_agent failure triggers cleanup — no partial project left."""
|
||||
monkeypatch.chdir(host_env)
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||
return_value={"success": False, "error": "template missing"},
|
||||
),
|
||||
pytest.raises(RuntimeError, match="spawn_agent failed"),
|
||||
):
|
||||
create_project("failspawn", template="empty", no_agent=False)
|
||||
|
||||
assert not (host_env / "projects" / "failspawn").exists()
|
||||
|
||||
|
||||
def test_create_project_no_agent_next_steps(host_env, monkeypatch):
|
||||
"""no_agent output omits 'meet your project agent' line."""
|
||||
from aipass.aipass.apps.modules.new_project import handle_command
|
||||
|
||||
monkeypatch.chdir(host_env)
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||
patch("aipass.aipass.apps.modules.new_project.console") as mock_con,
|
||||
):
|
||||
handle_command("new", ["cosmtest", "--template", "empty", "--no-agent"])
|
||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||
assert "meet your project agent" not in printed
|
||||
|
||||
|
||||
def test_create_project_no_agent_flag(host_env, monkeypatch):
|
||||
"""no_agent=True skips passport and registry seating."""
|
||||
monkeypatch.chdir(host_env)
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||
):
|
||||
result = create_project("noagent", template="empty", no_agent=True)
|
||||
|
||||
assert result["agent_created"] is False
|
||||
assert result["agent_home"] is None
|
||||
target = Path(result["target"])
|
||||
assert not (target / "src" / "noagent" / "noagent").exists()
|
||||
reg = json.loads((target / result["registry_file"]).read_text())
|
||||
assert reg["metadata"]["total_branches"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_projects_child (guard relaxation)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_projects_child_valid(tmp_path):
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import is_projects_child
|
||||
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||
target = tmp_path / "projects" / "myapp"
|
||||
target.mkdir(parents=True)
|
||||
assert is_projects_child(target) is True
|
||||
|
||||
|
||||
def test_is_projects_child_not_in_projects(tmp_path):
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import is_projects_child
|
||||
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||
target = tmp_path / "elsewhere" / "myapp"
|
||||
target.mkdir(parents=True)
|
||||
assert is_projects_child(target) is False
|
||||
|
||||
|
||||
def test_is_projects_child_no_host_registry(tmp_path):
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import is_projects_child
|
||||
|
||||
target = tmp_path / "projects" / "myapp"
|
||||
target.mkdir(parents=True)
|
||||
assert is_projects_child(target) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _guard_init relaxation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_guard_init_blocks_nested_by_default(tmp_path):
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import _guard_init
|
||||
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||
target = tmp_path / "projects" / "nested"
|
||||
target.mkdir(parents=True)
|
||||
with pytest.raises(RuntimeError, match="inside AIPass project"):
|
||||
_guard_init(target)
|
||||
|
||||
|
||||
def test_guard_init_allows_nested_with_flag(tmp_path):
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import _guard_init
|
||||
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||
target = tmp_path / "projects" / "nested"
|
||||
target.mkdir(parents=True)
|
||||
_guard_init(target, allow_projects_child=True)
|
||||
|
||||
|
||||
def test_guard_init_still_blocks_non_projects_nested(tmp_path):
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import _guard_init
|
||||
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||
target = tmp_path / "elsewhere" / "nested"
|
||||
target.mkdir(parents=True)
|
||||
with pytest.raises(RuntimeError, match="inside AIPass project"):
|
||||
_guard_init(target, allow_projects_child=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module handle_command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_module_handles_new_command():
|
||||
from aipass.aipass.apps.modules.new_project import handle_command
|
||||
|
||||
assert handle_command("notmine", []) is False
|
||||
|
||||
|
||||
def test_module_handles_help():
|
||||
from aipass.aipass.apps.modules.new_project import handle_command
|
||||
|
||||
assert handle_command("new", ["--help"]) is True
|
||||
|
||||
|
||||
def test_module_handles_no_args():
|
||||
from aipass.aipass.apps.modules.new_project import handle_command
|
||||
|
||||
assert handle_command("new", []) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive prompts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prompt_template_default():
|
||||
from aipass.aipass.apps.modules.new_project import _prompt_template
|
||||
|
||||
with patch("builtins.input", return_value=""):
|
||||
assert _prompt_template(["empty", "python"]) == "empty"
|
||||
|
||||
|
||||
def test_prompt_template_by_number():
|
||||
from aipass.aipass.apps.modules.new_project import _prompt_template
|
||||
|
||||
with patch("builtins.input", return_value="2"):
|
||||
assert _prompt_template(["empty", "python"]) == "python"
|
||||
|
||||
|
||||
def test_prompt_template_by_name():
|
||||
from aipass.aipass.apps.modules.new_project import _prompt_template
|
||||
|
||||
with patch("builtins.input", return_value="python"):
|
||||
assert _prompt_template(["empty", "python"]) == "python"
|
||||
|
||||
|
||||
def test_prompt_template_eof():
|
||||
from aipass.aipass.apps.modules.new_project import _prompt_template
|
||||
|
||||
with patch("builtins.input", side_effect=EOFError):
|
||||
assert _prompt_template(["empty", "python"]) == "empty"
|
||||
|
||||
|
||||
def test_prompt_agent_default_yes():
|
||||
from aipass.aipass.apps.modules.new_project import _prompt_agent
|
||||
|
||||
with patch("builtins.input", return_value=""):
|
||||
assert _prompt_agent() is False
|
||||
|
||||
|
||||
def test_prompt_agent_no():
|
||||
from aipass.aipass.apps.modules.new_project import _prompt_agent
|
||||
|
||||
with patch("builtins.input", return_value="n"):
|
||||
assert _prompt_agent() is True
|
||||
|
||||
|
||||
def test_prompt_agent_eof():
|
||||
from aipass.aipass.apps.modules.new_project import _prompt_agent
|
||||
|
||||
with patch("builtins.input", side_effect=EOFError):
|
||||
assert _prompt_agent() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TTY auto-launch (FIX 3: aipass new auto-launches on TTY)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tty_auto_launches_agent(host_env, monkeypatch):
|
||||
"""On a TTY with an agent created, launch_inline is called."""
|
||||
from aipass.aipass.apps.modules.new_project import handle_command
|
||||
|
||||
monkeypatch.chdir(host_env)
|
||||
spawn_ok = {
|
||||
"success": True,
|
||||
"branch_name": "LAUNCH",
|
||||
"path": str(host_env / "projects" / "launch"),
|
||||
"files_copied": 12,
|
||||
"registry_updated": True,
|
||||
"validation_issues": [],
|
||||
}
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch("builtins.input", return_value=""),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||
return_value=spawn_ok,
|
||||
),
|
||||
patch("aipass.aipass.apps.modules.new_project.console"),
|
||||
patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys,
|
||||
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||
):
|
||||
mock_sys.stdin.isatty.return_value = True
|
||||
handle_command("new", ["launch", "--template", "empty"])
|
||||
mock_launch.assert_called_once()
|
||||
assert "launch" in mock_launch.call_args[0][2]
|
||||
|
||||
|
||||
def test_no_tty_skips_auto_launch(host_env, monkeypatch):
|
||||
"""On a non-TTY, launch_inline is NOT called — fallback to printed instructions."""
|
||||
from aipass.aipass.apps.modules.new_project import handle_command
|
||||
|
||||
monkeypatch.chdir(host_env)
|
||||
spawn_ok = {
|
||||
"success": True,
|
||||
"branch_name": "PIPED",
|
||||
"path": str(host_env / "projects" / "piped"),
|
||||
"files_copied": 12,
|
||||
"registry_updated": True,
|
||||
"validation_issues": [],
|
||||
}
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch("builtins.input", return_value=""),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||
return_value=spawn_ok,
|
||||
),
|
||||
patch("aipass.aipass.apps.modules.new_project.console") as mock_con,
|
||||
patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys,
|
||||
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||
):
|
||||
mock_sys.stdin.isatty.return_value = False
|
||||
handle_command("new", ["piped", "--template", "empty"])
|
||||
mock_launch.assert_not_called()
|
||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||
assert "cd" in printed
|
||||
assert "claude" in printed
|
||||
|
||||
|
||||
def test_no_agent_skips_auto_launch(host_env, monkeypatch):
|
||||
"""With --no-agent, launch_inline is not called even on TTY."""
|
||||
from aipass.aipass.apps.modules.new_project import handle_command
|
||||
|
||||
monkeypatch.chdir(host_env)
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||
patch("aipass.aipass.apps.modules.new_project.console"),
|
||||
patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys,
|
||||
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||
):
|
||||
mock_sys.stdin.isatty.return_value = True
|
||||
handle_command("new", ["nolaunch", "--template", "empty", "--no-agent"])
|
||||
mock_launch.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# aipass.py entry point help (cli_ux)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_aipass_print_introspection():
|
||||
from aipass.aipass.apps.aipass import print_introspection
|
||||
|
||||
print_introspection([])
|
||||
|
||||
|
||||
def test_aipass_print_help():
|
||||
from aipass.aipass.apps.aipass import print_help
|
||||
|
||||
print_help([])
|
||||
@@ -0,0 +1,249 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_trust.py
|
||||
# Description: Tests for trust CLI commands and init enrollment (DPLAN-0244)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-15
|
||||
# Modified: 2026-07-15
|
||||
# =============================================
|
||||
|
||||
"""Tests for trust/revoke CLI commands and init auto-enrollment.
|
||||
|
||||
All tests use tmp dirs + monkeypatch REGISTRY_PATH so they never
|
||||
touch the real ~/.aipass registry.
|
||||
"""
|
||||
|
||||
import pytest # pyright: ignore[reportMissingImports]
|
||||
|
||||
from aipass.hooks.apps.handlers.config.trust_registry import (
|
||||
enroll,
|
||||
is_trusted,
|
||||
read_registry,
|
||||
revoke,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_registry(tmp_path, monkeypatch):
|
||||
"""Redirect REGISTRY_PATH to a tmp dir so tests never touch ~/.aipass."""
|
||||
fake_registry = tmp_path / "trusted_projects.json"
|
||||
monkeypatch.setattr(
|
||||
"aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH",
|
||||
fake_registry,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trust_registry direct tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_enroll_project(tmp_path):
|
||||
"""enroll() registers a project with .aipass/hooks.json."""
|
||||
project = tmp_path / "myproject"
|
||||
project.mkdir()
|
||||
hooks_dir = project / ".aipass"
|
||||
hooks_dir.mkdir()
|
||||
hooks_file = hooks_dir / "hooks.json"
|
||||
hooks_file.write_text('{"hooks_enabled": true}', encoding="utf-8")
|
||||
|
||||
assert enroll(str(project)) is True
|
||||
assert is_trusted(str(project)) is True
|
||||
|
||||
|
||||
def test_enroll_no_hooks_json(tmp_path):
|
||||
"""enroll() returns False when .aipass/hooks.json is missing."""
|
||||
project = tmp_path / "empty"
|
||||
project.mkdir()
|
||||
assert enroll(str(project)) is False
|
||||
|
||||
|
||||
def test_revoke_project(tmp_path):
|
||||
"""revoke() removes a previously enrolled project."""
|
||||
project = tmp_path / "myproject"
|
||||
project.mkdir()
|
||||
hooks_dir = project / ".aipass"
|
||||
hooks_dir.mkdir()
|
||||
hooks_file = hooks_dir / "hooks.json"
|
||||
hooks_file.write_text('{"hooks_enabled": true}', encoding="utf-8")
|
||||
|
||||
enroll(str(project))
|
||||
assert is_trusted(str(project)) is True
|
||||
|
||||
assert revoke(str(project)) is True
|
||||
assert is_trusted(str(project)) is False
|
||||
|
||||
|
||||
def test_revoke_not_enrolled(tmp_path):
|
||||
"""revoke() returns False cleanly for a non-enrolled project."""
|
||||
project = tmp_path / "never_enrolled"
|
||||
project.mkdir()
|
||||
assert revoke(str(project)) is False
|
||||
|
||||
|
||||
def test_is_trusted_hash_mismatch(tmp_path):
|
||||
"""is_trusted() returns False when hooks.json content changed after enrollment."""
|
||||
project = tmp_path / "myproject"
|
||||
project.mkdir()
|
||||
hooks_dir = project / ".aipass"
|
||||
hooks_dir.mkdir()
|
||||
hooks_file = hooks_dir / "hooks.json"
|
||||
hooks_file.write_text('{"hooks_enabled": true}', encoding="utf-8")
|
||||
|
||||
enroll(str(project))
|
||||
assert is_trusted(str(project)) is True
|
||||
|
||||
hooks_file.write_text('{"hooks_enabled": false, "modified": true}', encoding="utf-8")
|
||||
assert is_trusted(str(project)) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trust CLI module tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_trust_command_enrolls(tmp_path):
|
||||
"""aipass trust <path> enrolls the project."""
|
||||
from aipass.aipass.apps.modules.trust import handle_command
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
hooks_dir = project / ".aipass"
|
||||
hooks_dir.mkdir()
|
||||
(hooks_dir / "hooks.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
assert handle_command("trust", [str(project)]) is True
|
||||
assert is_trusted(str(project)) is True
|
||||
|
||||
|
||||
def test_revoke_command_removes(tmp_path):
|
||||
"""aipass revoke <path> removes enrollment."""
|
||||
from aipass.aipass.apps.modules.trust import handle_command
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
hooks_dir = project / ".aipass"
|
||||
hooks_dir.mkdir()
|
||||
(hooks_dir / "hooks.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
enroll(str(project))
|
||||
assert handle_command("revoke", [str(project)]) is True
|
||||
assert is_trusted(str(project)) is False
|
||||
|
||||
|
||||
def test_trust_command_no_hooks_json(tmp_path):
|
||||
"""aipass trust <path> prints error when hooks.json is missing."""
|
||||
from aipass.aipass.apps.modules.trust import handle_command
|
||||
|
||||
project = tmp_path / "bare"
|
||||
project.mkdir()
|
||||
assert handle_command("trust", [str(project)]) is True
|
||||
assert is_trusted(str(project)) is False
|
||||
|
||||
|
||||
def test_revoke_command_not_enrolled(tmp_path):
|
||||
"""aipass revoke <path> handles non-enrolled project cleanly."""
|
||||
from aipass.aipass.apps.modules.trust import handle_command
|
||||
|
||||
project = tmp_path / "ghost"
|
||||
project.mkdir()
|
||||
assert handle_command("revoke", [str(project)]) is True
|
||||
|
||||
|
||||
def test_trust_command_help():
|
||||
"""aipass trust --help returns True (handled)."""
|
||||
from aipass.aipass.apps.modules.trust import handle_command
|
||||
|
||||
assert handle_command("trust", ["--help"]) is True
|
||||
assert handle_command("trust", []) is True
|
||||
|
||||
|
||||
def test_trust_ignores_unrelated_command():
|
||||
"""handle_command returns False for unrelated commands."""
|
||||
from aipass.aipass.apps.modules.trust import handle_command
|
||||
|
||||
assert handle_command("doctor", []) is False
|
||||
|
||||
|
||||
def test_trust_not_a_directory(tmp_path):
|
||||
"""aipass trust <file> prints error."""
|
||||
from aipass.aipass.apps.modules.trust import handle_command
|
||||
|
||||
fake = tmp_path / "not_a_dir.txt"
|
||||
fake.write_text("hi", encoding="utf-8")
|
||||
assert handle_command("trust", [str(fake)]) is True
|
||||
assert is_trusted(str(fake)) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# init enrollment tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_init_project_enrolls(tmp_path, monkeypatch):
|
||||
"""init_project auto-enrolls after copying hooks.json."""
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import init_project
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||
lambda p: False,
|
||||
)
|
||||
|
||||
aipass_home = tmp_path / "aipass_home"
|
||||
aipass_home.mkdir()
|
||||
aipass_dir = aipass_home / ".aipass"
|
||||
aipass_dir.mkdir()
|
||||
template = aipass_dir / "project_hooks.json"
|
||||
template.write_text('{"hooks_enabled": true}', encoding="utf-8")
|
||||
(aipass_home / "CLAUDE.md").write_text("# Test", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
lambda: str(aipass_home),
|
||||
)
|
||||
|
||||
target = tmp_path / "newproject"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="test")
|
||||
|
||||
assert is_trusted(str(target.resolve())) is True
|
||||
|
||||
|
||||
def test_init_update_rehashes(tmp_path, monkeypatch):
|
||||
"""init update re-enrolls after merging hooks.json (hash tracks new content)."""
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import init_project, update_project
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||
lambda p: False,
|
||||
)
|
||||
|
||||
aipass_home = tmp_path / "aipass_home"
|
||||
aipass_home.mkdir()
|
||||
aipass_dir = aipass_home / ".aipass"
|
||||
aipass_dir.mkdir()
|
||||
template = aipass_dir / "project_hooks.json"
|
||||
template.write_text('{"hooks_enabled": true}', encoding="utf-8")
|
||||
(aipass_home / "CLAUDE.md").write_text("# Test", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
lambda: str(aipass_home),
|
||||
)
|
||||
|
||||
target = tmp_path / "updproj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="test")
|
||||
assert is_trusted(str(target.resolve())) is True
|
||||
|
||||
old_reg = read_registry()
|
||||
old_hash = old_reg["projects"][str(target.resolve())]["config_hash"]
|
||||
|
||||
new_template = (
|
||||
'{"hooks_enabled": true, "SessionStart": '
|
||||
'{"new_hook": {"handler": "aipass.hooks.apps.handlers.test.handle", "enabled": true}}}'
|
||||
)
|
||||
template.write_text(new_template, encoding="utf-8")
|
||||
update_project(target)
|
||||
|
||||
new_reg = read_registry()
|
||||
new_hash = new_reg["projects"][str(target.resolve())]["config_hash"]
|
||||
assert new_hash != old_hash
|
||||
assert is_trusted(str(target.resolve())) is True
|
||||
@@ -18,6 +18,27 @@ drone @api <command> [args]
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Validate your API key
|
||||
drone @api validate
|
||||
|
||||
# Test the connection
|
||||
drone @api test
|
||||
|
||||
# List available models
|
||||
drone @api models
|
||||
|
||||
# Make an API call
|
||||
drone @api call "Hello, world" --model anthropic/claude-3.5-sonnet
|
||||
|
||||
# Check usage stats
|
||||
drone @api stats
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|
||||
@@ -213,6 +213,24 @@ def print_help():
|
||||
console.print("─" * 70)
|
||||
console.print()
|
||||
|
||||
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [yellow]Credentials:[/yellow]")
|
||||
console.print(" [dim]drone @api get-key[/dim] [dim]# Show OpenRouter key[/dim]")
|
||||
console.print(" [dim]drone @api validate[/dim] [dim]# Validate OpenRouter key[/dim]")
|
||||
console.print(" [dim]drone @api validate google[/dim] [dim]# Validate Google OAuth2[/dim]")
|
||||
console.print()
|
||||
console.print(" [yellow]Models & calls:[/yellow]")
|
||||
console.print(" [dim]drone @api models --all[/dim] [dim]# List all models[/dim]")
|
||||
console.print(' [dim]drone @api call "Summarize this" --model anthropic/claude-3.5-sonnet[/dim]')
|
||||
console.print()
|
||||
console.print(" [yellow]Usage tracking:[/yellow]")
|
||||
console.print(" [dim]drone @api stats[/dim] [dim]# Overall usage stats[/dim]")
|
||||
console.print(" [dim]drone @api caller-usage flow[/dim] [dim]# Usage by caller[/dim]")
|
||||
console.print()
|
||||
console.print("─" * 70)
|
||||
console.print()
|
||||
|
||||
console.print(
|
||||
"[dim]Commands: get-key, get-secret, validate, test, models, status, call,"
|
||||
" list-providers, init, track, stats, session, caller-usage, cleanup[/dim]"
|
||||
|
||||
@@ -79,6 +79,27 @@ All 11 commands are auto-discovered by the entry point router.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Register a project for backup
|
||||
drone @backup register /path/to/project --name myapp
|
||||
|
||||
# Full mirror snapshot
|
||||
drone @backup snapshot @myapp
|
||||
|
||||
# Incremental timestamped backup
|
||||
drone @backup versioned @myapp
|
||||
|
||||
# Check backup status
|
||||
drone @backup status @myapp
|
||||
|
||||
# List available versions of a file
|
||||
drone @backup restore @myapp list src/main.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `.backup/` Store Structure
|
||||
|
||||
Each registered project gets a `.backup/` directory at its root:
|
||||
|
||||
@@ -42,7 +42,7 @@ def print_introspection(modules: list[Any]) -> None:
|
||||
console.print()
|
||||
console.print(f"[bold cyan]BACKUP[/bold cyan] v{VERSION} — project backup & drive sync")
|
||||
console.print()
|
||||
console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}")
|
||||
console.print(f"[bold dim]Discovered Modules:[/bold dim] {len(modules)}")
|
||||
console.print()
|
||||
for module in modules:
|
||||
name = module.__name__.split(".")[-1]
|
||||
@@ -83,6 +83,26 @@ def print_help() -> None:
|
||||
console.print(" [green]share[/green] Upload a single file to Drive + get a shareable link")
|
||||
console.print(" [green]drive_clear[/green] Clear backups from the remote drive")
|
||||
console.print()
|
||||
console.print("-" * 70)
|
||||
console.print()
|
||||
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [bold dim]Register & back up:[/bold dim]")
|
||||
console.print(" [dim]drone @backup register /path/to/project --name myapp[/dim]")
|
||||
console.print(" [dim]drone @backup snapshot @myapp[/dim]")
|
||||
console.print(" [dim]drone @backup versioned @myapp[/dim]")
|
||||
console.print(" [dim]drone @backup all @myapp[/dim]")
|
||||
console.print()
|
||||
console.print(" [bold dim]Status & restore:[/bold dim]")
|
||||
console.print(" [dim]drone @backup status @myapp[/dim]")
|
||||
console.print(" [dim]drone @backup restore @myapp list src/main.py[/dim]")
|
||||
console.print(" [dim]drone @backup restore @myapp file src/main.py ./restored.py[/dim]")
|
||||
console.print()
|
||||
console.print(" [bold dim]Drive sync:[/bold dim]")
|
||||
console.print(" [dim]drone @backup drive_check @myapp[/dim]")
|
||||
console.print(" [dim]drone @backup drive_sync @myapp[/dim]")
|
||||
console.print(" [dim]drone @backup share report.pdf --public[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def discover_modules() -> list[Any]:
|
||||
|
||||
@@ -7,7 +7,16 @@
|
||||
**Version:** 2.1.0
|
||||
**Seedgo:** 99%
|
||||
**Tests:** 127 passing (5 files)
|
||||
**Last Updated:** 2026-05-16
|
||||
**Last Updated:** 2026-07-17
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
drone @cli # Show discovered modules
|
||||
drone @cli display demo # Run display function showcase
|
||||
drone @cli templates demo # Run operation template showcase
|
||||
drone @cli --help # Full usage guide
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
+87
-78
@@ -37,7 +37,7 @@ from rich.panel import Panel
|
||||
from rich import box
|
||||
|
||||
# CLI modules (showcasing our own services!)
|
||||
from aipass.cli.apps.modules.display import console as CONSOLE, header, error
|
||||
from aipass.cli.apps.modules.display import console, header, error
|
||||
|
||||
VERSION = "2.1.0"
|
||||
CLI_ROOT = Path(__file__).parent
|
||||
@@ -110,37 +110,37 @@ def print_introspection() -> None:
|
||||
command_modules = [m for m in modules if getattr(m, "__name__", "").split(".")[-1] not in SERVICE_MODULES]
|
||||
service_modules = [m for m in modules if getattr(m, "__name__", "").split(".")[-1] in SERVICE_MODULES]
|
||||
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("[bold cyan]CLI - Command Line Interface Branch[/bold cyan]")
|
||||
CONSOLE.print(f" Version: {VERSION}")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("[dim]Universal Display & Output Service Provider[/dim]")
|
||||
CONSOLE.print()
|
||||
console.print()
|
||||
console.print("[bold cyan]CLI - Command Line Interface Branch[/bold cyan]")
|
||||
console.print(f" Version: {VERSION}")
|
||||
console.print()
|
||||
console.print("[dim]Universal Display & Output Service Provider[/dim]")
|
||||
console.print()
|
||||
|
||||
# Discovered command modules
|
||||
CONSOLE.print(f"[yellow]Discovered Modules:[/yellow] {len(command_modules)}")
|
||||
console.print(f"[yellow]Discovered Modules:[/yellow] {len(command_modules)}")
|
||||
for module in command_modules:
|
||||
name = getattr(module, "__name__", "unknown").split(".")[-1]
|
||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||
CONSOLE.print(f" [cyan]\u2022[/cyan] {name} \u2014 {desc}")
|
||||
console.print(f" [cyan]\u2022[/cyan] {name} \u2014 {desc}")
|
||||
if not command_modules:
|
||||
CONSOLE.print(" [dim]No command modules discovered[/dim]")
|
||||
CONSOLE.print()
|
||||
console.print(" [dim]No command modules discovered[/dim]")
|
||||
console.print()
|
||||
|
||||
# Service modules (import-only, but with utility commands)
|
||||
if service_modules:
|
||||
CONSOLE.print(f"[yellow]Services:[/yellow] {len(service_modules)}")
|
||||
console.print(f"[yellow]Services:[/yellow] {len(service_modules)}")
|
||||
for module in service_modules:
|
||||
name = getattr(module, "__name__", "unknown").split(".")[-1]
|
||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||
CONSOLE.print(f" [cyan]\u2022[/cyan] {name} \u2014 {desc}")
|
||||
CONSOLE.print()
|
||||
console.print(f" [cyan]\u2022[/cyan] {name} \u2014 {desc}")
|
||||
console.print()
|
||||
|
||||
CONSOLE.print("[yellow]Next:[/yellow] Explore a module")
|
||||
CONSOLE.print(" [green]drone @cli display[/green] [dim]# Display module info[/dim]")
|
||||
CONSOLE.print(" [green]drone @cli display demo[/green] [dim]# Run display showcase[/dim]")
|
||||
CONSOLE.print(" [green]drone @cli --help[/green] [dim]# Full usage guide[/dim]")
|
||||
CONSOLE.print()
|
||||
console.print("[yellow]Next:[/yellow] Explore a module")
|
||||
console.print(" [green]drone @cli display[/green] [dim]# Display module info[/dim]")
|
||||
console.print(" [green]drone @cli display demo[/green] [dim]# Run display showcase[/dim]")
|
||||
console.print(" [green]drone @cli --help[/green] [dim]# Full usage guide[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
@@ -149,45 +149,54 @@ def print_help() -> None:
|
||||
Shows COMMANDS, EXAMPLES, full reference.
|
||||
Follows seedgo help pattern.
|
||||
"""
|
||||
CONSOLE.print()
|
||||
console.print()
|
||||
|
||||
header("CLI - Display & Templates Service Provider")
|
||||
|
||||
CONSOLE.print("[dim]Universal display and output formatting for all AIPass branches[/dim]")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("\u2500" * 70)
|
||||
CONSOLE.print()
|
||||
console.print("[dim]Universal display and output formatting for all AIPass branches[/dim]")
|
||||
console.print()
|
||||
console.print("\u2500" * 70)
|
||||
console.print()
|
||||
|
||||
# Usage
|
||||
console.print("[bold cyan]USAGE:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]drone @cli <command> [args...][/dim]")
|
||||
console.print(" [dim]drone @cli --help[/dim]")
|
||||
console.print()
|
||||
console.print("\u2500" * 70)
|
||||
console.print()
|
||||
|
||||
# What is CLI
|
||||
CONSOLE.print("[bold cyan]WHAT IS CLI?[/bold cyan]")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("CLI is the [bold]Display & Templates Service[/bold] - like Prax for logging:")
|
||||
CONSOLE.print(" [green]\u2713[/green] Centralized display formatting (headers, tables, panels)")
|
||||
CONSOLE.print(" [green]\u2713[/green] Reusable templates for common operations")
|
||||
CONSOLE.print(" [green]\u2713[/green] Rich library integration for beautiful output")
|
||||
CONSOLE.print(" [green]\u2713[/green] Consistent styling across all AIPass branches")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("Update CLI once \u2192 All branches instantly benefit from improvements")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("\u2500" * 70)
|
||||
CONSOLE.print()
|
||||
console.print("[bold cyan]WHAT IS CLI?[/bold cyan]")
|
||||
console.print()
|
||||
console.print("CLI is the [bold]Display & Templates Service[/bold] - like Prax for logging:")
|
||||
console.print(" [green]\u2713[/green] Centralized display formatting (headers, tables, panels)")
|
||||
console.print(" [green]\u2713[/green] Reusable templates for common operations")
|
||||
console.print(" [green]\u2713[/green] Rich library integration for beautiful output")
|
||||
console.print(" [green]\u2713[/green] Consistent styling across all AIPass branches")
|
||||
console.print()
|
||||
console.print("Update CLI once \u2192 All branches instantly benefit from improvements")
|
||||
console.print()
|
||||
console.print("\u2500" * 70)
|
||||
console.print()
|
||||
|
||||
# Commands
|
||||
CONSOLE.print("[bold cyan]COMMANDS:[/bold cyan]")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print(" [green]drone @cli[/green] [dim]# Show discovered modules[/dim]")
|
||||
CONSOLE.print(" [green]drone @cli display[/green] [dim]# Display module info[/dim]")
|
||||
CONSOLE.print(" [green]drone @cli display demo[/green] [dim]# Run display demo[/dim]")
|
||||
CONSOLE.print(" [green]drone @cli templates[/green] [dim]# Templates module info[/dim]")
|
||||
CONSOLE.print(" [green]drone @cli templates demo[/green] [dim]# Run templates demo[/dim]")
|
||||
CONSOLE.print(" [green]drone @cli --help[/green] [dim]# This help message[/dim]")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("\u2500" * 70)
|
||||
CONSOLE.print()
|
||||
console.print("[bold cyan]COMMANDS:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [green]drone @cli[/green] [dim]# Show discovered modules[/dim]")
|
||||
console.print(" [green]drone @cli display[/green] [dim]# Display module info[/dim]")
|
||||
console.print(" [green]drone @cli display demo[/green] [dim]# Run display demo[/dim]")
|
||||
console.print(" [green]drone @cli templates[/green] [dim]# Templates module info[/dim]")
|
||||
console.print(" [green]drone @cli templates demo[/green] [dim]# Run templates demo[/dim]")
|
||||
console.print(" [green]drone @cli --help[/green] [dim]# This help message[/dim]")
|
||||
console.print()
|
||||
console.print("\u2500" * 70)
|
||||
console.print()
|
||||
|
||||
# Public services
|
||||
CONSOLE.print("[bold cyan]PUBLIC SERVICES (apps/modules/):[/bold cyan]")
|
||||
CONSOLE.print()
|
||||
console.print("[bold cyan]PUBLIC SERVICES (apps/modules/):[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
services_table = Table(show_header=True, header_style="bold cyan", border_style="dim")
|
||||
services_table.add_column("Module", style="green")
|
||||
@@ -199,34 +208,34 @@ def print_help() -> None:
|
||||
)
|
||||
services_table.add_row("templates", "operation_start(), operation_complete()", "Standard operation patterns")
|
||||
|
||||
CONSOLE.print(services_table)
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("\u2500" * 70)
|
||||
CONSOLE.print()
|
||||
console.print(services_table)
|
||||
console.print()
|
||||
console.print("\u2500" * 70)
|
||||
console.print()
|
||||
|
||||
# Import examples
|
||||
CONSOLE.print("[bold cyan]HOW TO IMPORT CLI SERVICES:[/bold cyan]")
|
||||
CONSOLE.print()
|
||||
console.print("[bold cyan]HOW TO IMPORT CLI SERVICES:[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
CONSOLE.print("[yellow]Display functions:[/yellow]")
|
||||
CONSOLE.print("[dim] from aipass.cli.apps.modules.display import header, success, error, warning[/dim]")
|
||||
CONSOLE.print()
|
||||
console.print("[yellow]Display functions:[/yellow]")
|
||||
console.print("[dim] from aipass.cli.apps.modules.display import header, success, error, warning[/dim]")
|
||||
console.print()
|
||||
|
||||
CONSOLE.print("[yellow]Templates:[/yellow]")
|
||||
CONSOLE.print("[dim] from aipass.cli.apps.modules.templates import operation_start, operation_complete[/dim]")
|
||||
CONSOLE.print()
|
||||
console.print("[yellow]Templates:[/yellow]")
|
||||
console.print("[dim] from aipass.cli.apps.modules.templates import operation_start, operation_complete[/dim]")
|
||||
console.print()
|
||||
|
||||
CONSOLE.print("[yellow]Rich console:[/yellow]")
|
||||
CONSOLE.print("[dim] from aipass.cli.apps.modules.display import console[/dim]")
|
||||
CONSOLE.print("[dim] console.print('[bold]Hello[/bold]') # Rich formatted output[/dim]")
|
||||
CONSOLE.print()
|
||||
console.print("[yellow]Rich console:[/yellow]")
|
||||
console.print("[dim] from aipass.cli.apps.modules.display import console[/dim]")
|
||||
console.print("[dim] console.print('[bold]Hello[/bold]') # Rich formatted output[/dim]")
|
||||
console.print()
|
||||
|
||||
CONSOLE.print("\u2500" * 70)
|
||||
CONSOLE.print()
|
||||
console.print("\u2500" * 70)
|
||||
console.print()
|
||||
|
||||
# Architecture
|
||||
CONSOLE.print("[bold cyan]ARCHITECTURE:[/bold cyan]")
|
||||
CONSOLE.print()
|
||||
console.print("[bold cyan]ARCHITECTURE:[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
arch_text = """[bold]CLI Branch Structure:[/bold]
|
||||
|
||||
@@ -240,19 +249,19 @@ def print_help() -> None:
|
||||
[green]\u2713[/green] Rich library = Underlying formatting engine
|
||||
- Console, Table, Panel, Columns, Text styling"""
|
||||
|
||||
CONSOLE.print(Panel(arch_text, border_style="green", padding=(1, 2), box=box.ROUNDED))
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("\u2500" * 70)
|
||||
CONSOLE.print()
|
||||
console.print(Panel(arch_text, border_style="green", padding=(1, 2), box=box.ROUNDED))
|
||||
console.print()
|
||||
console.print("\u2500" * 70)
|
||||
console.print()
|
||||
|
||||
# Drone compliance — commands line
|
||||
CONSOLE.print("[dim]Commands: display, templates, demo, --help[/dim]")
|
||||
CONSOLE.print()
|
||||
console.print("[dim]Commands: display, templates, demo, --help[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def show_version():
|
||||
"""Print version."""
|
||||
CONSOLE.print(f"CLI v{VERSION}")
|
||||
console.print(f"CLI v{VERSION}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -306,9 +315,9 @@ if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("CLI interrupted by user")
|
||||
CONSOLE.print("\n[yellow]Operation cancelled[/yellow]")
|
||||
console.print("\n[yellow]Operation cancelled[/yellow]")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
logger.error(f"CLI error: {e}", exc_info=True)
|
||||
CONSOLE.print(f"\n[red]Error: {e}[/red]")
|
||||
error(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
@@ -51,7 +51,7 @@ class TestMainFlow:
|
||||
"""No args shows introspection and returns 0."""
|
||||
cons, _get_output = _make_capture_console()
|
||||
with (
|
||||
patch.object(cli_module, "CONSOLE", cons),
|
||||
patch.object(cli_module, "console", cons),
|
||||
patch.object(display, "CONSOLE", cons),
|
||||
patch("sys.argv", ["cli"]),
|
||||
):
|
||||
@@ -63,7 +63,7 @@ class TestMainFlow:
|
||||
cons, _get_output = _make_capture_console()
|
||||
err_cons, _get_err = _make_capture_console()
|
||||
with (
|
||||
patch.object(cli_module, "CONSOLE", cons),
|
||||
patch.object(cli_module, "console", cons),
|
||||
patch.object(display, "CONSOLE", cons),
|
||||
patch.object(display, "err_console", err_cons),
|
||||
patch.object(display, "_TRIGGER", None),
|
||||
@@ -77,7 +77,7 @@ class TestMainFlow:
|
||||
"""--version returns 0."""
|
||||
cons, get_output = _make_capture_console()
|
||||
with (
|
||||
patch.object(cli_module, "CONSOLE", cons),
|
||||
patch.object(cli_module, "console", cons),
|
||||
patch.object(display, "CONSOLE", cons),
|
||||
patch("sys.argv", ["cli", "--version"]),
|
||||
):
|
||||
@@ -91,7 +91,7 @@ class TestMainFlow:
|
||||
cons, _get_output = _make_capture_console()
|
||||
err_cons, get_err = _make_capture_console()
|
||||
with (
|
||||
patch.object(cli_module, "CONSOLE", cons),
|
||||
patch.object(cli_module, "console", cons),
|
||||
patch.object(display, "CONSOLE", cons),
|
||||
patch.object(display, "err_console", err_cons),
|
||||
patch("sys.argv", ["cli", "nonexistent_cmd_xyz"]),
|
||||
@@ -106,7 +106,7 @@ class TestMainFlow:
|
||||
cons, _get_output = _make_capture_console()
|
||||
err_cons, _get_err = _make_capture_console()
|
||||
with (
|
||||
patch.object(cli_module, "CONSOLE", cons),
|
||||
patch.object(cli_module, "console", cons),
|
||||
patch.object(display, "CONSOLE", cons),
|
||||
patch.object(display, "err_console", err_cons),
|
||||
patch.object(display, "_TRIGGER", None),
|
||||
@@ -125,7 +125,7 @@ class TestMainFlow:
|
||||
"""cli_entry() is the console_scripts entry point — verify it's callable."""
|
||||
cons, _get_output = _make_capture_console()
|
||||
with (
|
||||
patch.object(cli_module, "CONSOLE", cons),
|
||||
patch.object(cli_module, "console", cons),
|
||||
patch.object(display, "CONSOLE", cons),
|
||||
patch("sys.argv", ["aipass", "--version"]),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
|
||||
@@ -10,6 +10,20 @@
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
drone @daemon # Show discovered modules
|
||||
drone @daemon update # Status digest
|
||||
drone @daemon activity # Quick 24h activity summary
|
||||
drone @daemon queue # View pending scheduled jobs
|
||||
drone @daemon run # Fire all due jobs now
|
||||
drone @daemon branch-health DAEMON # Deep dive on a branch
|
||||
drone @daemon install-timer # Enable systemd 2-min timer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Builder citizen -- full 3-layer architecture with identity and memory. DAEMON serves as the background orchestration branch: it discovers modules at startup, routes CLI commands to them, and provides introspection and help output via Rich console.
|
||||
|
||||
@@ -162,8 +162,22 @@ def print_help(modules: List[Any]):
|
||||
console.print("-" * 70)
|
||||
console.print()
|
||||
|
||||
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [yellow]Status & monitoring:[/yellow]")
|
||||
console.print(" [dim]drone @daemon update[/dim] [dim]# Status digest[/dim]")
|
||||
console.print(" [dim]drone @daemon activity[/dim] [dim]# Quick 24h summary[/dim]")
|
||||
console.print(" [dim]drone @daemon activity-report --json[/dim] [dim]# Full report (raw)[/dim]")
|
||||
console.print(" [dim]drone @daemon branch-health DAEMON[/dim] [dim]# Single branch dive[/dim]")
|
||||
console.print()
|
||||
console.print(" [yellow]Scheduler:[/yellow]")
|
||||
console.print(" [dim]drone @daemon queue[/dim] [dim]# View pending jobs[/dim]")
|
||||
console.print(" [dim]drone @daemon run[/dim] [dim]# Fire due jobs now[/dim]")
|
||||
console.print(" [dim]drone @daemon install-timer[/dim] [dim]# Enable systemd timer[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[bold]TIP:[/bold] For module-specific help:")
|
||||
console.print(" [dim]daemon <command> --help[/dim]")
|
||||
console.print(" [dim]drone @daemon <command> --help[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,18 @@ DevPulse handles the day-to-day: working with the user to plan, design, troubles
|
||||
| Active plans | `drone @flow list open` |
|
||||
| Branch list | `drone systems` |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Talk to the hub — it picks up where the last session left off
|
||||
cd src/aipass/devpulse
|
||||
claude
|
||||
|
||||
# Or drive it via drone from anywhere in AIPass
|
||||
drone @devpulse compass query "registry" # search rated decisions
|
||||
drone @devpulse feedback inbox # cross-project feedback
|
||||
```
|
||||
|
||||
## Invoke
|
||||
|
||||
```bash
|
||||
@@ -44,7 +56,7 @@ src/aipass/devpulse/
|
||||
│ │ └── watchdog/ # Agent, timer, schedule, registry
|
||||
│ └── plugins/ # Plugin extension point
|
||||
├── devpulse_json/ # JSON handler storage (config, data, logs per module)
|
||||
├── tests/ # 309 tests
|
||||
├── tests/ # 348 tests
|
||||
├── artifacts/ # Birth certificate, reports
|
||||
├── dropbox/ # Received files, archived plans, install audit
|
||||
├── docs/ # Transition notes
|
||||
|
||||
@@ -89,11 +89,14 @@ def print_introspection():
|
||||
"""Print branch introspection — discovered modules and capabilities."""
|
||||
modules = discover_modules()
|
||||
console.print("[bold cyan]DEVPULSE[/bold cyan] — Orchestration Hub")
|
||||
console.print("[dim]The user's primary collaborator — design, plan, dispatch, track[/dim]")
|
||||
console.print(f" Modules discovered: {len(modules)}")
|
||||
for module in modules:
|
||||
name = module.__name__.split(".")[-1]
|
||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||
console.print(f" {name:20} {desc}")
|
||||
console.print()
|
||||
console.print("Run 'drone @devpulse --help' for usage information")
|
||||
|
||||
|
||||
def print_help():
|
||||
@@ -112,6 +115,11 @@ def print_help():
|
||||
console.print("[bold]FLAGS:[/bold]")
|
||||
console.print(" --help, -h Show this help message")
|
||||
console.print(" --version, -V Show version")
|
||||
console.print()
|
||||
console.print("[bold]EXAMPLES:[/bold]")
|
||||
console.print(' drone @devpulse compass query "registry" Search rated decisions')
|
||||
console.print(" drone @devpulse watchdog agent @flow Watch a dispatched agent")
|
||||
console.print(" drone @devpulse feedback inbox Check cross-project feedback")
|
||||
|
||||
|
||||
def route_command(command: str, args: list[str], modules: list[Any]) -> bool:
|
||||
|
||||
@@ -23,9 +23,13 @@ from aipass.devpulse.apps.handlers.compass.store import (
|
||||
VALID_STATUSES,
|
||||
add_decision,
|
||||
archive,
|
||||
find_conflicts,
|
||||
mark_surfaced,
|
||||
query_decisions,
|
||||
rate,
|
||||
recall_decisions,
|
||||
review,
|
||||
set_note,
|
||||
stats,
|
||||
)
|
||||
|
||||
@@ -36,8 +40,12 @@ __all__ = [
|
||||
"VALID_STATUSES",
|
||||
"add_decision",
|
||||
"archive",
|
||||
"find_conflicts",
|
||||
"mark_surfaced",
|
||||
"query_decisions",
|
||||
"rate",
|
||||
"recall_decisions",
|
||||
"review",
|
||||
"set_note",
|
||||
"stats",
|
||||
]
|
||||
|
||||
@@ -29,6 +29,7 @@ command, and maintenance UX are later phases.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
@@ -57,6 +58,9 @@ VALID_SOURCES = ("devpulse", "user")
|
||||
VALID_STATUSES = ("active", "archived")
|
||||
|
||||
# Columns we return / surface from the decisions table (everything useful).
|
||||
# NOTE: ``score`` is deliberately absent — it is code-invisible (DPLAN-0246
|
||||
# seedgo ruling). The column stays physically on disk as inert NULL, but no
|
||||
# Python surface (SELECTs, returned dicts) touches it.
|
||||
_DECISION_COLUMNS = (
|
||||
"id",
|
||||
"created",
|
||||
@@ -66,10 +70,10 @@ _DECISION_COLUMNS = (
|
||||
"note",
|
||||
"tags",
|
||||
"source",
|
||||
"score",
|
||||
"status",
|
||||
"last_reviewed",
|
||||
"times_surfaced",
|
||||
"supersedes",
|
||||
)
|
||||
|
||||
_SCHEMA = """
|
||||
@@ -85,7 +89,8 @@ CREATE TABLE IF NOT EXISTS decisions (
|
||||
score INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived')),
|
||||
last_reviewed TEXT,
|
||||
times_surfaced INTEGER NOT NULL DEFAULT 0
|
||||
times_surfaced INTEGER NOT NULL DEFAULT 0,
|
||||
supersedes INTEGER
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS decisions_fts USING fts5(
|
||||
@@ -133,6 +138,42 @@ def _verify_fts5(conn: sqlite3.Connection) -> None:
|
||||
) from exc
|
||||
|
||||
|
||||
def _migrate(conn: sqlite3.Connection) -> None:
|
||||
"""Apply idempotent schema migrations to an already-open DB.
|
||||
|
||||
Adds the ``supersedes`` column to DBs created before it existed. Guarded by
|
||||
a ``PRAGMA table_info`` pre-check so it is safe to run on every connect —
|
||||
never a blind ``ALTER`` (DPLAN-0246 seedgo ruling: idempotent migration).
|
||||
Fresh DBs already carry the column from ``_SCHEMA``; the pre-check makes
|
||||
this a no-op for them.
|
||||
"""
|
||||
cols = {row["name"] for row in conn.execute("PRAGMA table_info(decisions)")}
|
||||
if "supersedes" not in cols:
|
||||
conn.execute("ALTER TABLE decisions ADD COLUMN supersedes INTEGER")
|
||||
conn.commit()
|
||||
logger.info("[compass] migration: added supersedes column")
|
||||
|
||||
|
||||
# FTS5 MATCH treats characters like " * ( ) : - ^ and the words AND/OR/NOT as
|
||||
# syntax. Untrusted text (a decision's own words) can therefore crash MATCH.
|
||||
# We defuse it by extracting bare word tokens and OR-ing them as quoted string
|
||||
# literals — no operator can survive, and quoting a bareword is exact.
|
||||
_FTS_WORD = re.compile(r"\w+", re.UNICODE)
|
||||
|
||||
|
||||
def _sanitize_fts_query(text: Optional[str]) -> Optional[str]:
|
||||
"""Turn arbitrary text into a safe FTS5 MATCH expression, or None.
|
||||
|
||||
Returns an ``OR`` of the text's word tokens, each quoted as a string
|
||||
literal so FTS5 syntax characters can never reach the parser. Returns None
|
||||
when there are no usable tokens (caller should skip the search).
|
||||
"""
|
||||
tokens = _FTS_WORD.findall(text or "")
|
||||
if not tokens:
|
||||
return None
|
||||
return " OR ".join(f'"{t}"' for t in tokens)
|
||||
|
||||
|
||||
def _resolve_db_path(db_path: Optional[Path | str]) -> Path:
|
||||
"""Resolve the effective DB path, defaulting to the branch-root location."""
|
||||
return Path(db_path) if db_path is not None else DEFAULT_DB_PATH
|
||||
@@ -141,8 +182,9 @@ def _resolve_db_path(db_path: Optional[Path | str]) -> Path:
|
||||
def _connect(db_path: Optional[Path | str]) -> sqlite3.Connection:
|
||||
"""Open (and lazily initialise) the compass DB.
|
||||
|
||||
Creates parent directories on first use, verifies FTS5, ensures schema.
|
||||
Rows come back as ``sqlite3.Row`` so we can build clean dicts.
|
||||
Creates parent directories on first use, verifies FTS5, ensures schema, and
|
||||
runs idempotent migrations. Rows come back as ``sqlite3.Row`` so we can
|
||||
build clean dicts.
|
||||
"""
|
||||
path = _resolve_db_path(db_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -151,6 +193,7 @@ def _connect(db_path: Optional[Path | str]) -> sqlite3.Connection:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
_verify_fts5(conn)
|
||||
conn.executescript(_SCHEMA)
|
||||
_migrate(conn)
|
||||
return conn
|
||||
|
||||
|
||||
@@ -168,6 +211,7 @@ def add_decision(
|
||||
source: str = "devpulse",
|
||||
db_path: Optional[Path | str] = None,
|
||||
created: Optional[str] = None,
|
||||
supersedes: Optional[int] = None,
|
||||
) -> int:
|
||||
"""Add a rated decision and return its new id.
|
||||
|
||||
@@ -181,12 +225,16 @@ def add_decision(
|
||||
db_path: Optional DB path override (tests pass a temp path).
|
||||
created: Optional ISO date override; defaults to today. This is the
|
||||
ONLY place a "today" date is stamped.
|
||||
supersedes: Optional id of the decision this entry corrects. When set,
|
||||
the new entry links to it AND that entry is archived — atomically,
|
||||
in one transaction. Errors cleanly (no write) if the id is unknown.
|
||||
|
||||
Returns:
|
||||
The new row's integer id.
|
||||
|
||||
Raises:
|
||||
ValueError: On empty context/decision or invalid rating/source.
|
||||
ValueError: On empty context/decision, invalid rating/source, or a
|
||||
``supersedes`` target that does not exist.
|
||||
"""
|
||||
if not context or not context.strip():
|
||||
raise ValueError("context must be a non-empty string")
|
||||
@@ -201,22 +249,46 @@ def add_decision(
|
||||
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
# Validate the supersede target BEFORE any write so a bad id never
|
||||
# leaves a partial insert behind.
|
||||
if supersedes is not None:
|
||||
target = conn.execute("SELECT 1 FROM decisions WHERE id = ?", (supersedes,)).fetchone()
|
||||
if target is None:
|
||||
raise ValueError(f"cannot supersede #{supersedes}: no decision with that id")
|
||||
|
||||
# Insert + archive-the-target in ONE transaction (commit once at the
|
||||
# end); any failure rolls the whole thing back — never half-applied.
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO decisions (created, context, decision, rating, note, tags, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO decisions (created, context, decision, rating, note, tags, source, supersedes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(stamp, context.strip(), decision.strip(), rating, note, tags, source),
|
||||
(stamp, context.strip(), decision.strip(), rating, note, tags, source, supersedes),
|
||||
)
|
||||
conn.commit()
|
||||
if cur.lastrowid is None: # pragma: no cover - sqlite always sets this on INSERT
|
||||
raise RuntimeError("compass: INSERT did not return a rowid")
|
||||
new_id = int(cur.lastrowid)
|
||||
|
||||
if supersedes is not None:
|
||||
conn.execute("UPDATE decisions SET status = 'archived' WHERE id = ?", (supersedes,))
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("[compass] added decision id=%s rating=%s source=%s", new_id, rating, source)
|
||||
json_handler.log_operation("compass_add", {"id": new_id, "rating": rating, "source": source})
|
||||
logger.info(
|
||||
"[compass] added decision id=%s rating=%s source=%s supersedes=%s",
|
||||
new_id,
|
||||
rating,
|
||||
source,
|
||||
supersedes,
|
||||
)
|
||||
json_handler.log_operation(
|
||||
"compass_add", {"id": new_id, "rating": rating, "source": source, "supersedes": supersedes}
|
||||
)
|
||||
return new_id
|
||||
|
||||
|
||||
@@ -224,21 +296,28 @@ def query_decisions(
|
||||
query: str,
|
||||
rating: Optional[str] = None,
|
||||
limit: int = 5,
|
||||
include_archived: bool = False,
|
||||
db_path: Optional[Path | str] = None,
|
||||
) -> list[dict]:
|
||||
"""Search active decisions, ranked by FTS5 BM25 relevance.
|
||||
"""Search decisions, ranked by FTS5 BM25 relevance.
|
||||
|
||||
Increments ``times_surfaced`` for every returned row.
|
||||
Increments ``times_surfaced`` for every returned row. Each result dict
|
||||
carries a computed ``superseded_by`` field: the id of the row that
|
||||
supersedes this one, or None. Combined with the ``supersedes`` column this
|
||||
lets callers render both pointer directions.
|
||||
|
||||
Args:
|
||||
query: FTS5 match query (keywords).
|
||||
rating: Optional exact rating filter (one of VALID_RATINGS).
|
||||
limit: Max rows to return (default 5).
|
||||
include_archived: When True, lift the ``status = 'active'`` filter so
|
||||
archived rows (the avoid-list) are searchable too. Default False —
|
||||
unchanged active-only behaviour.
|
||||
db_path: Optional DB path override.
|
||||
|
||||
Returns:
|
||||
A list of decision dicts (most relevant first). Each dict includes the
|
||||
rating and all useful fields.
|
||||
rating, all useful fields, and the computed ``superseded_by`` pointer.
|
||||
|
||||
Raises:
|
||||
ValueError: On empty query, bad rating filter, or non-positive limit.
|
||||
@@ -256,9 +335,10 @@ def query_decisions(
|
||||
FROM decisions_fts f
|
||||
JOIN decisions d ON d.id = f.rowid
|
||||
WHERE decisions_fts MATCH ?
|
||||
AND d.status = 'active'
|
||||
"""
|
||||
params: list = [query.strip()]
|
||||
if not include_archived:
|
||||
sql += " AND d.status = 'active'"
|
||||
if rating is not None:
|
||||
sql += " AND d.rating = ?"
|
||||
params.append(rating)
|
||||
@@ -269,24 +349,119 @@ def query_decisions(
|
||||
try:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
results = [_row_to_dict(r) for r in rows]
|
||||
for r in results:
|
||||
r["superseded_by"] = None
|
||||
ids = [r["id"] for r in results]
|
||||
if ids:
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
# Reverse-lookup: which returned rows are pointed AT by a superseder?
|
||||
successors: dict = {}
|
||||
for row in conn.execute(
|
||||
f"SELECT id, supersedes FROM decisions WHERE supersedes IN ({placeholders})",
|
||||
ids,
|
||||
):
|
||||
successors[row["supersedes"]] = row["id"]
|
||||
conn.execute(
|
||||
f"UPDATE decisions SET times_surfaced = times_surfaced + 1 WHERE id IN ({placeholders})",
|
||||
ids,
|
||||
)
|
||||
conn.commit()
|
||||
# Reflect the increment in the returned dicts without a re-query.
|
||||
# Reflect the increment + attach the reverse pointer without re-query.
|
||||
for r in results:
|
||||
r["times_surfaced"] = (r["times_surfaced"] or 0) + 1
|
||||
r["superseded_by"] = successors.get(r["id"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("[compass] query %r rating=%s -> %d hit(s)", query, rating, len(results))
|
||||
logger.info(
|
||||
"[compass] query %r rating=%s include_archived=%s -> %d hit(s)",
|
||||
query,
|
||||
rating,
|
||||
include_archived,
|
||||
len(results),
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def find_conflicts(
|
||||
context: str,
|
||||
decision: str,
|
||||
limit: int = 3,
|
||||
db_path: Optional[Path | str] = None,
|
||||
) -> list[dict]:
|
||||
"""Return ACTIVE decisions whose text overlaps a would-be new entry.
|
||||
|
||||
A write-time, side-effect-free advisory helper: it does NOT increment
|
||||
``times_surfaced`` and never writes. The combined ``context + decision``
|
||||
text is sanitised (:func:`_sanitize_fts_query`) so no FTS5 syntax character
|
||||
can crash the MATCH. Returns up to ``limit`` active hits by BM25 relevance,
|
||||
or an empty list when the text has no usable tokens / nothing overlaps.
|
||||
|
||||
Args:
|
||||
context: The would-be new entry's context.
|
||||
decision: The would-be new entry's decision.
|
||||
limit: Max advisory hits to return (default 3).
|
||||
db_path: Optional DB path override.
|
||||
|
||||
Returns:
|
||||
A list of active decision dicts (most relevant first), possibly empty.
|
||||
"""
|
||||
match = _sanitize_fts_query(f"{context or ''} {decision or ''}")
|
||||
if match is None:
|
||||
return []
|
||||
|
||||
select_cols = ", ".join(f"d.{c}" for c in _DECISION_COLUMNS)
|
||||
sql = f"""
|
||||
SELECT {select_cols}
|
||||
FROM decisions_fts f
|
||||
JOIN decisions d ON d.id = f.rowid
|
||||
WHERE decisions_fts MATCH ?
|
||||
AND d.status = 'active'
|
||||
ORDER BY bm25(decisions_fts) ASC
|
||||
LIMIT ?
|
||||
"""
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
rows = conn.execute(sql, (match, limit)).fetchall()
|
||||
results = [_row_to_dict(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("[compass] conflict-check -> %d active hit(s)", len(results))
|
||||
return results
|
||||
|
||||
|
||||
def set_note(
|
||||
decision_id: int,
|
||||
note: Optional[str],
|
||||
db_path: Optional[Path | str] = None,
|
||||
) -> bool:
|
||||
"""Set (replace) the note on an existing decision.
|
||||
|
||||
The FTS5 external-content ``decisions_au`` trigger re-indexes the row on
|
||||
UPDATE, so the new note is immediately searchable.
|
||||
|
||||
Args:
|
||||
decision_id: Target decision id.
|
||||
note: The note text to store (may be empty to clear).
|
||||
db_path: Optional DB path override.
|
||||
|
||||
Returns:
|
||||
True if a row was updated, False if no such id.
|
||||
"""
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
cur = conn.execute("UPDATE decisions SET note = ? WHERE id = ?", (note, decision_id))
|
||||
conn.commit()
|
||||
changed = cur.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("[compass] note id=%s (changed=%s)", decision_id, changed)
|
||||
json_handler.log_operation("compass_note", {"id": decision_id, "changed": changed})
|
||||
return changed
|
||||
|
||||
|
||||
def stats(db_path: Optional[Path | str] = None) -> dict:
|
||||
"""Return decision counts by rating, by status, and the total.
|
||||
|
||||
@@ -414,3 +589,174 @@ def review(
|
||||
logger.info("[compass] review surfaced id=%s stamped=%s", result["id"], stamp)
|
||||
json_handler.log_operation("compass_review", {"id": result["id"], "last_reviewed": stamp})
|
||||
return result
|
||||
|
||||
|
||||
# Ambient recall caps the OR-expansion of a raw prompt: beyond this many unique
|
||||
# tokens the extra words add noise, not recall, and the MATCH string balloons.
|
||||
_RECALL_MAX_TOKENS = 64
|
||||
|
||||
# Stopwords never reach the MATCH: in an OR-of-tokens query, high-frequency
|
||||
# filler ("lets keep working on the...") outweighs topic words in BM25 and
|
||||
# surfaces unrelated entries — proven live in the FPLAN-0332 acceptance run.
|
||||
# Three categories, all query-side only (entry text is never filtered):
|
||||
# grammatical stopwords; conversational filler verbs that open most prompts
|
||||
# ("lets keep working on / need to fix"); greeting/small-talk words ("good
|
||||
# morning", "how did it go last night"). In a technical store casual words are
|
||||
# RARE (df 1-2), so rarity scoring alone cannot reject them — they must never
|
||||
# become query tokens at all. The topic space is open; this filler set is
|
||||
# closed and small, which is why filtering here works.
|
||||
_RECALL_STOPWORDS = frozenset(
|
||||
"""a an and about again also are as at back be bit but by can could did do
|
||||
does for from had has have how i if in is it its just me my no not of on
|
||||
or our so still sure than thanks thank that the their then there these
|
||||
they this to too u ur us was way we well were what when where which who
|
||||
why will with would yes you your
|
||||
add check doing done fix get go going keep lets look make need now see
|
||||
should try use want work working write
|
||||
day days good hello hey hi im ive last morning night ok okay please right
|
||||
today tomorrow tonight week yesterday""".split()
|
||||
)
|
||||
|
||||
|
||||
def recall_decisions(
|
||||
prompt_text: str,
|
||||
limit: int = 3,
|
||||
db_path: Optional[Path | str] = None,
|
||||
) -> list[dict]:
|
||||
"""Return scored ambient-recall candidates for raw prompt text.
|
||||
|
||||
The Track 2 read path (DPLAN-0246): a hooks handler passes the raw user
|
||||
prompt; governance (@memory's ``should_surface``) judges the candidates.
|
||||
Side-effect-free — ``times_surfaced`` is NOT incremented here, because a
|
||||
candidate is not yet surfaced; the caller reports actual injections via
|
||||
:func:`mark_surfaced` so the counter stays honest.
|
||||
|
||||
The prompt is arbitrary text, never FTS5 syntax: unique word tokens (first
|
||||
``_RECALL_MAX_TOKENS``) are OR-ed as quoted literals, ACTIVE rows only,
|
||||
ranked by BM25. Each result dict carries ``relevance`` — the BM25 magnitude
|
||||
mapped to (0, 1) via ``m / (1 + m)``, higher = more relevant — so
|
||||
governance thresholds live on a bounded scale.
|
||||
|
||||
Args:
|
||||
prompt_text: Raw prompt text (any content, any length).
|
||||
limit: Max candidates to return (default 3).
|
||||
db_path: Optional DB path override.
|
||||
|
||||
Returns:
|
||||
A list of active decision dicts (most relevant first), each with a
|
||||
``relevance`` float in (0, 1); empty when the prompt has no usable
|
||||
tokens or nothing matches.
|
||||
|
||||
Raises:
|
||||
ValueError: On non-positive limit.
|
||||
"""
|
||||
if limit <= 0:
|
||||
raise ValueError(f"limit must be a positive integer, got {limit!r}")
|
||||
|
||||
tokens = _FTS_WORD.findall(prompt_text or "")
|
||||
unique: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for t in tokens:
|
||||
lowered = t.lower()
|
||||
if lowered not in seen and lowered not in _RECALL_STOPWORDS:
|
||||
seen.add(lowered)
|
||||
unique.append(t)
|
||||
if len(unique) >= _RECALL_MAX_TOKENS:
|
||||
break
|
||||
if not unique:
|
||||
return []
|
||||
match = " OR ".join(f'"{t}"' for t in unique)
|
||||
|
||||
select_cols = ", ".join(f"d.{c}" for c in _DECISION_COLUMNS)
|
||||
sql = f"""
|
||||
SELECT {select_cols}, bm25(decisions_fts) AS rank,
|
||||
(d.context || ' ' || d.decision || ' ' ||
|
||||
COALESCE(d.note, '') || ' ' || COALESCE(d.tags, '')) AS _text
|
||||
FROM decisions_fts f
|
||||
JOIN decisions d ON d.id = f.rowid
|
||||
WHERE decisions_fts MATCH ?
|
||||
AND d.status = 'active'
|
||||
ORDER BY bm25(decisions_fts) ASC
|
||||
LIMIT ?
|
||||
"""
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
# Over-fetch: rare-token scoring below reorders, so BM25's top-N alone
|
||||
# would let a filler-heavy row crowd out a topical one.
|
||||
rows = conn.execute(sql, (match, max(limit * 3, 10))).fetchall()
|
||||
|
||||
# Rarity cutoff: a token is evidence only if few entries contain it.
|
||||
active_total = conn.execute("SELECT count(*) FROM decisions WHERE status = 'active'").fetchone()[0]
|
||||
rare_cutoff = max(3, active_total // 10)
|
||||
|
||||
# Document frequency per prompt token, ONE query against the FTS index.
|
||||
df: dict[str, int] = {}
|
||||
for t in unique:
|
||||
df[t.lower()] = conn.execute(
|
||||
"SELECT count(*) FROM decisions_fts f JOIN decisions d ON d.id = f.rowid "
|
||||
"WHERE decisions_fts MATCH ? AND d.status = 'active'",
|
||||
(f'"{t}"',),
|
||||
).fetchone()[0]
|
||||
|
||||
results = []
|
||||
for row in rows:
|
||||
item = _row_to_dict(row)
|
||||
text = row["_text"].lower()
|
||||
# Rare-token evidence: how many DISTINCTIVE prompt words this entry
|
||||
# actually contains. Filler matches score zero — an entry with no
|
||||
# rare-token overlap must not surface (FPLAN-0332 acceptance: a
|
||||
# haiku prompt surfaced an unrelated ruling on BM25 alone).
|
||||
matched_rare = sum(
|
||||
1
|
||||
for t in unique
|
||||
if df[t.lower()] <= rare_cutoff and re.search(rf"\b{re.escape(t)}", text, re.IGNORECASE)
|
||||
)
|
||||
item["relevance"] = matched_rare / (1.0 + matched_rare)
|
||||
item["_bm25"] = row["rank"]
|
||||
results.append(item)
|
||||
|
||||
results.sort(key=lambda r: (-r["relevance"], r["_bm25"]))
|
||||
results = results[:limit]
|
||||
for r in results:
|
||||
del r["_bm25"]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("[compass] recall -> %d candidate(s)", len(results))
|
||||
return results
|
||||
|
||||
|
||||
def mark_surfaced(
|
||||
decision_ids: list[int],
|
||||
db_path: Optional[Path | str] = None,
|
||||
) -> int:
|
||||
"""Increment ``times_surfaced`` for decisions actually injected.
|
||||
|
||||
The write half of the recall contract: :func:`recall_decisions` returns
|
||||
candidates without side effects; whatever governance approves and the
|
||||
caller truly injects gets counted here — never the merely-considered.
|
||||
|
||||
Args:
|
||||
decision_ids: Ids of the decisions that were injected.
|
||||
db_path: Optional DB path override.
|
||||
|
||||
Returns:
|
||||
Number of rows updated (0 for an empty list).
|
||||
"""
|
||||
if not decision_ids:
|
||||
return 0
|
||||
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
placeholders = ",".join("?" for _ in decision_ids)
|
||||
cur = conn.execute(
|
||||
f"UPDATE decisions SET times_surfaced = times_surfaced + 1 WHERE id IN ({placeholders})",
|
||||
list(decision_ids),
|
||||
)
|
||||
conn.commit()
|
||||
updated = cur.rowcount
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("[compass] mark_surfaced -> %d row(s)", updated)
|
||||
return updated
|
||||
|
||||
@@ -18,11 +18,12 @@ This module is the thin command layer (FPLAN P2). It parses args, calls the
|
||||
No business logic lives here — that's the handler's job.
|
||||
|
||||
Subcommands:
|
||||
add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..]
|
||||
query "question" [--rating R] [--limit N]
|
||||
add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..] [--supersedes N]
|
||||
query "question" [--rating R] [--limit N] [--include-archived]
|
||||
stats
|
||||
rate <id> <rating>
|
||||
archive <id>
|
||||
note <id> "text"
|
||||
review
|
||||
|
||||
Every subcommand accepts ``--db PATH`` (passed through as ``db_path=``) for
|
||||
@@ -36,11 +37,19 @@ from typing import List, Optional
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import err_console, error, warning
|
||||
from aipass.devpulse.apps.handlers import compass
|
||||
from aipass.devpulse.apps.handlers.compass import mark_surfaced, recall_decisions
|
||||
from aipass.devpulse.apps.handlers.json import json_handler
|
||||
|
||||
# Public cross-branch recall API (DPLAN-0246 Track 2). Other branches import
|
||||
# at the modules/ boundary ONLY (seedgo boardroom ruling):
|
||||
# from aipass.devpulse.apps.modules.compass import recall_decisions, mark_surfaced
|
||||
# recall_decisions(prompt_text, limit) -> scored candidates, side-effect-free;
|
||||
# mark_surfaced(ids) counts only what the caller actually injected.
|
||||
__all__ = ["handle_command", "mark_surfaced", "recall_decisions"]
|
||||
|
||||
console = err_console
|
||||
|
||||
_VALID_SUBCOMMANDS = ("add", "query", "stats", "rate", "archive", "review")
|
||||
_VALID_SUBCOMMANDS = ("add", "query", "stats", "rate", "archive", "note", "review")
|
||||
|
||||
# Console colour per rating — the rating is the signal, so make it pop.
|
||||
_RATING_STYLE = {
|
||||
@@ -59,6 +68,7 @@ HELP_TEXT = """\
|
||||
compass stats Counts by rating/status
|
||||
compass rate <id> <rating> Re-rate a decision
|
||||
compass archive <id> Archive a decision
|
||||
compass note <id> "text" Set a decision's note
|
||||
compass review Surface one to review
|
||||
compass --help Show this help
|
||||
|
||||
@@ -70,19 +80,43 @@ HELP_TEXT = """\
|
||||
--note "..." Optional human observation.
|
||||
--tags a,b,c Optional comma-separated tags.
|
||||
--source S Optional. devpulse (default) or user.
|
||||
--supersedes N Optional. Archive decision #N and link this entry as its
|
||||
correction (atomic). At add time, overlapping active
|
||||
entries are shown as a non-blocking advisory.
|
||||
|
||||
[bold]Options (query):[/bold]
|
||||
--rating R Optional exact-rating filter.
|
||||
--limit N Optional max results (default 5).
|
||||
--include-archived Also search archived (avoid-list) entries; archived hits
|
||||
show their status + supersession pointer.
|
||||
|
||||
[bold]Options (all subcommands):[/bold]
|
||||
--db PATH Use an alternate SQLite store (testing / power use).
|
||||
|
||||
[bold]Examples:[/bold]
|
||||
drone @devpulse compass add "auth fork" "chose JWT over sessions" --rating good
|
||||
drone @devpulse compass add "auth fork" "switch to sessions" --rating good --supersedes 4
|
||||
drone @devpulse compass query "auth" --rating good --limit 3
|
||||
drone @devpulse compass query "auth" --include-archived
|
||||
drone @devpulse compass stats
|
||||
drone @devpulse compass rate 4 bad
|
||||
drone @devpulse compass archive 4
|
||||
drone @devpulse compass note 4 "revisited — this held up"
|
||||
drone @devpulse compass review
|
||||
|
||||
See DPLAN-0212 (design) and the compass handler (apps/handlers/compass/).
|
||||
See DPLAN-0212 / DPLAN-0246 (design) and the compass handler (apps/handlers/compass/).
|
||||
"""
|
||||
|
||||
|
||||
_NOTE_HELP_TEXT = """\
|
||||
[bold]compass note[/bold] — set (replace) a decision's note
|
||||
|
||||
Usage:
|
||||
compass note <id> "text" Set the note on decision #<id>
|
||||
compass note --help Show this help
|
||||
|
||||
The note is re-indexed for search immediately — the FTS5 mirror stays in sync,
|
||||
so the new note text is findable by 'compass query' right away.
|
||||
"""
|
||||
|
||||
|
||||
@@ -93,7 +127,7 @@ def print_introspection() -> None:
|
||||
console.print("[dim]Devpulse rated decision store. The truth-store of choices —[/dim]")
|
||||
console.print("[dim]each decision rated; the rating is the signal at a fork.[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]Subcommands:[/yellow] [cyan]add, query, stats, rate, archive, review[/cyan]")
|
||||
console.print("[yellow]Subcommands:[/yellow] [cyan]add, query, stats, rate, archive, note, review[/cyan]")
|
||||
console.print("[dim]Run 'compass --help' for full usage.[/dim]")
|
||||
console.print()
|
||||
|
||||
@@ -141,6 +175,8 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
return _handle_rate(sub_args)
|
||||
if subcommand == "archive":
|
||||
return _handle_archive(sub_args)
|
||||
if subcommand == "note":
|
||||
return _handle_note(sub_args)
|
||||
if subcommand == "review":
|
||||
return _handle_review(sub_args)
|
||||
|
||||
@@ -179,6 +215,18 @@ def _extract_db_path(args: List[str]) -> tuple[List[str], Optional[str]]:
|
||||
return _extract_flag(args, "--db")
|
||||
|
||||
|
||||
def _extract_bool_flag(args: List[str], flag: str) -> tuple[List[str], bool]:
|
||||
"""Pull a valueless boolean ``--flag`` out of args.
|
||||
|
||||
Returns the remaining args (every occurrence of the flag removed) and True
|
||||
if the flag was present, else False. Unlike ``_extract_flag`` this consumes
|
||||
no following value.
|
||||
"""
|
||||
if flag in args:
|
||||
return [a for a in args if a != flag], True
|
||||
return args, False
|
||||
|
||||
|
||||
def _rating_tag(rating: str) -> str:
|
||||
"""Render a coloured ``[RATING]`` tag for query/review output."""
|
||||
style = _RATING_STYLE.get(rating, "bold white")
|
||||
@@ -198,13 +246,16 @@ def _handle_add(sub_args: List[str]) -> bool:
|
||||
rest, note = _extract_flag(rest, "--note")
|
||||
rest, tags = _extract_flag(rest, "--tags")
|
||||
rest, source = _extract_flag(rest, "--source")
|
||||
rest, supersedes_raw = _extract_flag(rest, "--supersedes")
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] add arg-parse error: %s", exc)
|
||||
error(str(exc), suggestion="Use 'compass --help' for usage")
|
||||
return True
|
||||
|
||||
if len(rest) < 2:
|
||||
error('Usage: compass add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..]')
|
||||
error(
|
||||
'Usage: compass add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..] [--supersedes N]'
|
||||
)
|
||||
return True
|
||||
if rating is None:
|
||||
error("compass add requires --rating", suggestion="One of: good | bad | impressive | interesting")
|
||||
@@ -213,6 +264,34 @@ def _handle_add(sub_args: List[str]) -> bool:
|
||||
context = rest[0]
|
||||
decision = rest[1]
|
||||
|
||||
supersedes: Optional[int] = None
|
||||
if supersedes_raw is not None:
|
||||
try:
|
||||
supersedes = int(supersedes_raw)
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] add bad --supersedes %r: %s", supersedes_raw, exc)
|
||||
error(f"--supersedes must be an integer, got {supersedes_raw!r}")
|
||||
return True
|
||||
|
||||
# Write-time conflict check — a NON-BLOCKING advisory (DPLAN-0246). Skipped
|
||||
# when the writer already chose to supersede, and never allowed to block or
|
||||
# crash the add. Only shown when NOT already superseding.
|
||||
if supersedes is None:
|
||||
try:
|
||||
conflicts = compass.find_conflicts(context, decision, db_path=db_path)
|
||||
except Exception as exc: # advisory must never break a write
|
||||
logger.warning("[compass] conflict-check failed (non-blocking): %s", exc)
|
||||
conflicts = []
|
||||
for c in conflicts:
|
||||
cid = c.get("id")
|
||||
excerpt = (c.get("context") or "").strip()
|
||||
if len(excerpt) > 80:
|
||||
excerpt = excerpt[:77] + "..."
|
||||
console.print(
|
||||
f"[yellow]possible conflict with #{cid}[/yellow]: {excerpt} "
|
||||
f"[dim]— supersede? (--supersedes {cid})[/dim]"
|
||||
)
|
||||
|
||||
try:
|
||||
new_id = compass.add_decision(
|
||||
context,
|
||||
@@ -222,6 +301,7 @@ def _handle_add(sub_args: List[str]) -> bool:
|
||||
tags=tags,
|
||||
source=source if source is not None else "devpulse",
|
||||
db_path=db_path,
|
||||
supersedes=supersedes,
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] add rejected: %s", exc)
|
||||
@@ -235,6 +315,8 @@ def _handle_add(sub_args: List[str]) -> bool:
|
||||
console.print(f" [cyan]note:[/cyan] {note}")
|
||||
if tags:
|
||||
console.print(f" [cyan]tags:[/cyan] {tags}")
|
||||
if supersedes is not None:
|
||||
console.print(f" [magenta]supersedes #{supersedes}[/magenta] [dim](archived)[/dim]")
|
||||
return True
|
||||
|
||||
|
||||
@@ -242,6 +324,7 @@ def _handle_query(sub_args: List[str]) -> bool:
|
||||
"""Parse and dispatch ``compass query "question" [--rating R] [--limit N]``."""
|
||||
try:
|
||||
rest, db_path = _extract_db_path(sub_args)
|
||||
rest, include_archived = _extract_bool_flag(rest, "--include-archived")
|
||||
rest, rating = _extract_flag(rest, "--rating")
|
||||
rest, limit_raw = _extract_flag(rest, "--limit")
|
||||
except ValueError as exc:
|
||||
@@ -250,7 +333,7 @@ def _handle_query(sub_args: List[str]) -> bool:
|
||||
return True
|
||||
|
||||
if not rest:
|
||||
error('Usage: compass query "question" [--rating R] [--limit N]')
|
||||
error('Usage: compass query "question" [--rating R] [--limit N] [--include-archived]')
|
||||
return True
|
||||
|
||||
query_text = rest[0]
|
||||
@@ -265,7 +348,13 @@ def _handle_query(sub_args: List[str]) -> bool:
|
||||
return True
|
||||
|
||||
try:
|
||||
results = compass.query_decisions(query_text, rating=rating, limit=limit, db_path=db_path)
|
||||
results = compass.query_decisions(
|
||||
query_text,
|
||||
rating=rating,
|
||||
limit=limit,
|
||||
include_archived=include_archived,
|
||||
db_path=db_path,
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] query rejected: %s", exc)
|
||||
error(str(exc))
|
||||
@@ -294,6 +383,16 @@ def _render_query_results(query_text: str, rating: Optional[str], results: List[
|
||||
console.print(f" [cyan]note:[/cyan] {r['note']}")
|
||||
if r.get("tags"):
|
||||
console.print(f" [cyan]tags:[/cyan] {r['tags']}")
|
||||
# Supersession pointers — an archived hit must never masquerade as
|
||||
# current truth, so flag its status + who replaced it (DPLAN-0246).
|
||||
if r.get("status") == "archived":
|
||||
superseded_by = r.get("superseded_by")
|
||||
if superseded_by:
|
||||
console.print(f" [bold yellow]ARCHIVED[/bold yellow] — superseded by #{superseded_by}")
|
||||
else:
|
||||
console.print(" [bold yellow]ARCHIVED[/bold yellow] (avoid-list)")
|
||||
if r.get("supersedes"):
|
||||
console.print(f" [magenta]supersedes #{r['supersedes']}[/magenta]")
|
||||
meta = f"source={r.get('source', '?')} status={r.get('status', '?')} surfaced={r.get('times_surfaced', 0)}"
|
||||
console.print(f" [dim]{meta}[/dim]")
|
||||
console.print()
|
||||
@@ -389,6 +488,44 @@ def _handle_archive(sub_args: List[str]) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _handle_note(sub_args: List[str]) -> bool:
|
||||
"""Dispatch ``compass note <id> "text"`` — set a decision's note.
|
||||
|
||||
Follows the subcommand-help convention: ``compass note --help`` prints the
|
||||
per-subcommand help block; malformed input shows the Usage line.
|
||||
"""
|
||||
try:
|
||||
rest, db_path = _extract_db_path(sub_args)
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] note arg-parse error: %s", exc)
|
||||
error(str(exc))
|
||||
return True
|
||||
|
||||
if rest and rest[0] in ("--help", "-h", "help"):
|
||||
console.print(_NOTE_HELP_TEXT)
|
||||
return True
|
||||
|
||||
if len(rest) < 2:
|
||||
error('Usage: compass note <id> "text"')
|
||||
return True
|
||||
|
||||
try:
|
||||
decision_id = int(rest[0])
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] note bad id %r: %s", rest[0], exc)
|
||||
error(f"<id> must be an integer, got {rest[0]!r}")
|
||||
return True
|
||||
|
||||
note_text = rest[1]
|
||||
changed = compass.set_note(decision_id, note_text, db_path=db_path)
|
||||
if changed:
|
||||
console.print(f"[green]Note set[/green] on [bold]#{decision_id}[/bold]")
|
||||
console.print(f" [cyan]note:[/cyan] {note_text}")
|
||||
else:
|
||||
warning(f"No decision with id {decision_id} — nothing changed.")
|
||||
return True
|
||||
|
||||
|
||||
def _handle_review(sub_args: List[str]) -> bool:
|
||||
"""Dispatch ``compass review`` — surface one active decision to review."""
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
# S304 Discovery Mission — Autonomous System Walk
|
||||
|
||||
**Date:** 2026-07-12
|
||||
**Mandate (Patrick):** Walk AIPass autonomously, use the systems, probe commands randomly, hunt bugs/gaps. Search-only — no edits to existing files. New files in devpulse docs + DPLANs allowed. Second deliverable: ideas for attracting/retaining contributors ("we have no way to really attract people... want people to stay involved and eventually start contributing").
|
||||
|
||||
**Method:** Rounds of parallel probes — direct CLI walking + read-only Explore sub-agents + log/registry sweeps. Findings accumulate here with evidence. Watchdog timer keeps the session alive.
|
||||
|
||||
---
|
||||
|
||||
## EXECUTIVE SUMMARY (written mid-mission, maintained)
|
||||
|
||||
**122 findings + 7 adoption observations across 9 sub-agents + full CLI walk + RUNTIME probing (ran 6 test suites ~4k tests, live-process/resource/log/git-churn analysis). Zero system files edited; all fixes are proposals. COMPLETE COVERAGE across every dimension: all 17 branches code-swept + hooks engine + seedgo machinery + security gates + skills surface + newcomer path + adoption funnel + live system + git history. Many findings independently CONFIRMED by 2+ agents; the criticals dated by git-blame; F1 reproduced live; bug-magnet churn maps exactly onto surviving bugs.**
|
||||
|
||||
**Adoption-critical late find (F114):** the skills platform — the natural first contribution surface (DPLAN-0209/0240) — runs skills as UNSANDBOXED in-process code with no gate. Can't safely invite community skills until a trust model ships. Now a documented Tier-3 blocker in DPLAN-0240.
|
||||
|
||||
**TWO LIVE CRITICALS (both verified on disk):**
|
||||
- **F74 — memory rollover keeps 14 not 15, fleet-wide, RIGHT NOW.** Off-by-one trims at the keep target instead of above it; memory/drone/hooks/seedgo/flow all prove it (14/14). Every branch silently loses one extra entry per rollover. (todo 66 — Patrick monitors; documented not filed.)
|
||||
- **F5b — medic auto-dispatch has been OFF 63 days: `config.medic_enabled=false` since 2026-05-10.** Errors detected, zero dispatched — that's why the registry is a graveyard. NOTE: my first root cause (stuck circuit breaker) was WRONG; a sweep refuted it and I re-verified on disk (1,667 "Medic OFF" log entries, 0 breaker-OPEN). The stuck breaker (F5c) is a real but SEPARATE latent bug that must be fixed before medic is re-enabled. Good case study in verify-refutes-confident-findings.
|
||||
|
||||
**Two dominant PATTERNS across the 80:** (1) "write side shipped, read/recovery side missing" — F5b/F5/F26/F40/F46/F49/F75 + the atomic-write class F73/F79; (2) casing drift from mixed-case registry names leaking into 6+ surfaces — F15/F52/F69/F77/doctor/lint.
|
||||
|
||||
**Security note:** F59-F66 audit the gates. Honest framing: agents are same-user + cooperative, so these are "the owner model is ADVISORY not ENFORCED" integrity gaps, NOT remote exploits. F59 (owner git-access trusts an unprotected passport name, not the sealed registry_id) is the one that most undercuts a model Patrick deliberately built (DPLAN-0231).**
|
||||
|
||||
**Fix-first (HIGH):**
|
||||
- **F74 — CRITICAL (LIVE NOW): memory rollover keeps 14 not 15 fleet-wide** — off-by-one trims at the keep target; verified on disk. Silent memory loss every rollover. (todo 66 — documented, not filed per your standing hold.)
|
||||
- **F5b — CRITICAL: medic auto-dispatch OFF 63 days** — `config.medic_enabled=false` since 2026-05-10 (verified: 1,667 "Medic OFF" log entries, 0 breaker-OPEN). Root cause of the F5 graveyard. Re-enable = `drone @trigger medic on`, but ONLY after fixing F5c (stuck breaker) + F43 (fixture storm) or it re-floods. (My first breaker-based root cause was refuted by a sweep and corrected — see F5b/F5c.)
|
||||
- F1 — PR#696 red CI is ONE racy test (`test_mtime_cache_avoids_reread`); 2-line deterministic fix proposed, @prax owns
|
||||
- F14/F15/F16/F17 — `aipass doctor` cries wolf on healthy installs (backup-scan false positives, case-sensitive registry check vs UPPERCASE registry names, `{{BRANCHNAME}}` placeholder, ✓-with-warning-text) — the newcomer trust tool reports 7 false errors
|
||||
- F30 — `spawn repair` advises a command that would archive the LIVE @aipass branch
|
||||
- F5+F6+F43 — error registry: 198 errors nobody triages, polluted by test fixtures because pytest writes to production logs/ that the 24/7 log-watcher ingests
|
||||
- F22/F23 — the human-facing concierge has the worst help in the fleet (module dump; Q&A answers the wrong domain)
|
||||
- F46 — 14 feedback messages unread since April, incl. external bug reports we rediscovered ourselves months later
|
||||
- F52 — commons artifact gift/trade writes UPPERCASE owner → gifted artifacts invisible + permanently locked (verified in code)
|
||||
|
||||
**Adoption headline (A1–A5 + DPLAN-0240):** ~600 unique humans/month touch the repo (296 visitors + 298 cloners/14d), 237 stars — and conversion is ZERO because there's nothing to grab (no topics, no open issues, empty Commons, silence to the one human who ever PR'd). The April Precedent: VERA ran the fix-play in April; our issue-zero hygiene batch-deleted it 3 weeks later. Full plan: DPLAN-0240.
|
||||
|
||||
**Deliverables:** this file (findings F1–F46, evidence inline) + DPLAN-0240 (contributor funnel, 4 tiers) + CI root-cause for todo 66.
|
||||
|
||||
---
|
||||
|
||||
## FINDINGS — Bugs & Gaps
|
||||
|
||||
(rolling; newest at bottom; each entry: what, evidence, severity)
|
||||
|
||||
### F1 — PR#696 red CI: `test_mtime_cache_avoids_reread` is inherently racy [HIGH — blocks green CI]
|
||||
- **What:** Sole failure across ALL red jobs (4 Linux matrix jobs on PR run 29203756084 + push runs 29203754739/772 incl. Windows): `src/aipass/prax/tests/test_telegram_relay.py::TestReadControl::test_mtime_cache_avoids_reread` — `AssertionError: assert {} == {'paused': False}`.
|
||||
- **Why:** Test (line 441) writes valid JSON, reads (caches by `st_mtime`), overwrites with `"INVALID JSON"`, reads again, asserts cached value returned. It only passes if both writes land on the SAME `st_mtime` float — a filesystem-granularity coin flip. On GitHub runners the mtime ticks → `_read_control()` re-reads (telegram_relay.py:162), hits the parse-error path (:172), returns `{}`. Its sibling `test_mtime_change_triggers_reread` (line 452) correctly FORCES mtime difference via `os.utime`; this test never forces mtime EQUALITY.
|
||||
- **Proposed fix (NOT applied — search mission):** after the second `write_text`, pin mtime back: `os.utime(ctrl, (first_stat.st_mtime, first_stat.st_mtime))` — deterministic on every fs. 2-line change, @prax owns the file.
|
||||
- **Bonus design note (low):** on parse error `_read_control` caches `{}` keyed to the new mtime → a corrupted/half-written control file silently FAILS OPEN (unpauses a paused stream, resets level to all) until the next control write. Writer should write-temp-then-rename atomic; worth confirming it does.
|
||||
|
||||
### F3 — git gate false-positives on `git <word>` ANYWHERE in a Bash command [MEDIUM — UX trap]
|
||||
- **What:** The @hooks git gate blocks any Bash command whose text contains `git` followed by another word, even as pure DATA. Repro (blocked): `for a in cli git seedgo; do echo "item: $a"; done`. Control (passes): `for a in git; do echo $a; done` and `echo git`. Collateral: the block rejects the ENTIRE compound command — an innocent `sed` read chained in the same call died with it.
|
||||
- **Impact:** any agent iterating over branch names (cli git seedgo …) — an utterly natural loop in this ecosystem — gets a confusing "git via drone" refusal. Cost me a probe; will bite others. Gate should parse command position, not substring.
|
||||
- **Where:** @hooks git_gate handler (hooks owns; note for owner — no edits made).
|
||||
|
||||
### F4 — watchdog timer heartbeat: correct design, docs gap [LOW]
|
||||
- Timer pings progress to **stderr** every 10s by design (stdout stays quiet until the final "woke" result) — my first Monitor arm used `2>&1` and flooded the event stream (~1 event/10s). Docs (README watchdog section) never say "don't merge stderr when arming a Monitor on the timer". One sentence would prevent this trap. (My own module — still not editing during a search mission.)
|
||||
|
||||
### F2 — `gh run view --log-failed` yields 0 bytes (gh quirk); drone relays the silence [MEDIUM — diagnostic dead-end, REVISED]
|
||||
- **Revised root cause:** reproduced the passthrough's exact subprocess (`gh run view --job 86679445524 --log-failed`, capture_output): exit 0, stdout 0 bytes, 0.9s — gh ITSELF returns nothing for these jobs, drone's passthrough (git_module.py:203) is innocent. Same data via `gh api repos/.../jobs/<id>/logs` = 11,467 lines.
|
||||
- **Still a gap:** the CI-debug path every agent will try first silently yields nothing. Cheap win: drone's `run` passthrough could detect `--log*` + empty stdout and print "gh returned no log output — try: gh api repos/<owner>/<repo>/actions/jobs/<id>/logs".
|
||||
|
||||
---
|
||||
|
||||
### F5 — Error registry is a write-only graveyard: 198 errors, 196 forever-"new" [HIGH — systemic]
|
||||
- Detection works (medic v2 ingests everything) but NOTHING triages: statuses sit at `new` since May (first_seen 2026-05-18, still `new` 2026-07-12). `resolve`/`suppress`/`purge` commands exist and are never run by anyone — no daemon job, no owner ritual. The registry grows until it's noise.
|
||||
- **ROOT CAUSE FOUND — see F5b.** The reason nothing triages/auto-heals: the medic circuit breaker has been stuck OPEN since May 10.
|
||||
|
||||
### F5b — Medic auto-dispatch has been OFF since 2026-05-10 [CRITICAL — CORRECTED root cause]
|
||||
- **⚠️ My first root cause (stuck circuit breaker) was WRONG — a later sweep refuted it and I re-verified on disk.** The ACTUAL reason: `trigger_config.json → config.medic_enabled = **False**`, dated 2026-05-10. The dispatch path checks `_is_medic_enabled()` (error_detected.py:442) and bails to `medic_suppressed.jsonl` BEFORE the circuit breaker is ever consulted (line 481).
|
||||
- **Verified ground truth:** `logs/medic_suppressed.jsonl` = 1,681 entries, **1,667 "Medic OFF"**, **0 "Circuit breaker OPEN"**. Latest entry TODAY 19:47 ("Medic OFF", SKILLS bot poll error). Medic is dead because it's TURNED OFF, full stop.
|
||||
- **Why the breaker was a red herring:** it IS stuck open (F5c below) and tripped the same day (2026-05-10), which made it look causal. But the medic-off toggle short-circuits upstream, so the breaker never even runs. Two 05-10 events, one visible symptom — classic confounding. (Also: my earlier `medic_config.json → enabled:true` probe read the WRONG file; the authoritative source both `_is_medic_enabled` and `medic_state.is_enabled` read is `trigger_config.json → config.medic_enabled`, which is False.)
|
||||
- **Open question ANSWERED (log-sweep sub-agent + I verified):** `medic.log` shows the exact sequence — `2026-05-10 20:30:21 | [MEDIC] Medic DISABLED - error dispatch suppressed` → `20:30:22 | Log watcher service stopped`. That's the SAME MINUTE as the fixture storm (20:29:53–20:30:18) and the breaker trip (20:30:18). Causal story complete: **the fixture storm flooded → medic was DELIBERATELY disabled at 20:30:21 to stop the noise → never re-enabled.** A 25-second annoyance muted the whole error-notification system for 63 days. The registry graveyard (F5) is the direct consequence. This is F81/the-janitor-pattern in miniature: the OFF switch was pulled and no ritual ever pulled it back.
|
||||
- **SAFE IMMEDIATE MITIGATION (NOT run — search-only hold):** re-enabling is `drone @trigger medic on`. BUT do NOT re-enable until F5c (stuck breaker) + F43 (fixture storm) are fixed, or it'll immediately trip the breaker again and re-flood. Order: cut fixture storm (F43) → fix breaker recovery (F5c) → `medic on`. Left for Patrick.
|
||||
|
||||
### F5c — Circuit breaker cannot self-heal: half_open is a terminal trap + cooldown never decays [HIGH — latent, blocks medic re-enable]
|
||||
- Separate real bug (was tangled into my original F5b). The breaker IS stuck `open` since 2026-05-10 (opened_at 20:30:18, cooldown 3600s, should've half-opened at 21:30 that day; 63 days later still open). Even if it weren't, recovery is broken THREE ways:
|
||||
1. **open→half_open only runs inside `circuit_breaker_allows()`** (error_registry.py:253), called at exactly ONE site — the dispatch path (error_detected.py:481). No cron/daemon/tick re-evaluates it. With medic off, that site never runs, so the breaker is frozen.
|
||||
2. **half_open is terminal:** the one probe sets `half_open_allow=False` and NOTHING sets `state="closed"` after a successful send (docstring says "caller should reset," caller never does) — a successful probe wedges it in half_open forever with no cooldown timer, worse than open.
|
||||
3. **cooldown escalates but never decays:** pinned at the 3600s max; only manual `circuit_breaker_reset()` restores base 300s. Every future trip inherits the maxed hour.
|
||||
- So this must be fixed BEFORE medic is re-enabled or medic dies again on the first trip. Fix directions: evaluate cooldown on READ + a daemon tick that half-opens expired breakers + close on successful probe + reset cooldown on clean close. Manual unblock: `drone @trigger errors circuit-breaker reset` (verified: cleanly resets, error_registry.py:322).
|
||||
- "0s remaining until half-open" while State=open is a lying status (computes remaining, never acts).
|
||||
|
||||
### F5 (loop-closing note)
|
||||
- **Gap:** no closing half of the loop. Candidate: a daemon job or @trigger self-ritual that ages out stale entries + a weekly triage dispatch to component owners — AND a working CB recovery (F5b) so medic can actually dispatch again.
|
||||
|
||||
### F6 — Test-suite fixtures POLLUTE the production error registry [HIGH — data integrity]
|
||||
- 20 registry entries are obvious test fixtures: `Module bad_module error: boom`, `Module mod1 error: crash`, `broken_mod`, `doctor crashed: db connection failed`, `bad config /tmp/tmpykrlftog/...` (664+334 occurrences). last_seen updates on EVERY test run (07-11 18:45 = yesterday's test runs). The medic pipeline watches logs with no test/prod separation — echoes #694's hermeticity theme but for the error pipeline.
|
||||
- **Also:** same message double-filed under real component AND `UNKNOWN` (separate fingerprints → double counting); component attribution partially broken.
|
||||
|
||||
### F7 — Registry source-tracking fields never populated [MEDIUM]
|
||||
- `source_file: null, source_branch: null, occurrence_count: null` on many/most entries — can't trace an error back to its origin from the registry itself. Fields exist in schema, writers don't fill them.
|
||||
|
||||
### F8 — `errors list` table un-navigable: IDs truncated to 2 chars [MEDIUM — UX]
|
||||
- Rich table at default width elides the very column you need: ID shows `bc…`, fingerprint `8a66…` — you cannot copy an id to run `errors detail <id>`. Severity column also elides (`medi…`) and there's an empty unnamed column. Same disease in `@commons feed` (empty column, `Sco…`).
|
||||
- **Pattern:** Rich tables sized for wide terminals; agents live at ~100 cols. @cli owns display.
|
||||
|
||||
### F9 — "Bare command shows plumbing, not data" pattern across branches [MEDIUM — UX, repeated]
|
||||
- `drone @trigger errors` → handler list (help says `list` is the DEFAULT — the default never routes); `drone @trigger medic` → handler list instead of medic status; `drone @backup status` → "status Module / Phase 3 — implemented" instead of status (and no usage hint of required args). Introspection shadowing the default subcommand looks systemic in the module framework, not per-branch.
|
||||
- **Full observed set by end of mission:** @trigger errors/medic/branch_log_events, @backup status, @memory rollover/verify/pool/templates, @devpulse feedback (bare shows module + counts — best of the bunch, still not the inbox). One framework-level fix (bare module → run default subcommand if declared, else usage) clears ~9 surfaces at once.
|
||||
|
||||
### F10 — Telegram poll-error flood: ~48k occurrences accumulated [context for todo 67]
|
||||
- `Poll error: Name or service not known` — SKILLS 18,320x + API 17,835x + UNKNOWN 11,333x, plus timeouts (1,373x) and SSL EOF (1,302x). Confirms bots have NO backoff on DNS failure (~1 poll/sec × outage hours) and errors triple-file across components. The 401 Unauthorized (266x) was contained to 2026-06-24 (dev-era, stale). OAuth refresh failures last seen 06-26 (outage-correlated, stale).
|
||||
- Strengthens todo 67's case: wedged-socket self-heal + poll backoff.
|
||||
|
||||
### F11 — Daemon queue litter: 3 disabled `wake-test` jobs since 06-25 [LOW]
|
||||
- @backup/@cli/@commons `wake-test` interval jobs, all OFF, sitting in `drone @daemon queue` for 3 weeks. Test cruft in a production view.
|
||||
|
||||
### F12 — @daemon and @trigger introspection both self-describe as "Branch Management System" [LOW — copy-paste drift]
|
||||
- Neither is; that's @spawn's identity. First thing a curious visitor sees when introspecting.
|
||||
|
||||
### F13 — @api swallows unknown commands silently [MEDIUM — house-rule violation]
|
||||
- `drone @api definitely-not-a-command` → exit 0, prints Bridge/Registry module banners, NO error. `drone @api usage` (wrong name for `stats`) → same silent dump. Violates "fail to errors, never fall back silently." Also: Bridge + Registry banners print on EVERY api command (import side-effect noise — `api stats` buries its one data line under them).
|
||||
- **Compounding:** `drone @api models` error hint says "Run `drone @api setup` to configure" — but `setup` is NOT a command (`--help` lists `init` for the .env template). Running the advertised `drone @api setup` hits the silent-unknown fallback (exit 0, banners, nothing). A wrong hint pointing at a command that then silently no-ops = double dead-end for a user configuring API keys.
|
||||
|
||||
### F14 — `aipass doctor` false alarms from scanning `.backup/snapshots/` [HIGH — onboarding trust, EXACT root cause pinned]
|
||||
- Doctor "found 38 agents" (registry says 17) — counts backup mirrors as agents, then flags THEM: `! placement: ai_mail .../.backup/snapshots/...`, `✗ pollution: Duplicate registry_id at .../.backup/snapshots/...`.
|
||||
- **Root cause (pinned):** `structure_scanner.py:96 _SCAN_SKIP_DIRS = {.archive, .venv, .git, __pycache__, node_modules, .chroma}` — **`.backup` and `dropbox` are NOT in the set.** `scan_agents` (line 106) `rglob(".trinity/passport.json")` then descends into `.backup/snapshots/**/.trinity/passport.json` and counts every backup mirror. One-line fix: add `.backup`, `dropbox` to `_SCAN_SKIP_DIRS`. (This scanner is @aipass-owned and is ALSO where F30/F31's structure-validator fragmentation lives.)
|
||||
|
||||
### F15 — Doctor "✗ registry: X missing" ×5 — mechanism is PATH resolution, NOT casing [HIGH — my earlier attribution corrected]
|
||||
- **⚠️ Corrected + CONFIRMED on disk:** I first blamed case-sensitivity — WRONG. `structure_scanner.py:307-314` compares resolved PATHS (`reg_path = Path(path_str).resolve(); if not reg_path.exists(): → "missing"`), never names. Dumped the registry: the 5 flagged branches (BACKUP/COMMONS/DAEMON/HOOKS/SKILLS) are EXACTLY the ones with **relative** `path` (`src/aipass/backup`); all 12 healthy ones have **absolute** paths. `Path("src/aipass/backup").resolve()` resolves against CWD (I ran doctor from devpulse) → `.../devpulse/src/aipass/backup`, doesn't exist → false "missing".
|
||||
- **The real story:** those same 5 entries are BOTH uppercase-named AND relative-pathed — i.e. they were registered by a DIFFERENT/older spawn code path than the other 12 (lowercase + absolute). One registration-format bug produced both anomalies; the casing is a correlated fingerprint, not the cause of the doctor error. Fix: (a) doctor resolves registry paths against project root not CWD; (b) @spawn normalizes those 5 entries to absolute+lowercase and finds why they got a different format. (Casing still leaks into DISPLAY elsewhere — F69/F77/lint — separate symptom of the same 5 bad entries.)
|
||||
|
||||
### F16 — Doctor prints unsubstituted `{{BRANCHNAME}}` placeholder [LOW]
|
||||
- `✗ pollution: {{BRANCHNAME}} 2 copies` — template var never substituted in the message.
|
||||
|
||||
### F17 — Doctor status/text mismatches [MEDIUM — validation theater]
|
||||
- `✓ passport role: unknown` — "unknown" passes green. `✓ root: .venv Redundant venv — ...` — a ✓ whose text is a warning. Top-of-run preamble says "Provider settings not wired" while Services says `✓ wire verify provider hooks wired correctly` — contradictory in one run (different checks, colliding phrasing). Preamble also renders unformatted before the section layout starts.
|
||||
- **Adoption stake:** doctor is the FIRST health tool a newcomer runs. Our own flagship repo scores "30 pass / 23 warnings / 7 errors" — all 7 errors false. A stranger reads that as "my install is broken."
|
||||
|
||||
### F18 — Passport `registry_path` drift [LOW]
|
||||
- devpulse passport says `"registry_path": ".aipass/registry.json"`; real file is root `AIPASS_REGISTRY.json`. Likely fleet-wide passport field drift from the registry redesign.
|
||||
|
||||
### F19 — @memory search result renders empty "Time:" field [LOW]
|
||||
- Every result card ends `Time:` with no value — reads as broken metadata.
|
||||
|
||||
### F20 — Introspection advertises names the router won't accept [LOW→MEDIUM — trap, multiple instances]
|
||||
- @prax lists `log_audit`; real command `log-audit` ("❌ Unknown command" on the advertised name). @commons lists `central` among its 22 modules → `drone @commons central` = Unknown command. @flow lists `aggregate_central` → same. Introspection prints module names; router speaks a different dialect (hyphens, or module not CLI-exposed at all). Every advertised-but-unroutable name is a dead end handed to the user.
|
||||
- **@flow's --help actively LIES:** it prints "Commands can be called by short name (e.g. 'create') OR full name (e.g. 'create_plan')" and lists `aggregate, aggregate_central` / `registry, registry_monitor` as pairs. Verified: the SHORT names route (`aggregate`, `registry` work) but the FULL names it documents as equivalent DON'T (`aggregate_central`, `registry_monitor` → Unknown command). The help promises an alias that doesn't exist. Worse than a bare-name trap — it's a documented-equivalence trap.
|
||||
|
||||
### F21 — 208 memory-cap violations live on disk across all 17 branches [MEDIUM]
|
||||
- `drone @memory lint run`: 208 over-cap entries, worst `aipass observations 2147/300` (7x). Cap enforcement is edit-time (hooks) — legacy/bypass-written entries persist. Registry-casing leak visible here too (`COMMONS`, `HOOKS` uppercase).
|
||||
- **Open question RESOLVED live:** the gate validates only the EDITED entry (my 306-char new session entry was rejected with exact-char feedback — good UX! — while edits elsewhere in a file holding a 305-char legacy entry passed). So legacy violations don't trap branches; they're just rot for rollover to chew. Downgrade severity to LOW-MEDIUM.
|
||||
|
||||
### F22 — `aipass --help` = bare module dump; the human-facing tool has the least human help [HIGH — adoption]
|
||||
- `aipass --help` output is byte-identical to bare `aipass`: 8 modules incl. INTERNAL ones (doctor_wire, doctor_fix), no usage lines, no "new here? run aipass init", no description of what AIPass is. The concierge for humans has worse help than every agent-facing branch (@commons --help is beautifully structured). First-touch surface, worst help.
|
||||
|
||||
### F23 — `aipass help` Q&A retrieves wrong-domain snippets [HIGH — adoption]
|
||||
- Asked "how do I create a new branch" → returns `drone @git pr` usage, seedgo audit lines, drone purpose blurb. NEVER mentions @spawn (the real answer). Conflates git-branch with agent-branch; it's keyword grep over READMEs presented as a chatbot. A newcomer's most natural question gets a wrong answer with confident formatting.
|
||||
|
||||
### F24 — `@daemon update` prints "⚠️ ESCALATIONS NEEDED" over all-zero digest [LOW]
|
||||
- Banner fires while body says Total messages 0, Actionable None. Status/content mismatch (same family as F17).
|
||||
|
||||
### F25 — `drone scan` table fragments descriptions across rows [LOW]
|
||||
- `drone scan @devpulse`: feedback's description renders as the orphan fragment "mailbox." while compass's overflows — multi-line help text mis-parsed into the wrong rows.
|
||||
|
||||
### F26 — Presence service records stale since 06-30; live sessions unregistered [MEDIUM]
|
||||
- `drone @hooks presence`: two 12-day-old "stale" PIDs (devpulse 06-30, aipass 06-30); my CURRENT interactive session absent. Presence (FPLAN-0289) looks shipped-then-abandoned — nothing cleans stale records, nothing registers new sessions. Either finish it or retire the surface (it's the foundation DPLAN-0224/0225 assume).
|
||||
|
||||
### F27 — Both examples in `drone --help` are broken [MEDIUM — trust]
|
||||
- `drone @flow status` → "❌ Unknown command: status". `drone audit` → "unknown command 'audit'" (registered shortcuts are standards_audit etc., no `audit`). The router's OWN help teaches two commands that don't exist. First page a newcomer reads.
|
||||
|
||||
### F28 — @backup `<project|@name>` arg unclear; natural values fail bare [LOW]
|
||||
- `drone @backup status @devpulse` → "❌ Cannot resolve project: @devpulse" with no hint what @names ARE valid or how to list registered projects. Explicit path works.
|
||||
|
||||
### F29 — No scheduled backups; latest backup 4 days old [MEDIUM — ops gap]
|
||||
- `backup status` shows last run 2026-07-08; daemon queue has zero enabled jobs (F11). The "memory persists" system has no automatic backup cadence — snapshots happen only when someone remembers.
|
||||
|
||||
### F30 — `spawn repair` false-positive would archive the LIVE @aipass branch [HIGH — destructive advice]
|
||||
- `drone @spawn repair <root>` flags `src/aipass/aipass/` as "Duplicate nested directory" pollution and prints the fix `--clean-pollution` (= archive+remove). But that dir is the living @aipass concierge (passport, apps, docs all present) — package `aipass` + branch `aipass` legitimately nest same-name. Anyone following the tool's own advice archives the user-facing agent. Needs a passport-awareness guard: never flag a dir containing `.trinity/passport.json` that's registry-seated.
|
||||
|
||||
### F31 — Three structure validators, three different answers [MEDIUM — fragmentation]
|
||||
- doctor (placement/pollution/registry), `spawn repair`, and seedgo audit each scan structure with different logic: doctor false-flags `.backup/snapshots` (F14) but not aipass-nesting; spawn flags aipass-nesting (F30) but correctly ignores `.backup`; seedgo says trigger is 100% clean. No shared source of truth for "what a healthy project looks like."
|
||||
|
||||
### F33 — TG control file: non-atomic write + fail-open read = paused stream can silently unpause [MEDIUM]
|
||||
- Writer `prax_monitor_bot.py:160 _write_control` uses plain `write_text` (no temp+rename). Reader (relay, every 5s flush) on parse error CACHES `{}` keyed to the new mtime (telegram_relay.py:172-176) and `{}` means defaults = unpaused/level-all. A mid-write read silently reverts user's /pause until the next control write. Low probability per write, but the reader polls forever. Fix: temp+rename in writer; on parse error keep PREVIOUS cache instead of `{}`.
|
||||
|
||||
### F34 — Verified-correct (claims killed during discovery — for the record)
|
||||
- Bots ARE supervised: all 5 run as `telegram-bot@<id>.service` template units + `prax-monitor.service` + `trigger-log-watcher.service`, enabled, PPID=systemd. My draft "unsupervised fleet" claim was WRONG (first grep was head-truncated — probe your probes). Sharper restatement of todo 67: systemd restarts CRASHES, but a wedged socket doesn't crash → needs liveness (self-exit after N min without successful poll, or systemd WatchdogSec).
|
||||
- `trigger-log-watcher.service` running 24/7 is F6's mechanism: it watches branch logs; tests write fixture errors into real branch logs; medic ingests → registry pollution. Chain confirmed.
|
||||
- watchdog timer stderr pings (F4) = my own bad Monitor filter, module design correct.
|
||||
|
||||
### F27 (addendum) — exact source: `drone.py:104-110 show_help`
|
||||
- Sub-agent verified: `drone @flow status` (drone.py:107) — flow has no bare `status`; real command is `drone @flow registry status`. `drone audit` (drone.py:110) — depends on a custom shortcut that doesn't exist on fresh installs. drone/README.md itself is clean; the drift is runtime help only.
|
||||
|
||||
### F35 — github skill teaches invocations the git gate blocks [LOW — policy drift]
|
||||
- `src/aipass/skills/lib/github/SKILL.md` instructs `gh issue/pr/run ...` and "use `git` directly" for local ops — the @hooks gate refuses both (only `gh api` passes raw). An agent following the skill gets refusals. Skill predates the gate; needs a rewrite to route via `drone @git`.
|
||||
|
||||
### F36 — No native-Windows entry point; README quickstart silent about it [MEDIUM — onboarding]
|
||||
- Sub-agent verified: `aipass` launcher + `setup.sh` are bash-only (`#!/usr/bin/env bash`; OSTYPE detection assumes Git Bash/MSYS). No `.ps1`/`.bat` bootstrap exists. README Requirements says "Linux, macOS, or WSL" but Quick Start shows bare `./aipass install` with no "Windows: use Git Bash/WSL" note, while Roadmap claims "Windows native — CI green". A native PowerShell user cannot run the documented quickstart.
|
||||
|
||||
### F37 — Clean bills of health (assets, verified by sub-agent)
|
||||
- ZERO link rot in README/CONTRIBUTING (all 15 branch links + anchors + assets exist). Install docs match setup.sh behavior (incl. PATH wiring + PowerShell profile wrapper). flow/ and prax/ READMEs have no command drift. The doc hygiene machine (seedgo readme_update?) is working where it's pointed.
|
||||
|
||||
### F38 — Two @flow surfaces report wildly different counts, no scope label [LOW — confusing]
|
||||
- `drone @flow registry status`: "Total plans: 281, Open: 8" (watch location = AIPass). `drone @flow list open`: "Total 539, Open 44" (central aggregate incl. external projects). Neither output SAYS which scope it covers — an agent reading one after the other assumes breakage.
|
||||
|
||||
### F39 — PyPI page renders broken images (relative paths in README) [MEDIUM — adoption]
|
||||
- pyproject `readme = "README.md"`; README uses `assets/logo.png` + `assets/demo.gif` relative paths. PyPI does not resolve repo-relative paths → our PyPI landing page (5 releases live) shows a broken logo and broken demo — the two strongest visual assets. Fix: absolute raw.githubusercontent.com URLs (or a PyPI-specific readme).
|
||||
|
||||
### F40 — 15 unread "new" messages rot across 9 branch inboxes; no janitor [MEDIUM]
|
||||
- Night-shift "RE:" acks from 07-11 sit unread in daemon/backup/memory/trigger/api inboxes (agents finished + slept before the ack landed; nothing ever surfaces it). The reply-closes-loop protocol leaks at the last hop. Idea: daemon digest that ages inboxes fleet-wide, or auto-close acks addressed to sleeping agents.
|
||||
|
||||
### F41 — ai_mail delivery-failure notices become malformed unread inbox mail [LOW]
|
||||
- drone's inbox: 6x `[ERROR] Send failed to @vera: Unknown branch email` (07-11 17:29-17:30, vera-saga era) — sender renders `?`, body EMPTY, subject truncated ("17 branche"). System errors should go to prax/error-registry, not pile up as unread mail nobody reads.
|
||||
|
||||
### F42 — Wedged locks: trigger_config.lock + trigger_cb_state.lock at 63 days [MEDIUM — investigate]
|
||||
- `src/aipass/trigger/trigger_json/trigger_config.lock` and `trigger_cb_state.lock` mtimes 2026-05-10 — 2+ months. Sibling error_registry.lock cycles fresh. Either orphaned artifacts of a changed lock scheme (needs cleanup) or something silently failing to update config/circuit-breaker state since May. My own `.trinity/watchdog_active.json.lock` is 71 days too. @trigger/@devpulse owner check.
|
||||
|
||||
### F43 — Tests write fixtures into PRODUCTION log files [HIGH — F6's root, file-level proof]
|
||||
- `src/aipass/aipass/logs/doctor.log` carries pytest "disk full" fixture lines; hooks/rollover.log carries `/tmp/pytest-of-patrick/...` cwd lines; devpulse logs carry "Module empty" fixtures. Chain: pytest → prax logger writes REAL `logs/` → trigger-log-watcher (24/7 service) → error registry pollution (F6). Real fix is one cut: prax logger detects test context (PYTEST_CURRENT_TEST env) → routes to tmp, and the whole class disappears.
|
||||
|
||||
### Verification notes (sub-agent claims corrected)
|
||||
- "DNS errors recurring through today": last DNS error 2026-07-12 07:25:30 — pre-restart tail, NOT ongoing. 177 lines this morning only.
|
||||
- "drone burst 17:29 today": actually 2026-07-11 — during known vera work, not live-recurring.
|
||||
|
||||
### F44 — `spawn update` template preview touches live mailbox (`.ai_mail.local/inbox.json` deep-merge) [LOW — verify intent]
|
||||
- Preview lists a LIVE mailbox file as a template-managed merge target. Probably additive-safe, but templates writing into runtime mail state deserves an owner double-check (@spawn).
|
||||
|
||||
### F45 — Tier prompts exceed their own self-documented size caps [LOW]
|
||||
- tier0_kernel.md = 2,241 chars (target "<2,000 per its own header"); tier1_navmap.md = 8,321 (target ~8,000, truncation near 10k). Drift creep — the caps exist to protect turn budgets.
|
||||
|
||||
### F46 — devpulse feedback inbox: 14 unread since APRIL, incl. 4 external bug reports we later rediscovered the hard way [HIGH — process]
|
||||
- vera-studio filed precise bugs 04-12 (registry-ID mismatch → became #692's saga; AIPASS_HOME export → #688 family; external ai_mail; prompt injection). All sat status NEW for 3 months. The owner-to-owner channel works technically and fails operationally — no inbox check in my startup protocol, no aging alarm. Same root as F5/F40: we build write-side plumbing, never the read-side ritual.
|
||||
- Also unactioned: VERA's April good-first-issue play (#431–433) — see DPLAN-0240 "April Precedent": we batch-self-closed the shelf 05-16, 0 comments.
|
||||
|
||||
### F47 — Fleet standards re-verified: 17/17 branches @100%, 0 type errors (471s full audit) [ASSET — but see F99 caveat]
|
||||
- The S297 night-shift result still holds today. **CAVEAT (F99):** "100%" counts only files the checkers successfully PARSE — a file that makes a checker throw is silently dropped from the denominator, so the true figure could be lower. Trustworthy for parseable files, blind to files that choke a checker. Micro-nit: "TOP IMPROVEMENT AREAS" prints three 100%-scoring standards when all green — should say "none" (banner-ignores-data family, F24). Perf: 471s is sequential + no parse cache (F106).
|
||||
|
||||
### F48 — daemon activity_report freshness logic incoherent [MEDIUM]
|
||||
- "devpulse - WARNING (memory updated 0m ago)" (updated seconds before!), "prax - RED (47m ago)" vs "SKILLS - WARNING (48m ago)" — thresholds/labels contradict each other; URGENT tags assigned to the same message some branches get without urgency.
|
||||
|
||||
### F49 — activity_report enforces a RETIRED memory schema: `No 'limits' field in metadata` ×17 [MEDIUM — stale contract]
|
||||
- Every branch fails the same check — the `limits` metadata contract moved to @memory's memory.config.json + rendered `*_meta` lines (DPLAN-0227 era). The checker was never migrated, so memory health reads "0 OK / 13 warning / 4 red" fleet-wide = 100% noise. Same disease as F5/F26: shipped surface, contract moved, nobody re-pointed the consumer.
|
||||
|
||||
### F50 — External-project doctor: owner-seating fix HOLDS (asset); pollution message renders blank paths [LOW]
|
||||
- From Vera-Studio root: `✓ owner @VERA OK (seated, uid b91eefcf)` — DPLAN-0239's permanent fix proven portable. But its 1 error, `✗ pollution: WRITER 5 copies — Duplicate registry_id at:` lists NO paths (empty). Same message-assembly bug family as F16. UPPERCASE names in vera's registry too — casing debt is template-era, cross-project (F15 scope grows).
|
||||
|
||||
### F51 — seedgo's own self-proof fails 2/5 while the fleet audit reads 100% [MEDIUM — enforcer drift]
|
||||
- `drone @seedgo proof aipass`: readme_currency FAILED ("README is stale: count mismatch, 40 undocumented standards"), triplet FAILED (1 check-only, 1 missing-check, 2 incomplete, 1 orphaned of 41). The standards enforcer's own pack docs have drift its branch-level audit doesn't measure.
|
||||
- **The April thread completes:** deleted GFIs #431–433 were precisely "expand proof content handlers" (Currency/Plugin-Integrity/Interface) — closed 05-16 in the issue-zero sweep, NOT because the work was done; readme_currency still fails today. The shelf-deletion wasn't just process damage, it left real work undone and untracked.
|
||||
|
||||
### F52 — commons: artifact ownership breaks on casing after gift/trade/mint [HIGH — verified]
|
||||
- `trade_ops.py:58 _resolve_branch_name` does `.upper()` and writes it to `artifacts.owner` (:176/:265-266/:534); ALL ownership checks compare lowercase `caller["name"]` (identity_ops.py:220 lowercases as "the single choke point" — its docstring even explains the registry-casing history!). Concrete: gift to @seed → owner "SEED" → invisible in recipient's `artifacts` list, permanently un-giftable (`"SEED" != "seed"`), and the self-gift guard never trips. The F15 casing disease, DB edition. (Sub-agent found; I verified the code.)
|
||||
|
||||
### F53 — commons: systemic sqlite connection leak idiom in ~14 handler files [HIGH]
|
||||
- `conn = get_db()` … `close_db(conn)` on success path only, zero `finally:` in curation/search/profiles/artifacts/engagement/digest/notifications/rooms/social/identity ops (40+ sites). Hottest: `identity_ops.py:355 extract_mentions` runs on EVERY post/comment. posts/comments/central/dashboard do it correctly (`finally:`) — inconsistency, not design. Relies on CPython GC for cleanup; can transiently hold WAL locks.
|
||||
|
||||
### F54 — commons: shared JSON op-log is unlocked read-modify-write; corruption resets to [] silently [MEDIUM]
|
||||
- `json_handler.py:139 save_json` = direct open("w"), no temp+rename, no lock; `log_operation` load→append→save per MODULE file shared across all branches. Races lose updates; torn writes get "healed" by `ensure_json_exists` silently resetting the log to `[]`. backup's json_handler does temp+rename correctly — commons is the outlier.
|
||||
|
||||
### F55 — backup: drive upload mutates shared tracker dict across ThreadPoolExecutor workers unlocked [MEDIUM — plausible]
|
||||
- `upload.py:171-264`: workers update tracker entries while main thread json.dumps the same dict (batch save) — `RuntimeError: dictionary changed size during iteration` possible on larger batches.
|
||||
|
||||
### F56 — backup: load→modify→save races on timestamps/changelog/registry between concurrent modes [MEDIUM]
|
||||
- `backup_timestamps.py:39`, `changelog.py:19`, `registry.py:33` — atomic single write, non-atomic sequence; snapshot/versioned/drive_sync running concurrently on one project clobber each other's state updates.
|
||||
|
||||
### F57 — commons: naive local time in op-logs vs UTC everywhere else [LOW]
|
||||
- `json_handler.py:196 datetime.now()` (naive local) while DB rows are UTC — cross-correlating log↔DB is offset by the host TZ.
|
||||
|
||||
### F58 — backup: corrupt-file evidence overwritten on repeat corruption [LOW]
|
||||
- `json_handler.py:44 load_json` renames corrupt → `<name>.corrupt`; second corruption silently overwrites the first evidence file.
|
||||
|
||||
## ADOPTION — Observations & Ideas
|
||||
|
||||
### A7 — The contribution surface (skills) is scaffolded but has zero examples to copy
|
||||
- `drone @skills` has `create`/`validate`/3-layer scaffolding (DPLAN-0209's vision), and search paths for project-local (`.aipass/skills/`) + user (`~/.aipass/skills/`) skills. But only **6 built-in first-party skills exist and zero community/user skills** — both external search paths are empty. A skill is the ideal first contribution (self-contained, low blast radius, delightful) — but there's no "here's a community skill someone added" example, no gallery, no `skills search`. Pair with DPLAN-0240 Tier 3: ship ONE showcase community-style skill + a one-page "write your first skill" + a discoverable index. The on-ramp is 80% built.
|
||||
|
||||
### A3 — The funnel, measured: 237 stars → 33 forks → 1 external human → 0 retained
|
||||
- Repo stats (2026-07-12): 237 stars, 33 forks, **watchers 1**, **topics [] (empty!)**, homepage "", open issues 0 (only PR#696 open). Discussions: 3 total — 2 our own with 0 comments. Patrick's instinct confirmed: attraction works, conversion is broken.
|
||||
- **Zero-effort wins sitting on the table:** GitHub topics (ai-agents, multi-agent, claude, agent-memory, python — one settings edit); homepage field; pin a "start here" discussion.
|
||||
|
||||
### A4 — Case study: our only external contributor, YugantM [the retention story in one thread]
|
||||
- 05-29: PR #621 (add HVTrust badge) + issue #628. **Zero comments ever, from anyone, on both.** 06-06: both closed; PR unmerged. Meanwhile commit 0f26efd7 "add HVTracker badge (closes #628)" adopted the idea — attribution lives only inside a commit message. The badge then got 3 more commits of real care (official dynamic badge, hidden while bugged upstream, restored when fixed) — the IDEA was treated well; the PERSON was never spoken to.
|
||||
- **Lesson:** we have no reflex for external humans. One process rule fixes it: every external PR/issue gets a human(-agent) reply <24h; adopted ideas get a thank-you comment + release-note credit.
|
||||
|
||||
### A5 — Zero "good first issue" ever; backlog-zero hygiene starves entry points
|
||||
- The label exists, never applied (issues history: 84 AIOSAI, 14 dependabot, 2 YugantM). We drive issues to zero (great internally) so a visitor finds NOTHING to grab. Keep internal velocity, but maintain a curated, labeled shelf of contributor-sized work (docs, checkers, skills, integrations) that we deliberately DON'T self-clear.
|
||||
|
||||
### A6 — The Commons is a SHOCKINGLY rich, entirely unused world [biggest underused asset]
|
||||
- Walking it revealed: 5 rooms, posts/votes/comments, karma/leaderboards, **artifacts you craft/gift/trade/mint**, **time capsules** (`capsule "title" "content" <days>`), and a hidden **exploration/discovery game** (`commons explore`: "Hidden places exist... visit 2 more rooms to unlock a discovery", whispered hints "Errors have their own beauty"). 103 python files, 449 functions. This is a genuinely delightful agent-society sandbox — and it has **1 post, 0 activity**.
|
||||
- **This is the marketing asset.** "AI agents that craft artifacts, trade them, bury time capsules, and explore a hidden world together" is a headline no competitor can match. It exists, it's built, it's tested — and it's invisible. Reviving it (A2 rituals) + exposing a read-only public feed (DPLAN-0240 Tier 3) could be the single highest-leverage adoption move. The bug in F52 (gift/trade broken by casing) matters BECAUSE this should be live.
|
||||
|
||||
### A2 — Commons was reset 2026-06-15 and never repopulated
|
||||
- The single post IS the reset announcement ("Clean slate: old posts cleared... The bar's open again"). Nobody returned for a month. Infrastructure ≠ community; without rituals that generate posts, the bar stays empty.
|
||||
|
||||
### A1 — The Commons is empty (1 post ever, 0 comments, score 0)
|
||||
- The flagship "community/social" feature has ONE devpulse post from June. If the story is "agents form a community," the community must visibly exist — for visitors this is the difference between a demo and a ghost town. Idea: seed genuine agent rituals (weekly digests posted by branches, release notes, decision debates) so the Commons is alive BEFORE humans arrive.
|
||||
|
||||
(rolling; feeds the adoption DPLAN)
|
||||
|
||||
---
|
||||
|
||||
## REMEDIATION ROADMAP — sequenced, with dependency chains
|
||||
|
||||
Priority + ORDER (some fixes have prerequisites — the chains matter more than the labels):
|
||||
|
||||
**P0 — do these first, they're live or trust-critical:**
|
||||
1. **Restore medic (a CHAIN, order matters):** F43 (route test logs out of prod: honor `PYTEST_CURRENT_TEST`) → F5c (fix breaker self-heal: close-on-success, decay cooldown, tick) → THEN `drone @trigger medic on`. Re-enabling first just re-trips the breaker. Investigate the 05-10 disable while you're there.
|
||||
2. **Memory rollover keep-14 (F74):** 2-line fix (trigger `>` not `>=`, drop the `,1` floor) ×3 entry types. Standalone, no deps. Add an invariant test asserting post-rollover count == keep. (todo 66 — your monitor call on filing.)
|
||||
3. **PR#696 red CI (F1):** 1 test, `os.utime` to pin mtime equality. Unblocks the merge.
|
||||
|
||||
**P1 — the systemic multipliers (each retires many findings):**
|
||||
4. **Atomic-write helper + seedgo checker (F73):** one shared temp+rename+lock primitive; a checker forbidding raw `open("w")+dump` on shared state. Retires F33/F54/F67/F79/F87/F89/F90/F116 + guards F85. Biggest single win. The correct pattern already exists in-tree.
|
||||
5. **Fix seedgo's silent-skip (F99):** exception in discover_checkers/_run_all_files = FAIL not skip. Until this, "100%" isn't trustworthy — do it before trusting any audit as a gate.
|
||||
6. **Doctor false-errors (F14/F15/F16/F17):** all root-caused to exact lines (add `.backup`+`dropbox` to _SCAN_SKIP_DIRS; resolve registry paths vs project-root not CWD; exclude template dirs; read `identity.role`). First-touch trust tool — high adoption leverage, low effort.
|
||||
|
||||
**P1-SECURITY (the contribution-surface blocker + integrity):**
|
||||
7. **Skills trust model (F113/F114/F115):** unsandboxed `skills run` + shell-injectable builtin + name-shadowing. HARD BLOCKER for the community-skills adoption pitch — sandbox (reuse @hooks srt/bwrap) or consent-gate before inviting external skills.
|
||||
8. **Destructive guards:** F84 (spawn delete: check live-PID + owner, not a 3-name allowlist), F85 (flow aggregator: never write `{}` over a real registry).
|
||||
9. **Owner model (F59):** key git-access to sealed registry_id not the mutable passport; protect passport.json. Gates fail LOUD (F65/F100/F103).
|
||||
|
||||
**P2 — adoption (mostly Patrick, ~hours not days — see DPLAN-0240):**
|
||||
10. Tier 0 (30 min): GitHub topics, homepage, pin discussion, CODE_OF_CONDUCT. Tier 1: external-response reflex + protected good-first-issue shelf (the S304 [GFI] drafts are ~10 starters). Fix F22/F23 (concierge help), F39 (PyPI images), F36 (Windows quickstart).
|
||||
|
||||
**P3 — ops rituals (the empty-janitor fix, F81):** enable daemon jobs for error-registry triage, backup cadence, presence cleanup, CB-recovery tick, Commons digest. The scheduler runs; nothing's scheduled.
|
||||
|
||||
**The meta-fix (F117/provenance):** add INVARIANT checks that assert the invisible and fail loud (rollover leaves exactly N, medic is on, every registered checker ran, breaker not wedged, no over-cap entries persist). This is what would've caught most of the 116 months ago.
|
||||
|
||||
## APPENDIX — Ready-to-file issue drafts (NOT filed; Patrick's go required)
|
||||
|
||||
Each block is copy-paste ready for `drone @git issue create`. Small ones marked [GFI] are `good first issue` candidates for the DPLAN-0240 shelf. **Numbers are discovery-order labels, not priority and not sequential** (0a/0b are the criticals; renumber on filing). Priority order is in the Executive Summary. ~29 drafts total spanning F1–F106.
|
||||
|
||||
0a. **[CRITICAL] ops(trigger): medic is OFF — re-enable it (in the right order)** — `config.medic_enabled=false` since 2026-05-10 = 63 days of undelivered error notifications (F5b). Sequence: (1) cut fixture storm F43, (2) fix breaker recovery F5c, (3) `drone @trigger medic on`. Re-enabling first just re-trips the breaker. Investigate WHY it went off 05-10 (deliberate storm-silencing never undone?).
|
||||
0b. **[HIGH] fix(trigger): circuit breaker can't self-heal** — half_open is terminal (never closes on success), cooldown never decays (pinned at max), transition only runs in the dispatch path (no tick). Must be fixed before medic re-enable. Manual unblock: `drone @trigger errors circuit-breaker reset`. File: error_registry.py:253/262/286. (F5c)
|
||||
|
||||
1. **fix(prax): make test_mtime_cache_avoids_reread deterministic** — The test relies on two writes sharing an mtime tick (coin flip; red on all GH runners, PR#696). Pin mtime equality with os.utime after the second write, mirroring test_mtime_change_triggers_reread's forced-difference approach. File: src/aipass/prax/tests/test_telegram_relay.py:441. (F1)
|
||||
2. **fix(aipass): doctor false alarms on healthy installs** — (a) add `.backup`+`dropbox` to `_SCAN_SKIP_DIRS` (structure_scanner.py:96) so backup mirrors aren't counted/flagged as agents; (b) resolve registry `path` against PROJECT ROOT not CWD (structure_scanner.py:313) — the 5 "missing" entries have RELATIVE paths, this is NOT a casing bug (corrected); (c) substitute {{BRANCHNAME}} in pollution messages [GFI]; (d) align ✓/!/✗ glyphs with message content (role: unknown ≠ pass). (F14-F17)
|
||||
3. **fix(spawn): repair must never flag passport-seated dirs as pollution** — src/aipass/aipass currently flagged; printed remediation would archive the live concierge. Guard: skip dirs containing .trinity/passport.json with a live registry seat. (F30)
|
||||
4. **fix(prax/tests): route test logging out of production logs/** — pytest fixtures land in real branch logs, 24/7 trigger-log-watcher ingests them, error registry accumulates fixture noise (664+ occurrences of one /tmp config alone). Honor PYTEST_CURRENT_TEST in the logger path resolution. (F43/F6)
|
||||
5. **feat(trigger): error-registry lifecycle** — auto-purge stale (purge exists, nothing calls it: daemon job), stop double-filing UNKNOWN+component duplicates, populate source_file/source_branch, widen ID column or accept unique prefixes in `errors detail`. (F5/F7/F8)
|
||||
6. **fix(aipass): human help for the human tool** — `aipass --help` should describe AIPass + first 3 commands, hide doctor_wire/doctor_fix internals, put init first [GFI-ish]; `aipass help` Q&A needs domain-aware retrieval or a curated FAQ for top-20 questions. (F22/F23)
|
||||
7. **fix(drone): show_help teaches two broken examples** — drone.py:107 `drone @flow status` (real: `@flow registry status`), drone.py:110 `drone audit` (unregistered shortcut). [GFI] (F27)
|
||||
8. **fix(daemon): activity_report enforces retired memory schema** — "No 'limits' field" fails all 17 branches; freshness labels contradict (0m ago = WARNING). Re-point at memory.config.json contract. (F48/F49)
|
||||
9. **fix(skills/prax): TG control-file hardening** — writer temp+rename; reader keeps last-good cache on parse error instead of failing open to unpaused. (F33)
|
||||
10. **chore(docs): PyPI images broken** — README relative asset paths don't resolve on PyPI; use absolute raw URLs. [GFI] (F39)
|
||||
11. **docs(readme): Windows quickstart truth** — `./aipass install` requires bash (Git Bash/WSL); README Quick Start doesn't say so while Roadmap claims Windows-native. One sentence + optional .ps1 bootstrap issue. [GFI] (F36)
|
||||
12. **chore(spawn): normalize the 5 malformed registry entries** — BACKUP/COMMONS/DAEMON/HOOKS/SKILLS are BOTH uppercase-named AND relative-pathed (all other 12 are lowercase+absolute) = registered by an old/different code path. Normalize to lowercase+absolute AND find/fix the registration path that produced the wrong format. Fixes F15's real cause + the casing leaks (F69/F77/lint). (F15)
|
||||
13. **fix(commons): artifact ownership casing** — trade_ops._resolve_branch_name must route through identity_ops's lowercase choke point; add `COLLATE NOCASE` to owner comparisons or a one-shot data fix for existing uppercase owners. (F52)
|
||||
14. **chore(commons): standardize conn=None + finally: close_db idiom** — ~14 handler files, 40+ sites; posts/comments ops are the reference implementation. [GFI — mechanical, great first PR] (F53)
|
||||
15. **fix(commons): json_handler atomic writes + stop silent log reset** — port backup's temp+rename json_handler; corruption should quarantine, not reset to []. (F54)
|
||||
16. **fix(backup): concurrency guards** — lock around drive-upload tracker dict; file-lock the timestamps/changelog/registry read-modify-write sequences. (F55/F56)
|
||||
17. **[CORE] feat(fleet): atomic_write_json helper + seedgo checker** — one shared temp+rename+lock primitive; checker forbids raw open("w")+dump on shared *_data/*.json/inbox/runstate/registry. Retires F33/F54/F67/F79/F87/F89/F90/F6-family at once — the single biggest systemic win (a dozen findings collapse into one helper + one checker). (F73)
|
||||
23. **[HIGH] fix(spawn): delete_branch liveness+owner guard** — refuse to delete a branch with a live PID or `owner:true`; the 3-name allowlist is the wrong gate. (F84)
|
||||
24. **[HIGH] fix(flow): aggregator must not overwrite a branch registry it read as empty** — distinguish missing-vs-corrupt; never write `{}` back over a real registry; atomic write. (F85)
|
||||
25. **[HIGH] fix(spawn): structural registry locking** — every save_registry site takes the flock, not just some. (F86)
|
||||
26. **[MEDIUM] fix(prax): atomic module-registry + logger op-log writes, fix setup TOCTOU** — apply the existing atomic helper + double-checked lock. (F87/F89)
|
||||
18. **[SECURITY] fix(drone/auth): resolve owner via registry_id/is_owner, protect passport.json** — owner git-access must key to the sealed registry owner:true (machinery exists, S290), not the mutable passport branch_name; add passport.json to a gate. (F59)
|
||||
19. **fix(ai_mail): lowercase recipient before lookup** — mirror wake.py:466's `.lower()` in get_branch_by_email/delivery/resolve. [GFI] (F69)
|
||||
20. **fix(ai_mail): test_token reads wrong field** — `msg.get("body")` → `msg.get("message")`; add a test. [GFI] (F70)
|
||||
21. **fix(daemon): atomic runstate write** — temp+rename+lock save_runstate; fire-AFTER-persist or idempotency key. (F67/F71)
|
||||
22. **[SECURITY] harden gates** — edit_gate should cover Bash writes (F60); git_gate catch interpreter-wrapped + path-qualified git (F61/F62); gates fail LOUD not silent-open (F65). Scope: coordination not OS-security — frame accordingly.
|
||||
27. **[HIGH] fix(cli): display helpers must not crash callers** — `header`/`success`/operation templates need `markup=False` or escaped interpolation; they take down any branch on bracket-containing input. Blast radius = every branch. (F94)
|
||||
28. **[HIGH-SEC] fix(api): atomic 0o600 OAuth token write** — use `os.open(..., 0o600)` (pattern exists at secrets.py:154) so a live refresh token is never world-readable or truncated. (F95)
|
||||
29. **fix(api): don't relabel real command failures as 'unknown command'** — distinguish handler-raised from unrecognized. (F96)
|
||||
|
||||
## CORE-BRANCH CODE SWEEPS (ai_mail, daemon, commons, backup — sub-agent found, sharp claims I verified)
|
||||
|
||||
### F67 — daemon: non-atomic unlocked runstate write can wipe ALL job history → mass re-fire [HIGH]
|
||||
- `runstate.py:50-60 save_runstate` = direct open("w")+json.dump, no temp+rename, no lock. Crash mid-write (the per-job save in run.py:246) truncates the file; `load_runstate` catches JSONDecodeError and silently returns empty → next tick treats EVERY job on EVERY branch as never-run (daily/hourly/interval all "due" at once, completed `once` jobs re-fire). Blast radius = whole scheduler. Same write-atomicity gap as F54/F67 family.
|
||||
|
||||
### F68 — ai_mail: inbox READS bypass the lock writers hold → user-visible "Invalid inbox JSON" [MEDIUM]
|
||||
- `inbox_ops.py:63 load_inbox` json.loads with NO lock while writers hold `inbox_lock()` and truncate-then-write. A concurrent `drone @ai_mail inbox/view` can read mid-truncation → JSONDecodeError surfaces as failure. The `.inbox.lock` exists; the read path just doesn't use it.
|
||||
|
||||
### F69 — ai_mail: recipient casing breaks send/inbox for `@Branch` [MEDIUM — verified]
|
||||
- **Verified:** `registry/read.py:148 get_branch_by_email` does exact `branch["email"] == email`, delivery/resolve never lowercase user input → `drone @ai_mail send @Devpulse …` fails "Unknown branch" though @devpulse exists. **wake.py:466 resolve_branch DOES `.lower()`** (I read both) — so dispatch normalizes, plain send doesn't. Same casing disease as F15/F52, third surface.
|
||||
|
||||
### F70 — ai_mail: test-token auto-ack is DEAD CODE (wrong field name) [MEDIUM — verified]
|
||||
- **Verified:** `test_token.py:132` reads `msg.get("body","")` but the schema stores content under `"message"` (create.py:86 `"message": message_with_footer`; "body" set nowhere). So `has_test_token` always sees "" → never matches → liveness/test pings fall through to full dispatch and wake a whole Claude agent instead of a cheap ack. No test covers this handler (how it shipped broken). Ties to F41 (delivery-failure notices as malformed mail) — the test-ping path is unexercised.
|
||||
|
||||
### F71 — daemon: fire-then-persist ordering allows duplicate wake after crash [LOW-MEDIUM plausible]
|
||||
- run.py:237-249 fires the agent (durable side effect) THEN saves runstate. Killed in the gap → fire unrecorded → next tick re-fires unless the prior agent's dispatch.lock still held. No idempotency key. Narrow window, real.
|
||||
|
||||
### F72 — ai_mail: ~120 lines of lock/occupancy logic duplicated wake.py vs daemon.py [LOW — divergence risk]
|
||||
- `_check_lock`/`_acquire_lock`/`_is_branch_occupied`/`_pid_alive` copy-pasted between manual-wake and daemon-dispatch paths. Consistent now; a future fix to one copy silently diverges the two. (Same shape as the three structure-validators, F31.)
|
||||
|
||||
### F73 — Atomic-write helper is the missing shared primitive [MEDIUM — meta-finding]
|
||||
- F33(control), F54(commons log), F67(runstate), F6-family(status.py dispatch log) are ALL the same bug: `open("w")+dump` on shared state, no temp+rename, no lock. backup's json_handler and ai_mail's inbox_lock do it RIGHT — the correct pattern exists in-tree, just isn't centralized. One `atomic_write_json()` helper + a seedgo checker forbidding raw dump-to-shared-file would retire a whole class. Strongest single systemic fix from the sweeps.
|
||||
|
||||
### F74 — Memory rollover off-by-one is LIVE fleet-wide: keeps 14, not 15 [CRITICAL — verified on disk]
|
||||
- **Verified fleet-wide:** memory, drone, hooks, seedgo, flow `.trinity/local.json` ALL hold exactly **14 sessions / 14 key_learnings** (predicted keep-14 steady state; devpulse shows 15 only because I hand-edit, bypassing rollover). `orchestrator.log`: rollover fired at **"(15/15 sessions)" 53×**, "(15/15 observations)" 30×, "(15/15 key_learnings)" 18× — vs "(16/15)" only 3×. It rolls over AT the keep target, not above it. This is not a spot bug — it's every branch, every rollover, since the trigger was written.
|
||||
- **Mechanism (exact lines):** trigger `len(sessions) >= max_sessions` (detector.py:360 + extractor.py:207) fires at len==15; extractor `excess = max(len(sessions) - max_sessions, 1)` (extractor.py:208/218/228 for sessions/key_learnings/observations) forces trimming 1 even when real excess is 0 (`max(0,1)`). Every branch silently runs keep-14.
|
||||
- **PRECISE FIX (2 changes):** trigger `> max_sessions` (fire only when EXCEEDED, so 16 rolls to 15) AND drop the `,1` floor → `excess = len - max_sessions` (naturally ≥1 when the >-trigger fires). Apply to all three entry types (207-208/217-218/227-228). @memory-owned.
|
||||
- **FIX PROVEN EXECUTABLE (isolated logic replica, ran it):** shipped logic at exactly 15 entries → keeps **14** (bug); at 16 → keeps 15. Fixed logic at 15 → keeps **15** (correct); at 16 → keeps 15. All three assertions pass (`current(15)==14`, `fixed(15)==15`, `fixed(16)==15`). Not just asserted — demonstrated. The 2-line change is safe and correct.
|
||||
- **Dated + why-unnoticed:** `git blame` → introduced **2026-04-22 (commit 9634c5639)** — silently keep-14 fleet-wide for ~2.5 MONTHS. It survived because rollover ARCHIVES the trimmed entry to vectors (nothing is deleted, it just moves to @memory one cycle early) → **zero visible symptom.** The perfect silent bug: on the branch named `memory`, in the system whose pitch is "memory persists," a memory-loss bug is invisible precisely because the memory isn't lost, just archived early. This IS todo 66's "15/15 vs keep-15" — PROVEN actively trimming. **NOT filing** (todo 66: Patrick monitors rollover, no file without his go) — documented only.
|
||||
|
||||
### F75 — ai_mail error-escalation channel reports success on failure [HIGH]
|
||||
- `error_dispatch.py:61-88`: `deliver_fn("@drone", ...)` return discarded, hard `return True`. `deliver_email_to_branch` returns `(False, msg)` on failure (doesn't raise) → a failed escalation logs as success. The incident-visibility safety net can't see its own failures. Both call sites discard the result too. (Explains how F41's malformed vera notices piled up unnoticed.)
|
||||
|
||||
### F76 — drone: ~90 git/gh subprocess calls with NO timeout; `drone @git pr` can hang holding the repo-wide lock [HIGH]
|
||||
- Only 2 of ~90 `subprocess.run` git sites pass `timeout=`. `pr_handler.py:163→220`: `acquire_lock()` takes repo-wide `.git_pr.lock`, then UNBOUNDED `git push`; a credential/SSH/net stall → function never returns → `finally` never runs → lock never releases → every branch's `drone @git pr` blocked until a human force-unlocks (staleness is passive 600s, no auto-unlock). Real hang risk for the one git-write path the whole fleet shares.
|
||||
|
||||
### F77 — drone: dict-shaped registry keys never lowercased → `@Name` permanently unresolvable [HIGH — latent]
|
||||
- `registry_handler.py:273` lowercases names only when `branches` is a LIST; dict-shaped `branches` keys pass through untouched, while every lookup forces lowercase. With `{"branches":{"Prax":...}}`, `@prax` AND `@Prax` both fail. Dormant TODAY (prod registry is list-format — I verified) but a landmine if anything emits dict format. Casing-drift family (F15/F52/F69).
|
||||
|
||||
### F78 — drone registry credential check FAILS OPEN [MEDIUM — security-adjacent]
|
||||
- `registry_handler.py:105/217 _verify_registry_credential`: bare `except Exception → return True`. A corrupt/unreadable passport is treated as "credential matches" → cwd walk-up may adopt another citizen's registry. Same fail-open theme as F65, violates "fail to errors."
|
||||
|
||||
### F79 — drone custom-command registry: non-atomic write + read-modify-write race [MEDIUM]
|
||||
- `command_registry/ops.py:143` raw open("w")+dump (while the SAME tree's `json_handler._atomic_write_json` does it right — F73 again); unlocked add/remove/update. Concurrent `drone activate` → last-writer-wins drops commands; kill mid-dump → `load_registry` silently recreates EMPTY registry, all shortcuts lost.
|
||||
|
||||
### F80 — Verified-clean by the memory sweep [ASSET]
|
||||
- Memory rollover ORDERING is correct (backup→trim→embed→store, restore_from_backup on every failure path); primary memory files DO use atomic temp+os.replace; the old 30s-hook false-FAILED is resolved (heavy ops now 60/120s, zero timeout hits in logs). ai_mail wake/dispatch is poll-based (no write-vs-signal race), O_CREAT|O_EXCL locks, consistent subprocess timeouts on the dispatch side.
|
||||
|
||||
### F81 — The daemon scheduler works but the fleet has ZERO production jobs [HIGH — the empty janitor]
|
||||
- The whole automation layer (systemd daemon-tick.timer @1m + decentralized `.daemon/schedule.json` discovery) is BUILT and running — I ran a manual tick, it discovered and evaluated correctly. But across all 17 branches there are exactly **3 jobs, all `wake-test`, all disabled** (F11). Nothing is scheduled: no error-registry triage (→ F5 graveyard), no backup cadence (→ F29 4-day-old backups), no commons digest (→ A2 empty Commons), no stale-presence cleanup (→ F26), no CB-recovery tick (→ F5b stuck 63 days).
|
||||
- **This is the single infrastructure root of the "write side shipped, read side missing" pattern.** The janitor-RUNNER exists; nobody wrote the janitors. Every "nothing ever cleans/triages/recovers X" finding could be closed by a handful of enabled daemon jobs. Highest-leverage systemic fix on the ops side — and it's additive, low-risk (jobs are per-branch JSON).
|
||||
|
||||
### F82 — commons `welcome_new_branches` auto-post exists but is never triggered [MEDIUM]
|
||||
- `welcome/welcome_handler.py:116 welcome_new_branches` + `run_welcome` are built to auto-post welcomes for new branches — exactly the ritual that would keep the Commons alive (A2/A6). It's wired to a command, not to any event or schedule, so it never fires on its own. Another built-but-unpulled ritual; a daemon job (F81) or a spawn-hook would light it up.
|
||||
|
||||
### F83 — .backup store is 951M (versioned 671M) — growth vs max_versions:10 worth a look [LOW]
|
||||
- `.backup/versioned` = 671M, `.backup/snapshots` = 273M (25 snapshot dirs), `drive_tracker.json` = 4.9M. Backup config says `max_versions: 10` but the versioned store is large — either per-file baselines+diffs legitimately accumulate or pruning isn't keeping pace. Correctly gitignored (verified). Not urgent; worth a `backup` prune audit given F29 (no scheduled backups anyway). NOTE: initial `git ls-files` count looked alarming (62) but was a CWD artifact — real tracked count is 1843 (1304 py). Verified before recording.
|
||||
|
||||
### F93 — tier0_kernel loader docstring says "period 1", config + kernel header say period 5 [LOW — doc drift]
|
||||
- `tier0_kernel.py:32` docstring: "Load tier0 kernel — every turn (cadence period 1)." But cadence_config sets `tier0: period 5`, and the kernel file's own header says "injected every 5 turns (cadence period 5)". The docstring is stale. (Verified the `branch` loader's MISSING period is fine — cadence.py:204 inherits global_period=5 correctly; that one's not a bug.)
|
||||
|
||||
## FLOW / SPAWN / PRAX SWEEP — highest blast radius (sub-agent found; scariest two I verified in code)
|
||||
|
||||
### F84 — spawn `delete_branch` has NO liveness or owner guard [HIGH — destructive]
|
||||
- **Verified:** sole gate is `_PROTECTED_BRANCHES = {spawn, devpulse, drone}` (delete_ops.py:124) + `is_dir()`. No PID/heartbeat check, no `owner:true` check. `drone @spawn delete @<any-live-non-protected-branch> --yes` archives + rmtrees it whether or not it's running, and the sealed registry OWNER is deletable if not one of the 3 hardcoded names. Highest single-command destructive risk found. (Mitigant: it's an intentional admin verb with a confirm prompt + spawn is owner-tier — but the guard SET is wrong: it should be "not owner AND not live," not a 3-name allowlist.)
|
||||
|
||||
### F85 — flow central aggregator can overwrite ANOTHER branch's plan registry with empty [HIGH — cross-branch data loss]
|
||||
- **Verified the mechanism:** `aggregate_ops.py:86 load_branch_registry` fails open to `{"plans":{}, "next_number":1}` on ANY read/parse exception (no missing-vs-corrupt distinction); `save_branch_registry:94` writes back with raw `open("w")+json.dump` (non-atomic). The aggregator's heal pass runs against OTHER branches' registry.json. If branch B's registry is transiently unreadable (mid-write/truncated) when the heal runs → aggregator reads empty → "heals" by overwriting B's whole plan history with `{}`. A read hiccup in one branch, triggered by another branch's routine aggregation, destroys plan tracking.
|
||||
|
||||
### F86 — spawn registry lost-update race: locking is opt-in, not structural [HIGH]
|
||||
- registry.py:159/repair_ops take fcntl.flock before load→modify→save; but `delete_ops._remove_from_registry`, `sync_registry_ops` (2 sites), and registry.py:333 call `save_registry` with NO lock. flock is advisory → unlocked writers get zero protection. `delete @foo` (loads registry, then blocks on confirm + slow copytree) racing `create @bar` (locked, fast) → delete writes stale registry back, erasing @bar.
|
||||
|
||||
### F87 — prax module registry truncates to ONE entry → ecosystem-wide log-routing loss [HIGH]
|
||||
- `registry/save.py:96` raw open("w")+dump (ignores the atomic helper next door); `watcher.py on_created` does unlocked load→mutate→save. Kill mid-dump truncates `prax_registry.json`; load fails open to `{}` → next watcher event overwrites with just the one new module, discarding every previously-discovered module until a full rescan. This is the module→log-routing registry.
|
||||
|
||||
### F88 — spawn: 2 passport writers bypass the atomic helper [MEDIUM — identity loss]
|
||||
- `sync_registry_ops.py:603/628 fix_owner_identity` use raw `passport_path.write_text(json.dumps(...))` while every OTHER spawn passport writer routes through the mkstemp+fsync+os.replace helper. Crash mid-write → truncated passport → a branch loses its identity (and per F59, its git-access key).
|
||||
|
||||
### F89 — prax logger internal op-log + setup have corruption/TOCTOU races [MEDIUM]
|
||||
- `logging/operations.py:60` raw open("w") on the growing op-log, no lock → concurrent callers interleave partial writes → invalid JSON (hit from every handler). `logging/setup.py:93` checks `_captured_loggers` under lock, RELEASES, then mutates the stdlib singleton logger outside the lock → duplicate handlers (dup log lines) or dropped handler. The correct double-checked-lock pattern exists in logger.py:95 but isn't applied here.
|
||||
|
||||
### F90 — flow own plan registry + central aggregate non-atomic, fail-open-to-empty [MEDIUM]
|
||||
- `registry/save_registry.py:70` raw-writes `fplan_registry.json` (self-labeled DO NOT EDIT); `aggregate_ops.py:257 save_central` raw-writes `PLANS.central.json`. Both paired with fail-open-to-empty loads → a crash mid-write + any later load+save silently resets the registry. Same F73 atomic-write class.
|
||||
|
||||
### F91 — flow "read-only" scan silently RENAMES plan files across branch boundaries [MEDIUM — plausible]
|
||||
- `monitor_ops.py:189 scan_plan_files_impl` unconditionally `rename`s on a 4-digit-number collision even though the CLI reports "no changes applied." It walks from ECOSYSTEM_ROOT across ALL branches with no branch-boundary awareness; plan numbers are PER-BRANCH, so two branches legitimately holding e.g. FPLAN-0042 → one gets renamed out from under its owner by a "read-only" scan.
|
||||
|
||||
### F92 — Verified-clean by this sweep [ASSET]
|
||||
- Mixed-case @branch bug NOT present in flow/prax (prax consistently `.upper()`, flow delegates upstream). prax atomic-write PRIMITIVE is correct (just not used in 3 spots). spawn individual writes mostly careful (path containment, archive-before-delete). The correct patterns exist in every branch — the gaps are inconsistent APPLICATION, not absence.
|
||||
|
||||
## API / CLI / TRIGGER SWEEP (sub-agent; CLI-crash + OAuth window I verified)
|
||||
|
||||
### F94 — CLI display helpers crash the CALLER on bracket/markup-like input [HIGH — every-branch blast radius]
|
||||
- **Verified:** `display.py:328 header` and `success` interpolate caller strings into a Rich-markup-parsed `CONSOLE.print(f"...[dim]{key}:[/dim] {value}")` / `Panel(f"[bold cyan]{title}[/bold cyan]")`. Any value with an unmatched/closing tag — a path, git ref, JSON, regex, or exception text containing `[/x]` — raises `rich.errors.MarkupError` uncaught and takes down that branch's process. Nasty asymmetry: `error()`/`warning()`/`fatal()` use markup-safe `Text.append()` — only the HAPPY-PATH helpers crash, so a "success" message kills the app. Zero test coverage. This is the shared display layer every branch renders through — F8's truncation was the cosmetic tip; this is the crash. Fix: `markup=False` or escape interpolated values.
|
||||
|
||||
### F95 — api: live OAuth refresh token can be left world-readable / truncated on crash [HIGH — secret exposure window]
|
||||
- `google/auth.py:251 _save_credentials` (runs after EVERY refresh): `open(path,"w")` → write token → `os.chmod(0o600)`. File is created at umask (usually 0o644 = world-readable) and only tightened AFTER the write; a crash between write and chmod leaves `google_creds.json` (live refresh token) permanently world-readable, and nothing re-chmods later. Also non-atomic (no temp+rename). The correct pattern (`os.open(..., 0o600)` — mode atomic at creation) is ALREADY used at `secrets.py:154` and `api_key.py:234`. `env.py:94` has the same ordering (lower sev, placeholder only).
|
||||
|
||||
### F96 — api: dispatcher misreports real command failures as "unknown command" [MEDIUM — extends F13]
|
||||
- `api.py:240 route_command`: module loop `except Exception → log + continue`. When a REAL handler (e.g. api_key on a corrupt .env / perms error) raises, no module claims it, route returns False, main prints "Unknown command: get-secret" (exit 1). The true cause is buried in logs. Broader than F13's silent-fallback — it actively mislabels legitimate credential-command failures.
|
||||
|
||||
### F97 — api: `diagnose_key` echoes a real secret PREFIX to stdout [LOW — minor leak]
|
||||
- `auth/keys.py:216 diagnose_key` puts the first ~6-10 chars of the actual stored secret into a string that reaches stdout (openrouter_client.py:289). Prefix only, not full value, but a real secret fragment on a "diagnostic" path meant to be safe.
|
||||
|
||||
### F98 — Verified-clean by this sweep [ASSET]
|
||||
- api secret handling is DISCIPLINED: no raw key/token value hits any logger/print across apps/ (all masked `key[:6]+"****"+key[-4:]`); no bare `except: pass` around auth; OAuth refresh failure is LOUD (logs error, returns False, callers report "invalid" — doesn't treat stale token as valid). cli `error/warning/fatal` are markup-safe. The secret-masking discipline is genuinely good — F94/F95 are the gaps in an otherwise careful branch.
|
||||
|
||||
## SECURITY GATES — integrity audit (sub-agent found, core claims I verified in code)
|
||||
|
||||
**Threat-model framing (READ FIRST):** AIPass agents all run as the SAME OS user, locally, cooperatively. The gates are Claude Code hooks — coordination + accident-prevention, NOT an OS boundary against a hostile actor (who could run git directly). So "bypass" here means "the owner/ownership model is ADVISORY, not enforced" — an integrity/robustness gap, real and worth fixing to make the model mean what it claims, but NOT a remote exploit or live emergency. Severities below are relative to the model's own promises.
|
||||
|
||||
### F59 — Owner git-access trusts an UNPROTECTED, UNSIGNED passport [HIGH — the owner model is forgeable]
|
||||
- **Verified in code:** `auth.py:_find_caller` reads `branch_info.branch_name` straight from the CWD-hierarchy's `.trinity/passport.json` (`auth.py:18-48`); `verify_git_access` grants owner-tier if that string is in `allowed_callers` (["devpulse"]) — **no registry cross-check, no signature** (`auth.py:88-125`). And NO gate protects passport.json (edit_gate covers only local.json/observations.json; git_gate covers settings/hooks; registry_gate covers *_REGISTRY.json). So any dir with a passport saying `branch_name: devpulse` gets owner git-write. The whole DPLAN-0231 owner-capability model (built to key auth to the immutable registry_id) is undercut because the ACTUAL check reads the mutable passport name, not the sealed registry owner:true. Fix: `verify_git_access` should resolve owner via registry_id/is_owner (the machinery EXISTS — S290), not a passport string; and protect passport.json under a gate.
|
||||
- Same passport-trust pattern duplicated in `seedgo/permissions.py:identify_caller`.
|
||||
|
||||
### F60 — pre_edit_gate never runs for Bash → all its protections void via shell writes [HIGH]
|
||||
- Sub-agent claim (matcher-traced): edit_gate's matcher is `Edit|MultiEdit|Write|NotebookEdit` (no Bash) AND its `EDIT_TOOLS` set excludes Bash. So `echo … > inbox.json`, `python3 -c "open(...).write(...)"`, `tee`, `sed -i` skip edit_gate entirely — voiding its inbox-write block, daemon confinement, cross-branch block, and .trinity entry-limits. (Consistent with F3's observation that git_gate DOES match Bash but edit_gate doesn't — asymmetric tool coverage across gates.)
|
||||
|
||||
### F61 — git_gate quote-stripping blinds it to interpreter-wrapped git [MEDIUM]
|
||||
- git_gate replaces quoted-string contents before scanning (git_gate.py:164), so `bash -c 'git push'`, `sh -c "git push"`, `eval 'git push'`, `python3 -c "subprocess.run(['git','push'])"` pass. Heredoc-piped git IS caught (bodies not stripped) — so the miss is specifically the interpreter-string idiom.
|
||||
|
||||
### F62 — RAW_GIT_RE lookbehind excludes `/` and `.` → path-qualified git evades [MEDIUM]
|
||||
- `(?<![@\w/.])git\s` (git_gate.py:21): `/usr/bin/git push`, `./git push`, `GIT_SSH_COMMAND=x /usr/bin/git push` all pass. Exclusion was added to spare `.gitignore` filenames; it also blinds the matcher to the real binary by absolute/relative path. (Variable-indirection `g=git;$g push` is inherent to any regex approach — same limit hits rm_gate/registry_gate.)
|
||||
|
||||
### F63 — rm_gate is the ONLY interactive delete defense yet has clean misses [MEDIUM-HIGH]
|
||||
- The kernel sandbox rm_gate calls "the real boundary" is Phase-1, NOT wired into normal sessions (sandbox.py:267 says so; devpulse's own tools/rm_shim/FINDINGS.md already documents this). So rm_gate's misses are load-bearing: `find . -delete`, `shred -u`, `\rm -rf` (endswith-check miss), `find|xargs sh -c 'rm -rf {}'` (quote-stripped), `python3 -c "shutil.rmtree(...)"` all pass. Straight `rm -rf`, `rm -fr`, `find|xargs rm -rf` correctly blocked.
|
||||
|
||||
### F64 — Protected-path gates match raw strings, not resolved paths [MEDIUM]
|
||||
- git_gate BLOCKED_EDIT_PATTERNS + registry_gate check the literal `file_path`, no `Path.resolve()`. `.claude/settings.json` (relative, no leading `/`) passes where `./.claude/settings.json` is caught — inconsistent. A symlink whose name isn't `*_REGISTRY.json` but points at one evades registry_gate (PLAUSIBLE, depends on Write following symlink).
|
||||
|
||||
### F65 — All gates + the engine FAIL OPEN on exception [MEDIUM — by design, but]
|
||||
- Every gate's `handle()` wraps in `try/except Exception → allow + log "(allowing)"`; engine.py only special-cases exit_code==2, any other (incl. handler import crash, exit -1) falls through to allow. Deliberate availability tradeoff (a hook bug shouldn't brick all tools) — but means ANY parser crash = silent bypass. At minimum the fail-open should be LOUD (surface to prax/error-registry, not just a log line).
|
||||
|
||||
### F66 — What the gates get RIGHT [ASSET]
|
||||
- git_gate read/write split is an ALLOWLIST of read verbs (unknown → blocked = safe default). Compound-command splitting (`&&`/`||`/`;`/`|`/`$()`/backticks) is consistent across git/rm/registry gates and defeats naive chaining. Heredoc bodies not quote-stripped (piped-heredoc git caught). rm_gate follows single pipes. `drone <verb>` self-exemption uniform. The bones are good; the misses are specific parser gaps, not a broken design.
|
||||
|
||||
## HOOKS ENGINE / SEEDGO SWEEP (final sweep — two trust-undermining finds I verified)
|
||||
|
||||
### F99 — seedgo can silently STOP ENFORCING a standard AND drop crashing files → false 100% [HIGH — undermines "100%", two layers]
|
||||
- **Layer 1 (worse — whole checker vanishes, seedgo-audit sub-agent found, I verified):** `branch_audit.py:37-39 discover_checkers` — `except Exception: logger.info("Skipped checker %s: failed to load"); continue`. If a `*_check.py` fails to IMPORT (syntax error, missing env dep), it's silently dropped from the checker set → absent from scores AND gating → CI (THRESHOLD=100) passes the branch at 100% **while that entire standard goes unenforced, zero signal.** Break one import and a rule silently stops being checked fleet-wide.
|
||||
- **Layer 2 (file-level):** `branch_audit.py:95-97 _run_all_files` — `except Exception: continue`. A file that breaks a checker (non-UTF-8, parser edge, checker bug — AST checkers catch SyntaxError but not UnicodeDecodeError etc.) is dropped from `scores`, absent from the denominator → average rounds UP. Plus line 101: any file with a "skipped"/"not applicable" check message is excluded from the average even if another check on it FAILED.
|
||||
- **This means the fleet "100%" (F47/F92) is trustworthy only for files+checkers that PARSE/IMPORT — blind to anything that chokes.** Fix: exception in discover_checkers/`_run_all_files` = FAIL (score 0) or hard error, never silent skip. Fix F1 (layer 1) first — a standards enforcer that can silently stop enforcing is the worst failure mode here.
|
||||
- **Dated:** `git blame` → both silent-skip paths date to **2026-03-23 (commit 6bd1bd00f)**, near the audit's inception. So every "100% fleet-green" this project has ever celebrated has carried this blind spot from the start — foundational, not a regression.
|
||||
|
||||
### PROVENANCE — the big findings are OLD and symptomless, not fresh breakage [synthesis]
|
||||
- Dated the load-bearing ones via git blame / logs: **F74 rollover keep-14 = 2026-04-22** (~2.5 months); **F99 seedgo silent-skip = 2026-03-23** (inception); **F5b medic disabled = 2026-05-10** (config toggle, log-confirmed, 63 days); **skills json_handler F116 = 2026-03-17** (never migrated). Pattern: these survived MONTHS not because they're subtle to find but because they're **symptomless** — rollover archives (doesn't delete), medic-off just means silence, seedgo-skip just inflates a number, atomic-write races only bite on a crash. The system has no alarm for "a thing quietly stopped working correctly." That's the deepest gap: **AIPass optimizes for visible-failure (logs, errors, red CI) and is blind to silent-degradation.** The fix class isn't per-bug — it's invariant checks that assert the INVISIBLE (rollover leaves exactly N, medic is on, every registered checker ran, the breaker isn't wedged) and fail LOUD when violated.
|
||||
- **DEEPEST UNIFICATION — the observability layer is decorative, not measured (this is WHY degradation stays silent):** the runtime/data probes (F123/F124) showed the mechanism. AIPass's self-reported STATUS is systematically wrong while its underlying DATA is sound. Vector store: healthy 5,012 vectors, dashboard says 1,274. Dashboards: "live", actually up to 10 days stale. Doctor: 7 false errors on a healthy repo. activity_report: retired schema, 100% noise. daemon: contradictory labels. seedgo: "100%" blind to files it chokes on. Meanwhile the DATA is fine — compass integrity ok, chroma integrity ok, 11k tests collect clean, 6 suites green. **The data is trustworthy; the gauges are stale, mislabeled, or lying-green — and THAT is the silent-degradation mechanism.** You cannot notice a thing quietly breaking when every dial reads "fine" regardless of reality. The single highest-leverage meta-fix: make the observability layer MEASURED (real counts, real freshness, fail-loud invariants) before trusting any gauge as a signal. A striking share of these 124 findings is downstream of "nobody could see it."
|
||||
|
||||
### F100 — hooks: missing/corrupt `.aipass/hooks.json` silently disables ALL security gates [HIGH — fail-open at config layer]
|
||||
- **Verified:** `claude.py:47 find_project_config() → None` on missing/corrupt config → falls back to `{"hooks_enabled": True}` with no event key → `engine.py` sees empty event_hooks → returns allow. Any CWD whose tree up to $HOME lacks `.aipass/hooks.json` gets ZERO enforcement — git_gate/rm_gate/edit_gate/registry_gate/presence_gate all no-op, logged only at INFO. **Sharp edge:** `isolation: worktree` sub-agents (and any /tmp extraction) created OUTSIDE the main checkout can lack the config → run ungated. Ties the security cluster (F60/F65) together: the gates fail open at the CONFIG layer too, not just on parser crash.
|
||||
|
||||
### F101 — hooks presence.py is DEAD CODE presented as live [MEDIUM — confirms F26]
|
||||
- **Verified:** presence_gate v2.0 migrated source-of-truth to CC-native `~/.claude/sessions/<pid>.json` (cc_sessions.py); `presence.claim/release/refresh` are called ONLY from tests, zero production sites. `PRESENCE.central.json` is frozen pre-migration; nothing writes it. So F26's "12-day stale records" isn't a cleanup bug — the write path doesn't exist. Yet `drone @hooks presence` still renders the frozen entries with live/stale PID tags, actively misleading. Either delete the surface or repoint it at cc_sessions.
|
||||
|
||||
### F102 — seedgo CI audit ≠ local audit (branch scope AND pass/fail) [MEDIUM — substantiates DPLAN-0198]
|
||||
- Same checker pack, but: (a) CI (`.github/scripts/seedgo_audit.py:14`) discovers branches via naive `src.iterdir()` (any dir with `apps/`, no registry, no private-branch exclusion) while local uses registry-based `discover_branches()`; (b) CI hardcodes THRESHOLD=100 + `sys.exit(1)`, local command has NO threshold at all (pure display, always returns True). A dev CANNOT get a "would this fail CI" signal from the normal local command.
|
||||
- **Sharper (seedgo-audit sub-agent):** the two also resolve the branch ENTRY FILE differently — CI only tries `apps/{name}.py`, local also falls back to `apps/branch.py`. A branch using the `branch.py` convention gets entry_file="" in CI → entry-point checkers open "" → score 0 → **CI false-FAILs a branch that passes locally.** So the parity gap cuts BOTH ways (false-pass on scope, false-fail on entry resolution). Also: `ci.yml:50 fetch-depth:0` comment still cites the deleted git-log freshness check. That's DPLAN-0198, concretely.
|
||||
|
||||
### F103 — hooks engine fail-open: handler crash AND malformed stdin both skip gates [MEDIUM — confirms/extends F65]
|
||||
- Handler crash → exit_code -1 → falls through to allow (test_engine.py:211 literally asserts a crashed hook yields overall allow). Malformed stdin → `match_value=""` → `_matches` returns False for any NON-empty matcher — and every security gate uses a non-empty matcher while non-security handlers use empty ones, so malformed stdin skips EXACTLY the security hooks. Two more fail-open layers, text-log only.
|
||||
|
||||
### F104 — hooks cadence miscounts turns <2s apart [LOW]
|
||||
- `cadence.py:137 _should_increment` checks mtime-age <2s BEFORE checking if the transcript token actually changed → a genuinely new fast turn (agentic/scripted exchanges) is treated as a same-turn straggler and doesn't increment. The "every Nth turn" injection silently falls behind real turn count on fast turns. No test <2s apart.
|
||||
|
||||
### F105 — seedgo readme_currency proof is broken (explains F51) + one real drift [LOW]
|
||||
- **Verified by running scan():** `readme_currency.py:81` only recognizes a legacy prose pattern `pack checks: ...`; seedgo's README now uses a `## The 40 Standards` table, which the regex never matches → returns empty → flags all 40 as "undocumented." So F51's readme_currency FAIL is mostly a BROKEN PROOF, not real drift. BUT one genuine nugget: README.md:47 says "33 standards", actual is 40 — that line is real drift worth fixing.
|
||||
|
||||
### F106 — seedgo 471s fleet audit: sequential loop + no AST parse cache [MEDIUM — perf]
|
||||
- `standards_audit.py:289` iterates branches with a plain sequential `for` (branches are independent — trivially parallelizable). `_run_all_files` re-parses each file once PER checker (7 of 40 do their own `ast.parse`) instead of once per file with a shared cache. Two clear wins to cut the 471s (F47) — matters because slow audits get skipped.
|
||||
|
||||
## AIPASS + SKILLS SWEEP (final sweep — doctor findings LIVE-CONFIRMED + skills-security surface)
|
||||
|
||||
### F113 — drone_commands built-in skill: `shell=True` with caller-supplied command [HIGH — footgun + false safety claim]
|
||||
- **Verified:** `skills/lib/drone_commands/apps/handlers/executor.py:63 subprocess.run(command, shell=True, ...)` where `command` is the caller's `args["command"]`. Reached via documented `drone @skills run drone_commands run --args '{"command":"..."}'`; args parsed by naive key=value split, zero escaping. Any `;` `\`` `$()` `&&` `|` runs arbitrary shell. **Scope honestly:** invoked locally with your own args it's just you running your own shell (not a privilege gain). BUT it escalates to real arbitrary-exec if args ever flow from an untrusted channel (Telegram→skill, a community skill calling it), and the SKILL.md's claim "never runs commands that modify system state without explicit action / only drone commands" is FALSE — `shell=True` defeats the intended containment. Fix: drop shell=True, exec argv list, or validate the command is a drone invocation.
|
||||
|
||||
### F114 — `skills run` = unsandboxed in-process arbitrary code execution, ZERO gate [HIGH — THE contribution-surface trust model]
|
||||
- **Verified:** `loader_handler.py:92 spec.loader.exec_module(module)` on any discovered `handler.py`, then `runner_handler.py` calls `handler.run(...)` in-process; grep for sandbox/bwrap/confirm/input under skills/apps = ONLY the exec_module line (nothing else). `skills validate` only checks declared dep PRESENCE, and run never calls it. **This is the direct answer to A7/DPLAN-0209/0240's "open skills to the community":** dropping a folder in `~/.aipass/skills/` and running it == `python handler.py` with full process privileges — no sandbox, no confirm, no review. A community-skill ecosystem CANNOT ship on this as-is; it needs a trust model (signed/reviewed skills, a sandbox via the existing @hooks srt/bwrap wrapper, or an explicit consent gate) BEFORE inviting external skills. First-class design blocker for the contribution story, not just a bug.
|
||||
|
||||
### F115 — skill-name shadowing: a project/global skill can silently impersonate a built-in [HIGH]
|
||||
- `discovery_handler.py:93` keys skills by the frontmatter `name:` field (attacker-controlled), NOT the dir name; `registry.py:36` is first-match-wins in order project → global → builtin. A project/global skill declaring `name: github` (or `drone_commands`) silently SHADOWS the trusted builtin, no warning. Compounds F113/F114: shadow a trusted skill name + get it run unsandboxed. Fix: key by dir/namespace, warn on name collision, builtins win or are namespaced.
|
||||
|
||||
### F116 — skills json_handler non-atomic (F73 class, unmigrated) [MEDIUM]
|
||||
- `skills/apps/handlers/json/json_handler.py:127 open("w")+dump`, used on every skill run/create/validate via log_operation. `aipass/shared/json_handler.py` already has the correct mkstemp+fsync+os.replace — skills' copy (untouched since 2026-03-17) never got the migration. Corruption is silently self-healed (regenerate defaults) → invisible data loss. Another instance for the F73 helper+checker.
|
||||
|
||||
### F14–F23 doctor/help findings — LIVE-REPRODUCED with exact lines [confirms my behavioral findings]
|
||||
- The sweep live-reproduced every doctor false-error I found behaviorally, pinning exact lines: **F14** (.backup not in `_SCAN_SKIP_DIRS`, structure_scanner.py:96 → 38 vs 19 agents + cascading false pollution "ai_mail 30 copies"); **F15 CONFIRMED my correction** (structure_scanner.py:312 resolves relative registry path against `cwd()` — from repo root 0 issues, from a branch subdir all 5 "missing"; it's relative-path-not-casing, exactly as I corrected); **F16** ({{BRANCHNAME}} source pinned: `spawn/templates/aipass_framework/.trinity/passport.json:13` shipped scaffold with unsubstituted placeholder, scanned as a real agent); **F17** (doctor.py:332 reads top-level `role` but schema nests `identity.role` → always "unknown", line 333 appends PASS unconditionally → always green); **F22** (aipass.py:111 merges --help/help/bare into one internals dump); **F23 root cause** (help_chat BRANCHES is a hardcoded 12-name list MISSING skills/backup/commons/daemon/hooks; unmatched query greps all branches by keyword-count with no domain scoring → "create a skill" returns drone/seedgo/prax). 5 relative-path "missing" + 2 backup-pollution = exactly the 7 false errors. All my doctor findings now line-pinned and reproduced.
|
||||
|
||||
## SUB-AGENT CORROBORATION + NEW ITEMS (log-sweep, newcomer-audit, seedgo-audit — my own agents, reporting late)
|
||||
|
||||
### F107 — @memory rollover COMMAND times out at 30s via drone, ~every 1-3h for 2+ days [MEDIUM — sub-agent reported]
|
||||
- **CLOSED (I verified):** the 15 timeouts (`Command timed out after 30s: apps/memory.py rollover run`) are ALL in the rotated `drone.log.1`, 2026-07-10 00:24 → **last at 07-11 18:23:00**; ZERO in the current log. A live `rollover status` now returns in **8.3s** (well under 30s), local memory "OK". So this was REAL and recurring for ~2 days but has NOT recurred in ~25h — a transient (likely embedding-model cold-load or a backlog that cleared), not currently active. Distinct from F74 (keep-14 off-by-one). It's the "false FAILED" symptom of todo 66 — the 30s drone routing timeout < the actual rollover-run time. Keep an eye out for recurrence; if it returns, bump the routing timeout for the `rollover run` path or make rollover incremental.
|
||||
|
||||
### F108 — README/docs drift batch (newcomer-audit; adoption-facing) [LOW each, MEDIUM in aggregate]
|
||||
- README says "17 agents" ×3 but the Project Status table (README:245) says "13 core + user-created" — self-contradicting on the same page.
|
||||
- HVTrust badge (README:8) → hvtracker.net/agents/aipass returns HTTP 403 (re-check manually; may be bot-block vs down). This badge already has a saga (PR#655 hid it, #621 re-added).
|
||||
- Roadmap (README:262-269) frames #360/#329 as "under ongoing testing" — both are CLOSED per gh api.
|
||||
- **PyPI-vs-clone contradiction [MEDIUM — adoption]:** package `aipass` v2.7.0 IS live on PyPI, but TDPLAN-0010 stripped all `pip install aipass` refs from the README in favor of clone-only. A PyPI discoverer lands on a page whose own README tells them to git-clone instead, no explanation. Either document the PyPI path or explain the redirect.
|
||||
- Stale "Citizen Class: builder" survives the 2026-07-01 builder→aipass_framework rename in commons/README:8 and daemon/README:8.
|
||||
- daemon/README internal date contradiction (header "2026-04-07" vs footer "2026-06-29"); commons/README self-inconsistent post arg-count (3-arg vs 2-arg).
|
||||
- CONTRIBUTING.md:16 "4,900+ tests" — actual ~12k `def test_` (stale ~2.5×). 155KB CHANGELOG (excellent, root-cause+verify per fix) is NOT linked from README.
|
||||
|
||||
### F109 — Missing contributor infra: no PR template, no CoC, no FUNDING, no good-first-issue labels [MEDIUM — extends A5]
|
||||
- newcomer-audit confirmed via gh: no PULL_REQUEST_TEMPLATE.md, no CODE_OF_CONDUCT.md, no FUNDING.yml, no labeler/good-first-issue config. CONTRIBUTING.md is 23 lines, no citizen/branch/.trinity architecture onboarding. All 104 issues ever = AIOSAI-authored; sole external human = YugantM (confirms A3/A4). Feeds DPLAN-0240 Tier 0/1.
|
||||
|
||||
### F110 — commons gift/trade/mint brokenness is ALREADY KNOWN + documented [corroborates F52]
|
||||
- newcomer-audit: commons/README:77-82 marks gift/trade/mint/collab "not operational — registry path bug", lines 110-117 mark 3 dry-run paths "partial — routing error." So F52 (the owner-casing bug I found in code) is a KNOWN issue the branch documents publicly — good (honesty) and bad (root README pitches commons as live with no caveat). The casing root cause (F52) is likely THE "registry path bug" they mean. Fixing F52 could light up the whole artifact economy (A6).
|
||||
|
||||
### F111 — drone cross-project citizen-introspection registry-mismatch [MEDIUM — extends F41]
|
||||
- log-sweep: `drone.log` — `Introspection failed for @writer: Registry mismatch: citizen belongs to registry 'b91eefcf...' but found registry '8fb38c96...' at .../Vera-Studio/VERA-STUDIO_REGISTRY.json` (07-10 23:11). Plus @vera auth-denied + 6 bounced emails stuck unread in @drone inbox (F41). Suggests a path-resolution bug in drone's citizen-introspection that walks INTO a sibling project's registry. The 6 @vera bounces (F41) are the same cross-project-comms-is-feics theme (my key_learning 216).
|
||||
|
||||
### F112 — telegram_response stuck-pending: one session wedged 13+ hours [strengthens todo 67]
|
||||
- log-sweep: `hooks/telegram_response.log` — session `c10bd220` stuck at `start_line=5880`, retrying the same JSONL line 2026-07-11 18:16 → 07-12 07:28 (118 WARNING lines, ~13h) and never resolving. A concrete live instance of todo 67's stuck-pending (F223 key_learning: stale pending retries every Stop forever). The reap/expiry that todo 67 proposes would kill exactly this.
|
||||
|
||||
### F117 — SYSTEMIC: the exact code paths that crash/corrupt/leak are the ones with NO test [MEDIUM — meta-pattern]
|
||||
- Recurring across every sweep, the highest-severity findings share a tell: **"no test covers this."** F70 (test_token dead field — "no test covers this handler, presumably how it shipped broken"), F94 (CLI markup crash — "zero test coverage"), F104 (cadence <2s drift — "no test <2s apart"), F60/F103 (gate bypasses — "no test exercises malformed stdin with a non-empty matcher"), F99 (seedgo drop-on-exception paths untested), F33/F1 (racy/atomicity paths). The fleet has 371 test files / 10,458 functions and high nominal coverage — but it's concentrated on happy paths; the ERROR/CONCURRENCY/MALFORMED-INPUT branches (exactly where these bugs live) are systematically untested.
|
||||
- **Why it matters:** seedgo's Test_Quality standard scores 100% (F47) while the crash-on-bracket, silent-drop, and fail-open branches ship untested — the standard measures test PRESENCE, not adversarial coverage. A "test the unhappy path" checker or a mutation-testing pass would have caught most of these 116. The QA-layer expression of the "write-side shipped, read-side missing" culture (F81): tests assert what SHOULD happen, rarely what happens when it doesn't.
|
||||
|
||||
---
|
||||
|
||||
### F118 — RAN the tests (not just read them): F1 reproduces LOCALLY, and green suites harbor live bugs [strengthens F1 + F117 empirically]
|
||||
- **F1 is flakier than I thought — reproduced LIVE locally:** running the FULL prax suite → `test_mtime_cache_avoids_reread FAILED: assert {} == {'paused': False}` (1 failed, 990 passed). Earlier I ran `TestReadControl` in ISOLATION and it passed (6/6) → I wrongly concluded "only red on CI runners." Full-suite timing (other tests perturbing mtime granularity) triggers it locally too. So F1 isn't a CI-runner quirk — it's genuinely flaky anywhere under realistic timing. Even stronger case for the deterministic `os.utime` fix.
|
||||
- **F117 proven empirically:** ran memory (990 passed) and hooks (961 passed) suites — both FULLY GREEN while each harbors a live bug their tests never catch (memory: the keep-14 rollover F74; hooks: cadence <2s drift F104). Green suites + live bugs = exactly F117's thesis: coverage asserts the happy path, not the invariant. 990 memory tests, zero assert "rollover leaves exactly N."
|
||||
- **Codebase is structurally sound:** 11,170 tests collect with ZERO import/collection errors — no broken branches, no dead imports. The bones are solid; the gaps are adversarial-coverage + silent-degradation, not rot.
|
||||
- **Ran 6 branch suites (~4,000 tests):** memory 990, hooks 961, ai_mail 765, commons 449, spawn 346, flow 730 — ALL green in isolation (backup didn't finish in the time box). The ONLY failure anywhere is the flaky F1. Critical takeaway: NONE of the concurrency findings (F53 conn-leak, F54/F67/F85/F86 races) surface as test failures — they're all LATENT, green suites over untested race paths. Empirically nails F117: the suite is robust on the happy path and blind to exactly the branches these bugs live on.
|
||||
|
||||
### F119 — `@pytest.mark.integration` unregistered → silent no-op mark [LOW]
|
||||
- `devpulse/tests/test_watchdog_agent.py:469 @pytest.mark.integration` — mark not registered (PytestUnknownMarkWarning). Any `-m integration` selection silently matches nothing; the mark is decorative. Register it in pytest config or it's a filter that does nothing.
|
||||
|
||||
### F120 — Always-on fleet: 7 processes, ~22% CPU + 1.8GB RAM steady-state, forever [MEDIUM — resource/ops]
|
||||
- Live `ps`: trigger-log-watcher + 5 telegram bots + prax monitor = **7 persistent processes, 21.9% total CPU, 11.4% RAM (~1.77GB)** continuously on a 4-core/15.5GB machine — roughly a full core + 1.8GB permanently, before any actual work. No zombies (clean). Bots still logging poll errors TODAY (bot_base 246 / bot_devpulse 236 lines today; most recent ERROR 20:07 "read operation timed out"). Compounds F10 (no poll backoff): 5 separate bot processes each polling+erroring independently. Consideration: a shared poller (DPLAN-0219 mother-bot) would cut this materially. For a personal machine this steady-state load is worth a conscious decision, not an accident.
|
||||
|
||||
### F121 — Log volume dominated by two specific issues; medic-off cost quantified [LOW-MEDIUM]
|
||||
- Fleet logs = 73M total. Two files dominate: **`trigger/logs/medic_suppressed.log` = 9.9M (rotated once, +362K active)** — that's 10M+ of pure "Medic OFF - suppressed dispatch" lines, ONE PER dropped error over 63 days = a direct, quantified second cost of F5b (not just errors undelivered, but 10M of suppression noise written). And **`backup/logs/operations.jsonl.1` = 34M** — backup logs every per-file op (464-file versioned runs), rotates but runs heavy. Both rotate (not unbounded) but both trace to a specific fixable cause: re-enable medic (F5b) kills the first; backup could log op-summaries not per-file the second.
|
||||
|
||||
### F122 — Bug-magnet map: churn points EXACTLY at the surviving bugs [synthesis — where to harden]
|
||||
- `git log --grep=fix` over 6 months, most-touched source files: **memory-rollover is 4 of the top 6** — memory_watcher.py (19 fix commits), detector.py (17), rollover.py (16), extractor.py (16) = ~68 fixes to that one subsystem, AND F74's keep-14 off-by-one lives in the two most-fixed files (detector+extractor) and survived every one of those fixes. **ai_mail dispatch/delivery** is the other magnet: email.py (17), delivery.py (16), wake.py (15), daemon.py (15) = ~63 fixes, where F68/F69/F70/F72 + the F111 cross-project bleed live. **drone.py** (30 fixes, the router — F27 broken help lives here). **doctor.py** (15 fixes — F14-F17 live here).
|
||||
- **The signal:** the files fixed most often are the ones still harboring the bugs I found. Repeated patching hasn't converged — memory-rollover and ai_mail-dispatch are churn sinks that keep breaking. These two subsystems are candidates for a hardening PASS (invariants + adversarial tests + the atomic-write/lock discipline) rather than an (N+1)th patch. Churn + surviving-bug overlap = "stop patching, start hardening" list.
|
||||
|
||||
### F123 — Memory vector store: HEALTHY, but dashboard undercounts it ~4x [LOW-MEDIUM + an ASSET]
|
||||
- **Probed the actual ChromaDB backend** (the persistence layer of the whole "memory persists" value prop): `memory/.chroma/chroma.sqlite3` = 53M, **`PRAGMA integrity_check` = ok** (not corrupt), **5,012 embeddings across 29 collections**, and `drone @memory search` returns results in ~29s (works). The core memory backend is genuinely SOUND — good news for the value prop.
|
||||
- **BUT:** memory's DASHBOARD reports `vectors_stored: 1274` while the store actually holds **5,012 embeddings** — a ~4x undercount (likely counting one collection or a stale figure, mislabeled as the total). Another "displayed metric ≠ reality" instance (F17/F48/F49/F74 family) — the dashboard is a decorative number, not a measured one.
|
||||
- **Minor doc gap:** navmap says "two ChromaDB stores: local + a global one across all branches" — I find only per-branch `.chroma` dirs (+ backup copies); memory/.chroma (29 collections) appears to BE the aggregate. No separate "global" store exists as described; the doc and the reality have drifted.
|
||||
|
||||
### F124 — Dashboards are stale, not "live" — prax's own is 10 days old [MEDIUM]
|
||||
- `DASHBOARD.local.json` is documented as "Live state (refreshed by prax)" and is my startup protocol's "single status glance." Actual last_updated across branches: flow 07-13 (fresh), aipass 07-12, drone 07-11, memory 07-11, trigger 07-10, **prax 07-03 (10 DAYS stale)**. Only branches touched by live work update; the rest drift. The "glance" shows days-old data. Ironic: prax — the component that's supposed to refresh dashboards — has the STALEST one. Root: refresh isn't a scheduled ritual (F81 — the daemon runs zero jobs), so it only happens when something manually triggers it. Either schedule a periodic refresh or stop calling them "live."
|
||||
- **ASSET:** compass DB (devpulse-owned SQLite, my decision store) — integrity OK, 102 decisions, healthy. Memory vector store healthy (F123). The core data stores are SOUND; it's the freshness/accuracy of the DISPLAY layer that drifts.
|
||||
|
||||
### F125 — Verification spot-audit: sub-agent findings hold up (4/4 sampled confirm) [quality/credibility]
|
||||
- To gauge false-positive rate in the ~half of findings that came from sub-agents where I only spot-verified criticals/highs, I re-checked 4 random MEDIUM sub-agent findings against code: **F53** (commons curation/search/identity ops — `finally` count = 0 in all three, connections leak on exception ✓), **F68** (ai_mail `load_inbox` — `with open(...) json.load` and NO lock ✓), **F87** (prax registry save — raw `open("w")+json.dump`, no atomic helper ✓), **F96** (api `route_command:244` — `except Exception` swallow ✓). **4/4 confirmed, zero false positives.** Combined with: I personally verified every CRITICAL and HIGH, corrected 5 of my own claims when evidence disproved them, and 2+ agents independently corroborated the big findings. Confidence in the 124-finding set is high — the sub-agents cited exact file:line and the code matched every check. Treat medium/low findings as reliable leads; only the handful explicitly marked PLAUSIBLE need runtime confirmation.
|
||||
|
||||
### F126 — Injected-prompt drift: the always-on navmap makes a false claim to every agent [LOW — high frequency]
|
||||
- Audited the tier0/tier1/branch prompts (injected into EVERY agent every few turns) against the code reality I mapped. Mostly ACCURATE — good; and the navmap wisely lists agents live rather than hardcoding a count (so no "17 vs 18" drift there). Two real drifts: (1) **navmap line 98: "Two ChromaDB stores: local + a global one across all branches"** — F123 found NO separate global store exists; memory/.chroma IS the aggregate. Every agent is told to expect a store that isn't there. (2) devpulse/README:51 "DASHBOARD.local.json — Live state (refreshed by prax)" — F124 found dashboards up to 10 days stale. Low severity, but the navmap is the highest-read doc in the system (injected fleet-wide on a cadence), so a false claim there propagates to every session. The observability-is-decorative pattern (F117 unification) reaches even the docs: what the system SAYS about itself drifts from what IS.
|
||||
- **Net positive:** the prompts are otherwise consistent with reality — the drift is 2 specific stale claims, not systemic prompt-rot. The prompt layer is in better shape than the dashboard/metrics layer.
|
||||
|
||||
## MISSION STATUS — COMPLETE COVERAGE
|
||||
|
||||
All 17 branches code-swept, all 18 CLI surfaces walked, security gates + skills contribution surface audited, newcomer path + adoption funnel measured, error/medic/memory/daemon/commons internals traced. 9 read-only sub-agents + direct probing, ZERO system files edited. **116 findings, 2 verified-live criticals, 5 self-corrections** where evidence disproved a first claim (F5b root cause, F15 mechanism, supervised-bots, F99 scope, doctor case-vs-path). Deliverables: this doc (F1–F117 + A1–A7 + ~30 issue drafts) · DPLAN-0240 (adoption) · published dashboard · compass #94–#100 · todo 66 root-caused. Every fix is a proposal awaiting Patrick's go.
|
||||
|
||||
---
|
||||
|
||||
## COMMANDS WALKED
|
||||
|
||||
(coverage log so nothing is double-probed)
|
||||
|
||||
- **Bare introspection ×18:** drone, cli, git(--help), seedgo, spawn, ai_mail, api, backup, commons, daemon, devpulse, flow, hooks, memory, prax, skills, trigger, aipass
|
||||
- **--help:** drone@drone, git, api, backup, commons, spawn, ai_mail, prax, aipass, drone rm
|
||||
- **Data/state commands:** trigger errors(+stats/list/help/detail-via-raw-JSON), trigger medic, daemon queue/update/activity_report, prax status/monitor status(✗)/log_audit(✗)/log-audit, flow list open/templates/status(✗)/registry status, skills list/info github/validate telegram/run branch_health(+summary), backup status(bare✗/@name✗/path✓), memory search/lint(+run)/verify/pool/rollover/templates, ai_mail inbox, api usage(✗)/stats/validate/status/bogus(✗), commons feed/thread/activity/catchup/leaderboard/digest/room list/central(✗), hooks status/presence/cadence/hooksound, seedgo checklist/audit @trigger/audit(fleet)/standards_query/test_map @commons, spawn repair(scan)/update preview, devpulse compass query/feedback(+inbox/view)/watchdog status, drone scan/list/audit(✗), git status/log/lock/run list/run view(+--log-failed✗ = gh quirk), aipass doctor/help probe
|
||||
- **External/API:** gh api repo stats, labels, issues, PRs, discussions (graphql), traffic, releases, job logs
|
||||
- **System:** systemd units/timers, ps bot fleet, tmux ls, pytest TestReadControl (venv), subprocess gh repro
|
||||
- (✗) = found broken/misleading — see findings
|
||||
@@ -0,0 +1,353 @@
|
||||
<title>S304 Discovery Scan — AIPass</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
:root {
|
||||
/* neutrals — blue-biased ink, warm paper */
|
||||
--paper: #f4f1ea;
|
||||
--surface: #fbf9f4;
|
||||
--surface-2: #eeeae0;
|
||||
--ink: #1a1e27;
|
||||
--ink-soft: #454b58;
|
||||
--ink-faint: #6b7280;
|
||||
--hair: #ddd6c8;
|
||||
/* accent — passport gold, used sparingly */
|
||||
--accent: #b57e1f;
|
||||
--accent-soft: #e9d9b4;
|
||||
/* semantic severities */
|
||||
--crit: #c92a30;
|
||||
--high: #c0741f;
|
||||
--med: #4964a8;
|
||||
--low: #7b8494;
|
||||
--good: #2f8f5b;
|
||||
--crit-bg: #f6dcdc;
|
||||
--high-bg: #f4e6d0;
|
||||
--med-bg: #dde3f2;
|
||||
--low-bg: #e6e8ec;
|
||||
--good-bg: #d8ecdf;
|
||||
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--maxw: 940px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--paper: #10131a;
|
||||
--surface: #161a23;
|
||||
--surface-2: #1e2430;
|
||||
--ink: #e7e9ee;
|
||||
--ink-soft: #aab2c0;
|
||||
--ink-faint: #79828f;
|
||||
--hair: #2a3140;
|
||||
--accent: #d9a441;
|
||||
--accent-soft: #4a3d1c;
|
||||
--crit: #f0565b;
|
||||
--high: #e8973f;
|
||||
--med: #7d97d8;
|
||||
--low: #8a93a3;
|
||||
--good: #4cc082;
|
||||
--crit-bg: #3a1c1e;
|
||||
--high-bg: #382a17;
|
||||
--med-bg: #1f2740;
|
||||
--low-bg: #242a35;
|
||||
--good-bg: #16301f;
|
||||
}
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--paper: #f4f1ea; --surface: #fbf9f4; --surface-2: #eeeae0; --ink: #1a1e27;
|
||||
--ink-soft: #454b58; --ink-faint: #6b7280; --hair: #ddd6c8; --accent: #b57e1f;
|
||||
--accent-soft: #e9d9b4; --crit: #c92a30; --high: #c0741f; --med: #4964a8; --low: #7b8494; --good: #2f8f5b;
|
||||
--crit-bg: #f6dcdc; --high-bg: #f4e6d0; --med-bg: #dde3f2; --low-bg: #e6e8ec; --good-bg: #d8ecdf;
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--paper: #10131a; --surface: #161a23; --surface-2: #1e2430; --ink: #e7e9ee;
|
||||
--ink-soft: #aab2c0; --ink-faint: #79828f; --hair: #2a3140; --accent: #d9a441;
|
||||
--accent-soft: #4a3d1c; --crit: #f0565b; --high: #e8973f; --med: #7d97d8; --low: #8a93a3; --good: #4cc082;
|
||||
--crit-bg: #3a1c1e; --high-bg: #382a17; --med-bg: #1f2740; --low-bg: #242a35; --good-bg: #16301f;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: var(--paper); color: var(--ink);
|
||||
font-family: var(--sans); line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 22px; }
|
||||
|
||||
/* ── header ── */
|
||||
header { padding: 54px 0 30px; border-bottom: 1px solid var(--hair); }
|
||||
.eyebrow {
|
||||
font-family: var(--mono); font-size: 12px; letter-spacing: .14em;
|
||||
text-transform: uppercase; color: var(--accent); margin: 0 0 16px;
|
||||
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
|
||||
}
|
||||
.eyebrow .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--good); box-shadow: 0 0 0 3px var(--good-bg); }
|
||||
h1 {
|
||||
font-family: var(--mono); font-weight: 600; font-size: clamp(30px, 5.5vw, 50px);
|
||||
line-height: 1.04; letter-spacing: -0.02em; margin: 0 0 14px; text-wrap: balance;
|
||||
}
|
||||
h1 .stars { color: var(--accent); }
|
||||
.lede { font-size: 18px; color: var(--ink-soft); max-width: 62ch; margin: 0; }
|
||||
.meta {
|
||||
font-family: var(--mono); font-size: 12.5px; color: var(--ink-faint);
|
||||
margin-top: 22px; display: flex; gap: 8px 20px; flex-wrap: wrap;
|
||||
}
|
||||
.meta b { color: var(--ink-soft); font-weight: 600; }
|
||||
|
||||
/* ── stat row ── */
|
||||
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--hair);
|
||||
border: 1px solid var(--hair); border-radius: 10px; overflow: hidden; margin: 30px 0 8px; }
|
||||
.stat { background: var(--surface); padding: 18px 18px 16px; }
|
||||
.stat .n { font-family: var(--mono); font-size: 30px; font-weight: 600; letter-spacing: -0.02em;
|
||||
font-variant-numeric: tabular-nums; line-height: 1; }
|
||||
.stat .k { font-size: 12.5px; color: var(--ink-faint); margin-top: 7px; }
|
||||
.stat.crit .n { color: var(--crit); }
|
||||
@media (max-width: 620px) { .stats { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
/* severity meter */
|
||||
.meter { display: flex; height: 8px; border-radius: 6px; overflow: hidden; margin: 20px 0 4px; }
|
||||
.meter span { display: block; }
|
||||
.meter-key { display: flex; gap: 16px 20px; flex-wrap: wrap; font-family: var(--mono);
|
||||
font-size: 11.5px; color: var(--ink-faint); margin-bottom: 6px; }
|
||||
.meter-key i { font-style: normal; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.swatch { width: 9px; height: 9px; border-radius: 2px; display: inline-block; }
|
||||
|
||||
/* ── sections ── */
|
||||
section { padding: 40px 0; border-bottom: 1px solid var(--hair); }
|
||||
.sec-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 22px; }
|
||||
.sec-head h2 { font-family: var(--mono); font-size: 14px; letter-spacing: .1em; text-transform: uppercase;
|
||||
color: var(--ink); margin: 0; font-weight: 600; }
|
||||
.sec-head .rule { flex: 1; height: 1px; background: var(--hair); }
|
||||
.sec-head .count { font-family: var(--mono); font-size: 12px; color: var(--ink-faint); }
|
||||
|
||||
/* finding cards */
|
||||
.findings { display: flex; flex-direction: column; gap: 12px; }
|
||||
.f { background: var(--surface); border: 1px solid var(--hair); border-radius: 9px;
|
||||
border-left-width: 4px; padding: 16px 18px; }
|
||||
.f.crit { border-left-color: var(--crit); }
|
||||
.f.high { border-left-color: var(--high); }
|
||||
.f.med { border-left-color: var(--med); }
|
||||
.f.good { border-left-color: var(--good); }
|
||||
.f-top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 7px; }
|
||||
.id { font-family: var(--mono); font-size: 12px; font-weight: 600; color: var(--ink-faint); }
|
||||
.pill { font-family: var(--mono); font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase;
|
||||
font-weight: 600; padding: 2px 8px; border-radius: 20px; }
|
||||
.pill.crit { color: var(--crit); background: var(--crit-bg); }
|
||||
.pill.high { color: var(--high); background: var(--high-bg); }
|
||||
.pill.med { color: var(--med); background: var(--med-bg); }
|
||||
.pill.good { color: var(--good); background: var(--good-bg); }
|
||||
.f-title { font-weight: 600; font-size: 15.5px; color: var(--ink); flex: 1 1 100%; margin-top: 2px; }
|
||||
.f-body { font-size: 14px; color: var(--ink-soft); margin: 0; }
|
||||
.f-body code { font-family: var(--mono); font-size: 12.5px; background: var(--surface-2);
|
||||
padding: 1px 5px; border-radius: 4px; color: var(--ink); }
|
||||
.tag { font-family: var(--mono); font-size: 11px; color: var(--ink-faint); }
|
||||
|
||||
/* adoption / funnel */
|
||||
.funnel { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin: 4px 0 24px; }
|
||||
.fu { background: var(--surface); border: 1px solid var(--hair); border-radius: 9px; padding: 16px; text-align: center; }
|
||||
.fu .n { font-family: var(--mono); font-size: 26px; font-weight: 600; letter-spacing: -0.02em; }
|
||||
.fu .k { font-size: 12px; color: var(--ink-faint); margin-top: 5px; }
|
||||
.fu.drop .n { color: var(--crit); }
|
||||
@media (max-width: 620px) { .funnel { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
.callout { background: var(--surface-2); border: 1px solid var(--hair); border-radius: 9px;
|
||||
padding: 18px 20px; margin-top: 18px; }
|
||||
.callout h3 { font-family: var(--mono); font-size: 13px; letter-spacing: .06em; text-transform: uppercase;
|
||||
margin: 0 0 8px; color: var(--accent); }
|
||||
.callout p { margin: 0; font-size: 14px; color: var(--ink-soft); }
|
||||
|
||||
ul.ideas { list-style: none; padding: 0; margin: 8px 0 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
ul.ideas li { display: flex; gap: 12px; font-size: 14px; color: var(--ink-soft); align-items: baseline; }
|
||||
ul.ideas .tier { font-family: var(--mono); font-size: 11px; font-weight: 600; color: var(--accent);
|
||||
background: var(--accent-soft); padding: 2px 7px; border-radius: 5px; white-space: nowrap; }
|
||||
|
||||
.deliverables { display: flex; flex-direction: column; gap: 10px; }
|
||||
.d { display: flex; gap: 12px; align-items: baseline; font-size: 14px; color: var(--ink-soft);
|
||||
font-family: var(--mono); }
|
||||
.d .path { color: var(--ink); font-weight: 600; }
|
||||
.d .chk { color: var(--good); }
|
||||
|
||||
footer { padding: 30px 0 60px; font-family: var(--mono); font-size: 12px; color: var(--ink-faint); }
|
||||
footer .disc { color: var(--good); }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<p class="eyebrow"><span class="dot"></span> Session S304 · Autonomous Discovery Scan · Search-only</p>
|
||||
<h1>124 findings across a <span class="stars">237-star</span> system that no one has ever contributed to.</h1>
|
||||
<p class="lede">A full unsupervised walk of AIPass — all 18 agents' code, the logs, error registries, security gates, live processes, ~4,000 tests run, and the newcomer path. Zero system files changed. Every fix here is a proposal, evidence attached.</p>
|
||||
<p class="meta"><span><b>Date</b> 2026-07-12</span><span><b>Agent</b> @devpulse</span><span><b>Surfaces walked</b> 18/18</span><span><b>Sub-agents</b> 5 read-only</span><span><b>Files edited</b> 0 system</span></p>
|
||||
</header>
|
||||
|
||||
<div class="stats" role="list">
|
||||
<div class="stat" role="listitem"><div class="n">124</div><div class="k">bug & gap findings</div></div>
|
||||
<div class="stat crit" role="listitem"><div class="n">2</div><div class="k">live criticals</div></div>
|
||||
<div class="stat" role="listitem"><div class="n">29</div><div class="k">issue drafts, ready to file</div></div>
|
||||
<div class="stat" role="listitem"><div class="n">9</div><div class="k">read-only sub-agents</div></div>
|
||||
</div>
|
||||
|
||||
<div class="meter-key" aria-hidden="true">
|
||||
<i><span class="swatch" style="background:var(--crit)"></span>Critical 2</i>
|
||||
<i><span class="swatch" style="background:var(--high)"></span>High 28</i>
|
||||
<i><span class="swatch" style="background:var(--med)"></span>Medium 38</i>
|
||||
<i><span class="swatch" style="background:var(--low)"></span>Low 32</i>
|
||||
<i><span class="swatch" style="background:var(--good)"></span>Verified-good 6</i>
|
||||
</div>
|
||||
<div class="meter" aria-hidden="true">
|
||||
<span style="flex:2;background:var(--crit)"></span>
|
||||
<span style="flex:28;background:var(--high)"></span>
|
||||
<span style="flex:38;background:var(--med)"></span>
|
||||
<span style="flex:32;background:var(--low)"></span>
|
||||
<span style="flex:6;background:var(--good)"></span>
|
||||
</div>
|
||||
|
||||
<!-- CRITICAL + HIGH FINDINGS -->
|
||||
<section>
|
||||
<div class="sec-head"><h2>Fix First</h2><span class="rule"></span><span class="count">the load-bearing ones</span></div>
|
||||
<div class="findings">
|
||||
|
||||
<div class="f crit">
|
||||
<div class="f-top"><span class="id">F74</span><span class="pill crit">Critical · live now · verified</span>
|
||||
<span class="f-title">Memory rollover is keeping 14 entries, not 15 — on every branch, right now</span></div>
|
||||
<p class="f-body">An off-by-one trims memory at the keep target instead of above it: the trigger fires at <code>len >= 15</code> and the extractor forces <code>max(excess, 1)</code>, so at exactly 15 it removes one it should keep. The proof is on disk — memory's own <code>local.json</code> holds exactly 14 sessions and 14 learnings, and the rollover log shows <code>(15/15 sessions)</code> firing 53 times. Every branch silently loses one extra memory per rollover. Standing hold on rollover fixes respected — documented, not filed.</p>
|
||||
</div>
|
||||
|
||||
<div class="f crit">
|
||||
<div class="f-top"><span class="id">F5b</span><span class="pill crit">Critical · root cause corrected</span>
|
||||
<span class="f-title">Medic error-notification has been switched off for 63 days</span></div>
|
||||
<p class="f-body">The medic log tells the whole story in three lines: at <b>2026-05-10 20:30:21</b> — the same minute a 25-second burst of test-fixture errors flooded in — medic was switched off ("Medic DISABLED — error dispatch suppressed") and never switched back on. Since then <code>config.medic_enabled = false</code> makes the dispatch path drop every detected error before anything else runs; 63 days later it's still suppressing live errors (1,667 "Medic OFF" log lines, zero "Circuit breaker OPEN"). A brief annoyance muted the entire error-notification system for two months. <b>Honest note:</b> my first pass blamed a stuck circuit breaker; a later sweep refuted it and I re-verified on disk. That breaker is a real but separate latent bug (F5c) that must be fixed <em>before</em> medic is switched back on, or it re-floods.</p>
|
||||
</div>
|
||||
|
||||
<div class="f high">
|
||||
<div class="f-top"><span class="id">F1</span><span class="pill high">High</span>
|
||||
<span class="f-title">PR#696 red CI is one inherently racy test</span></div>
|
||||
<p class="f-body">Every red job fails on the same assertion: <code>test_mtime_cache_avoids_reread</code> passes only when two file writes share an mtime tick — a filesystem coin flip that lands green locally and red on GitHub runners. Two-line deterministic fix (<code>os.utime</code> to pin mtime equality). @prax owns.</p>
|
||||
</div>
|
||||
|
||||
<div class="f high">
|
||||
<div class="f-top"><span class="id">F14–F17</span><span class="pill high">High</span>
|
||||
<span class="f-title">aipass doctor cries wolf on a healthy install</span></div>
|
||||
<p class="f-body">Our first-touch trust tool reports <b>7 errors on a clean repo — all false</b>: it scans <code>.backup/snapshots/</code> as if they were live agents, compares registry names case-sensitively against 5 legitimately-uppercase entries, prints an unsubstituted <code>{{BRANCHNAME}}</code>, and shows ✓ marks whose text is a warning. A newcomer reads "my install is broken."</p>
|
||||
</div>
|
||||
|
||||
<div class="f high">
|
||||
<div class="f-top"><span class="id">F30</span><span class="pill high">High</span>
|
||||
<span class="f-title">spawn repair advises a command that would delete the live @aipass agent</span></div>
|
||||
<p class="f-body"><code>spawn repair</code> flags <code>src/aipass/aipass/</code> as duplicate "pollution" and prints the remediation <code>--clean-pollution</code> (archive + remove). That directory is the living concierge agent. Following the tool's own advice archives a user-facing citizen. Needs a passport-awareness guard.</p>
|
||||
</div>
|
||||
|
||||
<div class="f high">
|
||||
<div class="f-top"><span class="id">F52</span><span class="pill high">High · verified in code</span>
|
||||
<span class="f-title">Commons artifact gifting is broken by name-casing</span></div>
|
||||
<p class="f-body">Gift / trade / mint write an <code>UPPERCASE</code> owner (<code>trade_ops.py:58</code>) while every ownership check compares lowercase. Gift an artifact to <code>@seed</code> and it becomes invisible in their inventory and permanently un-tradeable (<code>"SEED" != "seed"</code>). The self-gift guard never trips. The registry-casing disease, database edition.</p>
|
||||
</div>
|
||||
|
||||
<div class="f high">
|
||||
<div class="f-top"><span class="id">F6 · F43</span><span class="pill high">High</span>
|
||||
<span class="f-title">Tests write fixtures into production logs, poisoning the error registry</span></div>
|
||||
<p class="f-body">pytest fixtures ("disk full", "boom", <code>/tmp/pytest-of-patrick/…</code>) land in real branch <code>logs/</code>; the 24/7 log-watcher ingests them into the error registry (664+ occurrences of one fixture line alone). One cut fixes the class: route logging to tmp when <code>PYTEST_CURRENT_TEST</code> is set.</p>
|
||||
</div>
|
||||
|
||||
<div class="f high">
|
||||
<div class="f-top"><span class="id">F22 · F23</span><span class="pill high">High</span>
|
||||
<span class="f-title">The human-facing concierge has the worst help in the fleet</span></div>
|
||||
<p class="f-body"><code>aipass --help</code> is a bare module dump — no "what is this," no first steps, internal modules exposed. <code>aipass help "how do I create a new branch"</code> answers with git-PR commands and never mentions @spawn. The one tool built for humans is the least helpful.</p>
|
||||
</div>
|
||||
|
||||
<div class="f high">
|
||||
<div class="f-top"><span class="id">F46</span><span class="pill high">High</span>
|
||||
<span class="f-title">14 feedback messages unread since April — including 4 external bug reports</span></div>
|
||||
<p class="f-body">Precise bug reports from an external project sat <code>NEW</code> for three months; we later rediscovered every one of them the hard way. The owner-to-owner channel works technically and fails operationally — no inbox check in startup, no aging alarm.</p>
|
||||
</div>
|
||||
|
||||
<div class="f high">
|
||||
<div class="f-top"><span class="id">F53 · F54</span><span class="pill high">High</span>
|
||||
<span class="f-title">Commons: systemic DB connection leak + unlocked log that self-wipes</span></div>
|
||||
<p class="f-body">~14 handler files close their sqlite connection only on the success path (no <code>finally:</code>), 40+ sites — hottest runs on every post. The shared op-log does unlocked read-modify-write; a torn write is "healed" by silently resetting the log to <code>[]</code>. The correct pattern already exists in sibling files.</p>
|
||||
</div>
|
||||
|
||||
<div class="f high">
|
||||
<div class="f-top"><span class="id">F59</span><span class="pill high">High · verified · integrity</span>
|
||||
<span class="f-title">The owner model is advisory: git-write trusts an unprotected passport name</span></div>
|
||||
<p class="f-body"><code>verify_git_access</code> grants owner-tier git if the <code>branch_name</code> in the caller's own <code>passport.json</code> reads "devpulse" — no registry cross-check, no signature, and no gate protects that file. The DPLAN-0231 owner-capability model was built to key on the sealed <code>registry_id</code>; the actual check reads the mutable name instead. <b>Scope honestly:</b> agents are same-user and cooperative, so this is "the model isn't enforced," not a remote exploit — but it undoes a guarantee you deliberately built.</p>
|
||||
</div>
|
||||
|
||||
<div class="f high">
|
||||
<div class="f-top"><span class="id">F84 · F85</span><span class="pill high">High · verified · destructive</span>
|
||||
<span class="f-title">Two commands can destroy a branch or another branch's plan history</span></div>
|
||||
<p class="f-body"><code>spawn delete_branch</code> guards only three hardcoded names — no check for a live process or <code>owner:true</code> — so it will archive-and-remove any other running branch on request. And the flow central aggregator, when it reads a branch's registry during a transient unreadable moment, "heals" it by writing an empty registry back — one branch's routine aggregation can wipe another's entire plan history. Both traced in code.</p>
|
||||
</div>
|
||||
|
||||
<div class="f high">
|
||||
<div class="f-top"><span class="id">F114</span><span class="pill high">High · verified · adoption-blocking</span>
|
||||
<span class="f-title">The contribution surface everyone would reach for first has no safety boundary</span></div>
|
||||
<p class="f-body">Running a skill executes its <code>handler.py</code> in-process with full privileges — no sandbox, no confirmation, no review (verified: the only relevant line is a bare <code>exec_module</code>). A built-in skill is also <code>shell=True</code>-injectable, and a skill dropped into <code>~/.aipass/skills/</code> can silently impersonate a trusted built-in by name. Skills are the natural first thing an outside contributor would build — so this is the one place the adoption plan and the security posture collide. A community-skills ecosystem needs a trust model (sandbox, review, or a first-run consent gate) before it can open.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PATTERN -->
|
||||
<section>
|
||||
<div class="sec-head"><h2>The Pattern Underneath</h2><span class="rule"></span></div>
|
||||
<div class="callout">
|
||||
<h3>The deepest root: the gauges are decorative, so nothing looks broken</h3>
|
||||
<p>Running the live system surfaced the mechanism behind everything else. The <em>data</em> is sound — the vector store passes an integrity check with 5,012 embeddings, the decision DB is clean, 11,000 tests collect without error, six suites run green. But the system's <em>self-report</em> is wrong almost everywhere it's checked: the dashboard claims 1,274 vectors when there are 5,012; dashboards labeled "live" are up to ten days stale; doctor invents seven errors on a healthy repo; the standards audit reports "100%" while silently ignoring any file it can't parse. When every gauge reads "fine" regardless of reality, a thing can break quietly and stay broken for months — which is exactly what medic, rollover, and the stuck breaker did. The highest-leverage fix isn't any single bug; it's making the instruments <em>measured</em> — real counts, real freshness, invariants that fail loud — so the system can finally see its own state.</p>
|
||||
</div>
|
||||
<div class="callout" style="margin-top:14px">
|
||||
<h3>We build the write side and never the read side</h3>
|
||||
<p>The same shape recurs across F5 (errors detected, never triaged), F26 (presence records written, never cleaned), F40/F46 (mail delivered, never read), F49 (a health check pointed at a retired schema), F5b (a breaker that opens but can't close), F81 (a scheduler running with zero jobs). Detection, ingestion, and enforcement ship; the closing ritual — the thing that reads, ages, resolves, or recovers — is the half that's consistently missing. It's a culture fix as much as a code fix: every new pipeline needs its janitor shipped with it.</p>
|
||||
</div>
|
||||
<div class="callout" style="margin-top:14px">
|
||||
<h3>One fix retires a dozen bugs</h3>
|
||||
<p>Across six branches the single most common defect is the same: shared state — registries, passports, inboxes, scheduler runstate, the logger's own data — written with a raw <code>open("w") + json.dump</code>, no temp-and-rename, no lock. A crash mid-write truncates the file; the paired loader "fails open" to empty and writes that empty back, turning a transient read hiccup into permanent data loss. The correct atomic-write helper <b>already exists in every branch</b> — it's just applied inconsistently. One shared primitive plus a seedgo checker that forbids the raw pattern closes roughly a dozen findings at once, including the two that can wipe another branch's plan registry or truncate a passport.</p>
|
||||
</div>
|
||||
<div class="callout" style="margin-top:14px">
|
||||
<h3>The deepest gap: blindness to silent degradation</h3>
|
||||
<p>I dated the biggest findings by git blame: the rollover bug has been trimming memory since April 22, the seedgo audit has silently dropped crashing checkers since March 23 (its inception), medic has been off since May 10. They survived for <em>months</em> — not because they're hard to find, but because none of them produces a symptom. Rollover archives the entry instead of deleting it, so nothing looks lost. Medic being off just means silence. A dropped checker just makes a number rounder. The whole system is instrumented for <em>visible</em> failure — logs, errors, red CI — and has almost no way to notice when something quietly starts doing the wrong thing. The durable fix isn't per-bug; it's a handful of invariants that assert the invisible — rollover leaves exactly N, medic is on, every registered checker actually ran — and shout when they don't.</p>
|
||||
</div>
|
||||
<div class="callout" style="margin-top:14px">
|
||||
<h3>The gates fail open, quietly</h3>
|
||||
<p>The security gates are well-built where they run — but they default to <em>allow</em> at four independent layers: a missing config file, a handler that crashes, malformed input, and any parser exception, each logged only as a line of text. And the standards audit does the same — a file that makes a checker throw is dropped from the score rather than failed, so "100%" quietly means "100% of the files we could parse." None of this is a remote exploit — these are cooperative same-user agents — but a guarantee that fails silently isn't a guarantee. The cheap half of the fix is to make every fail-open <em>loud</em>: surface it to the error registry, not just a log.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ADOPTION -->
|
||||
<section>
|
||||
<div class="sec-head"><h2>The Contributor Funnel</h2><span class="rule"></span><span class="count">DPLAN-0240</span></div>
|
||||
<div class="funnel">
|
||||
<div class="fu"><div class="n">~600</div><div class="k">unique humans / month</div></div>
|
||||
<div class="fu"><div class="n">237</div><div class="k">stars</div></div>
|
||||
<div class="fu"><div class="n">1</div><div class="k">external human, ever</div></div>
|
||||
<div class="fu drop"><div class="n">0</div><div class="k">retained / contributing</div></div>
|
||||
</div>
|
||||
<p class="f-body" style="margin-bottom:18px">Attraction works; conversion is broken. GitHub topics are <b>empty</b>, the homepage field is blank, Discussions are dead, and the Commons — a genuinely rich agent-society sandbox with craftable artifacts, time capsules, and a hidden exploration game — has exactly <b>one post</b>.</p>
|
||||
|
||||
<div class="callout">
|
||||
<h3>The April Precedent</h3>
|
||||
<p>This exact play was already run once. In April an external agent created three <code>good first issue</code> tasks and added the README "Need help?" line. Three weeks later our own issue-zero sweep batch-closed all three — same minute, zero comments — and the underlying work was never done (the seedgo <code>readme_currency</code> proof still fails today). We didn't just lose a contributor's momentum; our tidiness reflex deleted the on-ramp.</p>
|
||||
</div>
|
||||
|
||||
<ul class="ideas">
|
||||
<li><span class="tier">Tier 0</span><span>Set GitHub topics, fill the homepage field, pin a "Start here" discussion, add a code of conduct — ~30 minutes, near-zero risk.</span></li>
|
||||
<li><span class="tier">Tier 1</span><span>An external-response reflex (<24h reply, via our own dispatch plumbing) and a protected shelf of <code>good first issue</code> tasks we deliberately don't self-clear.</span></li>
|
||||
<li><span class="tier">Tier 2</span><span>Fix the first-ten-minutes tools: doctor's false errors, the concierge's help, the two broken examples in <code>drone --help</code>.</span></li>
|
||||
<li><span class="tier">Tier 3</span><span>A docs front-door, and revive the Commons with automatic agent rituals — then expose a read-only public feed. "Watch AI agents run their own society" is a headline no competitor can match.</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- DELIVERABLES -->
|
||||
<section>
|
||||
<div class="sec-head"><h2>Deliverables</h2><span class="rule"></span></div>
|
||||
<div class="deliverables">
|
||||
<div class="d"><span class="chk">✓</span><span><span class="path">docs/discovery/S304_discovery_mission.md</span> — 58 findings + 6 adoption notes + 17 ready-to-file issue drafts, evidence inline</span></div>
|
||||
<div class="d"><span class="chk">✓</span><span><span class="path">DPLAN-0240</span> — contributor funnel, four tiers, the April Precedent</span></div>
|
||||
<div class="d"><span class="chk">✓</span><span><span class="path">todo 66</span> — CI red root-caused to F1; local.json updated</span></div>
|
||||
<div class="d"><span class="chk">✓</span><span><span class="path">compass #94 / #95</span> — circuit-breaker lesson + April-Precedent decision recorded</span></div>
|
||||
<div class="d"><span class="chk">✓</span><span><span class="path">5 read-only sub-agents</span> — newcomer, logs, code sweeps (commons/backup/ai_mail/daemon), security gates</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p class="disc">// search-only mission — no system files edited, no issues filed, no settings changed, no posts made.</p>
|
||||
<p>Every outward-facing action awaits your go. @devpulse · S304</p>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -275,3 +275,176 @@ def test_flag_without_value_errors(capsys, db):
|
||||
assert compass_cmd.handle_command("compass", ["query", "x", "--rating"]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "rating" in out and "value" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# supersedes — atomic archive + link, both pointer directions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_supersedes_archives_and_links(capsys, db):
|
||||
"""add --supersedes N archives #N, links the new row, shows 'supersedes #N'."""
|
||||
old = _add(capsys, db, "old ctx sessions", "use sessions", "good")
|
||||
capsys.readouterr()
|
||||
assert (
|
||||
compass_cmd.handle_command(
|
||||
"compass",
|
||||
[
|
||||
"add",
|
||||
"new ctx jwt",
|
||||
"switch to jwt",
|
||||
"--rating",
|
||||
"good",
|
||||
"--supersedes",
|
||||
str(old),
|
||||
"--db",
|
||||
db,
|
||||
],
|
||||
)
|
||||
is True
|
||||
)
|
||||
out = _output(capsys)
|
||||
assert f"supersedes #{old}" in out
|
||||
|
||||
# The archived row is gone from the default (active-only) query...
|
||||
q = _query_out(capsys, db, "sessions")
|
||||
assert "0 result(s)" in q
|
||||
|
||||
# ...but --include-archived surfaces it WITH its status + forward pointer.
|
||||
q2 = _query_out(capsys, db, "sessions", "--include-archived")
|
||||
assert "ARCHIVED" in q2.upper()
|
||||
assert "superseded by #" in q2.lower()
|
||||
|
||||
|
||||
def test_add_supersedes_bad_id_errors_no_write(capsys, db):
|
||||
"""add --supersedes to a missing id errors and writes nothing."""
|
||||
capsys.readouterr()
|
||||
compass_cmd.handle_command(
|
||||
"compass",
|
||||
["add", "ctx", "dec", "--rating", "good", "--supersedes", "9999", "--db", db],
|
||||
)
|
||||
out = _output(capsys).lower()
|
||||
assert "9999" in out
|
||||
assert "total decisions: 0" in _stats_out(capsys, db).lower()
|
||||
|
||||
|
||||
def test_add_supersedes_non_integer_errors(capsys, db):
|
||||
"""A non-integer --supersedes fails loud."""
|
||||
assert (
|
||||
compass_cmd.handle_command(
|
||||
"compass",
|
||||
["add", "ctx", "dec", "--rating", "good", "--supersedes", "abc", "--db", db],
|
||||
)
|
||||
is True
|
||||
)
|
||||
out = _output(capsys).lower()
|
||||
assert "supersedes" in out and "integer" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# write-time conflict advisory — non-blocking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_conflict_advisory_prints_but_does_not_block(capsys, db):
|
||||
"""An overlapping active row triggers an advisory; the add still succeeds."""
|
||||
_add(capsys, db, "caching layer strategy", "add redis caching", "good")
|
||||
capsys.readouterr()
|
||||
compass_cmd.handle_command(
|
||||
"compass",
|
||||
["add", "caching approach again", "another caching layer", "--rating", "good", "--db", db],
|
||||
)
|
||||
out = _output(capsys)
|
||||
assert "possible conflict" in out.lower()
|
||||
assert "--supersedes" in out # advisory hints the fix
|
||||
assert "Added decision" in out # NON-BLOCKING: still added
|
||||
|
||||
|
||||
def test_add_no_conflict_on_empty_store(capsys, db):
|
||||
"""First add on an empty store prints no advisory."""
|
||||
capsys.readouterr()
|
||||
compass_cmd.handle_command("compass", ["add", "unique ctx", "unique dec", "--rating", "good", "--db", db])
|
||||
out = _output(capsys).lower()
|
||||
assert "possible conflict" not in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# note — set a note, prove it is immediately searchable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_note_command_sets_and_is_searchable(capsys, db):
|
||||
"""note <id> "text" sets the note; a later query finds the new note text."""
|
||||
did = _add(capsys, db, "note cmd ctx", "note cmd dec", "good")
|
||||
capsys.readouterr()
|
||||
assert compass_cmd.handle_command("compass", ["note", str(did), "findme pterodactyl", "--db", db]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "note set" in out
|
||||
|
||||
q = _query_out(capsys, db, "pterodactyl")
|
||||
assert "1 result(s)" in q
|
||||
|
||||
|
||||
def test_note_help(capsys):
|
||||
"""compass note --help prints per-subcommand usage."""
|
||||
assert compass_cmd.handle_command("compass", ["note", "--help"]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "note" in out and "usage" in out
|
||||
|
||||
|
||||
def test_note_missing_id_warns(capsys, db):
|
||||
"""note on a non-existent id reports nothing changed, does not crash."""
|
||||
assert compass_cmd.handle_command("compass", ["note", "999", "text", "--db", db]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "999" in out and ("nothing changed" in out or "no decision" in out)
|
||||
|
||||
|
||||
def test_note_missing_args_shows_usage(capsys, db):
|
||||
"""note with too few args shows usage."""
|
||||
assert compass_cmd.handle_command("compass", ["note", "5", "--db", db]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "usage" in out
|
||||
|
||||
|
||||
def test_help_and_introspection_list_note(capsys):
|
||||
"""Both --help and bare introspection advertise the note subcommand."""
|
||||
compass_cmd.handle_command("compass", ["--help"])
|
||||
assert "note" in _output(capsys).lower()
|
||||
compass_cmd.handle_command("compass", [])
|
||||
assert "note" in _output(capsys).lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --include-archived — archived hits must show status + supersession pointer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_include_archived_shows_archived_pointer(capsys, db):
|
||||
"""--include-archived surfaces an archived row flagged with its successor."""
|
||||
old = _add(capsys, db, "archived-visible ctx", "the old choice", "bad")
|
||||
capsys.readouterr()
|
||||
compass_cmd.handle_command(
|
||||
"compass",
|
||||
[
|
||||
"add",
|
||||
"replacement ctx",
|
||||
"the new choice",
|
||||
"--rating",
|
||||
"good",
|
||||
"--supersedes",
|
||||
str(old),
|
||||
"--db",
|
||||
db,
|
||||
],
|
||||
)
|
||||
capsys.readouterr()
|
||||
|
||||
# Default query hides the archived row.
|
||||
q = _query_out(capsys, db, "old choice")
|
||||
assert "0 result(s)" in q
|
||||
|
||||
# With the flag it appears, unmistakably marked archived + superseded.
|
||||
q2 = _query_out(capsys, db, "old choice", "--include-archived")
|
||||
assert "1 result(s)" in q2
|
||||
assert "archived" in q2.lower()
|
||||
assert "superseded by #" in q2.lower()
|
||||
|
||||
@@ -283,3 +283,252 @@ class TestInputValidation:
|
||||
compass.add_decision("ctx", "dec", "good", db_path=db)
|
||||
with pytest.raises(ValueError):
|
||||
compass.query_decisions("ctx", limit=0, db_path=db)
|
||||
|
||||
|
||||
class TestSupersedes:
|
||||
"""supersedes column, atomic archive+link, idempotent migration (DPLAN-0246)."""
|
||||
|
||||
def test_add_with_supersedes_archives_and_links(self, db):
|
||||
"""--supersedes links the corrector AND archives the target, atomically."""
|
||||
old = compass.add_decision("old auth ctx", "use sessions", "good", db_path=db)
|
||||
new = compass.add_decision("new auth ctx", "switch to JWT", "good", db_path=db, supersedes=old)
|
||||
# The corrector row links back to what it replaced.
|
||||
hit = compass.query_decisions("JWT", db_path=db)[0]
|
||||
assert hit["supersedes"] == old
|
||||
# The old entry is archived → gone from the active query.
|
||||
assert compass.query_decisions("sessions", db_path=db) == []
|
||||
# With include_archived it reappears, pointing FORWARD to its successor.
|
||||
arch = compass.query_decisions("sessions", include_archived=True, db_path=db)[0]
|
||||
assert arch["status"] == "archived"
|
||||
assert arch["superseded_by"] == new
|
||||
|
||||
def test_supersedes_nonexistent_raises_no_partial_write(self, db):
|
||||
"""A bad --supersedes id errors cleanly and leaves NO partial write."""
|
||||
with pytest.raises(ValueError):
|
||||
compass.add_decision("ctx", "dec", "good", db_path=db, supersedes=9999)
|
||||
assert compass.stats(db_path=db)["total"] == 0
|
||||
|
||||
def test_supersedes_defaults_none(self, db):
|
||||
"""A plain add has supersedes=None and superseded_by=None."""
|
||||
compass.add_decision("plain ctx", "plain dec", "good", db_path=db)
|
||||
hit = compass.query_decisions("plain", db_path=db)[0]
|
||||
assert hit["supersedes"] is None
|
||||
assert hit["superseded_by"] is None
|
||||
|
||||
def test_active_hit_shows_its_supersedes_pointer(self, db):
|
||||
"""An ACTIVE corrector still exposes its supersedes pointer on query."""
|
||||
old = compass.add_decision("legacy topic zzz", "old way", "bad", db_path=db)
|
||||
compass.add_decision("current topic zzz", "new way", "good", db_path=db, supersedes=old)
|
||||
hit = compass.query_decisions("current", db_path=db)[0]
|
||||
assert hit["supersedes"] == old
|
||||
assert hit["superseded_by"] is None # nothing supersedes the corrector
|
||||
|
||||
def test_migration_idempotent_repeated_connects(self, db):
|
||||
"""Re-opening the DB re-runs migration harmlessly; column stays present."""
|
||||
compass.add_decision("ctx one", "dec one", "good", db_path=db)
|
||||
for _ in range(3):
|
||||
conn = store._connect(db)
|
||||
try:
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(decisions)")}
|
||||
finally:
|
||||
conn.close()
|
||||
assert "supersedes" in cols
|
||||
|
||||
def test_migration_adds_column_to_legacy_db(self, db):
|
||||
"""A pre-supersedes DB gets the column added in via _connect migration."""
|
||||
# Build a legacy `decisions` table WITHOUT supersedes, as older builds had.
|
||||
conn = sqlite3.connect(str(db))
|
||||
conn.execute(
|
||||
"CREATE TABLE decisions ("
|
||||
"id INTEGER PRIMARY KEY, created TEXT, context TEXT NOT NULL, "
|
||||
"decision TEXT NOT NULL, rating TEXT NOT NULL, note TEXT, tags TEXT, "
|
||||
"source TEXT, score INTEGER, status TEXT DEFAULT 'active', "
|
||||
"last_reviewed TEXT, times_surfaced INTEGER DEFAULT 0)"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
# Legacy table lacks the column...
|
||||
conn = sqlite3.connect(str(db))
|
||||
pre = {r[1] for r in conn.execute("PRAGMA table_info(decisions)")}
|
||||
conn.close()
|
||||
assert "supersedes" not in pre
|
||||
# ...a store connect migrates it in (CREATE IF NOT EXISTS is a no-op here).
|
||||
conn = store._connect(db)
|
||||
try:
|
||||
post = {r["name"] for r in conn.execute("PRAGMA table_info(decisions)")}
|
||||
finally:
|
||||
conn.close()
|
||||
assert "supersedes" in post
|
||||
|
||||
|
||||
class TestFindConflicts:
|
||||
"""Write-time conflict check: FTS over ACTIVE rows, sanitized, side-effect-free."""
|
||||
|
||||
def test_finds_overlapping_active_row(self, db):
|
||||
"""A would-be entry surfaces an existing active row it overlaps."""
|
||||
compass.add_decision("caching strategy for the API", "add a redis caching layer", "good", db_path=db)
|
||||
hits = compass.find_conflicts("caching approach", "use a caching layer", db_path=db)
|
||||
assert len(hits) >= 1
|
||||
assert any("caching" in h["context"] for h in hits)
|
||||
|
||||
def test_ignores_archived_rows(self, db):
|
||||
"""Conflict check searches active rows only — archived never surfaces."""
|
||||
did = compass.add_decision("archived topic xyzzy", "some decision", "good", db_path=db)
|
||||
compass.archive(did, db_path=db)
|
||||
assert compass.find_conflicts("xyzzy topic", "another decision", db_path=db) == []
|
||||
|
||||
def test_no_side_effects_on_times_surfaced(self, db):
|
||||
"""The advisory must NOT bump times_surfaced — it is not a real surface."""
|
||||
compass.add_decision("surfacing guard ctx", "a decision here", "good", db_path=db)
|
||||
compass.find_conflicts("surfacing guard", "a decision", db_path=db)
|
||||
hit = compass.query_decisions("surfacing", db_path=db)[0]
|
||||
assert hit["times_surfaced"] == 1 # only the query above counted
|
||||
|
||||
def test_sanitizes_fts_special_chars(self, db):
|
||||
"""Raw FTS5 syntax characters must not crash MATCH — sanitized to literals."""
|
||||
compass.add_decision("special ctx", "a normal decision", "good", db_path=db)
|
||||
weird = 'broken " ( ) * : query -term AND OR NOT'
|
||||
result = compass.find_conflicts(weird, "more * (text) ^caret", db_path=db)
|
||||
assert isinstance(result, list) # no exception raised
|
||||
|
||||
def test_empty_text_returns_empty(self, db):
|
||||
"""Text with no usable tokens yields no conflicts (and no crash)."""
|
||||
assert compass.find_conflicts(" ", " ", db_path=db) == []
|
||||
|
||||
|
||||
class TestSetNote:
|
||||
"""note edits persist AND re-index immediately via the FTS5 UPDATE trigger."""
|
||||
|
||||
def test_set_note_updates_and_returns_true(self, db):
|
||||
"""set_note stores the note and reports the row was changed."""
|
||||
did = compass.add_decision("note ctx", "note dec", "good", db_path=db)
|
||||
assert compass.set_note(did, "a fresh observation", db_path=db) is True
|
||||
hit = compass.query_decisions("note ctx", db_path=db)[0]
|
||||
assert hit["note"] == "a fresh observation"
|
||||
|
||||
def test_note_edit_is_immediately_fts_searchable(self, db):
|
||||
"""PROOF: the decisions_au trigger re-indexes a note UPDATE for FTS."""
|
||||
did = compass.add_decision("indexing ctx", "indexing dec", "good", db_path=db)
|
||||
# 'zebra' appears nowhere yet.
|
||||
assert compass.query_decisions("zebra", db_path=db) == []
|
||||
compass.set_note(did, "mentions zebra now", db_path=db)
|
||||
# Immediately findable through the freshly re-indexed note column.
|
||||
found = compass.query_decisions("zebra", db_path=db)
|
||||
assert len(found) == 1
|
||||
assert found[0]["id"] == did
|
||||
|
||||
def test_set_note_missing_id_returns_false(self, db):
|
||||
"""set_note on a non-existent id returns False (no silent create)."""
|
||||
assert compass.set_note(9999, "nope", db_path=db) is False
|
||||
|
||||
|
||||
class TestScoreRemoved:
|
||||
"""score is gone from every Python surface (DPLAN-0246 seedgo ruling)."""
|
||||
|
||||
def test_score_not_in_decision_columns(self):
|
||||
"""The code-level column list no longer names score."""
|
||||
assert "score" not in store._DECISION_COLUMNS
|
||||
|
||||
def test_score_absent_from_query_dict(self, db):
|
||||
"""Query result dicts carry no score key."""
|
||||
compass.add_decision("score ctx", "score dec", "good", db_path=db)
|
||||
hit = compass.query_decisions("score", db_path=db)[0]
|
||||
assert "score" not in hit
|
||||
|
||||
def test_score_absent_from_review_dict(self, db):
|
||||
"""Review result dicts carry no score key."""
|
||||
compass.add_decision("review score ctx", "dec", "good", db_path=db)
|
||||
result = compass.review(db_path=db)
|
||||
assert result is not None
|
||||
assert "score" not in result
|
||||
|
||||
|
||||
class TestRecall:
|
||||
"""recall_decisions/mark_surfaced — the Track 2 ambient-recall read path."""
|
||||
|
||||
def test_recall_returns_scored_active_candidates(self, db):
|
||||
"""Raw prompt text yields active hits, each with relevance in (0, 1)."""
|
||||
compass.add_decision("vectorization pipeline ctx", "salt vector ids", "good", db_path=db)
|
||||
hits = compass.recall_decisions("we are working on the vectorization pipeline", db_path=db)
|
||||
assert hits and hits[0]["decision"] == "salt vector ids"
|
||||
assert 0.0 < hits[0]["relevance"] < 1.0
|
||||
|
||||
def test_recall_is_side_effect_free(self, db):
|
||||
"""A recall does NOT bump times_surfaced — candidates are not surfacings."""
|
||||
compass.add_decision("recall counter ctx", "stay untouched", "good", db_path=db)
|
||||
rid = compass.recall_decisions("recall counter", db_path=db)[0]["id"]
|
||||
compass.recall_decisions("recall counter", db_path=db)
|
||||
hit = compass.query_decisions("untouched", db_path=db)[0]
|
||||
assert hit["id"] == rid
|
||||
# query_decisions itself increments once; recalls added nothing.
|
||||
assert hit["times_surfaced"] == 1
|
||||
|
||||
def test_recall_excludes_archived(self, db):
|
||||
"""Archived rows never come back as ambient candidates."""
|
||||
rid = compass.add_decision("archived recall ctx", "dead ruling", "bad", db_path=db)
|
||||
compass.archive(rid, db_path=db)
|
||||
assert compass.recall_decisions("archived recall dead ruling", db_path=db) == []
|
||||
|
||||
def test_recall_survives_fts_syntax_in_prompt(self, db):
|
||||
"""FTS5 syntax characters in a prompt cannot crash the MATCH."""
|
||||
compass.add_decision("syntax safety ctx", "quote all tokens", "good", db_path=db)
|
||||
hits = compass.recall_decisions('safety AND (tokens) OR "quote" NEAR *:^-', db_path=db)
|
||||
assert hits and hits[0]["decision"] == "quote all tokens"
|
||||
|
||||
def test_recall_empty_prompt_returns_empty(self, db):
|
||||
"""No usable tokens → empty list, no error."""
|
||||
assert compass.recall_decisions("", db_path=db) == []
|
||||
assert compass.recall_decisions("()!@#$", db_path=db) == []
|
||||
|
||||
def test_recall_ranks_most_relevant_first(self, db):
|
||||
"""Denser overlap outranks a single shared token."""
|
||||
compass.add_decision("alpha beta gamma delta", "dense match", "good", db_path=db)
|
||||
compass.add_decision("alpha unrelated topic here", "sparse match", "good", db_path=db)
|
||||
hits = compass.recall_decisions("alpha beta gamma delta", limit=2, db_path=db)
|
||||
assert hits[0]["decision"] == "dense match"
|
||||
assert hits[0]["relevance"] > hits[1]["relevance"]
|
||||
|
||||
def test_recall_invalid_limit_raises(self, db):
|
||||
"""Non-positive limit fails honestly."""
|
||||
with pytest.raises(ValueError):
|
||||
compass.recall_decisions("anything", limit=0, db_path=db)
|
||||
|
||||
def test_mark_surfaced_counts_only_injected(self, db):
|
||||
"""mark_surfaced increments exactly the ids the caller reports."""
|
||||
a = compass.add_decision("mark ctx one", "dec one xyzzy", "good", db_path=db)
|
||||
b = compass.add_decision("mark ctx two", "dec two xyzzy", "good", db_path=db)
|
||||
assert compass.mark_surfaced([a], db_path=db) == 1
|
||||
hits = {h["id"]: h for h in compass.query_decisions("xyzzy", limit=5, db_path=db)}
|
||||
# query bumps both by 1; only a carries the extra mark_surfaced bump.
|
||||
assert hits[a]["times_surfaced"] == 2
|
||||
assert hits[b]["times_surfaced"] == 1
|
||||
|
||||
def test_mark_surfaced_empty_list_is_noop(self, db):
|
||||
"""An empty id list returns 0 and touches nothing."""
|
||||
assert compass.mark_surfaced([], db_path=db) == 0
|
||||
|
||||
def test_recall_stopwords_do_not_drive_ranking(self, db):
|
||||
"""Filler words in the prompt cannot outrank topic words (FPLAN-0332)."""
|
||||
compass.add_decision(
|
||||
"docker sop ctx with the and on for filler heavy text",
|
||||
"push dev first clone in container",
|
||||
"good",
|
||||
db_path=db,
|
||||
)
|
||||
compass.add_decision(
|
||||
"compass curation ctx",
|
||||
"supersedes links archive corrections",
|
||||
"good",
|
||||
db_path=db,
|
||||
)
|
||||
hits = compass.recall_decisions(
|
||||
"lets keep working on the compass supersedes links and curation",
|
||||
limit=2,
|
||||
db_path=db,
|
||||
)
|
||||
assert hits[0]["decision"] == "supersedes links archive corrections"
|
||||
|
||||
def test_recall_all_stopword_prompt_returns_empty(self, db):
|
||||
"""A prompt made only of stopwords yields no candidates, no error."""
|
||||
compass.add_decision("some ctx", "some dec", "good", db_path=db)
|
||||
assert compass.recall_decisions("what is it and how do we", db_path=db) == []
|
||||
|
||||
@@ -21,6 +21,17 @@
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
drone systems # See all registered branches
|
||||
drone @seedgo audit aipass # Route a command to a branch
|
||||
drone @flow --help # Show help for any branch
|
||||
drone scan @memory # Discover available commands
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands / Usage
|
||||
|
||||
Drone provides a CLI for terminal use and a Python API for programmatic access.
|
||||
|
||||
@@ -51,6 +51,21 @@ INTERACTIVE_COMMANDS = ("monitor", "audit", "watchdog", "status")
|
||||
INTERACTIVE_BRANCHES = ("cli", "backup")
|
||||
|
||||
|
||||
def _extract_timeout(args: list[str]) -> tuple[list[str], int | None]:
|
||||
"""Extract --drone-timeout N from an arg list. Returns (cleaned_args, timeout_or_None)."""
|
||||
if "--drone-timeout" not in args:
|
||||
return args, None
|
||||
idx = args.index("--drone-timeout")
|
||||
if idx + 1 >= len(args):
|
||||
return args, None
|
||||
try:
|
||||
timeout = int(args[idx + 1])
|
||||
except ValueError:
|
||||
logger.info("--drone-timeout value %r is not an integer, ignoring", args[idx + 1])
|
||||
return args, None
|
||||
return args[:idx] + args[idx + 2 :], timeout
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AUTO-DISCOVERY
|
||||
# =============================================================================
|
||||
@@ -78,7 +93,7 @@ def _discover_modules() -> list[tuple[str, str]]:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def show_help() -> None:
|
||||
def print_help() -> None:
|
||||
"""Display drone help with Rich formatting."""
|
||||
table = Table(show_header=False, box=None, pad_edge=False, show_edge=False)
|
||||
table.add_column(style="cyan", no_wrap=True)
|
||||
@@ -92,6 +107,7 @@ def show_help() -> None:
|
||||
table.add_row("list", "List registered custom commands")
|
||||
table.add_row("remove <name>", "Remove a custom command")
|
||||
table.add_row("rm <path> [<path>...]", "Contained safe-delete (project + tmp)")
|
||||
table.add_row("--drone-timeout <seconds>", "Override subprocess timeout (default 30s)")
|
||||
table.add_row("--help", "Show this help")
|
||||
table.add_row("--version", "Show version")
|
||||
|
||||
@@ -100,22 +116,22 @@ def show_help() -> None:
|
||||
console.print()
|
||||
console.print("[dim]Routes commands to registered AIPass branches and modules.[/dim]")
|
||||
console.print()
|
||||
console.print("[bold cyan]USAGE:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]drone @<target> <command> [args...][/dim]")
|
||||
console.print(" [dim]drone <built-in> [args...][/dim]")
|
||||
console.print(" [dim]drone --help[/dim]")
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
console.print("[bold]Examples:[/bold]")
|
||||
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [green]drone @seedgo audit aipass[/green]")
|
||||
console.print(" [green]drone @flow status[/green]")
|
||||
console.print(" [green]drone systems[/green]")
|
||||
console.print(" [green]drone activate @seedgo[/green]")
|
||||
console.print(" [green]drone audit[/green] [dim](custom shortcut)[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Alias for seedgo standard compliance (audit expects print_help)."""
|
||||
show_help()
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Display branch overview — auto-discovers modules."""
|
||||
console.print()
|
||||
@@ -340,6 +356,7 @@ def _handle_custom_command(args: list[str]) -> int:
|
||||
target = cmd_data["target"]
|
||||
command = cmd_data["command"]
|
||||
cmd_args = list(cmd_data.get("args", [])) + remaining_args
|
||||
cmd_args, explicit_timeout = _extract_timeout(cmd_args)
|
||||
module_name = target.lstrip("@").lower()
|
||||
|
||||
interactive = command in INTERACTIVE_COMMANDS or module_name in INTERACTIVE_BRANCHES
|
||||
@@ -349,6 +366,7 @@ def _handle_custom_command(args: list[str]) -> int:
|
||||
target,
|
||||
command,
|
||||
args=cmd_args if cmd_args else None,
|
||||
timeout=explicit_timeout,
|
||||
interactive=interactive,
|
||||
)
|
||||
except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc:
|
||||
@@ -412,6 +430,7 @@ def _handle_target(args: List[str]) -> int:
|
||||
"""Handle `drone @target command [args]` or `drone @target --help`."""
|
||||
target = args[0]
|
||||
rest = args[1:]
|
||||
rest, explicit_timeout = _extract_timeout(rest)
|
||||
module_name = target.lstrip("@").lower()
|
||||
|
||||
first_cmd = rest[0] if rest and rest[0] not in ("--help", "-h") else None
|
||||
@@ -470,6 +489,7 @@ def _handle_target(args: List[str]) -> int:
|
||||
target,
|
||||
command,
|
||||
args=cmd_args if cmd_args else None,
|
||||
timeout=explicit_timeout,
|
||||
interactive=interactive,
|
||||
)
|
||||
except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc:
|
||||
|
||||
@@ -21,6 +21,29 @@ from .exceptions import CommandExecutionError
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
DEFAULT_TIMEOUT = 30
|
||||
|
||||
TIMEOUT_OVERRIDES: dict[str, dict[str, int]] = {
|
||||
"memory": {"process-plans": 120},
|
||||
"flow": {"close": 90},
|
||||
}
|
||||
|
||||
|
||||
def resolve_timeout(branch: str, command: str | None, explicit: int | None = None) -> int:
|
||||
"""Resolve subprocess timeout for a branch command.
|
||||
|
||||
Priority: explicit flag > per-command policy > DEFAULT_TIMEOUT.
|
||||
"""
|
||||
if explicit is not None:
|
||||
return explicit
|
||||
branch_key = branch.lstrip("@").lower()
|
||||
if command and branch_key in TIMEOUT_OVERRIDES:
|
||||
cmd_timeout = TIMEOUT_OVERRIDES[branch_key].get(command)
|
||||
if cmd_timeout is not None:
|
||||
return cmd_timeout
|
||||
return DEFAULT_TIMEOUT
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""Result of a routed command execution."""
|
||||
@@ -82,7 +105,10 @@ def execute_command(
|
||||
return CommandResult(stdout="", stderr="", exit_code=130, branch="", command="")
|
||||
raise
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise CommandExecutionError(f"Command timed out after {timeout}s: {' '.join(full_cmd)}") from e
|
||||
raise CommandExecutionError(
|
||||
f"Command timed out after {timeout}s: {' '.join(full_cmd)}\n"
|
||||
f" Override with: drone @<target> <command> --drone-timeout <seconds>"
|
||||
) from e
|
||||
except FileNotFoundError as e:
|
||||
raise CommandExecutionError(f"Executable not found: {executable!r}") from e
|
||||
except OSError as e:
|
||||
|
||||
@@ -20,7 +20,7 @@ from typing import Dict, List, Optional
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.drone.apps.handlers.executor import CommandResult
|
||||
from aipass.drone.apps.handlers.executor import CommandResult, resolve_timeout
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
from aipass.drone.apps.handlers.router_handler import (
|
||||
detect_caller_branch_name,
|
||||
@@ -90,28 +90,38 @@ def route_command(
|
||||
target: str,
|
||||
command: Optional[str] = None,
|
||||
args: Optional[List[str]] = None,
|
||||
timeout: int = 30,
|
||||
timeout: int | None = None,
|
||||
interactive: bool = False,
|
||||
) -> CommandResult:
|
||||
"""Route a command to a branch's entry point.
|
||||
|
||||
Resolves @target to a path, then delegates to the handler for execution.
|
||||
When command is None, runs the branch with no args (introspection).
|
||||
|
||||
Timeout resolution: explicit value > per-command policy > DEFAULT_TIMEOUT.
|
||||
"""
|
||||
branch_path = resolve_branch(target)
|
||||
branch_name = target.lstrip("@").lower()
|
||||
resolved_timeout = resolve_timeout(branch_name, command, timeout)
|
||||
|
||||
caller = detect_caller_branch_name(Path.cwd())
|
||||
if not caller:
|
||||
caller = os.environ.get("AIPASS_BRANCH_NAME")
|
||||
caller_tag = f" [CALLER:{caller.upper()}]" if caller else ""
|
||||
logger.info("Routing @%s%s → %s %s", branch_name, caller_tag, command or "(introspection)", args or [])
|
||||
logger.info(
|
||||
"Routing @%s%s → %s %s (timeout=%ds)",
|
||||
branch_name,
|
||||
caller_tag,
|
||||
command or "(introspection)",
|
||||
args or [],
|
||||
resolved_timeout,
|
||||
)
|
||||
return execute_branch_command(
|
||||
branch_path=branch_path,
|
||||
branch_name=branch_name,
|
||||
command=command,
|
||||
args=args,
|
||||
timeout=timeout,
|
||||
timeout=resolved_timeout,
|
||||
interactive=interactive,
|
||||
)
|
||||
|
||||
@@ -147,7 +157,7 @@ def print_introspection():
|
||||
def route_all(
|
||||
command: str,
|
||||
args: Optional[List[str]] = None,
|
||||
timeout: int = 30,
|
||||
timeout: int | None = None,
|
||||
) -> Dict[str, CommandResult]:
|
||||
"""Route the same command to ALL active branches in the registry."""
|
||||
if args is None:
|
||||
|
||||
@@ -355,6 +355,7 @@ class TestHandleCustomCommand:
|
||||
"@seedgo",
|
||||
"audit",
|
||||
args=["aipass"],
|
||||
timeout=None,
|
||||
interactive=True,
|
||||
)
|
||||
|
||||
@@ -380,6 +381,7 @@ class TestHandleCustomCommand:
|
||||
"@seedgo",
|
||||
"audit",
|
||||
args=["aipass", "@drone"],
|
||||
timeout=None,
|
||||
interactive=True,
|
||||
)
|
||||
|
||||
@@ -616,6 +618,7 @@ class TestMainIntegration:
|
||||
"@seedgo",
|
||||
"audit",
|
||||
args=["aipass"],
|
||||
timeout=None,
|
||||
interactive=True,
|
||||
)
|
||||
|
||||
@@ -642,6 +645,7 @@ class TestMainIntegration:
|
||||
"@seedgo",
|
||||
"audit",
|
||||
args=["aipass", "@drone"],
|
||||
timeout=None,
|
||||
interactive=True,
|
||||
)
|
||||
|
||||
@@ -714,5 +718,6 @@ class TestMatchCommandIntegration:
|
||||
"@flow",
|
||||
"create",
|
||||
args=["--type=plan", "my-plan"],
|
||||
timeout=None,
|
||||
interactive=False,
|
||||
)
|
||||
|
||||
@@ -903,3 +903,80 @@ class TestAipassIntercept:
|
||||
):
|
||||
result = main()
|
||||
assert result == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _extract_timeout — --timeout flag parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractTimeout:
|
||||
"""Tests for --drone-timeout flag extraction from arg lists."""
|
||||
|
||||
def test_no_flag(self) -> None:
|
||||
"""Args without --drone-timeout pass through unchanged."""
|
||||
from aipass.drone.apps.drone import _extract_timeout
|
||||
|
||||
args = ["close", "FPLAN-0313"]
|
||||
cleaned, timeout = _extract_timeout(args)
|
||||
assert cleaned == ["close", "FPLAN-0313"]
|
||||
assert timeout is None
|
||||
|
||||
def test_flag_at_end(self) -> None:
|
||||
"""--drone-timeout N at end of args is extracted."""
|
||||
from aipass.drone.apps.drone import _extract_timeout
|
||||
|
||||
cleaned, timeout = _extract_timeout(["process-plans", "--drone-timeout", "120"])
|
||||
assert cleaned == ["process-plans"]
|
||||
assert timeout == 120
|
||||
|
||||
def test_flag_at_start(self) -> None:
|
||||
"""--drone-timeout N at start of args is extracted."""
|
||||
from aipass.drone.apps.drone import _extract_timeout
|
||||
|
||||
cleaned, timeout = _extract_timeout(["--drone-timeout", "90", "close", "FPLAN-0313"])
|
||||
assert cleaned == ["close", "FPLAN-0313"]
|
||||
assert timeout == 90
|
||||
|
||||
def test_flag_in_middle(self) -> None:
|
||||
"""--drone-timeout N in the middle of args is extracted."""
|
||||
from aipass.drone.apps.drone import _extract_timeout
|
||||
|
||||
cleaned, timeout = _extract_timeout(["close", "--drone-timeout", "60", "FPLAN-0313"])
|
||||
assert cleaned == ["close", "FPLAN-0313"]
|
||||
assert timeout == 60
|
||||
|
||||
def test_flag_without_value(self) -> None:
|
||||
"""--drone-timeout at end with no value returns None and leaves args."""
|
||||
from aipass.drone.apps.drone import _extract_timeout
|
||||
|
||||
args = ["close", "--drone-timeout"]
|
||||
cleaned, timeout = _extract_timeout(args)
|
||||
assert cleaned == args
|
||||
assert timeout is None
|
||||
|
||||
def test_flag_non_integer_value(self) -> None:
|
||||
"""--drone-timeout with non-integer value returns None and leaves args."""
|
||||
from aipass.drone.apps.drone import _extract_timeout
|
||||
|
||||
args = ["close", "--drone-timeout", "abc"]
|
||||
cleaned, timeout = _extract_timeout(args)
|
||||
assert cleaned == args
|
||||
assert timeout is None
|
||||
|
||||
def test_empty_args(self) -> None:
|
||||
"""Empty arg list returns empty with None timeout."""
|
||||
from aipass.drone.apps.drone import _extract_timeout
|
||||
|
||||
cleaned, timeout = _extract_timeout([])
|
||||
assert cleaned == []
|
||||
assert timeout is None
|
||||
|
||||
def test_plain_timeout_passes_through(self) -> None:
|
||||
"""--timeout (without drone- prefix) is NOT consumed — passes to target."""
|
||||
from aipass.drone.apps.drone import _extract_timeout
|
||||
|
||||
args = ["watchdog", "agent", "@memory", "--timeout", "1800"]
|
||||
cleaned, timeout = _extract_timeout(args)
|
||||
assert cleaned == args
|
||||
assert timeout is None
|
||||
|
||||
@@ -8,7 +8,12 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from aipass.drone.apps.handlers.exceptions import CommandExecutionError
|
||||
from aipass.drone.apps.handlers.executor import execute_command
|
||||
from aipass.drone.apps.handlers.executor import (
|
||||
DEFAULT_TIMEOUT,
|
||||
TIMEOUT_OVERRIDES,
|
||||
execute_command,
|
||||
resolve_timeout,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -389,3 +394,63 @@ class TestShellSecurity:
|
||||
# The semicolon is treated as literal text, not a shell separator
|
||||
assert result.stdout.strip() == "hello; echo pwned"
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. resolve_timeout — policy resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveTimeout:
|
||||
"""Timeout resolution: explicit > policy > default."""
|
||||
|
||||
def test_default_timeout(self):
|
||||
"""Unknown branch+command returns DEFAULT_TIMEOUT."""
|
||||
assert resolve_timeout("unknown", "whatever") == DEFAULT_TIMEOUT
|
||||
|
||||
def test_policy_override(self):
|
||||
"""Known branch+command returns the policy value."""
|
||||
for branch, cmds in TIMEOUT_OVERRIDES.items():
|
||||
for cmd, expected in cmds.items():
|
||||
assert resolve_timeout(branch, cmd) == expected
|
||||
|
||||
def test_explicit_wins_over_policy(self):
|
||||
"""Explicit timeout overrides the policy map."""
|
||||
branch = next(iter(TIMEOUT_OVERRIDES))
|
||||
cmd = next(iter(TIMEOUT_OVERRIDES[branch]))
|
||||
assert resolve_timeout(branch, cmd, explicit=999) == 999
|
||||
|
||||
def test_explicit_wins_over_default(self):
|
||||
"""Explicit timeout overrides the default."""
|
||||
assert resolve_timeout("unknown", "whatever", explicit=42) == 42
|
||||
|
||||
def test_none_command_returns_default(self):
|
||||
"""None command (introspection) returns default."""
|
||||
assert resolve_timeout("memory", None) == DEFAULT_TIMEOUT
|
||||
|
||||
def test_at_prefix_stripped(self):
|
||||
"""Leading @ on branch name is stripped before lookup."""
|
||||
for branch in TIMEOUT_OVERRIDES:
|
||||
cmd = next(iter(TIMEOUT_OVERRIDES[branch]))
|
||||
expected = TIMEOUT_OVERRIDES[branch][cmd]
|
||||
assert resolve_timeout(f"@{branch}", cmd) == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 12. Timeout error message includes --timeout hint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTimeoutErrorMessage:
|
||||
"""Timeout error tells the caller how to override."""
|
||||
|
||||
def test_timeout_error_includes_override_hint(self, temp_test_dir: Path):
|
||||
"""The timeout error message mentions --timeout."""
|
||||
with pytest.raises(CommandExecutionError, match="--drone-timeout") as exc_info:
|
||||
execute_command(
|
||||
sys.executable,
|
||||
["-c", "import time; time.sleep(10)"],
|
||||
cwd=str(temp_test_dir),
|
||||
timeout=1,
|
||||
)
|
||||
assert "--drone-timeout" in str(exc_info.value)
|
||||
|
||||
@@ -26,6 +26,18 @@ Flow is AIPass's plan management system. Every branch uses flow to create, track
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
drone @flow create . "My task description" # Create a plan in the current directory
|
||||
drone @flow list open # See all open plans
|
||||
drone @flow close FPLAN-0042 # Close a completed plan
|
||||
drone @flow create . "Design topic" dplan # Create a design plan (DPLAN)
|
||||
drone @flow templates # List available plan types
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -19,13 +19,16 @@ Key Functions:
|
||||
- verify_and_heal_orphaned_plans() - Orphan healing logic
|
||||
"""
|
||||
|
||||
# ruff: noqa: E402
|
||||
from pathlib import Path
|
||||
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
# Standard imports
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Any
|
||||
|
||||
@@ -42,6 +45,35 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
FLOW_ROOT = _PKG_ROOT / "flow"
|
||||
FLOW_JSON_DIR = FLOW_ROOT / "flow_json"
|
||||
|
||||
MODULE_NAME = "mbank_process"
|
||||
_LOCK_RETRIES = 10
|
||||
_LOCK_BACKOFF_BASE = 0.05
|
||||
|
||||
|
||||
def _acquire_lock(lock_path: Path) -> bool:
|
||||
"""Atomically acquire a lockfile via O_CREAT|O_EXCL with retry+backoff."""
|
||||
for attempt in range(_LOCK_RETRIES):
|
||||
try:
|
||||
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
os.write(fd, str(os.getpid()).encode())
|
||||
os.close(fd)
|
||||
return True
|
||||
except FileExistsError:
|
||||
logger.info("[%s] Lock contention on %s, retry %d", MODULE_NAME, lock_path, attempt + 1)
|
||||
time.sleep(_LOCK_BACKOFF_BASE * (2**attempt))
|
||||
except OSError as exc:
|
||||
logger.warning("[%s] Lock creation failed for %s: %s", MODULE_NAME, lock_path, exc)
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _release_lock(lock_path: Path) -> None:
|
||||
"""Remove lockfile, tolerating already-removed."""
|
||||
try:
|
||||
lock_path.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
logger.warning("[%s] Could not release lock %s: %s", MODULE_NAME, lock_path, exc)
|
||||
|
||||
|
||||
def _find_repo_root() -> Path:
|
||||
"""Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json)."""
|
||||
@@ -97,12 +129,22 @@ def load_flow_registry(registry_file: str | None = None) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def save_flow_registry(registry: Dict[str, Any], registry_file: str | None = None) -> None:
|
||||
"""Save a plan registry."""
|
||||
"""Save a plan registry with lockfile + atomic write."""
|
||||
target = FLOW_JSON_DIR / registry_file if registry_file else REGISTRY_FILE
|
||||
lock_path = target.with_suffix(".lock")
|
||||
|
||||
try:
|
||||
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
with open(target, "w", encoding="utf-8") as f:
|
||||
json.dump(registry, f, indent=2, ensure_ascii=False)
|
||||
if not _acquire_lock(lock_path):
|
||||
raise OSError(f"Could not acquire lock for {target}")
|
||||
|
||||
try:
|
||||
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
tmp_path = target.with_suffix(".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(registry, f, indent=2, ensure_ascii=False)
|
||||
os.replace(str(tmp_path), str(target))
|
||||
finally:
|
||||
_release_lock(lock_path)
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to save flow registry: {e}")
|
||||
|
||||
@@ -361,7 +403,10 @@ def is_template_content(content: str) -> bool:
|
||||
# today = datetime.now().strftime("%Y%m%d")
|
||||
# plan_num = plan_path.stem.replace("FPLAN-", "")
|
||||
# template_suffix = "-TEMP" if is_template else ""
|
||||
# filename = f"{folder_context}-{analysis['type']}-{analysis['category']}-{analysis['action']}-FPLAN-{plan_num}{template_suffix}-{today}.md"
|
||||
# filename = (
|
||||
# f"{folder_context}-{analysis['type']}-{analysis['category']}"
|
||||
# f"-{analysis['action']}-FPLAN-{plan_num}{template_suffix}-{today}.md"
|
||||
# )
|
||||
#
|
||||
# filename = re.sub(r'[<>:"|?*]', '-', filename)
|
||||
# filename = re.sub(r'-+', '-', filename)
|
||||
@@ -627,7 +672,7 @@ def process_closed_plans() -> Dict[str, Any]:
|
||||
|
||||
if archive_success:
|
||||
processed_count += 1
|
||||
# Vector intake handled by close_ops.py via drone @memory process-plans
|
||||
# Vector intake triggered by post_close_runner via direct import
|
||||
results.append({"plan": plan_label, "status": "archived", "correlation_id": correlation_id})
|
||||
else:
|
||||
error_count += 1
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Description: Closed Plans Local Registry Handler
|
||||
# Version: 0.1.0
|
||||
# Created: 2026-03-03
|
||||
# Modified: 2026-03-03
|
||||
# Modified: 2026-07-15
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
@@ -13,8 +13,11 @@ Appends a closed plan entry to the branch's CLOSED_PLANS.local.json file.
|
||||
Creates the file if it doesn't exist.
|
||||
"""
|
||||
|
||||
# ruff: noqa: E402
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# INFRASTRUCTURE IMPORT PATTERN
|
||||
@@ -27,6 +30,31 @@ from aipass.flow.apps.handlers.json import json_handler
|
||||
MODULE_NAME = "append_closed_plan"
|
||||
CLOSED_PLANS_FILE = "CLOSED_PLANS.local.json"
|
||||
|
||||
_LOCK_RETRIES = 10
|
||||
_LOCK_BACKOFF_BASE = 0.05
|
||||
|
||||
|
||||
def _acquire_append_lock(lock_path: Path) -> bool:
|
||||
"""Atomically acquire a lockfile via O_CREAT|O_EXCL with retry+backoff."""
|
||||
for attempt in range(_LOCK_RETRIES):
|
||||
try:
|
||||
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
os.write(fd, str(os.getpid()).encode())
|
||||
os.close(fd)
|
||||
return True
|
||||
except FileExistsError:
|
||||
logger.info("[%s] Lock contention on %s, retry %d", MODULE_NAME, lock_path, attempt + 1)
|
||||
time.sleep(_LOCK_BACKOFF_BASE * (2**attempt))
|
||||
return False
|
||||
|
||||
|
||||
def _release_append_lock(lock_path: Path) -> None:
|
||||
"""Remove lockfile, tolerating already-removed."""
|
||||
try:
|
||||
lock_path.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
logger.warning("[%s] Could not release lock %s: %s", MODULE_NAME, lock_path, exc)
|
||||
|
||||
|
||||
def append_to_closed_plans(plan_key: str, plan_info: dict, plan_location: Path) -> bool:
|
||||
"""
|
||||
@@ -61,27 +89,35 @@ def append_to_closed_plans(plan_key: str, plan_info: dict, plan_location: Path)
|
||||
"location": plan_info.get("relative_path", ""),
|
||||
}
|
||||
|
||||
# Read existing file or create new structure
|
||||
# Locked read-modify-write to prevent lost updates under concurrent close
|
||||
closed_plans_path = plan_location / CLOSED_PLANS_FILE
|
||||
lock_path = closed_plans_path.with_suffix(".lock")
|
||||
|
||||
if closed_plans_path.exists():
|
||||
with open(closed_plans_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
data = {"closed_plans": []}
|
||||
if not _acquire_append_lock(lock_path):
|
||||
logger.error(
|
||||
f"[{MODULE_NAME}] Could not acquire lock for {closed_plans_path} after {_LOCK_RETRIES} retries"
|
||||
)
|
||||
return False
|
||||
|
||||
# Check for duplicate plan_id before appending
|
||||
existing_ids = {p.get("plan_id") for p in data.get("closed_plans", [])}
|
||||
if plan_id in existing_ids:
|
||||
logger.info(f"[{MODULE_NAME}] {plan_id} already in {CLOSED_PLANS_FILE} at {plan_location}, skipping")
|
||||
return True
|
||||
try:
|
||||
if closed_plans_path.exists():
|
||||
with open(closed_plans_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
data = {"closed_plans": []}
|
||||
|
||||
# Append and write
|
||||
data["closed_plans"].append(entry)
|
||||
existing_ids = {p.get("plan_id") for p in data.get("closed_plans", [])}
|
||||
if plan_id in existing_ids:
|
||||
logger.info(f"[{MODULE_NAME}] {plan_id} already in {CLOSED_PLANS_FILE} at {plan_location}, skipping")
|
||||
return True
|
||||
|
||||
with open(closed_plans_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
data["closed_plans"].append(entry)
|
||||
|
||||
with open(closed_plans_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
finally:
|
||||
_release_append_lock(lock_path)
|
||||
|
||||
logger.info(f"[{MODULE_NAME}] Appended {plan_id} to {closed_plans_path}")
|
||||
json_handler.log_operation(
|
||||
|
||||
@@ -379,9 +379,22 @@ def close_plan_impl(
|
||||
try:
|
||||
from aipass.flow.apps.handlers.plan.append_closed_plan import append_to_closed_plans
|
||||
|
||||
append_to_closed_plans(plan_key, plan_info, plan_file.parent)
|
||||
if not append_to_closed_plans(plan_key, plan_info, plan_file.parent):
|
||||
logger.error(f"[{MODULE_NAME}] CLOSED_PLANS append failed for {plan_prefix}-{plan_key}")
|
||||
messages.append(
|
||||
{
|
||||
"type": "warning",
|
||||
"text": f" CLOSED_PLANS append failed for {plan_prefix}-{plan_key}",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] CLOSED_PLANS update failed (non-critical): {e}")
|
||||
logger.error(f"[{MODULE_NAME}] CLOSED_PLANS update failed: {e}")
|
||||
messages.append(
|
||||
{
|
||||
"type": "warning",
|
||||
"text": f" CLOSED_PLANS update failed: {e}",
|
||||
}
|
||||
)
|
||||
|
||||
# Fire trigger event for plan closure
|
||||
if trigger_fire_fn is not None:
|
||||
|
||||
@@ -25,6 +25,8 @@ Usage:
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any
|
||||
@@ -44,6 +46,35 @@ MODULE_NAME = "save_registry"
|
||||
FLOW_JSON_DIR = FLOW_ROOT / "flow_json"
|
||||
REGISTRY_FILE = FLOW_JSON_DIR / "fplan_registry.json"
|
||||
|
||||
_LOCK_RETRIES = 10
|
||||
_LOCK_BACKOFF_BASE = 0.05
|
||||
|
||||
|
||||
def _acquire_lock(lock_path: Path) -> bool:
|
||||
"""Atomically acquire a lockfile via O_CREAT|O_EXCL with retry+backoff."""
|
||||
for attempt in range(_LOCK_RETRIES):
|
||||
try:
|
||||
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
os.write(fd, str(os.getpid()).encode())
|
||||
os.close(fd)
|
||||
return True
|
||||
except FileExistsError:
|
||||
logger.info("[%s] Lock contention on %s, retry %d", MODULE_NAME, lock_path, attempt + 1)
|
||||
time.sleep(_LOCK_BACKOFF_BASE * (2**attempt))
|
||||
except OSError as exc:
|
||||
logger.warning("[%s] Lock creation failed for %s: %s", MODULE_NAME, lock_path, exc)
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _release_lock(lock_path: Path) -> None:
|
||||
"""Remove lockfile, tolerating already-removed."""
|
||||
try:
|
||||
lock_path.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
logger.warning("[%s] Could not release lock %s: %s", MODULE_NAME, lock_path, exc)
|
||||
|
||||
|
||||
# =============================================
|
||||
# HANDLER FUNCTION
|
||||
# =============================================
|
||||
@@ -64,15 +95,30 @@ def save_registry(registry: Dict[str, Any], registry_file: str | None = None) ->
|
||||
|
||||
Automatically updates the last_updated timestamp before saving.
|
||||
Creates the flow_json directory if it doesn't exist.
|
||||
Uses a lockfile to serialize concurrent writes and atomic
|
||||
tempfile+rename to prevent torn reads.
|
||||
"""
|
||||
target = FLOW_JSON_DIR / registry_file if registry_file else REGISTRY_FILE
|
||||
lock_path = target.with_suffix(".lock")
|
||||
|
||||
try:
|
||||
FLOW_JSON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
registry["_notice"] = "DO NOT MANUALLY EDIT — managed by flow close pipeline"
|
||||
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
with open(target, "w", encoding="utf-8") as f:
|
||||
json.dump(registry, f, indent=2, ensure_ascii=False)
|
||||
|
||||
if not _acquire_lock(lock_path):
|
||||
logger.error("[%s] Could not acquire lock for %s after %d retries", MODULE_NAME, target, _LOCK_RETRIES)
|
||||
return False
|
||||
|
||||
try:
|
||||
registry["_notice"] = "DO NOT MANUALLY EDIT — managed by flow close pipeline"
|
||||
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
tmp_path = target.with_suffix(".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(registry, f, indent=2, ensure_ascii=False)
|
||||
os.replace(str(tmp_path), str(target))
|
||||
finally:
|
||||
_release_lock(lock_path)
|
||||
|
||||
json_handler.log_operation(
|
||||
"registry_saved",
|
||||
{
|
||||
|
||||
@@ -32,7 +32,7 @@ if sys.platform == "win32":
|
||||
|
||||
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.flow.apps.handlers.json import json_handler
|
||||
from aipass.flow.apps.handlers.mbank.process import process_closed_plans
|
||||
from aipass.flow.apps.handlers.runner.lock_ops import acquire_lock, release_lock
|
||||
@@ -80,7 +80,25 @@ def handle_command(command: str, args: list) -> bool:
|
||||
|
||||
try:
|
||||
process_closed_plans()
|
||||
console.print("[green]Processing complete[/green]")
|
||||
|
||||
try:
|
||||
import importlib
|
||||
|
||||
_plans_mod = importlib.import_module("aipass.memory.apps.handlers.intake.plans_processor")
|
||||
result = _plans_mod.process_plans()
|
||||
if result.get("success"):
|
||||
count = result.get("files_processed", 0)
|
||||
chunks = result.get("total_chunks", 0)
|
||||
if count > 0:
|
||||
success(f"Vectorized {count} plan(s) ({chunks} chunks)")
|
||||
logger.info("[%s] Plan vectorization: %s", MODULE_NAME, result)
|
||||
else:
|
||||
logger.error("[%s] Plan vectorization failed: %s", MODULE_NAME, result.get("error", "unknown"))
|
||||
error(f"Vectorization failed: {result.get('error', 'unknown')}")
|
||||
except Exception as e:
|
||||
logger.error("[%s] Plan vectorization error: %s", MODULE_NAME, e)
|
||||
error(f"Vectorization error: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{MODULE_NAME}] Background processing failed: {e}")
|
||||
error(f"Processing failed: {e}")
|
||||
@@ -130,6 +148,19 @@ if __name__ == "__main__":
|
||||
|
||||
try:
|
||||
process_closed_plans()
|
||||
|
||||
try:
|
||||
import importlib
|
||||
|
||||
_plans_mod = importlib.import_module("aipass.memory.apps.handlers.intake.plans_processor")
|
||||
result = _plans_mod.process_plans()
|
||||
if result.get("success"):
|
||||
logger.info("[%s] Plan vectorization: %s", MODULE_NAME, result)
|
||||
else:
|
||||
logger.error("[%s] Plan vectorization failed: %s", MODULE_NAME, result.get("error", "unknown"))
|
||||
except Exception as e:
|
||||
logger.error("[%s] Plan vectorization error: %s", MODULE_NAME, e)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{MODULE_NAME}] Background processing failed: {e}")
|
||||
finally:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# {plan_number} - {subject} (MERGE)
|
||||
|
||||
> **Create:** `drone @flow create . "Merge summary" merge pplan` (template name before type)
|
||||
|
||||
**Created**: {today}
|
||||
**Branch**: {location}
|
||||
**Status**: Active
|
||||
@@ -59,10 +61,9 @@ just that one merge commit — **cosmetic and trivially resolved**.
|
||||
WORKS** — a clean fast-forward realign, no merge commit created, no history rewrite.
|
||||
- **Realign dev to even** (recommended): `drone @git sync` from dev (clean FF), or
|
||||
manually `git merge --ff-only origin/main` on dev. No force-push, no rebase needed.
|
||||
- **Sync local `main` ref WITHOUT checkout**: `git fetch origin main:main` — updates the
|
||||
local main ref to match origin with **zero working-tree touch, no checkout**. This is the
|
||||
answer to the IDE "switch to main → your local changes would be overwritten by checkout"
|
||||
dialog: that dialog is git SAFETY working — **Cancel, never Force Checkout**. You never
|
||||
- **Local main behind?** `drone @git sync` from dev handles it (stays on dev, clean FF).
|
||||
If the IDE shows "switch to main → your local changes would be overwritten by checkout" —
|
||||
that dialog is git SAFETY working — **Cancel, never Force Checkout**. You never
|
||||
need to stand on main.
|
||||
|
||||
---
|
||||
@@ -73,13 +74,14 @@ just that one merge commit — **cosmetic and trivially resolved**.
|
||||
- [ ] Confirm what's shipping — scan uncommitted changes + already-pushed dev commits ahead of main: `git rev-list --count main..dev` (read git, raw ok)
|
||||
- [ ] No surprise files (stray `/tmp` artifacts, test pollution, `.recovery`/`.archive` churn). Clean = archive, never delete.
|
||||
- [ ] **Version state check** (informs the bump decision): read the **two** release-tied versions — `grep '^version' pyproject.toml` and `grep __version__ src/aipass/__init__.py` (they should match; if drifted, note it) — and what PyPI already has: `curl -s https://pypi.org/pypi/aipass/json | python3 -c "import sys,json;print(json.load(sys.stdin)['info']['version'])"`. PyPI rejects a duplicate, so the target must be > published.
|
||||
- [ ] Decide: **release tag this merge?** (tag = PyPI publish + GitHub Release). If yes, note target version. (Significance call is the user's — the PATCH-default rule below is guidance, and the actual release history is a useful tie-breaker.)
|
||||
- [ ] **Release tag: default YES with PATCH bump.** Every dev-to-main merge ships a PATCH bump + tag so PyPI always tracks main (Patrick ruling S318, 2026-07-17 — version numbers carry no significance during beta). Override to MINOR/MAJOR only when warranted; skip only if explicitly told.
|
||||
|
||||
## 2. Verify, commit, CHANGELOG
|
||||
|
||||
- [ ] **Run the CI audit gate LOCALLY before pushing** (local == CI, S199 parity — catches red before the PR): `cd <repo-root> && .venv/bin/python .github/scripts/seedgo_audit.py` → expect all 13 branches `>=100%`, exit 0. Uses a relative `src/aipass` path, so run from the repo **root**, not a branch dir.
|
||||
- [ ] Update `CHANGELOG.md` — add entries under a dated section header `## [YYYY-MM-DD]` (the merge date), one section per merge. Sort into Added / Changed / Fixed.
|
||||
- [ ] Commit: `drone @git commit "msg" --all` (from a branch dir, e.g. devpulse). New/untracked files (e.g. new templates) — confirm they got staged: `git ls-files <path>` after; `--all` may not pick up untracked.
|
||||
- [ ] All commits are auto-SSH-signed via repo-level git config (key `~/.ssh/aipass_signing`, wired 2026-07-15). Nothing manual required; verify with `git log --show-signature -1` if in doubt.
|
||||
- [ ] Every commit pushed — local-only commits are invisible
|
||||
|
||||
## 3. Open / update the PR
|
||||
@@ -95,6 +97,7 @@ The PR gate (verified against `.github/workflows/`):
|
||||
- [ ] `security.yml` → Security Scan / dependency-scan
|
||||
- [ ] `e2e-wheel.yml` → 3-OS wheel smoke (path-filtered: fires on `src/**`, `tests/e2e/**`, `pyproject.toml`)
|
||||
- [ ] `windows-test.yml` / `macos-test.yml` → required checks, run on every PR (must NEVER be path-filtered or they park as "Expected/waiting" forever and block merge)
|
||||
- [ ] **Hash-pinned CI deps:** tool installs are pinned from `.github/requirements/*.txt` locks. A new security advisory against a pinned dep (esp. pip-audit's 29-package tree) can red `security.yml` with zero code change on our side. Fix = dependabot lock bump (grouped weekly, label `ci`) or regen via the `pip-compile` command in each `.in` file header — never a code revert.
|
||||
- [ ] If "all green but can't merge": it's usually post-push mergeability **lag**. Confirm ground truth via the public API (no gh, no gate):
|
||||
- `curl -s https://api.github.com/repos/AIOSAI/AIPass/commits/<sha>/check-runs` → all check-runs success (incl. app checks: codecov, CodeQL)
|
||||
- `curl -s https://api.github.com/repos/AIOSAI/AIPass/pulls/<n>` → `mergeable_state: clean`
|
||||
@@ -110,20 +113,22 @@ The PR gate (verified against `.github/workflows/`):
|
||||
|
||||
- [ ] **Expect `dev` to show "1 behind main" — that's the merge commit, it's cosmetic + fast-forwardable.** See "Why dev shows behind main" up top.
|
||||
- [ ] **Realign dev** (recommended): `drone @git sync` from dev, or `git merge --ff-only origin/main` on dev. Clean FF, no merge commit, no rewrite.
|
||||
- [ ] **Stay on `dev`. Do not check out `main`.** Local main being behind is fine — sync it without checkout: `git fetch origin main:main` (zero working-tree touch).
|
||||
- [ ] **Stay on `dev`. Do not check out `main`.** Local main being behind is fine — `drone @git sync` from dev covers it.
|
||||
- [ ] Never rebase, never reset, never checkout main.
|
||||
- [ ] Dependabot / other PRs targeting main: they go green once main has the fix + bots rebase — check after the push
|
||||
|
||||
## 7. Release tag (only if cutting a release)
|
||||
## 7. Release tag
|
||||
|
||||
**Versioning rule — bump by SIGNIFICANCE, not cadence:**
|
||||
- **PATCH** (`x.y.Z+1`) = fix / internal / standards / UX only → the default for most merges
|
||||
**Standing default: PATCH bump every merge** (Patrick ruling S318, 2026-07-17). PyPI should always track main; version numbers carry no significance during beta. Big jump reserved for beta exit.
|
||||
|
||||
Reference (SemVer — for when significance matters post-beta):
|
||||
- **PATCH** (`x.y.Z+1`) = fix / internal / standards / UX only
|
||||
- **MINOR** (`x.Y+1.0`) = a new backward-compatible user-facing feature shipped
|
||||
- **MAJOR** (`X+1.0.0`) = breaking public-API change
|
||||
|
||||
(aipass is a 2.x library others pin → keep SemVer; the CHANGELOG uses `YYYY-MM-DD` dated section headers.)
|
||||
|
||||
How the release fires (verified `publish.yml`): a `v*` **git tag push** runs build → PyPI publish → GitHub Release. Key facts:
|
||||
How the release fires (verified `publish.yml`): a `v*` **git tag push** runs build → provenance attestation → PyPI publish → GitHub Release. The attestation step (`actions/attest-build-provenance`, SHA-pinned) runs between build and upload — expect it in the run log; PyPI publish + Release notes extraction unchanged. Key facts:
|
||||
- PyPI version = `pyproject.toml [project] version` at the tagged commit — **NOT** the tag string (the tag only *triggers* the build).
|
||||
- Tag and `pyproject` version **must match** (`v2.5.2` ⇄ `version = "2.5.2"`), or PyPI publishes the wrong number while the Release is named the tag.
|
||||
- PyPI **rejects a duplicate version** → if shipping, you MUST bump.
|
||||
|
||||
@@ -406,6 +406,11 @@
|
||||
"standard": "json_structure",
|
||||
"reason": "Sound handler \u2014 no JSON operations, plays WAV files."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/config/trust_registry.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "enroll() and revoke() are the public API consumed CROSS-BRANCH by @aipass CLI (init/trust/revoke commands, DPLAN-0244 phase 2). seedgo's intra-branch static analysis cannot see cross-branch callers. read_registry() also exported for @aipass CLI use."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/config/loader.py",
|
||||
"standard": "json_structure",
|
||||
@@ -441,6 +446,11 @@
|
||||
"standard": "trigger",
|
||||
"reason": "MUTE_FLAG.unlink() removes a /tmp mute flag file for sound toggle \u2014 not a tracked resource or production data deletion. Deliberate user action via 'drone @hooks hooksound on'."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/feedback.py",
|
||||
"standard": "trigger",
|
||||
"reason": "sentinel.unlink() removes a .aipass/feedback_off toggle file \u2014 not a tracked resource. Deliberate user action via 'drone @hooks feedback on'."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/hookstatus.py",
|
||||
"standard": "json_structure",
|
||||
|
||||
@@ -2,15 +2,25 @@
|
||||
|
||||
# Hooks
|
||||
|
||||
> Hook infrastructure for AIPass. Single engine dispatches all hooks across platforms (Claude, Codex) with per-project config, full logging, and crash isolation. The 13th citizen.
|
||||
> Hook infrastructure for AIPass. A single dispatch engine routes hook events across platforms (Claude Code, Codex) with per-project configuration, full logging, and crash isolation.
|
||||
|
||||
Every hook event flows through one engine. Platform bridges normalize the event format, the engine reads per-project config (`.aipass/hooks.json`), dispatches matching handlers, and logs everything to prax + JSONL.
|
||||
Every hook event flows through one engine. Platform bridges normalize the event format, the engine reads per-project config (`.aipass/hooks.json`), dispatches matching handlers, and logs everything to JSONL diagnostics.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
drone @hooks status # Show hook config for current project
|
||||
drone @hooks log # Tail recent hook activity
|
||||
drone @hooks engine # Show connected handlers
|
||||
drone @hooks verify # Cross-check provider ↔ project wiring
|
||||
drone @hooks --help # Full help reference
|
||||
```
|
||||
|
||||
## Start here
|
||||
|
||||
| You want to | Read |
|
||||
|---|---|
|
||||
| Identity, memory, session history | [`.trinity/`](.trinity/) |
|
||||
| Identity, session history | [`.trinity/`](.trinity/) |
|
||||
| Hook engine design | `DPLAN-0184` |
|
||||
| Per-project config | `.aipass/hooks.json` |
|
||||
|
||||
@@ -25,6 +35,10 @@ Every hook event flows through one engine. Platform bridges normalize the event
|
||||
| `drone @hooks hooksound` | Show current sound mute status |
|
||||
| `drone @hooks hooksound off` | Mute all hook sounds |
|
||||
| `drone @hooks hooksound on` | Unmute all hook sounds |
|
||||
| `drone @hooks feedback` | Show feedback pulse status (enabled/disabled) |
|
||||
| `drone @hooks feedback off` | Disable feedback pulse for this project |
|
||||
| `drone @hooks feedback on` | Enable feedback pulse for this project |
|
||||
| `drone @hooks dismiss <alert-id>` | Remove an alert from `.aipass/alerts.json` |
|
||||
| `drone @hooks cadence` | Show prompt injection cadence config and state |
|
||||
| `drone @hooks verify` | Cross-check provider settings vs project hook config |
|
||||
| `drone @hooks --help` | Full help reference |
|
||||
@@ -40,6 +54,8 @@ Hooks operate on two tiers:
|
||||
|
||||
**Why provider-only wiring?** Claude Code does not fire `PreToolUse`/`PostToolUse` hooks from project-level settings — only from user-level settings (DPLAN-0160 platform limitation). So all hook entries live in provider settings, and per-project control happens through `.aipass/hooks.json`.
|
||||
|
||||
**Deploying new handlers:** Registering a handler in `.aipass/hooks.json` is necessary but not sufficient. Each event type also needs a matching bridge command entry in `~/.claude/settings.json` — this is human-gated (agents cannot edit provider settings). After building a new handler, email @devpulse to wire the settings.json entry. Without it, the engine never receives the event and the handler never fires.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
@@ -53,8 +69,10 @@ src/aipass/hooks/
|
||||
│ │ ├── hook_test.py # Portable test runner (drone @hooks test)
|
||||
│ │ ├── cc_sessions.py # CC-native session file reader (~/.claude/sessions/<pid>.json)
|
||||
│ │ ├── engine.py # Core dispatch — routes events to handlers
|
||||
│ │ ├── feedback.py # Feedback pulse toggle (drone @hooks feedback on/off)
|
||||
│ │ ├── hooksound.py # Sound control (drone @hooks hooksound on/off)
|
||||
│ │ ├── hookstatus.py # Config viewer (drone @hooks status)
|
||||
│ │ ├── alert_dismiss.py # Dismiss alerts (drone @hooks dismiss <id>)
|
||||
│ │ ├── presence.py # Branch presence — claim/release/refresh for .ai_central/PRESENCE.central.json
|
||||
│ │ ├── sandbox.py # Kernel sandbox — srt/bwrap wrapper + per-role policy generator
|
||||
│ │ └── wire_verify.py # Wire verification — provider ↔ project hook wiring checker
|
||||
@@ -66,7 +84,9 @@ src/aipass/hooks/
|
||||
│ │ │ ├── branch_loader.py # Injects aipass_local_prompt.md
|
||||
│ │ │ ├── tier0_kernel.py # Injects tier0 kernel prompt (every turn)
|
||||
│ │ │ ├── navmap.py # Injects tier1 navmap prompt (periodic)
|
||||
│ │ │ └── identity.py # Injects passport identity block
|
||||
│ │ │ ├── identity.py # Injects passport identity block
|
||||
│ │ │ ├── feedback_pulse.py # Periodic feedback ask (~10 turns, toggleable)
|
||||
│ │ │ └── persistent_alert.py # Injects advisory banners from .aipass/alerts.json
|
||||
│ │ ├── security/ # Enforcement hooks
|
||||
│ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics)
|
||||
│ │ │ ├── git_gate.py # Enforces git access tiers
|
||||
@@ -91,7 +111,7 @@ src/aipass/hooks/
|
||||
│ └── diagnostics.py # JSONL logging for hook execution
|
||||
├── logs/
|
||||
│ └── engine.jsonl # JSONL diagnostics (every hook execution)
|
||||
└── tests/ # 913 tests across 28 test files
|
||||
└── tests/ # 1071 tests across 29 test files
|
||||
```
|
||||
|
||||
## How It Works
|
||||
@@ -112,7 +132,7 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im
|
||||
|
||||
| Event | Hooks | Description |
|
||||
|---|---|---|
|
||||
| UserPromptSubmit | presence_gate, identity, email, branch_loader, tier0_kernel, navmap | Presence gate + prompt injection + inbox check |
|
||||
| UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, feedback_pulse, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + feedback + auto-process + TG mirror |
|
||||
| PreToolUse | tool_sound, edit_gate, git_gate, rm_gate, registry_gate | Security gates + guardrails + sound |
|
||||
| PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog |
|
||||
| SubagentStop | subagent_gate | Seedgo validation |
|
||||
@@ -140,6 +160,27 @@ The `git_gate` handler (`security/git_gate.py`) enforces git access via drone to
|
||||
|
||||
**Why it's on by default:** Agents reflexively reach for raw git, which causes state chaos in a multi-agent system. The gate redirects to `drone @git` which enforces access tiers (read-only for most branches, write-only for devpulse). External users who don't need multi-agent git orchestration can safely disable it.
|
||||
|
||||
## Persistent Alerts
|
||||
|
||||
The `persistent_alert` handler (`prompt/persistent_alert.py`) injects advisory banners into every prompt when active alerts exist. General-purpose — any agent can raise alerts (prax for runaway logs, trigger for medic, backup for sync failures).
|
||||
|
||||
**How it works:** Reads `.aipass/alerts.json` at the project root. Each alert has an ID, source, severity (`warning`/`critical`), title, body, and optional `expires_at`. Active alerts render as a banner every turn until dismissed or expired. Expired alerts are auto-cleaned on read.
|
||||
|
||||
**Sound:** Piper TTS fires on first injection per alert ID — subsequent turns are silent for known alerts. New alerts trigger a fresh announcement.
|
||||
|
||||
**Dismissing alerts:** `drone @hooks dismiss <alert-id>` removes an alert by ID from `alerts.json`.
|
||||
|
||||
**Schema:**
|
||||
```json
|
||||
{
|
||||
"alerts": [{
|
||||
"id": "uuid", "source": "prax", "severity": "warning",
|
||||
"title": "High log rate", "body": "commons exceeds 50 lines/s",
|
||||
"created_at": "iso", "expires_at": "iso or null"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## Kernel Sandbox (srt/bwrap)
|
||||
|
||||
The sandbox module (`apps/modules/sandbox.py`) provides the kernel-level filesystem boundary for agent sessions. It wraps Anthropic's `@anthropic-ai/sandbox-runtime` (srt) library, which uses bubblewrap (bwrap) + Landlock + seccomp on Linux to enforce write/read restrictions at the OS level.
|
||||
@@ -180,7 +221,7 @@ The @drone broker validates sandbox policy before agent launch. @ai_mail's dispa
|
||||
- All branches via hook dispatch — every Claude Code session routes through the engine
|
||||
- @ai_mail dispatch_monitor — sandbox_launch + build_policy for agent launch boundary
|
||||
|
||||
*Last Updated: 2026-06-29*
|
||||
*Last Updated: 2026-07-15*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -20,19 +20,39 @@ AIPASS_HOME = os.environ.get("AIPASS_HOME", "")
|
||||
|
||||
|
||||
def find_project_config() -> dict | None:
|
||||
"""Walk up from CWD looking for .aipass/hooks.json."""
|
||||
"""Walk up from CWD looking for .aipass/hooks.json, with trust verification."""
|
||||
from aipass.hooks.apps.handlers.config.trust_registry import (
|
||||
REGISTRY_PATH,
|
||||
bootstrap,
|
||||
is_trusted,
|
||||
)
|
||||
|
||||
search = Path.cwd()
|
||||
home = Path.home()
|
||||
while search != home and search.parent != search:
|
||||
config = search / ".aipass" / "hooks.json"
|
||||
if config.exists():
|
||||
config_file = search / ".aipass" / "hooks.json"
|
||||
if config_file.exists():
|
||||
project_dir = str(search)
|
||||
|
||||
if not REGISTRY_PATH.exists():
|
||||
bootstrap()
|
||||
|
||||
if not is_trusted(project_dir):
|
||||
logger.warning(
|
||||
"[HOOKS] project not enrolled in trust registry: %s (run: aipass init update)",
|
||||
project_dir,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
raw = config.read_text(encoding="utf-8")
|
||||
raw = config_file.read_text(encoding="utf-8")
|
||||
if AIPASS_HOME:
|
||||
raw = raw.replace("$AIPASS_HOME", AIPASS_HOME)
|
||||
return json.loads(raw)
|
||||
parsed = json.loads(raw)
|
||||
parsed["_source"] = "project"
|
||||
return parsed
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.error("[HOOKS] bad config %s: %s", config, exc)
|
||||
logger.error("[HOOKS] bad config %s: %s", config_file, exc)
|
||||
return None
|
||||
search = search.parent
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: trust_registry.py
|
||||
# Version: 1.0.0
|
||||
# Description: Trusted-project registry — DPLAN-0244 Layer B
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/config
|
||||
# Created: 2026-07-15
|
||||
# Modified: 2026-07-15
|
||||
# =============================================
|
||||
|
||||
"""Trusted-project registry for hook config loading.
|
||||
|
||||
Single source of truth for which projects are trusted to have their
|
||||
.aipass/hooks.json loaded by the hook engine. Registry lives at
|
||||
~/.aipass/trusted_projects.json. @aipass CLI (init/trust/revoke)
|
||||
imports this module for enrollment operations.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.handlers.json import json_handler
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
REGISTRY_PATH = Path.home() / ".aipass" / "trusted_projects.json"
|
||||
|
||||
|
||||
def _hash_file(path: Path) -> str:
|
||||
"""Compute sha256 of a file's contents."""
|
||||
data = path.read_bytes()
|
||||
return f"sha256:{hashlib.sha256(data).hexdigest()}"
|
||||
|
||||
|
||||
def read_registry() -> dict:
|
||||
"""Read the trusted-project registry. Returns empty registry if absent or corrupt."""
|
||||
if not REGISTRY_PATH.exists():
|
||||
return {"version": 1, "projects": {}}
|
||||
try:
|
||||
data = json_handler.read_json_file(REGISTRY_PATH)
|
||||
if not isinstance(data.get("projects"), dict):
|
||||
return {"version": 1, "projects": {}}
|
||||
return data
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.error("[HOOKS] bad trust registry %s: %s", REGISTRY_PATH, exc)
|
||||
return {"version": 1, "projects": {}}
|
||||
|
||||
|
||||
def _write_registry(registry: dict) -> None:
|
||||
"""Write the registry to disk, creating parent dirs if needed."""
|
||||
REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_handler.write_json_file(REGISTRY_PATH, registry)
|
||||
|
||||
|
||||
def enroll(project_dir: str) -> bool:
|
||||
"""Enroll a project in the trusted registry. Returns True on success."""
|
||||
project_path = Path(project_dir).resolve()
|
||||
config_path = project_path / ".aipass" / "hooks.json"
|
||||
if not config_path.exists():
|
||||
logger.warning("[HOOKS] cannot enroll %s: no .aipass/hooks.json", project_path)
|
||||
return False
|
||||
config_hash = _hash_file(config_path)
|
||||
registry = read_registry()
|
||||
registry["projects"][str(project_path)] = {
|
||||
"enrolled": _isoformat_now(),
|
||||
"config_hash": config_hash,
|
||||
"config_path": str(config_path),
|
||||
}
|
||||
_write_registry(registry)
|
||||
json_handler.log_operation("enroll", {"project": str(project_path)}, module_name="trust_registry")
|
||||
logger.info("[HOOKS] enrolled %s (hash=%s)", project_path, config_hash)
|
||||
return True
|
||||
|
||||
|
||||
def revoke(project_dir: str) -> bool:
|
||||
"""Remove a project from the trusted registry. Returns True if it was present."""
|
||||
project_path = str(Path(project_dir).resolve())
|
||||
registry = read_registry()
|
||||
if project_path not in registry["projects"]:
|
||||
return False
|
||||
del registry["projects"][project_path]
|
||||
_write_registry(registry)
|
||||
json_handler.log_operation("revoke", {"project": project_path}, module_name="trust_registry")
|
||||
logger.info("[HOOKS] revoked %s", project_path)
|
||||
return True
|
||||
|
||||
|
||||
def is_trusted(project_dir: str) -> bool:
|
||||
"""Check if a project is enrolled with a matching config hash."""
|
||||
project_path = str(Path(project_dir).resolve())
|
||||
registry = read_registry()
|
||||
entry = registry["projects"].get(project_path)
|
||||
if entry is None:
|
||||
return False
|
||||
config_path = Path(project_dir).resolve() / ".aipass" / "hooks.json"
|
||||
if not config_path.exists():
|
||||
return False
|
||||
current_hash = _hash_file(config_path)
|
||||
return current_hash == entry.get("config_hash", "")
|
||||
|
||||
|
||||
def bootstrap() -> bool:
|
||||
"""Bootstrap the registry with ONLY the AIPass install. Returns True on success.
|
||||
|
||||
Called when the registry file does not exist. Enrolls the AIPass
|
||||
install identified by $AIPASS_HOME — never the current CWD.
|
||||
"""
|
||||
aipass_home = os.environ.get("AIPASS_HOME", "")
|
||||
if not aipass_home:
|
||||
logger.warning("[HOOKS] registry absent and AIPASS_HOME not set — cannot bootstrap")
|
||||
return False
|
||||
aipass_path = Path(aipass_home).resolve()
|
||||
config_path = aipass_path / ".aipass" / "hooks.json"
|
||||
if not config_path.exists():
|
||||
logger.warning(
|
||||
"[HOOKS] registry absent and AIPass hooks.json not found at %s",
|
||||
config_path,
|
||||
)
|
||||
return False
|
||||
config_hash = _hash_file(config_path)
|
||||
registry = {"version": 1, "projects": {}}
|
||||
registry["projects"][str(aipass_path)] = {
|
||||
"enrolled": _isoformat_now(),
|
||||
"config_hash": config_hash,
|
||||
"config_path": str(config_path),
|
||||
}
|
||||
_write_registry(registry)
|
||||
json_handler.log_operation("bootstrap", {"aipass_home": str(aipass_path)}, module_name="trust_registry")
|
||||
logger.info("[HOOKS] registry bootstrapped, enrolled AIPass install: %s", aipass_path)
|
||||
return True
|
||||
|
||||
|
||||
def _isoformat_now() -> str:
|
||||
"""Return current UTC time as ISO string."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
@@ -0,0 +1,3 @@
|
||||
"""JSON Handler — Hooks Branch."""
|
||||
|
||||
__all__ = []
|
||||
@@ -0,0 +1,165 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: json_handler.py
|
||||
# Description: JSON auto-creating handler for hooks data files
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-15
|
||||
# Modified: 2026-07-15
|
||||
# =============================================
|
||||
|
||||
"""JSON auto-creating handler for hooks data files."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import inspect
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
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")
|
||||
|
||||
_BRANCH_ROOT = Path(__file__).resolve().parents[3]
|
||||
_BRANCH_NAME = _BRANCH_ROOT.name
|
||||
JSON_DIR = _BRANCH_ROOT / f"{_BRANCH_NAME}_json"
|
||||
|
||||
|
||||
def _get_caller_module_name() -> str:
|
||||
"""Auto-detect calling module name from call stack."""
|
||||
stack = inspect.stack()
|
||||
if len(stack) > 2:
|
||||
caller_frame = stack[2]
|
||||
caller_path = Path(caller_frame.filename)
|
||||
module_name = caller_path.stem
|
||||
if module_name and not module_name.startswith("_"):
|
||||
return module_name
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _create_default(json_type: str, module_name: str) -> Any:
|
||||
"""Create default JSON structure from inline code defaults."""
|
||||
today = datetime.now().date().isoformat()
|
||||
if json_type == "config":
|
||||
return {
|
||||
"module_name": module_name,
|
||||
"version": "1.0.0",
|
||||
"config": {"max_log_entries": 100},
|
||||
"created": today,
|
||||
}
|
||||
elif json_type == "data":
|
||||
return {
|
||||
"module_name": module_name,
|
||||
"created": today,
|
||||
"last_updated": today,
|
||||
}
|
||||
elif json_type == "log":
|
||||
return []
|
||||
raise ValueError(f"Unknown json_type: {json_type}")
|
||||
|
||||
|
||||
def validate_json_structure(data: Any, json_type: str) -> bool:
|
||||
"""Validate JSON structure matches expected type."""
|
||||
if json_type == "config":
|
||||
return isinstance(data, dict) and all(k in data for k in ["module_name", "version", "config"])
|
||||
elif json_type == "data":
|
||||
return isinstance(data, dict) and all(k in data for k in ["created", "last_updated"])
|
||||
elif json_type == "log":
|
||||
return isinstance(data, list)
|
||||
return False
|
||||
|
||||
|
||||
def get_json_path(module_name: str, json_type: str) -> Path:
|
||||
"""Get path for module JSON file."""
|
||||
return JSON_DIR / f"{module_name}_{json_type}.json"
|
||||
|
||||
|
||||
def ensure_json_exists(module_name: str, json_type: str) -> bool:
|
||||
"""Ensure JSON file exists, create from template if missing."""
|
||||
JSON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
if json_path.exists():
|
||||
try:
|
||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
if validate_json_structure(data, json_type):
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("[HOOKS] json_handler: ensure_json_exists failed for %s_%s: %s", module_name, json_type, exc)
|
||||
template = _create_default(json_type, module_name)
|
||||
json_path.write_text(json.dumps(template, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def load_json(module_name: str, json_type: str) -> Any | None:
|
||||
"""Load JSON file, auto-create if missing."""
|
||||
if not ensure_json_exists(module_name, json_type):
|
||||
return None
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
return json.loads(json_path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def save_json(module_name: str, json_type: str, data: Any) -> bool:
|
||||
"""Save JSON file."""
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
if not validate_json_structure(data, json_type):
|
||||
raise ValueError(f"Invalid structure for {json_type} JSON")
|
||||
if json_type == "data" and isinstance(data, dict):
|
||||
data["last_updated"] = datetime.now().date().isoformat()
|
||||
json_path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def ensure_module_jsons(module_name: str) -> bool:
|
||||
"""Ensure all 3 JSON files exist for a module."""
|
||||
ensure_json_exists(module_name, "config")
|
||||
ensure_json_exists(module_name, "data")
|
||||
ensure_json_exists(module_name, "log")
|
||||
return True
|
||||
|
||||
|
||||
def log_operation(
|
||||
operation: str,
|
||||
data: dict[str, Any] | None = None,
|
||||
module_name: str | None = None,
|
||||
) -> bool:
|
||||
"""Add entry to module log with automatic rotation.
|
||||
|
||||
Auto-detects calling module if module_name not provided.
|
||||
"""
|
||||
if module_name is None:
|
||||
module_name = _get_caller_module_name()
|
||||
ensure_module_jsons(module_name)
|
||||
|
||||
config = load_json(module_name, "config")
|
||||
max_entries = 100
|
||||
if config and "config" in config:
|
||||
max_entries = config["config"].get("max_log_entries", 100)
|
||||
|
||||
log = load_json(module_name, "log")
|
||||
if log is None:
|
||||
log = []
|
||||
|
||||
entry: dict[str, Any] = {"timestamp": datetime.now().isoformat(), "operation": operation}
|
||||
if data:
|
||||
entry["data"] = data
|
||||
|
||||
log.append(entry)
|
||||
if len(log) > max_entries:
|
||||
log = log[-max_entries:]
|
||||
|
||||
return save_json(module_name, "log", log)
|
||||
|
||||
|
||||
def read_json_file(path: Path) -> Any:
|
||||
"""Read and parse a JSON file at an arbitrary path."""
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def write_json_file(path: Path, data: Any) -> None:
|
||||
"""Write data as JSON to an arbitrary path."""
|
||||
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
@@ -1,25 +1,39 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: session_boot.py
|
||||
# Version: 1.1.0
|
||||
# Description: Boot wrapper — attach-if-live or start-in-tmux for Claude Code
|
||||
# Version: 4.0.0
|
||||
# Description: Boot wrapper — attach-first menu for Claude Code sessions
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/lifecycle
|
||||
# Created: 2026-06-30
|
||||
# Modified: 2026-06-30
|
||||
# Modified: 2026-07-14
|
||||
# =============================================
|
||||
|
||||
"""Boot wrapper for Claude Code sessions.
|
||||
|
||||
When Patrick boots Claude in a branch directory, this wrapper:
|
||||
1. If already inside tmux ($TMUX set) → execs claude directly (no nesting).
|
||||
2. Checks CC-native ~/.claude/sessions/ for a live session at this cwd.
|
||||
3. If live AND hosted in a tmux session → attaches to that tmux session.
|
||||
4. If live but NOT in tmux → warns (can't inject into a plain terminal).
|
||||
5. If no live session → starts fresh inside a new tmux session.
|
||||
When Patrick runs `claude` in a branch directory, this wrapper presents a menu:
|
||||
|
||||
Tmux sessions are named after the branch directory (e.g., "hooks", "devpulse").
|
||||
All sessions use --permission-mode bypassPermissions (TG user can't answer prompts).
|
||||
Remote visibility is preserved (tmux composes with remoteControlAtStartup).
|
||||
Live session (interactive):
|
||||
hooks — live chat: PID 1234 · abc12345 · interactive · 2h old
|
||||
[Enter] resume this chat
|
||||
[n] start new chat (closes the one above first)
|
||||
[c] close it and exit
|
||||
|
||||
Live session (background):
|
||||
devpulse — live chat: PID 773292 · c624cbcd · background "chroma review" · 2h old
|
||||
[Enter] resume this chat (stops bg, reopens as normal chat)
|
||||
[n] start new chat (stops bg first)
|
||||
[c] close it and exit (stops bg)
|
||||
|
||||
No live session:
|
||||
devpulse — no live chat
|
||||
[Enter] continue last chat
|
||||
[n] new chat
|
||||
|
||||
All interactive launches are tmux-wrapped (closed terminal = recoverable).
|
||||
|
||||
Special cases:
|
||||
- Already inside tmux → execs claude directly (no nesting).
|
||||
- Headless (-p flag) → execs claude directly.
|
||||
|
||||
Entry points:
|
||||
drone @hooks boot [claude args...]
|
||||
@@ -27,6 +41,7 @@ Entry points:
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -37,12 +52,7 @@ _DEFAULT_ARGS = ["--permission-mode", "bypassPermissions"]
|
||||
|
||||
|
||||
def _resolve_claude_binary() -> str:
|
||||
"""Resolve the REAL claude binary path from PATH.
|
||||
|
||||
Uses shutil.which, which searches the filesystem PATH — it does NOT see
|
||||
shell functions. So even when a claude() shell function shadows the binary,
|
||||
this finds the real one.
|
||||
"""
|
||||
"""Resolve the REAL claude binary path from PATH."""
|
||||
path = shutil.which("claude")
|
||||
if path:
|
||||
return path
|
||||
@@ -128,8 +138,148 @@ def _is_descendant(target_pid: int, ancestor_pid: int) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _format_age(session: dict) -> str:
|
||||
"""Format session age from its start time."""
|
||||
started = session.get("startedAt") or session.get("started", "")
|
||||
if not started:
|
||||
return ""
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
if isinstance(started, (int, float)):
|
||||
start_dt = datetime.fromtimestamp(started / 1000, tz=timezone.utc)
|
||||
else:
|
||||
start_dt = datetime.fromisoformat(str(started).replace("Z", "+00:00"))
|
||||
delta = datetime.now(tz=timezone.utc) - start_dt
|
||||
hours = int(delta.total_seconds() // 3600)
|
||||
minutes = int((delta.total_seconds() % 3600) // 60)
|
||||
if hours > 0:
|
||||
return f"{hours}h{minutes}m"
|
||||
return f"{minutes}m"
|
||||
except Exception as exc:
|
||||
logger.info("[SESSION_BOOT] age format error: %s", exc)
|
||||
return ""
|
||||
|
||||
|
||||
def _session_short_id(session: dict) -> str:
|
||||
"""Extract first 8 chars of sessionId."""
|
||||
return str(session.get("sessionId", ""))[:8]
|
||||
|
||||
|
||||
def _session_label(session: dict, branch: str) -> str:
|
||||
"""Format a session's one-line label: PID · short-id · kind [auto-name] · age."""
|
||||
pid = session.get("pid", "?")
|
||||
short_id = _session_short_id(session)
|
||||
kind = session.get("kind", "unknown")
|
||||
auto_name = session.get("name", "")
|
||||
name_str = f' "{auto_name}"' if auto_name else ""
|
||||
age = _format_age(session)
|
||||
age_str = f" · {age} old" if age else ""
|
||||
return f"PID {pid} · {short_id} · {kind}{name_str}{age_str}"
|
||||
|
||||
|
||||
def _read_choice(prompt: str = "> ") -> str:
|
||||
"""Read a single-line choice from /dev/tty (works even when stdin is piped)."""
|
||||
try:
|
||||
tty = open("/dev/tty", "r", encoding="utf-8")
|
||||
sys.stderr.write(prompt)
|
||||
sys.stderr.flush()
|
||||
choice = tty.readline().strip().lower()
|
||||
tty.close()
|
||||
return choice
|
||||
except OSError as exc:
|
||||
logger.info("[SESSION_BOOT] /dev/tty not available: %s", exc)
|
||||
return ""
|
||||
|
||||
|
||||
def _stop_session(session: dict, claude_bin: str) -> str:
|
||||
"""Stop a session. Returns description of action taken.
|
||||
|
||||
bg sessions: no per-job stop exists in the CLI. Returns an honest
|
||||
message — never SIGTERMs bg (daemon respawns it).
|
||||
"""
|
||||
pid = session.get("pid")
|
||||
kind = session.get("kind", "unknown")
|
||||
|
||||
if kind in ("bg", "background"):
|
||||
logger.info("[SESSION_BOOT] Cannot stop bg PID %s — no per-job stop in CLI", pid)
|
||||
return f"PID {pid}: bg session — no per-job stop available"
|
||||
|
||||
tmux_session = _find_tmux_session_for_pid(pid) if pid else None
|
||||
if tmux_session:
|
||||
subprocess.run(["tmux", "kill-session", "-t", tmux_session], check=False)
|
||||
logger.info("[SESSION_BOOT] Killed tmux session '%s' (PID %d)", tmux_session, pid)
|
||||
return f"PID {pid}: killed tmux session '{tmux_session}'"
|
||||
|
||||
if pid:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
logger.info("[SESSION_BOOT] Sent SIGTERM to PID %d", pid)
|
||||
return f"PID {pid}: sent SIGTERM"
|
||||
except ProcessLookupError:
|
||||
logger.info("[SESSION_BOOT] PID %d already dead", pid)
|
||||
return f"PID {pid}: already dead"
|
||||
except OSError as exc:
|
||||
logger.warning("[SESSION_BOOT] SIGTERM PID %d failed: %s", pid, exc)
|
||||
return f"PID {pid}: SIGTERM failed ({exc})"
|
||||
return f"PID {pid}: no action"
|
||||
|
||||
|
||||
def _resume_session(
|
||||
session: dict, branch: str, claude_bin: str, defaults: list[str], extra_args: list[str] | None = None
|
||||
) -> dict:
|
||||
"""Resume a session — right mechanism per kind.
|
||||
|
||||
bg: takeover (daemon stop + --resume in tmux). Never opens agents view.
|
||||
tmux: attach to existing tmux session.
|
||||
dead-window: --continue in a new tmux session.
|
||||
"""
|
||||
pid = session.get("pid")
|
||||
kind = session.get("kind", "unknown")
|
||||
ea = list(extra_args or [])
|
||||
|
||||
if kind in ("bg", "background"):
|
||||
return _takeover_bg(session, branch, claude_bin, defaults, extra_args)
|
||||
|
||||
tmux_session = _find_tmux_session_for_pid(pid) if pid else None
|
||||
if tmux_session:
|
||||
logger.info("[SESSION_BOOT] Attaching to tmux session '%s'", tmux_session)
|
||||
os.execvp("tmux", ["tmux", "attach-session", "-t", tmux_session])
|
||||
return {"exit_code": 0, "action": "attached", "tmux_session": tmux_session}
|
||||
|
||||
logger.info("[SESSION_BOOT] Continuing dead-window session via --continue")
|
||||
sid = session.get("sessionId", "")
|
||||
nf = _name_flag(branch, sid, extra_args)
|
||||
return _exec_in_tmux(branch, "", claude_bin, [claude_bin] + defaults + ["--continue"] + ea + nf)
|
||||
|
||||
|
||||
def _make_session_name(branch: str, session_id: str = "") -> str:
|
||||
"""Generate tmux session name: branch-shortid."""
|
||||
short_id = session_id[:8] if session_id else ""
|
||||
if short_id:
|
||||
return f"{branch}-{short_id}"
|
||||
return branch
|
||||
|
||||
|
||||
def _name_flag(branch: str, session_id: str = "", extra_args: list[str] | None = None) -> list[str]:
|
||||
"""Build --name args for session stamping, unless user already provided one."""
|
||||
if extra_args and ("-n" in extra_args or "--name" in extra_args):
|
||||
return []
|
||||
return ["--name", _make_session_name(branch, session_id)]
|
||||
|
||||
|
||||
def _exec_in_tmux(branch: str, session_id: str, claude_bin: str, claude_cmd: list[str]) -> dict:
|
||||
"""Exec a claude command inside a new tmux session."""
|
||||
session_name = _make_session_name(branch, session_id)
|
||||
if _tmux_session_exists(session_name):
|
||||
subprocess.run(["tmux", "kill-session", "-t", session_name], check=False)
|
||||
logger.info("[SESSION_BOOT] Launching in tmux '%s': %s", session_name, " ".join(claude_cmd))
|
||||
os.execvp("tmux", ["tmux", "new-session", "-s", session_name, "--"] + claude_cmd)
|
||||
return {"exit_code": 0, "action": "started", "tmux_session": session_name}
|
||||
|
||||
|
||||
def boot(cwd: str | None = None, extra_args: list[str] | None = None) -> dict:
|
||||
"""Boot Claude Code — attach if live, else start in tmux.
|
||||
"""Boot Claude Code — present menu when sessions exist.
|
||||
|
||||
Args:
|
||||
cwd: Branch directory (defaults to current working directory).
|
||||
@@ -153,7 +303,7 @@ def boot(cwd: str | None = None, extra_args: list[str] | None = None) -> dict:
|
||||
|
||||
if os.environ.get("TMUX"):
|
||||
logger.info("[SESSION_BOOT] Already inside tmux — running claude directly")
|
||||
claude_cmd = [claude_bin] + defaults
|
||||
claude_cmd = [claude_bin] + defaults + _name_flag(branch, extra_args=extra_args)
|
||||
if extra_args:
|
||||
claude_cmd.extend(extra_args)
|
||||
os.execvp(claude_bin, claude_cmd)
|
||||
@@ -167,40 +317,302 @@ def boot(cwd: str | None = None, extra_args: list[str] | None = None) -> dict:
|
||||
live = _find_live_sessions(cwd)
|
||||
|
||||
if live:
|
||||
session = live[0]
|
||||
pid = session["pid"]
|
||||
name = session.get("name", "")
|
||||
logger.info("[SESSION_BOOT] Live session found: PID %d%s", pid, f" ({name})" if name else "")
|
||||
return _menu_live(live, branch, claude_bin, defaults, extra_args)
|
||||
return _menu_no_live(branch, claude_bin, defaults, extra_args)
|
||||
|
||||
tmux_session = _find_tmux_session_for_pid(pid)
|
||||
if tmux_session:
|
||||
logger.info("[SESSION_BOOT] Attaching to tmux session '%s'", tmux_session)
|
||||
os.execvp("tmux", ["tmux", "attach-session", "-t", tmux_session])
|
||||
return {"exit_code": 0, "action": "attached", "tmux_session": tmux_session}
|
||||
|
||||
return {
|
||||
"exit_code": 1,
|
||||
"action": "warn",
|
||||
"error": (
|
||||
f"{branch} already has a live Claude session (PID {pid}) running outside tmux"
|
||||
f" — Claude allows one session per branch.\n"
|
||||
f" • Reattach in its own terminal, OR\n"
|
||||
f" • Reclaim it here: kill {pid} && claude\n"
|
||||
f" • Or bypass this wrapper: command claude --resume"
|
||||
),
|
||||
}
|
||||
def _has_bg(sessions: list[dict]) -> bool:
|
||||
"""Check if any session is a background session."""
|
||||
return any(s.get("kind") in ("bg", "background") for s in sessions)
|
||||
|
||||
if _tmux_session_exists(branch):
|
||||
logger.info("[SESSION_BOOT] Killing stale tmux session '%s'", branch)
|
||||
subprocess.run(["tmux", "kill-session", "-t", branch], check=False)
|
||||
|
||||
claude_cmd = [claude_bin] + defaults
|
||||
def _get_collateral_bg(branch: str) -> list[dict]:
|
||||
"""Find live bg sessions outside the given branch (blast-radius check)."""
|
||||
import importlib
|
||||
|
||||
cc_sessions = importlib.import_module("aipass.hooks.apps.modules.cc_sessions")
|
||||
collateral = []
|
||||
for s in cc_sessions.read_all_sessions():
|
||||
if s.get("kind") not in ("bg", "background"):
|
||||
continue
|
||||
s_branch = Path(s.get("cwd", "")).name
|
||||
if s_branch != branch and s.get("pid") and cc_sessions._is_pid_alive(s["pid"]):
|
||||
collateral.append(s)
|
||||
return collateral
|
||||
|
||||
|
||||
def _daemon_stop(claude_bin: str, branch: str, pid: int | None) -> dict:
|
||||
"""Run daemon stop --any with blast-radius confirmation.
|
||||
|
||||
Returns {"ok": True} on success, {"ok": False, "error": "..."} on failure.
|
||||
"""
|
||||
collateral = _get_collateral_bg(branch)
|
||||
if collateral:
|
||||
sys.stderr.write(" Other branches have live bg sessions that will also stop:\n")
|
||||
for s in collateral:
|
||||
coll_branch = Path(s.get("cwd", "")).name
|
||||
sys.stderr.write(f" PID {s.get('pid')} · {coll_branch} · {_session_short_id(s)}\n")
|
||||
sys.stderr.write(" Continue? [y/N] ")
|
||||
confirm = _read_choice("")
|
||||
if confirm != "y":
|
||||
return {"ok": False, "error": "cancelled by user"}
|
||||
|
||||
sys.stderr.write(" Stopping background sessions (daemon stop --any)...\n")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[claude_bin, "daemon", "stop", "--any"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr_msg = result.stderr.strip()
|
||||
logger.warning("[SESSION_BOOT] daemon stop exit %d: %s", result.returncode, stderr_msg)
|
||||
sys.stderr.write(f" daemon stop failed (exit {result.returncode}): {stderr_msg}\n")
|
||||
return {"ok": False, "error": f"daemon stop exit {result.returncode}: {stderr_msg}"}
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
logger.warning("[SESSION_BOOT] daemon stop failed: %s", exc)
|
||||
sys.stderr.write(f" daemon stop failed: {exc}\n")
|
||||
return {"ok": False, "error": f"daemon stop failed: {exc}"}
|
||||
|
||||
import time
|
||||
|
||||
for _ in range(10):
|
||||
time.sleep(1)
|
||||
if not _is_session_file_present(pid):
|
||||
break
|
||||
else:
|
||||
logger.warning("[SESSION_BOOT] Session file for PID %s did not clear after daemon stop", pid)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _takeover_bg(
|
||||
session: dict, branch: str, claude_bin: str, defaults: list[str], extra_args: list[str] | None = None
|
||||
) -> dict:
|
||||
"""Take over a bg session: daemon stop --any, poll, then --resume in tmux.
|
||||
|
||||
Checks blast radius first (other branches' bg sessions). On daemon stop
|
||||
failure, aborts honestly. Resumes inside a tmux session so a closed
|
||||
terminal is always recoverable.
|
||||
"""
|
||||
session_id = session.get("sessionId", "")
|
||||
pid = session.get("pid")
|
||||
ea = list(extra_args or [])
|
||||
nf = _name_flag(branch, session_id, extra_args)
|
||||
|
||||
stop_result = _daemon_stop(claude_bin, branch, pid)
|
||||
if not stop_result["ok"]:
|
||||
return {"exit_code": 1, "error": stop_result["error"]}
|
||||
|
||||
if session_id:
|
||||
logger.info("[SESSION_BOOT] Resuming session %s after takeover", session_id[:8])
|
||||
return _exec_in_tmux(
|
||||
branch, session_id, claude_bin, [claude_bin] + defaults + ["--resume", session_id] + ea + nf
|
||||
)
|
||||
|
||||
logger.info("[SESSION_BOOT] No sessionId for takeover — continuing last")
|
||||
return _exec_in_tmux(branch, "", claude_bin, [claude_bin] + defaults + ["--continue"] + ea + nf)
|
||||
|
||||
|
||||
def _is_session_file_present(pid: int | None) -> bool:
|
||||
"""Check if a CC session file exists for the given PID."""
|
||||
if pid is None:
|
||||
return False
|
||||
session_file = Path.home() / ".claude" / "sessions" / f"{pid}.json"
|
||||
return session_file.exists()
|
||||
|
||||
|
||||
def _menu_single_session(
|
||||
session: dict,
|
||||
branch: str,
|
||||
claude_bin: str,
|
||||
defaults: list[str],
|
||||
extra_args: list[str] | None,
|
||||
) -> dict:
|
||||
"""Handle menu for a single live session."""
|
||||
label = _session_label(session, branch)
|
||||
is_bg = session.get("kind") in ("bg", "background")
|
||||
sys.stderr.write(f"\n{branch} — live chat: {label}\n")
|
||||
if is_bg:
|
||||
sys.stderr.write(" [Enter] resume this chat (stops bg, reopens as normal chat)\n")
|
||||
sys.stderr.write(" [n] start new chat (stops bg first)\n")
|
||||
sys.stderr.write(" [c] close it and exit (stops bg)\n\n")
|
||||
else:
|
||||
sys.stderr.write(" [Enter] resume this chat\n")
|
||||
sys.stderr.write(" [n] start new chat (closes the one above first)\n")
|
||||
sys.stderr.write(" [c] close it and exit\n\n")
|
||||
|
||||
choice = _read_choice()
|
||||
|
||||
if choice in ("", "r"):
|
||||
return _resume_session(session, branch, claude_bin, defaults, extra_args)
|
||||
if choice == "n":
|
||||
return _menu_single_new(session, is_bg, branch, claude_bin, defaults, extra_args)
|
||||
if choice == "c":
|
||||
return _menu_single_close(session, is_bg, branch, claude_bin)
|
||||
if choice in ("exit", "q", "quit"):
|
||||
return {"exit_code": 0, "action": "quit"}
|
||||
sys.stderr.write(" Unknown choice. Exiting.\n")
|
||||
return {"exit_code": 1, "error": "unknown choice"}
|
||||
|
||||
|
||||
def _menu_single_new(
|
||||
session: dict,
|
||||
is_bg: bool,
|
||||
branch: str,
|
||||
claude_bin: str,
|
||||
defaults: list[str],
|
||||
extra_args: list[str] | None,
|
||||
) -> dict:
|
||||
"""Handle 'n' choice for single session — stop current, start fresh."""
|
||||
if is_bg:
|
||||
stop = _daemon_stop(claude_bin, branch, session.get("pid"))
|
||||
if not stop["ok"]:
|
||||
return {"exit_code": 1, "error": stop["error"]}
|
||||
else:
|
||||
_stop_session(session, claude_bin)
|
||||
return _start_fresh(branch, claude_bin, defaults, extra_args)
|
||||
|
||||
|
||||
def _menu_single_close(session: dict, is_bg: bool, branch: str, claude_bin: str) -> dict:
|
||||
"""Handle 'c' choice for single session — close and exit."""
|
||||
if is_bg:
|
||||
stop = _daemon_stop(claude_bin, branch, session.get("pid"))
|
||||
if not stop["ok"]:
|
||||
return {"exit_code": 1, "error": stop["error"]}
|
||||
sys.stderr.write(f" Stopped bg session PID {session.get('pid')}.\n")
|
||||
else:
|
||||
result = _stop_session(session, claude_bin)
|
||||
sys.stderr.write(f" {result}\n")
|
||||
return {"exit_code": 0, "action": "closed"}
|
||||
|
||||
|
||||
def _menu_live(
|
||||
live: list[dict],
|
||||
branch: str,
|
||||
claude_bin: str,
|
||||
defaults: list[str],
|
||||
extra_args: list[str] | None,
|
||||
) -> dict:
|
||||
"""Display menu when live session(s) exist."""
|
||||
if len(live) == 1:
|
||||
return _menu_single_session(live[0], branch, claude_bin, defaults, extra_args)
|
||||
|
||||
sys.stderr.write(f"\n{branch} — {len(live)} live sessions:\n")
|
||||
for i, session in enumerate(live, 1):
|
||||
label = _session_label(session, branch)
|
||||
sys.stderr.write(f" [{i}] {label}\n")
|
||||
sys.stderr.write(" [n] start new chat\n")
|
||||
sys.stderr.write(" [c] close all and exit\n\n")
|
||||
|
||||
choice = _read_choice()
|
||||
|
||||
if choice == "c":
|
||||
return _close_all(live, branch, claude_bin)
|
||||
|
||||
if choice == "n":
|
||||
return _new_over_all(live, branch, claude_bin, defaults, extra_args)
|
||||
|
||||
if choice in ("exit", "q", "quit"):
|
||||
return {"exit_code": 0, "action": "quit"}
|
||||
|
||||
try:
|
||||
idx = int(choice) - 1
|
||||
if 0 <= idx < len(live):
|
||||
return _resume_session(live[idx], branch, claude_bin, defaults, extra_args)
|
||||
except (ValueError, IndexError):
|
||||
logger.info("[SESSION_BOOT] Invalid menu choice: %r", choice)
|
||||
|
||||
sys.stderr.write(" Pick a number, 'n', or 'c'. Exiting.\n")
|
||||
return {"exit_code": 1, "error": "unknown choice"}
|
||||
|
||||
|
||||
def _close_all(live: list[dict], branch: str, claude_bin: str) -> dict:
|
||||
"""Close all sessions — stop what's stoppable, honest about bg."""
|
||||
non_bg = [s for s in live if s.get("kind") not in ("bg", "background")]
|
||||
bg = [s for s in live if s.get("kind") in ("bg", "background")]
|
||||
for s in non_bg:
|
||||
result = _stop_session(s, claude_bin)
|
||||
sys.stderr.write(f" {result}\n")
|
||||
if bg:
|
||||
stop = _daemon_stop(claude_bin, branch, bg[0].get("pid"))
|
||||
if stop["ok"]:
|
||||
sys.stderr.write(f" Stopped {len(bg)} bg session(s) via daemon stop.\n")
|
||||
else:
|
||||
for s in bg:
|
||||
sys.stderr.write(f" PID {s.get('pid')}: bg session remains — daemon stop failed\n")
|
||||
return {"exit_code": 0, "action": "closed_all"}
|
||||
|
||||
|
||||
def _new_over_all(
|
||||
live: list[dict],
|
||||
branch: str,
|
||||
claude_bin: str,
|
||||
defaults: list[str],
|
||||
extra_args: list[str] | None,
|
||||
) -> dict:
|
||||
"""Start new chat, stopping what's stoppable first."""
|
||||
non_bg = [s for s in live if s.get("kind") not in ("bg", "background")]
|
||||
bg = [s for s in live if s.get("kind") in ("bg", "background")]
|
||||
for s in non_bg:
|
||||
result = _stop_session(s, claude_bin)
|
||||
sys.stderr.write(f" {result}\n")
|
||||
if bg:
|
||||
stop = _daemon_stop(claude_bin, branch, bg[0].get("pid"))
|
||||
if not stop["ok"]:
|
||||
sys.stderr.write(" Cannot start new — bg session(s) still running.\n")
|
||||
return {"exit_code": 1, "error": "daemon stop failed, aborting to preserve one-brain"}
|
||||
return _start_fresh(branch, claude_bin, defaults, extra_args)
|
||||
|
||||
|
||||
def _menu_no_live(
|
||||
branch: str,
|
||||
claude_bin: str,
|
||||
defaults: list[str],
|
||||
extra_args: list[str] | None,
|
||||
) -> dict:
|
||||
"""Display menu when no live session exists."""
|
||||
sys.stderr.write(f"\n{branch} — no live chat\n")
|
||||
sys.stderr.write(" [Enter] continue last chat\n")
|
||||
sys.stderr.write(" [n] new chat\n\n")
|
||||
|
||||
choice = _read_choice()
|
||||
|
||||
if choice in ("", "r"):
|
||||
logger.info("[SESSION_BOOT] Continuing last chat via --continue")
|
||||
nf = _name_flag(branch, extra_args=extra_args)
|
||||
cmd = [claude_bin] + defaults + ["--continue"] + list(extra_args or []) + nf
|
||||
return _exec_in_tmux(branch, "", claude_bin, cmd)
|
||||
elif choice == "n":
|
||||
return _start_fresh(branch, claude_bin, defaults, extra_args)
|
||||
elif choice in ("exit", "q", "quit"):
|
||||
return {"exit_code": 0, "action": "quit"}
|
||||
else:
|
||||
sys.stderr.write(" Unknown choice. Exiting.\n")
|
||||
return {"exit_code": 1, "error": "unknown choice"}
|
||||
|
||||
|
||||
def _start_fresh(
|
||||
branch: str,
|
||||
claude_bin: str,
|
||||
defaults: list[str],
|
||||
extra_args: list[str] | None,
|
||||
) -> dict:
|
||||
"""Start a fresh Claude session in a new tmux session."""
|
||||
session_name = _make_session_name(branch)
|
||||
|
||||
if _tmux_session_exists(session_name):
|
||||
logger.info("[SESSION_BOOT] Killing stale tmux session '%s'", session_name)
|
||||
subprocess.run(["tmux", "kill-session", "-t", session_name], check=False)
|
||||
|
||||
claude_cmd = [claude_bin] + defaults + _name_flag(branch, extra_args=extra_args)
|
||||
if extra_args:
|
||||
claude_cmd.extend(extra_args)
|
||||
|
||||
logger.info("[SESSION_BOOT] Starting fresh in tmux session '%s': %s", branch, " ".join(claude_cmd))
|
||||
os.execvp("tmux", ["tmux", "new-session", "-s", branch, "--"] + claude_cmd)
|
||||
return {"exit_code": 0, "action": "started", "tmux_session": branch}
|
||||
logger.info("[SESSION_BOOT] Starting fresh in tmux session '%s': %s", session_name, " ".join(claude_cmd))
|
||||
os.execvp("tmux", ["tmux", "new-session", "-s", session_name, "--"] + claude_cmd)
|
||||
return {"exit_code": 0, "action": "started", "tmux_session": session_name}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user