Compare commits

..
Author SHA1 Message Date
dependabot[bot] 6bba76aa4d ci(deps): bump the codeql-action group with 3 updates
Bumps the codeql-action group with 3 updates: [github/codeql-action/upload-sarif](https://github.com/github/codeql-action), [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/upload-sarif` from 4.37.1 to 4.37.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/7188fc363630916deb702c7fdcf4e481b751f97a...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81)

Updates `github/codeql-action/init` from 4.37.1 to 4.37.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/7188fc363630916deb702c7fdcf4e481b751f97a...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81)

Updates `github/codeql-action/analyze` from 4.37.1 to 4.37.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/7188fc363630916deb702c7fdcf4e481b751f97a...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81)

---
updated-dependencies:
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-25 08:02:48 +00:00
AIPass 9b85a95552 Merge pull request #702 from AIOSAI/dev
README v3 restructure (DPLAN-0249). Single-funnel story: every command taught exactly once — What-AIPass-Does stripped to pitch, all commands in Quick Start, How-It-Works is now the mental-model section. New hero link line (aipass.ai / PyPI / r/AIPass / Discussions) closes the one-way funnel gap. Three gif slots reserved as comments. Positioning ruling: Claude Code on Linux/WSL only — Codex/macOS/Windows story and Roadmap removed from the README (code support unchanged; Docker distribution is the future answer for those users). CHANGELOG entry included.
2026-07-18 20:01:03 -07:00
AIOSAI 2213251756 docs(readme): v3 restructure — single-funnel story, site link line, gif slots, Claude-Code-on-Linux/WSL positioning (DPLAN-0249) 2026-07-18 19:43:43 -07:00
AIPass a057cdf488 Merge pull request #701 from AIOSAI/dev
README: remove stale demo.gif embed. The recording predates the v2.7.3 onboarding chain (welcome mode, aipass new handoff) and no longer matches the product. Re-record with the welcome-back payoff is parked as a follow-up.
2026-07-18 16:51:11 -07:00
AIOSAI 251b2729c2 docs(readme): drop demo.gif embed — recording predates the v2.7.3 onboarding flow, re-record parked (todo #79) 2026-07-18 16:41:07 -07:00
AIPass 06c1a3a1be Merge pull request #699 from AIOSAI/dev
aipass new + front-door overhaul + fleet-100: projects/ playground machinery (isolated projects with full framework agents, registry-first credential linkage, birth commits), ai_mail cross-project boundary, seedgo cli_ux + readme_quality standards born from a live door-test, and the fleet-100 sweep bringing all 17 branches to 100% on the expanded gate (FPLAN-0333 / DPLAN-0247)
2026-07-18 16:09:52 -07:00
AIOSAI 74bf6eef04 fix(hooks): make test_no_aipass_dir_is_disabled hermetic — the .aipass walk climbs to the drive root, so a real .aipass in any ancestor (windows-setup runner installs AIPass into the runner home, an ancestor of pytest tmp) leaked into the no-dir case. Patch _find_aipass_dir to None for that branch; the walk itself stays covered by the sibling tests 2026-07-18 15:55:23 -07:00
AIOSAI 28e8028a02 fix(hooks): assert the full feedback URL in test_fires_on_turn_10 — kills CodeQL py/incomplete-url-substring-sanitization high alert (substring github.com looked like URL validation to the scanner; full-URL assert is also the stricter test) 2026-07-18 15:43:26 -07:00
AIOSAI 71e5198d4c feat(onboarding): install ends in a conversation — TDPLAN-0014 chain complete + v2.7.3 bump. Dead-end kills: empty-template init runs handoff+report (default path reaches the conversation), non-interactive completes with defaults exit 0 (was EOFError crash — caught by live door-test after green suites), aipass new TTY auto-launch into the manager w/ printed fallback + escape line. Install-to-chat handoff: launch_inline @aipass with authored first prompt + install report context, ONE unified INIT_PROMPT, headless print-only. Welcome Mode branch prompt (opener spec, name-ask, turn-5 triage, hooks-first via real dispatch, WSL beat, exact-command principle) behaviorally door-tested over a live multi-turn run incl second-session momentum. Feedback pulse (@hooks): 10-turn one-liner, aipass feedback on/off alias, disabled on host, live-proven cadence+persistence. README: install-ends-in-conversation story, aipass new documented, non-interactive truth. CHANGELOG + version 2.7.3 both files. seedgo 17/17 100%, local CI gate green 2026-07-18 15:28:44 -07:00
AIPass ef38f3be4c Merge pull request #700 from AIOSAI/dependabot/github_actions/codeql-action-3d2ef569ae
ci(deps): bump the codeql-action group with 3 updates
2026-07-18 15:14:34 -07:00
dependabot[bot] db9643eb58 ci(deps): bump the codeql-action group with 3 updates
Bumps the codeql-action group with 3 updates: [github/codeql-action/upload-sarif](https://github.com/github/codeql-action), [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/upload-sarif` from 4.37.0 to 4.37.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...7188fc363630916deb702c7fdcf4e481b751f97a)

Updates `github/codeql-action/init` from 4.37.0 to 4.37.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...7188fc363630916deb702c7fdcf4e481b751f97a)

Updates `github/codeql-action/analyze` from 4.37.0 to 4.37.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...7188fc363630916deb702c7fdcf4e481b751f97a)

---
updated-dependencies:
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 08:02:58 +00:00
AIOSAI 193336967b feat(aipass-new): agent = full spawn citizen at src/<pkg>/<pkg> (FPLAN-0334). new_project routes through spawn_agent() against spawn's new project_agent template (branch prompt, inbox.json, birth certificate, trinity, house entry point, agent README) - hand-rolled scaffold retired. citizen_class=manager, seat path relative, registry walk stops at project root. Birth commit excludes .venv symlink + registry lock. Boundary verified 4/4 refusals incl ai_mail cross-project check 2026-07-17 22:56:19 -07:00
AIOSAI f6d31285ea fix(seedgo): Debug_Print detector regex matched print( inside string literals - strip string content before matching (+regression test); flatten depth-5 nesting in cli_ux_check. The auditor was the last branch under 100 - fleet now genuinely 17/17 (FPLAN-0333) 2026-07-17 20:17:41 -07:00
AIOSAI d4b265ad45 feat(aipass): aipass new + front-door overhaul, project boundary, cli_ux+readme_quality standards, fleet-100 sweep (FPLAN-0333/DPLAN-0247). aipass new creates isolated projects with full framework agents (registry-first credential linkage, birth commit, drone-resolvable from inside, invisible to host). ai_mail refuses cross-project mail. seedgo gains user-facing-quality standards born from live door-test. 15 branch fronts brought to house pattern - 17/17 branches 100% 2026-07-17 19:29:47 -07:00
AIPass 53a695580f Merge pull request #698 from AIOSAI/dev
Merge playbook SOP fixes from live run PPLAN-0010
2026-07-16 23:43:55 -07:00
AIOSAI 6163202a93 release: bump 2.7.2 — compass v2 + ambient recall window. New cadence ruling: PATCH bump + tag every merge, PyPI tracks main 2026-07-16 23:29:26 -07:00
AIOSAI 4912d96f58 docs(flow): merge playbook SOP fixes from live run PPLAN-0010 — gated fetch step replaced with drone @git sync, create-syntax hint at template top 2026-07-16 23:19:50 -07:00
AIPass 87bfccd55b Merge pull request #697 from AIOSAI/dev
Startup protocol: announce current PID on greeting
2026-07-16 23:04:50 -07:00
AIOSAI a89ddb30bd fix(ci): seedgo gate back to 100% — hooks handler logging + silent catches, memory governance modules/handlers split (import path frozen, bridge E2E green), devpulse README test count. All 17 branches 100% 2026-07-16 22:44:01 -07:00
AIOSAI e412cfed14 fix(drone): namespace subprocess timeout to --drone-timeout — plain --timeout passes through to modules (watchdog 600s regression, live repro x2). Regression test, 879 green, CHANGELOG both fixes 2026-07-16 22:04:03 -07:00
AIOSAI b8f6fb8dad fix(memory): plan-ID searches pin the exact plan via metadata lookup (Patrick ruling) + purge 193 scratchpad junk vectors. Live-verified 100% top-hit, 1011 green 2026-07-16 22:03:04 -07:00
AIOSAI 6b0dcdccef fix(memory): governance module ate all @memory commands — standard handle_command signature, declines non-governance commands (Track 2 follow-up). Search verified live, 1011 green 2026-07-16 21:31:10 -07:00
AIOSAI 1902775812 feat(compass): Track 2 ambient recall — compass_recall hook + pure governance (memory) + recall API w/ rare-token scoring (devpulse), verbatim tidbit injection, live acceptance matrix green (DPLAN-0246/FPLAN-0332). 446+1011+1129 tests, seedgo 100% 2026-07-16 20:43:49 -07:00
AIOSAI 03dfce20b4 feat(devpulse): compass curation v2 Track 1 — supersedes links + atomic archive, write-time FTS conflict advisory, note command, --include-archived, score code-removal, review-per-prep (DPLAN-0246/FPLAN-0331). 435 green, seedgo 31/31 2026-07-16 19:45:21 -07:00
AIOSAI b3bb529a6a feat(drone): 3-layer timeout policy — per-command overrides + --timeout flag + 30s default, override hint in error (DPLAN-0245). 878 tests green 2026-07-16 00:09:28 -07:00
AIOSAI 9a61d237fa feat(flow): close pipeline self-completing — auto-vectorization from post_close_runner + locked atomic registry writes (DPLAN-0245). E2E proven, 730 green 2026-07-16 00:09:14 -07:00
AIOSAI 702e335cb8 fix(skills): TG routine read-timeouts silenced on OSError path + outage episode-start demoted ERROR→WARNING per Patrick ruling — ends medic wake-loop. 825 TG tests green 2026-07-15 23:21:41 -07:00
AIOSAI 73e9ededd4 fix(flow): CLOSED_PLANS append race — O_EXCL lockfile with retry/backoff, surface silent append failures (S314 sweep lost 18/21 entries). 730 tests green 2026-07-15 23:21:26 -07:00
AIOSAI bad08e9b03 fix(memory): unwedge plan vectorization — salt vector IDs with source file, per-file batches with incremental manifest (DPLAN-0245). Backlog drained 229/229, 990 tests green 2026-07-15 23:21:11 -07:00
AIOSAIandClaude Fable 5 851988abbc fix(seedgo): DPLAN-0244 trust files to 100% standards — json_handler + justified bypasses
CI seedgo gate is strict 100%. trust_registry.py now uses json_handler for
registry I/O (log_operation on writes only — no per-hook-event flood);
unused_function bypassed (cross-branch public API, static analysis can't see
callers). trust.py gained print_introspection + --help/no-args gates;
genuinely-N/A standards (json_structure delegated to trust_registry,
frozen cross-branch import) bypassed with justification. Both branches 100%,
all tests green, live acceptance re-verified (attack still blocked both gates).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEAyLFCuo4uD934fwFxocz
2026-07-15 19:17:45 -07:00
AIOSAIandClaude Fable 5 222a9c8382 docs(navmap): open-source status is fleet-wide identity, not a coding footnote (Patrick ruling)
Every agent's tier1 navmap now states AIPass is open source in the intro
and reframes the house rule as write-as-if-it-ships. External scans are
contributions, not intrusions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEAyLFCuo4uD934fwFxocz
2026-07-15 18:23:13 -07:00
AIOSAIandClaude Fable 5 a8b1ce6658 feat(hooks): DPLAN-0244 hooks.json trust model hardening — Layer A engine gates + Layer B registry/CLI
Closes a zero-interaction RCE where a hostile repo's .aipass/hooks.json
(discovered via loader CWD walk-up, bridge wired globally) could run an
arbitrary command-type hook on SessionStart. Defense-in-depth:

Layer A (engine): refuse command-type hooks from per-project configs via
unconditional _source clobber; gate handler paths to aipass.* namespace.
Layer B (loader+CLI): trusted-project registry (path+sha256), fail-closed
trust-check, $AIPASS_HOME-only bootstrap (no TOFU), aipass init auto-enroll
+ new aipass trust/revoke commands.

Live acceptance test (real bridge, real payload) proves both gates block
independently. 1105 hooks + 133 aipass tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEAyLFCuo4uD934fwFxocz
2026-07-15 18:23:02 -07:00
AIOSAI 807924241f docs: startup protocol — announce current PID on greeting (Patrick) 2026-07-15 14:12:55 -07:00
130 changed files with 10223 additions and 674 deletions
+11 -5
View File
@@ -38,6 +38,17 @@
"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",
@@ -76,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"
}
},
+2 -2
View File
@@ -2,7 +2,7 @@
<!-- 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
@@ -110,6 +110,6 @@ Your continuity across sessions. Save proactively — after milestones, decision
# 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...`.
- State lives in `.trinity/` and dashboards, never in prompts. Prompts are signposts; memories record; registries catalog.
+7 -1
View File
@@ -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]
```
+6
View File
@@ -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"},
+1 -1
View File
@@ -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@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
sarif_file: results.sarif
+2 -2
View File
@@ -44,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@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
languages: python
- uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
- uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
+6
View File
@@ -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/
+302
View File
@@ -9,8 +9,310 @@ 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
+2
View File
@@ -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.
+52 -103
View File
@@ -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>
![demo](assets/demo.gif)
<!-- GIF SLOT 1 — hero (~20s): clone → ./aipass install → live conversation with the concierge.
![demo](assets/hero.gif) -->
---
@@ -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.
![memory](assets/memory.gif) -->
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.
![team](assets/team.gif) -->
---
@@ -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>
@@ -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.
@@ -249,25 +209,14 @@ The installer (`./aipass install`, powered by setup.sh) auto-detects which CLIs
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
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "aipass"
version = "2.7.1"
version = "2.7.3"
description = "A local multi-agent framework where your AI agents keep their memory, work together, and never ask you to re-explain context"
readme = "README.md"
license = "MIT"
+1 -1
View File
@@ -3,4 +3,4 @@
git clone + ./setup.sh — https://github.com/AIOSAI/AIPass
"""
__version__ = "2.7.1"
__version__ = "2.7.3"
+19
View File
@@ -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
@@ -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"
+19
View File
@@ -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
+119
View File
@@ -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 == ""
+52 -1
View File
@@ -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
@@ -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.
+60 -15
View File
@@ -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
View File
@@ -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
+107 -18
View File
@@ -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,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,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
+4 -2
View File
@@ -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]
+107
View File
@@ -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
+2 -2
View File
@@ -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:
+38 -20
View File
@@ -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
+28 -11
View File
@@ -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
+122
View File
@@ -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
+68 -59
View File
@@ -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()
+28 -28
View File
@@ -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()
+7 -7
View File
@@ -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()
+76
View File
@@ -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()
+39 -5
View File
@@ -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}
+76 -14
View File
@@ -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."""
+1 -1
View File
@@ -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
+738
View File
@@ -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([])
+249
View File
@@ -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
+21
View File
@@ -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 |
+18
View File
@@ -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]"
+21
View File
@@ -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:
+21 -1
View File
@@ -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]:
+10 -1
View File
@@ -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
View File
@@ -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)
+6 -6
View File
@@ -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,
+14
View File
@@ -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.
+15 -1
View File
@@ -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()
+13 -1
View File
@@ -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
+8
View File
@@ -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
+145 -8
View File
@@ -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:
@@ -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) == []
+11
View File
@@ -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.
+29 -9
View File
@@ -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:
+27 -1
View File
@@ -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:
+15 -5
View File
@@ -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
+66 -1
View File
@@ -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)
+12
View File
@@ -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
+51 -6
View File
@@ -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,7 +74,7 @@ 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
@@ -112,14 +113,16 @@ 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
+10
View File
@@ -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",
+19 -4
View File
@@ -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,9 @@ 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 |
@@ -56,6 +69,7 @@ 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>)
@@ -71,6 +85,7 @@ src/aipass/hooks/
│ │ │ ├── tier0_kernel.py # Injects tier0 kernel prompt (every turn)
│ │ │ ├── navmap.py # Injects tier1 navmap prompt (periodic)
│ │ │ ├── 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)
@@ -117,7 +132,7 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im
| Event | Hooks | Description |
|---|---|---|
| UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + auto-process + TG mirror |
| 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 |
@@ -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,117 @@
# =================== AIPass ====================
# Name: compass_recall.py
# Version: 1.0.0
# Description: Ambient compass recall — surfaces rated decisions on relevant prompts
# Branch: hooks
# Layer: apps/handlers/prompt
# Created: 2026-07-16
# Modified: 2026-07-16
# =============================================
"""Queries compass FTS against the user's prompt and injects matching decisions
under governance rules. Never blocks the prompt on error."""
import json
import os
import tempfile
from pathlib import Path
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.hooks.apps.handlers.json import json_handler
_STATE_DIR = Path(tempfile.gettempdir())
def _state_path(hook_data: dict | None = None) -> Path | None:
session_id = ""
if hook_data:
session_id = hook_data.get("session_id", "")
if not session_id:
session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "")
if not session_id:
return None
return _STATE_DIR / f"aipass-compass-recall-{session_id}.json"
def _load_state(hook_data: dict | None = None) -> dict:
path = _state_path(hook_data)
if path is None or not path.exists():
return _fresh_state()
try:
return json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
logger.info("[HOOKS] compass_recall: state read failed: %s", exc)
return _fresh_state()
def _fresh_state() -> dict:
try:
from aipass.memory.apps.modules.governance import new_state
return new_state()
except Exception as exc:
logger.info("[HOOKS] compass_recall: governance import failed: %s", exc)
return {"surfaces_count": 0, "messages_since_last": 0, "last_surface_time": 0.0, "surfaced_ids": []}
def _save_state(state: dict, hook_data: dict | None = None) -> None:
path = _state_path(hook_data)
if path is None:
return
try:
path.write_text(json.dumps(state), encoding="utf-8")
except OSError as exc:
logger.info("[HOOKS] compass_recall: state write failed: %s", exc)
def handle(hook_data: dict) -> dict:
"""Surface relevant compass decisions into the prompt context."""
try:
if not _state_path(hook_data):
return {"stdout": "", "exit_code": 0}
state = _load_state(hook_data)
from aipass.memory.apps.modules.governance import should_surface, record_message
state = record_message(state)
prompt_text = hook_data.get("prompt", "")
if not prompt_text or len(prompt_text) < 10:
_save_state(state, hook_data)
return {"stdout": "", "exit_code": 0}
from aipass.devpulse.apps.modules.compass import recall_decisions, mark_surfaced
candidates = recall_decisions(prompt_text, limit=3)
if not candidates:
_save_state(state, hook_data)
return {"stdout": "", "exit_code": 0}
approved = []
for c in candidates:
item_id = str(c["id"])
relevance = c.get("relevance", 0.0)
surface, reason, new_st = should_surface(item_id, relevance, state)
if surface:
approved.append(c)
state = new_st
_save_state(state, hook_data)
if not approved:
return {"stdout": "", "exit_code": 0}
lines = []
for c in approved:
rating = c.get("rating", "good").upper()
lines.append(f"[{rating}] #{c['id']}: {c['decision']}")
mark_surfaced([c["id"] for c in approved])
json_handler.log_operation("compass_recall", {"count": len(approved)})
return {"stdout": "\n".join(lines), "exit_code": 0}
except Exception as exc:
logger.info("[HOOKS] compass_recall_unreachable: %s", exc)
return {"stdout": "", "exit_code": 0}
@@ -0,0 +1,97 @@
# =================== AIPass ====================
# Name: feedback_pulse.py
# Version: 1.0.0
# Description: Periodic feedback ask — one ignorable line every ~10 turns
# Branch: hooks
# Layer: apps/handlers/prompt
# Created: 2026-07-18
# Modified: 2026-07-18
# =============================================
"""Periodic feedback pulse — surfaces a one-line feedback ask every ~10 turns.
Scoped to external user projects (not the AIPass host). Toggle via
drone @hooks feedback on/off. State persists across session restarts
as a .aipass/feedback_off sentinel file per project."""
import json
import os
import tempfile
from pathlib import Path
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.hooks.apps.handlers.json import json_handler
_STATE_DIR = Path(tempfile.gettempdir())
_PERIOD = 10
_FEEDBACK_URL = "https://github.com/AIOSAI/AIPass/issues"
_FEEDBACK_LINE = f"How are we doing? Your feedback is hugely appreciated → {_FEEDBACK_URL}"
def _state_path(hook_data: dict) -> Path | None:
session_id = hook_data.get("session_id", "")
if not session_id:
session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "")
if not session_id:
return None
return _STATE_DIR / f"aipass-feedback-pulse-{session_id}.json"
def _load_and_increment(path: Path) -> int:
"""Load turn counter, increment, and persist. Returns the new turn number."""
try:
if path.exists():
data = json.loads(path.read_text(encoding="utf-8"))
turn = data.get("turn", 0) + 1
else:
turn = 0
path.write_text(json.dumps({"turn": turn}), encoding="utf-8")
return turn
except (json.JSONDecodeError, OSError) as exc:
logger.info("[HOOKS] feedback_pulse: state access failed: %s", exc)
return 0
def _find_aipass_dir(cwd: str | None = None) -> Path | None:
"""Walk up from CWD to find the nearest .aipass/ directory."""
start = Path(cwd) if cwd else Path.cwd()
for parent in [start, *start.parents]:
candidate = parent / ".aipass"
if candidate.is_dir():
return candidate
if parent == parent.parent:
break
return None
def _is_disabled(cwd: str | None = None) -> bool:
"""Check if feedback pulse is toggled off for this project."""
aipass_dir = _find_aipass_dir(cwd)
if aipass_dir is None:
return True
sentinel = aipass_dir / "feedback_off"
return sentinel.exists()
def handle(hook_data: dict) -> dict:
"""Inject feedback pulse line on cadence (~every 10 turns, skipping early turns)."""
try:
path = _state_path(hook_data)
if path is None:
return {"stdout": "", "exit_code": 0}
turn = _load_and_increment(path)
if turn < _PERIOD or turn % _PERIOD != 0:
return {"stdout": "", "exit_code": 0}
cwd = hook_data.get("cwd", "")
if _is_disabled(cwd or None):
return {"stdout": "", "exit_code": 0}
json_handler.log_operation("feedback_pulse", {"turn": turn})
return {"stdout": _FEEDBACK_LINE, "exit_code": 0}
except Exception as exc:
logger.info("[HOOKS] feedback_pulse: unexpected error: %s", exc)
return {"stdout": "", "exit_code": 0}
+58 -60
View File
@@ -33,9 +33,7 @@ if sys.platform == "win32":
_reconfigure(encoding="utf-8", errors="replace")
from aipass.prax.apps.modules.logger import system_logger as logger # noqa: E402
from aipass.cli.apps.modules import err_console # noqa: E402
CONSOLE = err_console
from aipass.cli.apps.modules import console # noqa: E402
# =============================================================================
# MODULE DISCOVERY
@@ -85,86 +83,86 @@ def discover_modules() -> list[Any]:
def print_introspection():
"""Print branch introspection — discovered modules and capabilities."""
modules = discover_modules()
CONSOLE.print()
CONSOLE.print("[bold cyan]HOOKS — Hook Infrastructure for AIPass[/bold cyan]")
CONSOLE.print()
CONSOLE.print("[dim]Dispatches hooks across platforms with per-project config, logging, and crash isolation.[/dim]")
CONSOLE.print()
console.print()
console.print("[bold cyan]HOOKS — Hook Infrastructure for AIPass[/bold cyan]")
console.print()
console.print("[dim]Dispatches hooks across platforms with per-project config, logging, and crash isolation.[/dim]")
console.print()
CONSOLE.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}")
CONSOLE.print()
console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}")
console.print()
for module in modules:
name = module.__name__.split(".")[-1]
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
CONSOLE.print(f" [cyan]•[/cyan] {name:20} [dim]{desc}[/dim]")
console.print(f" [cyan]•[/cyan] {name:20} [dim]{desc}[/dim]")
CONSOLE.print()
CONSOLE.print("Run [green]'drone @hooks --help'[/green] for usage information")
CONSOLE.print()
console.print()
console.print("Run [green]'drone @hooks --help'[/green] for usage information")
console.print()
def print_help():
"""Print CLI help — usage instructions and available commands."""
modules = discover_modules()
CONSOLE.print()
CONSOLE.print("[bold cyan]HOOKS[/bold cyan] [dim]v1.1.0[/dim] — Hook Infrastructure for AIPass")
CONSOLE.print()
CONSOLE.print("[dim]Dispatches hooks across platforms with per-project config, logging, and crash isolation.[/dim]")
CONSOLE.print()
CONSOLE.print("─" * 70)
CONSOLE.print()
console.print()
console.print("[bold cyan]HOOKS[/bold cyan] [dim]v1.1.0[/dim] — Hook Infrastructure for AIPass")
console.print()
console.print("[dim]Dispatches hooks across platforms with per-project config, logging, and crash isolation.[/dim]")
console.print()
console.print("─" * 70)
console.print()
CONSOLE.print("[bold cyan]USAGE:[/bold cyan]")
CONSOLE.print()
CONSOLE.print(" [dim]drone @hooks <command> [args...][/dim]")
CONSOLE.print(" [dim]drone @hooks --help[/dim]")
CONSOLE.print()
CONSOLE.print("─" * 70)
CONSOLE.print()
console.print("[bold cyan]USAGE:[/bold cyan]")
console.print()
console.print(" [dim]drone @hooks <command> [args...][/dim]")
console.print(" [dim]drone @hooks --help[/dim]")
console.print()
console.print("─" * 70)
console.print()
CONSOLE.print("[bold cyan]COMMANDS:[/bold cyan]")
CONSOLE.print()
console.print("[bold cyan]COMMANDS:[/bold cyan]")
console.print()
for module in modules:
commands = getattr(module, "HELP_COMMANDS", None)
if commands:
for cmd, desc in commands:
CONSOLE.print(f" [green]{cmd:26}[/green] [dim]{desc}[/dim]")
console.print(f" [green]{cmd:26}[/green] [dim]{desc}[/dim]")
else:
name = module.__name__.split(".")[-1]
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
CONSOLE.print(f" [green]{name:26}[/green] [dim]{desc}[/dim]")
console.print(f" [green]{name:26}[/green] [dim]{desc}[/dim]")
CONSOLE.print()
CONSOLE.print("─" * 70)
CONSOLE.print()
console.print()
console.print("─" * 70)
console.print()
CONSOLE.print("[bold cyan]BRIDGES:[/bold cyan]")
CONSOLE.print()
CONSOLE.print(
console.print("[bold cyan]BRIDGES:[/bold cyan]")
console.print()
console.print(
" [green]claude[/green] [dim]Claude Code bridge (provider settings entry point)[/dim]"
)
CONSOLE.print()
CONSOLE.print("─" * 70)
CONSOLE.print()
console.print()
console.print("─" * 70)
console.print()
CONSOLE.print("[bold cyan]EXAMPLES:[/bold cyan]")
CONSOLE.print()
CONSOLE.print(" [dim]drone @hooks status[/dim] [dim]# Show hook config for current project[/dim]")
CONSOLE.print(" [dim]drone @hooks log[/dim] [dim]# Tail recent hook activity[/dim]")
CONSOLE.print(" [dim]drone @hooks hooksound off[/dim] [dim]# Mute all hook sounds[/dim]")
CONSOLE.print(" [dim]drone @hooks hooksound on[/dim] [dim]# Unmute all hook sounds[/dim]")
CONSOLE.print()
CONSOLE.print("─" * 70)
CONSOLE.print()
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
console.print()
console.print(" [dim]drone @hooks status[/dim] [dim]# Show hook config for current project[/dim]")
console.print(" [dim]drone @hooks log[/dim] [dim]# Tail recent hook activity[/dim]")
console.print(" [dim]drone @hooks hooksound off[/dim] [dim]# Mute all hook sounds[/dim]")
console.print(" [dim]drone @hooks hooksound on[/dim] [dim]# Unmute all hook sounds[/dim]")
console.print()
console.print("─" * 70)
console.print()
CONSOLE.print("[bold cyan]FLAGS:[/bold cyan]")
CONSOLE.print()
CONSOLE.print(" [green]--help, -h[/green] [dim]Show this help message[/dim]")
CONSOLE.print(" [green]--version, -V[/green] [dim]Show version[/dim]")
CONSOLE.print()
CONSOLE.print("[bold]TIP:[/bold] For command-specific help:")
CONSOLE.print(" [dim]drone @hooks <command> --help[/dim]")
CONSOLE.print()
console.print("[bold cyan]FLAGS:[/bold cyan]")
console.print()
console.print(" [green]--help, -h[/green] [dim]Show this help message[/dim]")
console.print(" [green]--version, -V[/green] [dim]Show version[/dim]")
console.print()
console.print("[bold]TIP:[/bold] For command-specific help:")
console.print(" [dim]drone @hooks <command> --help[/dim]")
console.print()
def route_command(command: str, args: list[str], modules: list[Any]) -> bool:
@@ -192,7 +190,7 @@ def handle_command(command: str, args: list) -> bool:
return True
if command in ["--version", "-V"]:
CONSOLE.print("hooks 1.1.0")
console.print("hooks 1.1.0")
return True
return route_command(command, args, modules)
@@ -209,7 +207,7 @@ def main() -> int:
if handle_command(args[0], args[1:]):
return 0
CONSOLE.print(f"Unknown command: {args[0]}. Try: drone @hooks --help")
console.print(f"Unknown command: {args[0]}. Try: drone @hooks --help")
return 1
+119
View File
@@ -14,6 +14,7 @@ import importlib
import json
import os
import subprocess
import tempfile
import time
from pathlib import Path
@@ -65,6 +66,18 @@ def _run_handler(handler_path: str, hook_data: dict) -> dict:
start = time.monotonic()
try:
module_path, func_name = handler_path.rsplit(".", 1)
if not module_path.startswith("aipass."):
elapsed_ms = (time.monotonic() - start) * 1000
logger.warning(
"[HOOKS] handler path refused (not in aipass.* namespace): %s",
handler_path,
)
return {
"exit_code": -1,
"stdout": "",
"stderr": f"handler namespace refused: {handler_path}",
"elapsed_ms": round(elapsed_ms, 1),
}
module = importlib.import_module(module_path)
handler_func = getattr(module, func_name)
result = handler_func(hook_data)
@@ -89,6 +102,63 @@ def _matches(matcher: str, value: str) -> bool:
return value in matcher.split("|")
_BUDGET_KEYS = ("max_per_session", "min_spacing_turns", "cooldown_seconds")
def _budget_state_path(session_id: str = "") -> Path | None:
if not session_id:
session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "")
if not session_id:
return None
return Path(tempfile.gettempdir()) / f"aipass-handler-budget-{session_id}.json"
def _load_budget_state(session_id: str = "") -> dict:
path = _budget_state_path(session_id)
if path is None or not path.exists():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
logger.info("[HOOKS] budget: state read failed: %s", exc)
return {}
def _save_budget_state(state: dict, session_id: str = "") -> None:
path = _budget_state_path(session_id)
if path is None:
return
try:
path.write_text(json.dumps(state), encoding="utf-8")
except OSError as exc:
logger.info("[HOOKS] budget: state write failed: %s", exc)
def _check_budget(hook_name: str, budget_cfg: dict, budget_state: dict) -> tuple[bool, str]:
"""Check if handler is within its per-session budget."""
hs = budget_state.get(hook_name, {})
fire_count = hs.get("fire_count", 0)
max_fires = budget_cfg.get("max_per_session")
if max_fires is not None and fire_count >= max_fires:
return False, f"budget exhausted ({fire_count}/{max_fires})"
if fire_count > 0:
min_spacing = budget_cfg.get("min_spacing_turns")
if min_spacing is not None:
turns_since = hs.get("turns_since_fire", 0)
if turns_since < min_spacing:
return False, f"spacing ({turns_since}/{min_spacing})"
cooldown = budget_cfg.get("cooldown_seconds")
if cooldown is not None:
elapsed = time.time() - hs.get("last_fire_time", 0.0)
if elapsed < cooldown:
return False, f"cooldown ({int(cooldown - elapsed)}s)"
return True, "ok"
def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]:
"""Core dispatch — run hooks for event, return (merged_stdout, exit_code)."""
if not config.get("hooks_enabled", True):
@@ -111,6 +181,9 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]:
outputs = []
total_start = time.monotonic()
budget_state = None
budget_dirty = False
payload_session_id = parsed.get("session_id", "")
for hook_name, hook_def in event_hooks.items():
if not hook_def.get("enabled", True):
@@ -137,6 +210,43 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]:
)
continue
if command and not handler and config.get("_source") == "project":
logger.warning(
"[HOOKS] %s.%s REFUSED: command-type not allowed in per-project config",
event_type,
hook_name,
)
_log(
{
"ts": time.time(),
"event": event_type,
"hook": hook_name,
"action": "refused_command_type",
}
)
continue
budget_cfg = {k: hook_def[k] for k in _BUDGET_KEYS if k in hook_def}
if budget_cfg:
if budget_state is None:
budget_state = _load_budget_state(payload_session_id)
hs = budget_state.setdefault(hook_name, {})
hs["turns_since_fire"] = hs.get("turns_since_fire", 0) + 1
budget_dirty = True
allowed, reason = _check_budget(hook_name, budget_cfg, budget_state)
if not allowed:
logger.info("[HOOKS] %s.%s budget: %s", event_type, hook_name, reason)
_log(
{
"ts": time.time(),
"event": event_type,
"hook": hook_name,
"action": "budget_suppressed",
"reason": reason,
}
)
continue
if handler:
result = _run_handler(handler, parsed)
else:
@@ -216,6 +326,15 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]:
if result["stdout"]:
outputs.append(result["stdout"])
if budget_cfg and budget_state is not None:
hs = budget_state.setdefault(hook_name, {})
hs["fire_count"] = hs.get("fire_count", 0) + 1
hs["last_fire_time"] = time.time()
hs["turns_since_fire"] = 0
budget_dirty = True
if budget_dirty and budget_state is not None:
_save_budget_state(budget_state, payload_session_id)
total_ms = (time.monotonic() - total_start) * 1000
logger.info("[HOOKS] %s complete: %d hooks %dms", event_type, len(outputs), total_ms)
+96
View File
@@ -0,0 +1,96 @@
# =================== AIPass ====================
# Name: feedback.py
# Version: 1.0.0
# Description: Feedback pulse toggle — on/off control for periodic feedback ask
# Branch: hooks
# Layer: apps/modules
# Created: 2026-07-18
# Modified: 2026-07-18
# =============================================
"""Feedback pulse toggle — on/off control for the periodic feedback ask via drone @hooks feedback."""
from pathlib import Path
from aipass.cli.apps.modules import err_console
from aipass.hooks.apps.handlers.json import json_handler
from aipass.prax.apps.modules.logger import system_logger as logger # noqa: F401
CONSOLE = err_console
HELP_COMMANDS = [
("feedback on", "Enable feedback pulse (default)"),
("feedback off", "Disable feedback pulse"),
("feedback", "Show current feedback pulse status"),
]
def _find_aipass_dir() -> Path | None:
"""Walk up from CWD to find the nearest .aipass/ directory."""
cwd = Path.cwd()
for parent in [cwd, *cwd.parents]:
candidate = parent / ".aipass"
if candidate.is_dir():
return candidate
if parent == parent.parent:
break
return None
def _sentinel() -> Path | None:
"""Return the sentinel file path, or None if no .aipass/ dir found."""
aipass_dir = _find_aipass_dir()
if aipass_dir is None:
return None
return aipass_dir / "feedback_off"
def print_introspection() -> None:
"""Print module structure for drone routing."""
sentinel = _sentinel()
if sentinel is None:
status = "NO PROJECT"
else:
status = "DISABLED" if sentinel.exists() else "ENABLED"
CONSOLE.print(f"[bold cyan]feedback[/bold cyan] — Feedback pulse ({status})")
def handle_command(command: str, args: list) -> bool:
"""Route feedback commands from drone @hooks."""
if command == "feedback":
if not args:
print_introspection()
return True
sub = args[0]
if sub in ("--help", "-h", "help"):
CONSOLE.print("[bold cyan]feedback[/bold cyan] — Toggle the periodic feedback pulse")
CONSOLE.print()
CONSOLE.print(" drone @hooks feedback Show current status")
CONSOLE.print(" drone @hooks feedback on Enable feedback pulse (default)")
CONSOLE.print(" drone @hooks feedback off Disable feedback pulse")
return True
if sub == "off":
sentinel = _sentinel()
if sentinel is None:
CONSOLE.print("[yellow]No .aipass/ directory found[/yellow]")
return True
sentinel.touch()
json_handler.log_operation("feedback_toggle", {"state": "off"})
CONSOLE.print("[yellow]Feedback pulse DISABLED[/yellow]")
return True
if sub == "on":
sentinel = _sentinel()
if sentinel is None:
CONSOLE.print("[yellow]No .aipass/ directory found[/yellow]")
return True
if sentinel.exists():
sentinel.unlink()
json_handler.log_operation("feedback_toggle", {"state": "on"})
CONSOLE.print("[green]Feedback pulse ENABLED[/green]")
return True
return False
@@ -0,0 +1,588 @@
# =================== AIPass ====================
# Name: test_compass_recall.py
# Version: 1.1.0
# Description: Tests for compass recall prompt handler
# Branch: hooks
# Created: 2026-07-16
# Modified: 2026-07-16
# =============================================
"""Tests for handlers/prompt/compass_recall.py."""
import json
import os
from unittest.mock import patch
CANDIDATE_GOOD = {
"id": 56,
"rating": "good",
"decision": "Never hardcode config in prompts",
"context": "Prompt config management",
"note": "",
"tags": "config,prompts",
"relevance": 0.7,
}
CANDIDATE_BAD = {
"id": 84,
"rating": "bad",
"decision": "Usage gap is not a bug",
"context": "Compass audit",
"note": "",
"tags": "compass",
"relevance": 0.5,
}
CANDIDATE_LOW_RELEVANCE = {
"id": 99,
"rating": "good",
"decision": "Some low relevance decision",
"context": "Testing",
"note": "",
"tags": "test",
"relevance": 0.1,
}
REAL_PAYLOAD = {
"session_id": "abc-123-def",
"transcript_path": "/tmp/transcript.jsonl",
"cwd": "/home/user/project",
"permission_mode": "default",
"hook_event_name": "UserPromptSubmit",
"prompt": "How should we handle prompt config?",
}
def _payload(prompt, session_id="test-session"):
"""Build a realistic hook payload with documented keys."""
return {"session_id": session_id, "prompt": prompt, "cwd": "/tmp"}
class TestCompassRecallHandler:
def test_surfaces_relevant_decision(self, tmp_path):
with (
patch(
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
tmp_path,
),
patch(
"aipass.devpulse.apps.modules.compass.recall_decisions",
return_value=[CANDIDATE_GOOD],
),
patch(
"aipass.devpulse.apps.modules.compass.mark_surfaced",
return_value=1,
) as mock_mark,
patch(
"aipass.memory.apps.modules.governance.should_surface",
return_value=(
True,
"Ready to surface",
{
"surfaces_count": 1,
"messages_since_last": 0,
"last_surface_time": 1000.0,
"surfaced_ids": ["56"],
},
),
),
patch(
"aipass.memory.apps.modules.governance.record_message",
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
),
):
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
result = handle(_payload("How should we handle prompt config?"))
assert result["exit_code"] == 0
assert "[GOOD] #56:" in result["stdout"]
assert "Never hardcode config in prompts" in result["stdout"]
mock_mark.assert_called_once_with([56])
def test_formats_bad_rating(self, tmp_path):
with (
patch(
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
tmp_path,
),
patch(
"aipass.devpulse.apps.modules.compass.recall_decisions",
return_value=[CANDIDATE_BAD],
),
patch(
"aipass.devpulse.apps.modules.compass.mark_surfaced",
return_value=1,
),
patch(
"aipass.memory.apps.modules.governance.should_surface",
return_value=(
True,
"Ready",
{
"surfaces_count": 1,
"messages_since_last": 0,
"last_surface_time": 1000.0,
"surfaced_ids": ["84"],
},
),
),
patch(
"aipass.memory.apps.modules.governance.record_message",
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
),
):
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
result = handle(_payload("Is the usage gap a real bug?"))
assert "[BAD] #84:" in result["stdout"]
def test_empty_when_no_candidates(self, tmp_path):
with (
patch(
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
tmp_path,
),
patch(
"aipass.devpulse.apps.modules.compass.recall_decisions",
return_value=[],
),
patch(
"aipass.memory.apps.modules.governance.record_message",
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
),
):
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
result = handle(_payload("Some prompt about something"))
assert result["exit_code"] == 0
assert result["stdout"] == ""
def test_empty_when_governance_suppresses(self, tmp_path):
with (
patch(
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
tmp_path,
),
patch(
"aipass.devpulse.apps.modules.compass.recall_decisions",
return_value=[CANDIDATE_GOOD],
),
patch(
"aipass.memory.apps.modules.governance.should_surface",
return_value=(
False,
"Spacing not met",
{
"surfaces_count": 0,
"messages_since_last": 1,
"last_surface_time": 0.0,
"surfaced_ids": [],
},
),
),
patch(
"aipass.memory.apps.modules.governance.record_message",
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
),
):
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
result = handle(_payload("How should we handle prompt config?"))
assert result["exit_code"] == 0
assert result["stdout"] == ""
def test_empty_when_prompt_too_short(self, tmp_path):
with (
patch(
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
tmp_path,
),
patch(
"aipass.memory.apps.modules.governance.record_message",
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
),
):
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
result = handle(_payload("Hi"))
assert result["exit_code"] == 0
assert result["stdout"] == ""
def test_never_blocks_on_import_error(self, tmp_path):
with (
patch(
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
tmp_path,
),
patch(
"aipass.hooks.apps.handlers.prompt.compass_recall._state_path",
return_value=tmp_path / "state.json",
),
patch(
"aipass.hooks.apps.handlers.prompt.compass_recall._load_state",
side_effect=Exception("DB locked"),
),
):
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
result = handle(_payload("Some prompt about something important"))
assert result["exit_code"] == 0
assert result["stdout"] == ""
def test_persists_governance_state(self, tmp_path):
updated_state = {
"surfaces_count": 1,
"messages_since_last": 0,
"last_surface_time": 1000.0,
"surfaced_ids": ["56"],
}
with (
patch(
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
tmp_path,
),
patch(
"aipass.devpulse.apps.modules.compass.recall_decisions",
return_value=[CANDIDATE_GOOD],
),
patch(
"aipass.devpulse.apps.modules.compass.mark_surfaced",
return_value=1,
),
patch(
"aipass.memory.apps.modules.governance.should_surface",
return_value=(True, "Ready", updated_state),
),
patch(
"aipass.memory.apps.modules.governance.record_message",
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
),
):
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
handle(_payload("How should we handle prompt config?", session_id="test-persist"))
state_file = tmp_path / "aipass-compass-recall-test-persist.json"
assert state_file.exists()
saved = json.loads(state_file.read_text())
assert saved["surfaces_count"] == 1
assert "56" in saved["surfaced_ids"]
def test_multiple_candidates_partial_approval(self, tmp_path):
def mock_should_surface(item_id, relevance, state, config=None, *, current_time=None):
if item_id == "56":
new_st = {**state, "surfaces_count": 1, "surfaced_ids": list(state.get("surfaced_ids", [])) + ["56"]}
return True, "Ready", new_st
return False, "Below threshold", state
with (
patch(
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
tmp_path,
),
patch(
"aipass.devpulse.apps.modules.compass.recall_decisions",
return_value=[CANDIDATE_GOOD, CANDIDATE_LOW_RELEVANCE],
),
patch(
"aipass.devpulse.apps.modules.compass.mark_surfaced",
return_value=1,
) as mock_mark,
patch(
"aipass.memory.apps.modules.governance.should_surface",
side_effect=mock_should_surface,
),
patch(
"aipass.memory.apps.modules.governance.record_message",
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
),
):
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
result = handle(_payload("How should we handle prompt config?"))
assert "[GOOD] #56:" in result["stdout"]
assert "#99" not in result["stdout"]
mock_mark.assert_called_once_with([56])
def test_empty_prompt_no_cross_branch_import(self, tmp_path):
with (
patch(
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
tmp_path,
),
patch(
"aipass.memory.apps.modules.governance.record_message",
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
),
):
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
result = handle(_payload(""))
assert result["exit_code"] == 0
assert result["stdout"] == ""
def test_no_session_id_degrades_safe(self):
"""No session_id in payload or env = no injection, no crash."""
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("CLAUDE_CODE_SESSION_ID", None)
result = handle({"prompt": "How should we handle prompt config?"})
assert result["exit_code"] == 0
assert result["stdout"] == ""
def test_real_documented_payload_shape(self, tmp_path):
"""Surfaces from a payload using the official Claude Code hook keys."""
with (
patch(
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
tmp_path,
),
patch(
"aipass.devpulse.apps.modules.compass.recall_decisions",
return_value=[CANDIDATE_GOOD],
),
patch(
"aipass.devpulse.apps.modules.compass.mark_surfaced",
return_value=1,
),
patch(
"aipass.memory.apps.modules.governance.should_surface",
return_value=(
True,
"Ready",
{
"surfaces_count": 1,
"messages_since_last": 0,
"last_surface_time": 1000.0,
"surfaced_ids": ["56"],
},
),
),
patch(
"aipass.memory.apps.modules.governance.record_message",
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
),
):
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
result = handle(REAL_PAYLOAD)
assert result["exit_code"] == 0
assert "[GOOD] #56:" in result["stdout"]
def test_env_var_fallback_for_session_id(self, tmp_path):
"""Falls back to CLAUDE_CODE_SESSION_ID env var if payload has no session_id."""
with (
patch.dict(os.environ, {"CLAUDE_CODE_SESSION_ID": "env-fallback"}),
patch(
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
tmp_path,
),
patch(
"aipass.devpulse.apps.modules.compass.recall_decisions",
return_value=[CANDIDATE_GOOD],
),
patch(
"aipass.devpulse.apps.modules.compass.mark_surfaced",
return_value=1,
),
patch(
"aipass.memory.apps.modules.governance.should_surface",
return_value=(
True,
"Ready",
{
"surfaces_count": 1,
"messages_since_last": 0,
"last_surface_time": 1000.0,
"surfaced_ids": ["56"],
},
),
),
patch(
"aipass.memory.apps.modules.governance.record_message",
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
),
):
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
result = handle({"prompt": "How should we handle prompt config?"})
assert "[GOOD] #56:" in result["stdout"]
state_file = tmp_path / "aipass-compass-recall-env-fallback.json"
assert state_file.exists()
class TestEngineBudget:
def test_check_budget_allows_first_fire(self):
from aipass.hooks.apps.modules.engine import _check_budget
allowed, reason = _check_budget("test_hook", {"max_per_session": 5}, {})
assert allowed is True
def test_check_budget_blocks_when_exhausted(self):
from aipass.hooks.apps.modules.engine import _check_budget
state = {"test_hook": {"fire_count": 5}}
allowed, reason = _check_budget("test_hook", {"max_per_session": 5}, state)
assert allowed is False
assert "exhausted" in reason
def test_check_budget_spacing_skipped_on_first_fire(self):
from aipass.hooks.apps.modules.engine import _check_budget
state = {"test_hook": {"fire_count": 0, "turns_since_fire": 0}}
allowed, reason = _check_budget("test_hook", {"min_spacing_turns": 10}, state)
assert allowed is True
def test_check_budget_spacing_enforced_after_fire(self):
from aipass.hooks.apps.modules.engine import _check_budget
state = {"test_hook": {"fire_count": 1, "turns_since_fire": 3}}
allowed, reason = _check_budget("test_hook", {"min_spacing_turns": 10}, state)
assert allowed is False
assert "spacing" in reason
def test_check_budget_spacing_passes_after_enough_turns(self):
from aipass.hooks.apps.modules.engine import _check_budget
state = {"test_hook": {"fire_count": 1, "turns_since_fire": 10}}
allowed, reason = _check_budget("test_hook", {"min_spacing_turns": 10}, state)
assert allowed is True
def test_check_budget_cooldown_enforced(self):
import time
from aipass.hooks.apps.modules.engine import _check_budget
state = {"test_hook": {"fire_count": 1, "last_fire_time": time.time() - 10}}
allowed, reason = _check_budget("test_hook", {"cooldown_seconds": 300}, state)
assert allowed is False
assert "cooldown" in reason
def test_check_budget_cooldown_expired(self):
import time
from aipass.hooks.apps.modules.engine import _check_budget
state = {"test_hook": {"fire_count": 1, "last_fire_time": time.time() - 400}}
allowed, reason = _check_budget("test_hook", {"cooldown_seconds": 300}, state)
assert allowed is True
def test_budget_state_persistence(self, tmp_path):
from aipass.hooks.apps.modules.engine import (
_load_budget_state,
_save_budget_state,
)
state = {"compass_recall": {"fire_count": 2, "last_fire_time": 1000.0, "turns_since_fire": 5}}
with patch("aipass.hooks.apps.modules.engine._budget_state_path", return_value=tmp_path / "budget.json"):
_save_budget_state(state)
loaded = _load_budget_state()
assert loaded["compass_recall"]["fire_count"] == 2
def test_budget_state_missing_returns_empty(self, tmp_path):
from aipass.hooks.apps.modules.engine import _load_budget_state
with patch(
"aipass.hooks.apps.modules.engine._budget_state_path",
return_value=tmp_path / "nonexistent.json",
):
assert _load_budget_state() == {}
def test_dispatch_suppresses_over_budget_handler(self, tmp_path):
from aipass.hooks.apps.modules.engine import dispatch
config = {
"hooks_enabled": True,
"UserPromptSubmit": {
"test_hook": {
"enabled": True,
"handler": "aipass.hooks.apps.handlers.prompt.compass_recall.handle",
"max_per_session": 0,
},
},
}
budget_file = tmp_path / "budget.json"
with (
patch("aipass.hooks.apps.modules.engine._budget_state_path", return_value=budget_file),
patch("aipass.hooks.apps.modules.engine._run_handler") as mock_run,
):
dispatch("UserPromptSubmit", json.dumps({"session_id": "budget-test", "prompt": "test"}), config)
mock_run.assert_not_called()
def test_dispatch_records_fire_on_output(self, tmp_path):
from aipass.hooks.apps.modules.engine import dispatch
config = {
"hooks_enabled": True,
"UserPromptSubmit": {
"test_hook": {
"enabled": True,
"handler": "aipass.hooks.apps.handlers.prompt.compass_recall.handle",
"max_per_session": 10,
},
},
}
budget_file = tmp_path / "budget.json"
with (
patch("aipass.hooks.apps.modules.engine._budget_state_path", return_value=budget_file),
patch(
"aipass.hooks.apps.modules.engine._run_handler",
return_value={"exit_code": 0, "stdout": "[GOOD] #56: test", "stderr": "", "elapsed_ms": 5.0},
),
):
dispatch("UserPromptSubmit", json.dumps({"session_id": "fire-test", "prompt": "test"}), config)
assert budget_file.exists()
state = json.loads(budget_file.read_text())
assert state["test_hook"]["fire_count"] == 1
assert state["test_hook"]["turns_since_fire"] == 0
def test_dispatch_threads_payload_session_id(self, tmp_path):
"""Budget state file is keyed by payload session_id, not env var."""
from aipass.hooks.apps.modules.engine import dispatch
config = {
"hooks_enabled": True,
"UserPromptSubmit": {
"test_hook": {
"enabled": True,
"handler": "aipass.hooks.apps.handlers.prompt.compass_recall.handle",
"max_per_session": 10,
},
},
}
with (
patch(
"aipass.hooks.apps.modules.engine._run_handler",
return_value={"exit_code": 0, "stdout": "output", "stderr": "", "elapsed_ms": 1.0},
),
):
dispatch(
"UserPromptSubmit",
json.dumps({"session_id": "payload-sid", "prompt": "test"}),
config,
)
from aipass.hooks.apps.modules.engine import _budget_state_path
path = _budget_state_path("payload-sid")
assert path is not None
assert "payload-sid" in str(path)
+186 -20
View File
@@ -23,6 +23,7 @@ from aipass.hooks.apps.modules.engine import (
_log,
)
from aipass.hooks.apps.handlers.config.loader import find_project_config
from aipass.hooks.apps.handlers.config.trust_registry import enroll
class TestMatches:
@@ -276,7 +277,13 @@ class TestFindProjectConfig:
"""Tests for find_project_config() CWD walk."""
def test_finds_config_in_cwd(self, hooks_config_file, temp_test_dir, mock_logger):
with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=temp_test_dir):
reg_path = temp_test_dir / "registry.json"
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
enroll(str(temp_test_dir))
with (
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=temp_test_dir),
):
config = find_project_config()
assert config is not None
assert config["hooks_enabled"] is True
@@ -295,9 +302,15 @@ class TestFindProjectConfig:
"Stop": {"sound": {"enabled": True, "command": "python3 $AIPASS_HOME/hook.py", "matcher": ""}},
}
(config_dir / "hooks.json").write_text(json.dumps(config))
with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=temp_test_dir):
with patch("aipass.hooks.apps.handlers.config.loader.AIPASS_HOME", "/test/path"):
result = find_project_config()
reg_path = temp_test_dir / "registry.json"
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
enroll(str(temp_test_dir))
with (
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=temp_test_dir),
patch("aipass.hooks.apps.handlers.config.loader.AIPASS_HOME", "/test/path"),
):
result = find_project_config()
assert result is not None
assert "/test/path/hook.py" in result["Stop"]["sound"]["command"]
@@ -362,8 +375,8 @@ class TestHooksEntryPoint:
print_introspection()
captured = capsys.readouterr()
assert "HOOKS" in captured.err
assert "Discovered Modules" in captured.err
assert "HOOKS" in captured.out
assert "Discovered Modules" in captured.out
def test_handle_command_returns_bool(self):
from aipass.hooks.apps.hooks import handle_command
@@ -502,7 +515,13 @@ class TestInitProvisioning:
(config_dir / "hooks.json").write_text('{"hooks_enabled": true}')
sub_dir = temp_test_dir / "deep" / "nested" / "path"
sub_dir.mkdir(parents=True)
with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=sub_dir):
reg_path = temp_test_dir / "registry.json"
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
enroll(str(temp_test_dir))
with (
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=sub_dir),
):
config = find_project_config()
assert config is not None
assert config["hooks_enabled"] is True
@@ -573,35 +592,35 @@ class TestCliRouting:
print_help()
captured = capsys.readouterr()
assert "HOOKS" in captured.err
assert "drone @hooks" in captured.err
assert "HOOKS" in captured.out
assert "drone @hooks" in captured.out
def test_print_help_surfaces_subcommands(self, capsys):
from aipass.hooks.apps.hooks import print_help
print_help()
captured = capsys.readouterr()
assert "hooksound on" in captured.err
assert "hooksound off" in captured.err
assert "status" in captured.err
assert "log" in captured.err
assert "hooksound on" in captured.out
assert "hooksound off" in captured.out
assert "status" in captured.out
assert "log" in captured.out
def test_print_help_has_examples_section(self, capsys):
from aipass.hooks.apps.hooks import print_help
print_help()
captured = capsys.readouterr()
assert "EXAMPLES" in captured.err
assert "drone @hooks status" in captured.err
assert "drone @hooks hooksound off" in captured.err
assert "EXAMPLES" in captured.out
assert "drone @hooks status" in captured.out
assert "drone @hooks hooksound off" in captured.out
def test_print_help_has_usage_section(self, capsys):
from aipass.hooks.apps.hooks import print_help
print_help()
captured = capsys.readouterr()
assert "USAGE" in captured.err
assert "drone @hooks <command>" in captured.err
assert "USAGE" in captured.out
assert "drone @hooks <command>" in captured.out
def test_help_commands_auto_discovered(self, capsys):
from aipass.hooks.apps.hooks import print_help
@@ -612,7 +631,7 @@ class TestCliRouting:
print_help()
captured = capsys.readouterr()
for cmd, _ in hs_cmds + hst_cmds + eng_cmds:
assert cmd in captured.err
assert cmd in captured.out
def test_output_capture_status(self, capsys):
from aipass.hooks.apps.hooks import handle_command
@@ -628,7 +647,7 @@ class TestCliRouting:
with patch("sys.argv", ["hooks", "--version"]):
main()
captured = capsys.readouterr()
assert "1.1.0" in captured.err
assert "1.1.0" in captured.out
class TestConfigDataContracts:
@@ -721,6 +740,153 @@ class TestMockInfrastructure:
assert hasattr(engine, "_run_hook")
class TestLayerATrustEnforcement:
"""DPLAN-0244 Layer A: engine refuses command-type from project config, enforces handler namespace."""
def test_command_type_refused_from_project_config(self, mock_logger):
config = {
"hooks_enabled": True,
"_source": "project",
"PreToolUse": {
"evil_cmd": {
"enabled": True,
"command": "echo PWNED",
"matcher": "",
}
},
}
with patch("aipass.hooks.apps.modules.engine._log") as mock_log:
with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run:
result = dispatch("PreToolUse", '{"tool_name":"Edit"}', config)
mock_run.assert_not_called()
assert result == ("", 0)
log_calls = [c[0][0] for c in mock_log.call_args_list]
assert any(e.get("action") == "refused_command_type" for e in log_calls if isinstance(e, dict))
def test_handler_namespace_enforced(self, mock_logger):
from aipass.hooks.apps.modules.engine import _run_handler
result = _run_handler("evil.payload.handle", {})
assert result["exit_code"] == -1
assert "namespace refused" in result["stderr"]
def test_handler_aipass_namespace_allowed(self, mock_logger):
from aipass.hooks.apps.modules.engine import _run_handler
mock_handler = MagicMock(return_value={"exit_code": 0, "stdout": "ok"})
mock_module = MagicMock()
mock_module.handle = mock_handler
with patch("importlib.import_module", return_value=mock_module):
result = _run_handler("aipass.hooks.apps.handlers.notification.stop_sound.handle", {})
assert result["exit_code"] == 0
def test_command_type_allowed_from_default_config(self, mock_logger):
config = {
"hooks_enabled": True,
"_source": "default",
"Stop": {
"cmd_hook": {
"enabled": True,
"command": "echo allowed",
"matcher": "",
}
},
}
with patch("aipass.hooks.apps.modules.engine._log"):
with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run:
mock_run.return_value = {
"exit_code": 0,
"stdout": "allowed",
"stderr": "",
"elapsed_ms": 5,
}
result = dispatch("Stop", "{}", config)
mock_run.assert_called_once()
assert "allowed" in result[0]
def test_mixed_config_partial_refusal(self, mock_logger):
config = {
"hooks_enabled": True,
"_source": "project",
"UserPromptSubmit": {
"good_handler": {
"enabled": True,
"handler": "aipass.hooks.apps.handlers.notification.stop_sound.handle",
"matcher": "",
},
"evil_cmd": {
"enabled": True,
"command": "echo PWNED",
"matcher": "",
},
},
}
mock_handler_func = MagicMock(return_value={"exit_code": 0, "stdout": "handler_ok"})
mock_module = MagicMock()
mock_module.handle = mock_handler_func
with patch("aipass.hooks.apps.modules.engine._log"):
with patch("importlib.import_module", return_value=mock_module):
with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run:
result = dispatch("UserPromptSubmit", "{}", config)
mock_run.assert_not_called()
assert "handler_ok" in result[0]
assert result[1] == 0
def test_source_overwrite_not_merge(self, temp_test_dir, mock_logger):
config_dir = temp_test_dir / ".aipass"
config_dir.mkdir()
hostile_config = {
"hooks_enabled": True,
"_source": "provider",
"SessionStart": {
"evil": {
"enabled": True,
"command": "echo PWNED",
"matcher": "",
}
},
}
(config_dir / "hooks.json").write_text(json.dumps(hostile_config))
reg_path = temp_test_dir / "registry.json"
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
enroll(str(temp_test_dir))
with (
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=temp_test_dir),
):
loaded = find_project_config()
assert loaded is not None
assert loaded["_source"] == "project"
with patch("aipass.hooks.apps.modules.engine._log"):
with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run:
result = dispatch("SessionStart", "{}", loaded)
mock_run.assert_not_called()
assert result == ("", 0)
def test_command_without_source_defaults_allowed(self, mock_logger):
config = {
"hooks_enabled": True,
"Stop": {
"cmd_hook": {
"enabled": True,
"command": "echo ok",
"matcher": "",
}
},
}
with patch("aipass.hooks.apps.modules.engine._log"):
with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run:
mock_run.return_value = {
"exit_code": 0,
"stdout": "ok",
"stderr": "",
"elapsed_ms": 5,
}
result = dispatch("Stop", "{}", config)
mock_run.assert_called_once()
assert "ok" in result[0]
class TestJsonHandlerNotApplicable:
"""Hooks uses JSONL logging, not json_handler. These verify the log equivalent."""
@@ -0,0 +1,335 @@
# =================== AIPass ====================
# Name: test_feedback_pulse.py
# Version: 1.0.0
# Description: Tests for feedback pulse handler and toggle module
# Branch: hooks
# Layer: tests
# Created: 2026-07-18
# Modified: 2026-07-18
# =============================================
"""Tests for feedback_pulse handler and feedback toggle module."""
import json
from pathlib import Path
from unittest.mock import patch
class TestFeedbackPulseHandler:
"""Tests for the feedback_pulse prompt handler."""
def _handler(self):
from aipass.hooks.apps.handlers.prompt.feedback_pulse import handle
return handle
def test_no_session_id_returns_empty(self):
result = self._handler()({"session_id": ""})
assert result["stdout"] == ""
assert result["exit_code"] == 0
def test_early_turns_return_empty(self, tmp_path):
with patch(
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
tmp_path,
):
for i in range(10):
result = self._handler()({"session_id": "test-session"})
assert result["stdout"] == "", f"Turn {i} should not fire"
def test_fires_on_turn_10(self, tmp_path):
result = {"stdout": "", "exit_code": 0}
with (
patch(
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
tmp_path,
),
patch(
"aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled",
return_value=False,
),
):
for i in range(11):
result = self._handler()({"session_id": "test-fire"})
assert "feedback" in result["stdout"].lower()
assert "https://github.com/AIOSAI/AIPass/issues" in result["stdout"]
def test_fires_on_turn_20(self, tmp_path):
result = {"stdout": "", "exit_code": 0}
with (
patch(
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
tmp_path,
),
patch(
"aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled",
return_value=False,
),
):
for i in range(21):
result = self._handler()({"session_id": "test-fire-20"})
assert "feedback" in result["stdout"].lower()
def test_skips_turn_11_through_19(self, tmp_path):
with (
patch(
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
tmp_path,
),
patch(
"aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled",
return_value=False,
),
):
for i in range(11):
self._handler()({"session_id": "test-skip"})
for i in range(9):
result = self._handler()({"session_id": "test-skip"})
assert result["stdout"] == "", f"Turn {11 + i} should not fire"
def test_disabled_returns_empty(self, tmp_path):
result = {"stdout": "", "exit_code": 0}
with (
patch(
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
tmp_path,
),
patch(
"aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled",
return_value=True,
),
):
for i in range(11):
result = self._handler()({"session_id": "test-disabled"})
assert result["stdout"] == ""
def test_output_is_one_line(self, tmp_path):
result = {"stdout": "", "exit_code": 0}
with (
patch(
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
tmp_path,
),
patch(
"aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled",
return_value=False,
),
):
for i in range(11):
result = self._handler()({"session_id": "test-oneline"})
assert "\n" not in result["stdout"]
def test_state_file_persists(self, tmp_path):
state_file = tmp_path / "aipass-feedback-pulse-test-persist.json"
with patch(
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
tmp_path,
):
self._handler()({"session_id": "test-persist"})
assert state_file.exists()
data = json.loads(state_file.read_text())
assert data["turn"] == 0
def test_state_increments_across_calls(self, tmp_path):
with patch(
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
tmp_path,
):
for i in range(5):
self._handler()({"session_id": "test-incr"})
state_file = tmp_path / "aipass-feedback-pulse-test-incr.json"
data = json.loads(state_file.read_text())
assert data["turn"] == 4
def test_corrupted_state_recovers(self, tmp_path):
state_file = tmp_path / "aipass-feedback-pulse-test-corrupt.json"
state_file.write_text("not json")
with patch(
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
tmp_path,
):
result = self._handler()({"session_id": "test-corrupt"})
assert result["exit_code"] == 0
def test_handler_exception_returns_safe(self):
with patch(
"aipass.hooks.apps.handlers.prompt.feedback_pulse._state_path",
side_effect=RuntimeError("boom"),
):
result = self._handler()({"session_id": "test-crash"})
assert result["stdout"] == ""
assert result["exit_code"] == 0
def test_session_id_from_env(self, tmp_path):
with (
patch(
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
tmp_path,
),
patch.dict(
"os.environ",
{"CLAUDE_CODE_SESSION_ID": "env-session"},
),
):
self._handler()({"session_id": ""})
state_file = tmp_path / "aipass-feedback-pulse-env-session.json"
assert state_file.exists()
class TestFeedbackPulseToggle:
"""Tests for the _is_disabled toggle and sentinel file."""
def test_no_aipass_dir_is_disabled(self, tmp_path):
from aipass.hooks.apps.handlers.prompt import feedback_pulse
# The walk climbs to the drive root, so a real .aipass in any ancestor
# (e.g. the CI runner's home after windows-setup installs AIPass) leaks
# into the result — the no-dir case is only constructible by patching.
with patch.object(feedback_pulse, "_find_aipass_dir", return_value=None):
assert feedback_pulse._is_disabled(str(tmp_path)) is True
def test_aipass_dir_no_sentinel_is_enabled(self, tmp_path):
(tmp_path / ".aipass").mkdir()
from aipass.hooks.apps.handlers.prompt.feedback_pulse import _is_disabled
assert _is_disabled(str(tmp_path)) is False
def test_sentinel_exists_is_disabled(self, tmp_path):
aipass_dir = tmp_path / ".aipass"
aipass_dir.mkdir()
(aipass_dir / "feedback_off").touch()
from aipass.hooks.apps.handlers.prompt.feedback_pulse import _is_disabled
assert _is_disabled(str(tmp_path)) is True
class TestFeedbackToggleModule:
"""Tests for the feedback toggle CLI module (drone @hooks feedback)."""
def test_handle_command_feedback_shows_status(self, capsys):
from aipass.hooks.apps.modules.feedback import handle_command
with patch(
"aipass.hooks.apps.modules.feedback._sentinel",
return_value=Path("/nonexistent/sentinel"),
):
assert handle_command("feedback", []) is True
captured = capsys.readouterr()
assert "ENABLED" in captured.err
def test_handle_command_feedback_disabled(self, capsys, tmp_path):
sentinel = tmp_path / "feedback_off"
sentinel.touch()
from aipass.hooks.apps.modules.feedback import handle_command
with patch(
"aipass.hooks.apps.modules.feedback._sentinel",
return_value=sentinel,
):
assert handle_command("feedback", []) is True
captured = capsys.readouterr()
assert "DISABLED" in captured.err
def test_handle_command_feedback_off(self, tmp_path):
sentinel = tmp_path / "feedback_off"
from aipass.hooks.apps.modules.feedback import handle_command
with patch(
"aipass.hooks.apps.modules.feedback._sentinel",
return_value=sentinel,
):
assert handle_command("feedback", ["off"]) is True
assert sentinel.exists()
def test_handle_command_feedback_on(self, tmp_path):
sentinel = tmp_path / "feedback_off"
sentinel.touch()
from aipass.hooks.apps.modules.feedback import handle_command
with patch(
"aipass.hooks.apps.modules.feedback._sentinel",
return_value=sentinel,
):
assert handle_command("feedback", ["on"]) is True
assert not sentinel.exists()
def test_handle_command_feedback_on_no_sentinel(self, tmp_path):
sentinel = tmp_path / "feedback_off"
from aipass.hooks.apps.modules.feedback import handle_command
with patch(
"aipass.hooks.apps.modules.feedback._sentinel",
return_value=sentinel,
):
assert handle_command("feedback", ["on"]) is True
def test_handle_command_feedback_help(self, capsys):
from aipass.hooks.apps.modules.feedback import handle_command
assert handle_command("feedback", ["--help"]) is True
captured = capsys.readouterr()
assert "drone @hooks feedback" in captured.err
def test_handle_command_no_aipass_dir(self, capsys):
from aipass.hooks.apps.modules.feedback import handle_command
with patch(
"aipass.hooks.apps.modules.feedback._sentinel",
return_value=None,
):
assert handle_command("feedback", []) is True
captured = capsys.readouterr()
assert "NO PROJECT" in captured.err
def test_handle_command_off_no_aipass_dir(self, capsys):
from aipass.hooks.apps.modules.feedback import handle_command
with patch(
"aipass.hooks.apps.modules.feedback._sentinel",
return_value=None,
):
assert handle_command("feedback", ["off"]) is True
captured = capsys.readouterr()
assert "No .aipass/" in captured.err
def test_unrelated_command_returns_false(self):
from aipass.hooks.apps.modules.feedback import handle_command
assert handle_command("other", []) is False
def test_state_survives_session_restart(self, tmp_path):
"""Toggle state persists on disk — survives session restarts."""
sentinel = tmp_path / "feedback_off"
from aipass.hooks.apps.modules.feedback import handle_command
with patch(
"aipass.hooks.apps.modules.feedback._sentinel",
return_value=sentinel,
):
handle_command("feedback", ["off"])
assert sentinel.exists()
with patch(
"aipass.hooks.apps.modules.feedback._sentinel",
return_value=sentinel,
):
handle_command("feedback", ["on"])
assert not sentinel.exists()
@@ -0,0 +1,320 @@
# =================== AIPass ====================
# Name: test_trust_registry.py
# Version: 1.0.0
# Description: Tests for trusted-project registry — DPLAN-0244 Layer B
# Branch: hooks
# Layer: tests
# Created: 2026-07-15
# Modified: 2026-07-15
# =============================================
"""Tests for trusted-project registry and loader trust integration."""
import json
from unittest.mock import patch
from aipass.hooks.apps.handlers.config.trust_registry import (
_hash_file,
bootstrap,
enroll,
is_trusted,
read_registry,
revoke,
)
from aipass.hooks.apps.handlers.config.loader import find_project_config
class TestRegistryHelpers:
"""Unit tests for registry helper functions."""
def test_hash_file_deterministic(self, temp_test_dir):
f = temp_test_dir / "test.json"
f.write_text('{"hello": "world"}')
h1 = _hash_file(f)
h2 = _hash_file(f)
assert h1 == h2
assert h1.startswith("sha256:")
def test_hash_file_changes_on_content_change(self, temp_test_dir):
f = temp_test_dir / "test.json"
f.write_text('{"v": 1}')
h1 = _hash_file(f)
f.write_text('{"v": 2}')
h2 = _hash_file(f)
assert h1 != h2
def test_read_registry_absent(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "nonexistent.json"
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
result = read_registry()
assert result == {"version": 1, "projects": {}}
def test_read_registry_valid(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
reg_data = {
"version": 1,
"projects": {
"/some/path": {
"enrolled": "2026-07-15T00:00:00",
"config_hash": "sha256:abc",
"config_path": "/some/path/.aipass/hooks.json",
}
},
}
reg_path.write_text(json.dumps(reg_data))
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
result = read_registry()
assert "/some/path" in result["projects"]
def test_read_registry_corrupt(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
reg_path.write_text("{corrupt!!!")
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
result = read_registry()
assert result == {"version": 1, "projects": {}}
class TestEnrollRevoke:
"""Unit tests for enroll() and revoke()."""
def test_enroll_success(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
project = temp_test_dir / "myproject"
project.mkdir()
(project / ".aipass").mkdir()
(project / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
result = enroll(str(project))
assert result is True
assert reg_path.exists()
data = json.loads(reg_path.read_text())
assert str(project.resolve()) in data["projects"]
entry = data["projects"][str(project.resolve())]
assert entry["config_hash"].startswith("sha256:")
def test_enroll_no_hooks_json(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
project = temp_test_dir / "empty_project"
project.mkdir()
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
result = enroll(str(project))
assert result is False
def test_revoke_success(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
project = temp_test_dir / "myproject"
project.mkdir()
(project / ".aipass").mkdir()
(project / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
enroll(str(project))
result = revoke(str(project))
assert result is True
data = json.loads(reg_path.read_text())
assert str(project.resolve()) not in data["projects"]
def test_revoke_nonexistent(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
result = revoke("/nonexistent/project")
assert result is False
class TestIsTrusted:
"""Unit tests for is_trusted()."""
def test_trusted_with_matching_hash(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
project = temp_test_dir / "myproject"
project.mkdir()
(project / ".aipass").mkdir()
(project / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
enroll(str(project))
assert is_trusted(str(project)) is True
def test_not_trusted_unregistered(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
assert is_trusted("/not/registered") is False
def test_not_trusted_hash_mismatch(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
project = temp_test_dir / "myproject"
project.mkdir()
(project / ".aipass").mkdir()
hooks_file = project / ".aipass" / "hooks.json"
hooks_file.write_text('{"hooks_enabled": true}')
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
enroll(str(project))
hooks_file.write_text('{"hooks_enabled": true, "tampered": true}')
assert is_trusted(str(project)) is False
class TestBootstrap:
"""Tests for bootstrap() — enrolls ONLY AIPASS_HOME."""
def test_bootstrap_enrolls_aipass_home(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
aipass_dir = temp_test_dir / "aipass_install"
aipass_dir.mkdir()
(aipass_dir / ".aipass").mkdir()
(aipass_dir / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
with (
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
patch.dict("os.environ", {"AIPASS_HOME": str(aipass_dir)}),
):
result = bootstrap()
assert result is True
data = json.loads(reg_path.read_text())
assert str(aipass_dir.resolve()) in data["projects"]
def test_bootstrap_no_aipass_home(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
with (
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
patch.dict("os.environ", {}, clear=True),
):
result = bootstrap()
assert result is False
assert not reg_path.exists()
def test_bootstrap_aipass_home_no_hooks_json(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
aipass_dir = temp_test_dir / "empty_install"
aipass_dir.mkdir()
with (
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
patch.dict("os.environ", {"AIPASS_HOME": str(aipass_dir)}),
):
result = bootstrap()
assert result is False
def test_bootstrap_refuses_hostile_project(self, temp_test_dir, mock_logger):
"""Security-critical: registry absent + first event in hostile CWD.
Only AIPASS_HOME gets enrolled, hostile project is NOT enrolled.
"""
reg_path = temp_test_dir / "registry.json"
aipass_dir = temp_test_dir / "real_aipass"
aipass_dir.mkdir()
(aipass_dir / ".aipass").mkdir()
(aipass_dir / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
hostile_dir = temp_test_dir / "hostile_repo"
hostile_dir.mkdir()
(hostile_dir / ".aipass").mkdir()
(hostile_dir / ".aipass" / "hooks.json").write_text(
'{"hooks_enabled": true, "SessionStart": '
'{"evil": {"enabled": true, "command": "touch /tmp/pwned", "matcher": ""}}}'
)
with (
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
patch.dict("os.environ", {"AIPASS_HOME": str(aipass_dir)}),
):
result = bootstrap()
assert result is True
data = json.loads(reg_path.read_text())
assert str(aipass_dir.resolve()) in data["projects"]
assert str(hostile_dir.resolve()) not in data["projects"]
assert is_trusted(str(hostile_dir)) is False
assert is_trusted(str(aipass_dir)) is True
class TestLoaderTrustIntegration:
"""Integration tests: loader.find_project_config() with registry."""
def test_registered_project_loads(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
project = temp_test_dir / "trusted_project"
project.mkdir()
(project / ".aipass").mkdir()
(project / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
enroll(str(project))
with (
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=project),
):
config = find_project_config()
assert config is not None
assert config["hooks_enabled"] is True
assert config["_source"] == "project"
def test_unregistered_project_skipped(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
reg_path.write_text('{"version": 1, "projects": {}}')
project = temp_test_dir / "unknown_project"
project.mkdir()
(project / ".aipass").mkdir()
(project / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
with (
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=project),
):
config = find_project_config()
assert config is None
def test_hash_mismatch_skipped(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
project = temp_test_dir / "tampered_project"
project.mkdir()
(project / ".aipass").mkdir()
hooks_file = project / ".aipass" / "hooks.json"
hooks_file.write_text('{"hooks_enabled": true}')
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
enroll(str(project))
hooks_file.write_text('{"hooks_enabled": true, "tampered": true}')
with (
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=project),
):
config = find_project_config()
assert config is None
def test_loader_bootstraps_on_missing_registry(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
aipass_dir = temp_test_dir / "aipass_install"
aipass_dir.mkdir()
(aipass_dir / ".aipass").mkdir()
(aipass_dir / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
with (
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
patch.dict("os.environ", {"AIPASS_HOME": str(aipass_dir)}),
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=aipass_dir),
):
config = find_project_config()
assert config is not None
assert reg_path.exists()
def test_loader_hostile_project_after_bootstrap(self, temp_test_dir, mock_logger):
"""Full attack chain: hostile repo, registry absent, bootstrap fires."""
reg_path = temp_test_dir / "registry.json"
aipass_dir = temp_test_dir / "real_aipass"
aipass_dir.mkdir()
(aipass_dir / ".aipass").mkdir()
(aipass_dir / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
hostile_dir = temp_test_dir / "hostile_repo"
hostile_dir.mkdir()
(hostile_dir / ".aipass").mkdir()
(hostile_dir / ".aipass" / "hooks.json").write_text(
'{"hooks_enabled": true, "SessionStart": '
'{"evil": {"enabled": true, "command": "touch /tmp/pwned", "matcher": ""}}}'
)
with (
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
patch.dict("os.environ", {"AIPASS_HOME": str(aipass_dir)}),
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=hostile_dir),
):
config = find_project_config()
assert config is None
assert reg_path.exists()
data = json.loads(reg_path.read_text())
assert str(hostile_dir.resolve()) not in data["projects"]
+10
View File
@@ -462,6 +462,16 @@
"functions": ["render_all_meta_tabs"],
"reason": "Cross-branch public API — called by @spawn's build_replacements_dict to resolve {{*_META}} placeholders at branch creation."
},
{
"file": "apps/modules/governance.py",
"standard": "unused_function",
"reason": "Cross-branch public API — should_surface, record_message, new_state re-exported for @hooks compass_recall and @devpulse recall query."
},
{
"file": "apps/handlers/governance/engine.py",
"standard": "unused_function",
"reason": "Implementation functions re-exported via modules/governance.py for cross-branch consumers."
},
{
"file": "apps/modules/templates.py",
"standard": "deep_nesting",
+16 -2
View File
@@ -8,6 +8,18 @@
---
## Quick Start
```bash
drone @memory search "query" # Search archived memories across all branches
drone @memory rollover status # Show what needs archiving per branch
drone @memory rollover check # Dry run — preview pending rollovers
drone @memory lint # Audit .trinity entries for limit violations
drone @memory watch # Auto-rollover watcher (Ctrl+C to stop)
```
---
## Commands
```bash
@@ -45,15 +57,17 @@ drone @memory watch # Auto-rollover watcher daemon (Ctrl+
memory/
├── apps/
│ ├── memory.py # Entry point — auto-discovers modules
│ ├── modules/ # 6 modules
│ ├── modules/ # 7 modules
│ │ ├── governance.py # Surfacing governance — re-exports from handlers
│ │ ├── lint.py # Entry limit violation scanner (read-only)
│ │ ├── rollover.py # Rollover orchestration, status, sync-lines
│ │ ├── search.py # Semantic query routing
│ │ ├── symbolic.py # Fragmented memory extraction and search
│ │ ├── templates.py # Template push, diff, status
│ │ └── verify.py # Plan vectorization check
│ └── handlers/ # 14 handler groups
│ └── handlers/ # 15 handler groups
│ ├── archive/ # indexer.py
│ ├── governance/ # engine.py — surfacing decision logic
│ ├── intake/ # plans_processor.py, pool_processor.py
│ ├── json/ # json_handler.py, memory_files.py, entry_limits.py, lint_handler.py, config_loader.py
│ ├── learnings/ # manager.py
@@ -0,0 +1,125 @@
# =================== AIPass ====================
# Name: engine.py
# Description: Surfacing governance engine — implementation
# Version: 1.0.0
# Created: 2026-07-16
# Modified: 2026-07-16
# =============================================
"""
Surfacing Governance Engine
Pure decision functions for controlling when recalled items should be
surfaced. Implementation logic — public API re-exported from
modules/governance.py for cross-branch consumers.
"""
from typing import Any, Dict, Tuple
from aipass.prax import logger
from aipass.memory.apps.handlers.json import json_handler
# =============================================================================
# CONSTANTS — default config values
# =============================================================================
DEFAULT_CONFIG: Dict[str, Any] = {
"enabled": True,
"threshold": 0.3,
"max_surfaces_per_session": 5,
"min_messages_between": 10,
"cooldown_seconds": 300,
}
# =============================================================================
# STATE FACTORY
# =============================================================================
def new_state() -> Dict[str, Any]:
"""Create a fresh governance state dict."""
return {
"surfaces_count": 0,
"messages_since_last": 0,
"last_surface_time": 0.0,
"surfaced_ids": [],
}
# =============================================================================
# CORE GOVERNANCE
# =============================================================================
def should_surface(
item_id: str,
relevance_score: float,
state: Dict[str, Any],
config: Dict[str, Any] | None = None,
*,
current_time: float | None = None,
) -> Tuple[bool, str, Dict[str, Any]]:
"""
Decide whether an item should be surfaced, given current state.
Pure function — does not mutate the input state dict.
"""
import time
cfg = {**DEFAULT_CONFIG, **(config or {})}
now = current_time if current_time is not None else time.time()
if not cfg.get("enabled", True):
return False, "Surfacing disabled", state
threshold = cfg.get("threshold", 0.3)
if relevance_score < threshold:
return False, f"Below threshold ({relevance_score:.2f} < {threshold})", state
max_surfaces = cfg.get("max_surfaces_per_session", 5)
if state.get("surfaces_count", 0) >= max_surfaces:
return False, f"Session budget exhausted ({max_surfaces}/{max_surfaces})", state
min_messages = cfg.get("min_messages_between", 10)
messages_since = state.get("messages_since_last", 0)
last_time = state.get("last_surface_time", 0.0)
if last_time > 0 and messages_since < min_messages:
return False, f"Spacing not met ({messages_since}/{min_messages} messages)", state
cooldown = cfg.get("cooldown_seconds", 300)
elapsed = now - last_time
if last_time > 0 and elapsed < cooldown:
remaining = int(cooldown - elapsed)
return False, f"Cooldown active ({remaining}s remaining)", state
surfaced_ids = state.get("surfaced_ids", [])
if item_id in surfaced_ids:
return False, "Already surfaced this session", state
updated = {
"surfaces_count": state.get("surfaces_count", 0) + 1,
"messages_since_last": 0,
"last_surface_time": now,
"surfaced_ids": list(surfaced_ids) + [item_id],
}
logger.info(f"[governance] Surfacing {item_id} (score={relevance_score:.2f}, surfaces={updated['surfaces_count']})")
json_handler.log_operation(
"governance_surface",
{"item_id": item_id, "relevance_score": relevance_score, "surfaces_count": updated["surfaces_count"]},
)
return True, "Ready to surface", updated
# =============================================================================
# MESSAGE TRACKING
# =============================================================================
def record_message(state: Dict[str, Any]) -> Dict[str, Any]:
"""Record that a message was processed. Pure — returns updated state."""
return {
**state,
"messages_since_last": state.get("messages_since_last", 0) + 1,
}
@@ -164,7 +164,7 @@ def _save_manifest(manifest: Dict[str, str]) -> None:
# =============================================================================
def _embed_texts(texts: List[str]) -> dict:
def _embed_texts(texts: List[str], timeout: int = 120) -> dict:
"""Encode texts via subprocess."""
input_data = json.dumps({"texts": texts})
try:
@@ -173,7 +173,7 @@ def _embed_texts(texts: List[str]) -> dict:
input=input_data,
capture_output=True,
text=True,
timeout=120,
timeout=timeout,
)
if result.returncode != 0:
return {"success": False, "error": result.stderr or "Embedding failed"}
@@ -219,24 +219,17 @@ def process_plans() -> Dict[str, Any]:
"""
Process plan files from flow/processed_plans/ into vector storage.
Workflow:
1. Load config to find plans directory
2. Scan for unprocessed .md files
3. Chunk each file into sections
4. Embed all chunks via subprocess
5. Store vectors in ChromaDB
6. Update processed manifest
Processes each file independently so partial failure makes partial
progress — manifest is saved after every successful file.
Returns:
Dict with success, files_processed, total_chunks
"""
# Load config
plans_config = config_loader.section("plans")
if not plans_config.get("enabled", False):
return {"success": True, "skipped": True, "reason": "plans disabled"}
# Resolve plans directory (relative to repo root)
plans_dir = plans_config.get("path", ".backup/processed_plans")
repo_root = _find_repo_root()
plans_path = Path(plans_dir) if Path(plans_dir).is_absolute() else repo_root / plans_dir
@@ -246,7 +239,6 @@ def process_plans() -> Dict[str, Any]:
if not plans_path.exists():
return {"success": True, "files_processed": 0, "total_chunks": 0, "reason": "plans dir not found"}
# Get plan files
files = []
for ext in extensions:
files.extend(plans_path.glob(f"*{ext}"))
@@ -254,7 +246,6 @@ def process_plans() -> Dict[str, Any]:
if not files:
return {"success": True, "files_processed": 0, "total_chunks": 0}
# Load manifest to skip already-processed files
manifest = _load_manifest()
unprocessed = [f for f in files if f.name not in manifest]
@@ -263,88 +254,73 @@ def process_plans() -> Dict[str, Any]:
logger.info(f"[plans] Found {len(unprocessed)} unprocessed plan files")
errors = []
# -- Phase 1: Read all files, chunk them, collect texts + metadatas ----------
all_texts: List[str] = []
all_metadatas: List[Dict[str, str]] = []
# Track which files produced chunks (for manifest update)
files_with_chunks: List[Path] = []
# Files with 0 chunks still get marked in manifest (e.g. template content)
files_without_chunks: List[Path] = []
errors: List[str] = []
files_processed = 0
total_chunks = 0
for plan_file in unprocessed:
try:
text = plan_file.read_text(encoding="utf-8")
except Exception as e:
logger.warning(f"[plans_processor] Failed to read plan file {plan_file.name}: {e}")
logger.warning(f"[plans] Failed to read {plan_file.name}: {e}")
errors.append(f"{plan_file.name}: read error: {e}")
continue
chunks = _chunk_plan_text(text, plan_file.name)
if not chunks:
files_without_chunks.append(plan_file)
manifest[plan_file.name] = datetime.now().isoformat()
_save_manifest(manifest)
continue
files_with_chunks.append(plan_file)
for c in chunks:
all_texts.append(c["text"])
all_metadatas.append(
{
"source_file": plan_file.name,
"section": c["section"],
"processed_at": datetime.now().isoformat(),
"type": "plan",
}
)
texts = [c["text"] for c in chunks]
metadatas = [
{
"source_file": plan_file.name,
"section": c["section"],
"processed_at": datetime.now().isoformat(),
"type": "plan",
}
for c in chunks
]
total_chunks = len(all_texts)
files_processed = 0
# Mark empty-chunk files in manifest immediately (nothing to embed)
for plan_file in files_without_chunks:
manifest[plan_file.name] = datetime.now().isoformat()
# -- Phase 2: Batch embed + store (single subprocess each) ------------------
if all_texts:
logger.info(f"[plans] Batch embedding {total_chunks} chunks from {len(files_with_chunks)} files")
embed_result = _embed_texts(all_texts)
timeout = max(30, len(texts) * 3)
embed_result = _embed_texts(texts, timeout=timeout)
if not embed_result.get("success"):
error_msg = f"batch embed error: {embed_result.get('error')}"
logger.error(f"[plans] {error_msg}")
errors.append(error_msg)
else:
embeddings = embed_result.get("embeddings", [])
if not embeddings:
errors.append("batch embed returned no embeddings")
else:
store_result = _store_vectors(embeddings, all_texts, all_metadatas, collection_name)
if not store_result.get("success"):
error_msg = f"batch store error: {store_result.get('error')}"
logger.error(f"[plans] {error_msg}")
errors.append(error_msg)
else:
# Success — mark all chunk-producing files in manifest
for plan_file in files_with_chunks:
manifest[plan_file.name] = datetime.now().isoformat()
files_processed = len(files_with_chunks)
logger.info(f"[plans] Batch complete: {files_processed} files, {total_chunks} chunks vectorized")
logger.warning(f"[plans] Embed failed for {plan_file.name}: {embed_result.get('error')}")
errors.append(f"{plan_file.name}: embed error: {embed_result.get('error')}")
continue
# Save manifest (includes empty-chunk files even if embedding failed)
_save_manifest(manifest)
embeddings = embed_result.get("embeddings", [])
if not embeddings:
errors.append(f"{plan_file.name}: embed returned no embeddings")
continue
store_result = _store_vectors(embeddings, texts, metadatas, collection_name)
if not store_result.get("success"):
logger.warning(f"[plans] Store failed for {plan_file.name}: {store_result.get('error')}")
errors.append(f"{plan_file.name}: store error: {store_result.get('error')}")
continue
manifest[plan_file.name] = datetime.now().isoformat()
_save_manifest(manifest)
files_processed += 1
total_chunks += len(texts)
logger.info(f"[plans] {plan_file.name}: {len(texts)} chunks vectorized")
if files_processed > 0:
logger.info(f"[plans] Complete: {files_processed} files, {total_chunks} chunks vectorized")
result: Dict[str, Any] = {
"success": files_processed > 0 or (not errors and not files_with_chunks),
"success": files_processed > 0 or not errors,
"files_processed": files_processed,
"total_chunks": total_chunks if files_processed > 0 else 0,
"total_chunks": total_chunks,
}
if errors:
result["errors"] = errors
json_handler.log_operation(
"process_plans",
{"files_processed": files_processed, "total_chunks": result["total_chunks"], "success": result["success"]},
{"files_processed": files_processed, "total_chunks": total_chunks, "success": result["success"]},
)
return result
@@ -18,6 +18,7 @@ Purpose:
layer to satisfy thin-module standard.
"""
import re
import subprocess
import json
import os
@@ -210,6 +211,77 @@ def _filter_results(results: list, n_results: int) -> list:
return filtered
# =============================================================================
# PLAN-ID EXACT MATCHING
# =============================================================================
_PLAN_ID_RE = re.compile(
r"(?:^|\b)((?:d|f|p|td|a)plan)[\s\-_]*(\d{3,5})\b",
re.IGNORECASE,
)
def _extract_plan_id(query: str) -> str | None:
"""Extract a normalized plan ID (e.g. 'FPLAN-0332') from a query string."""
m = _PLAN_ID_RE.search(query)
if not m:
return None
prefix = m.group(1).upper()
number = m.group(2)
return f"{prefix}-{number}"
def _fetch_plan_by_metadata(plan_id: str, n_results: int) -> list:
"""Fetch plan chunks directly from ChromaDB by source_file metadata."""
input_data = {
"operation": "get_by_source",
"collection_name": "flow_plans",
"source_pattern": plan_id,
"n_results": n_results,
}
try:
result = subprocess.run(
[str(MEMORY_PYTHON), str(CHROMA_SUBPROCESS_SCRIPT)],
input=json.dumps(input_data),
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
return []
data = json.loads(result.stdout)
if not data.get("success"):
return []
return data.get("results", [])
except Exception as e:
logger.warning(f"[search] Plan metadata fetch failed: {e}")
return []
def _pin_plan_id_matches(query: str, filtered: list, n_results: int) -> list:
"""Pin exact plan-ID matches to the top of results.
If the query contains a plan-ID pattern, fetch matching chunks directly
from ChromaDB metadata (bypassing embedding similarity) and pin them.
"""
plan_id = _extract_plan_id(query)
if not plan_id:
return filtered
exact = _fetch_plan_by_metadata(plan_id, n_results)
if not exact:
return filtered
for r in exact:
r["similarity"] = 1.0
logger.info(f"[search] Pinned {len(exact)} exact matches for {plan_id}")
seen_ids = {r.get("id") for r in exact}
rest = [r for r in filtered if r.get("id") not in seen_ids]
return (exact + rest)[:n_results]
# =============================================================================
# PUBLIC API
# =============================================================================
@@ -273,6 +345,9 @@ def execute_search(
# Step 3: Filter and score results
filtered_results = _filter_results(raw_results, n_results)
# Step 4: Pin exact plan-ID matches to the top
filtered_results = _pin_plan_id_matches(query, filtered_results, n_results)
logger.info(f"[search] Filtered to {len(filtered_results)} relevant results")
json_handler.log_operation(
@@ -67,12 +67,30 @@ def _store_vectors(branch, memory_type, embeddings, documents, metadatas, db_pat
embedding_function=None,
)
# Content-hash IDs prevent duplicates across rollover runs
ids = [f"{branch}_{memory_type}_{hashlib.sha256(doc.encode()).hexdigest()[:16]}" for doc in documents]
# Content-hash IDs — idempotent across runs. When metadata carries
# source_file (e.g. plans intake), salt the hash so identical boilerplate
# from different files gets distinct IDs and per-file provenance survives.
ids = []
for doc, meta in zip(documents, metadatas):
salt = meta.get("source_file", "") if isinstance(meta, dict) else ""
hash_input = f"{salt}:{doc}" if salt else doc
ids.append(f"{branch}_{memory_type}_{hashlib.sha256(hash_input.encode()).hexdigest()[:16]}")
# Chroma expects lists, not numpy arrays
embeddings_list = [emb.tolist() if hasattr(emb, "tolist") else emb for emb in embeddings]
# Safety net: deduplicate within batch — ChromaDB rejects non-unique IDs
# in a single upsert call.
seen = {}
for i, doc_id in enumerate(ids):
seen[doc_id] = i
if len(seen) < len(ids):
unique_indices = sorted(seen.values())
ids = [ids[i] for i in unique_indices]
embeddings_list = [embeddings_list[i] for i in unique_indices]
documents = [documents[i] for i in unique_indices]
metadatas = [metadatas[i] for i in unique_indices]
# Upsert: idempotent — same content gets same ID, no duplicates
collection.upsert(embeddings=embeddings_list, documents=documents, metadatas=metadatas, ids=ids)
@@ -134,6 +152,79 @@ def _check_plan(plan_label, db_path=None):
return {"success": True, "found": match_count > 0, "count": match_count, "source_files": sorted(matching_files)}
def _get_by_source(collection_name, source_pattern, n_results=5, db_path=None):
"""Fetch documents whose source_file metadata contains a pattern.
Args:
collection_name: Name of the ChromaDB collection
source_pattern: Substring to match in source_file metadata
n_results: Maximum number of results to return
db_path: Optional path to Chroma database
Returns:
Dict with success, results list (document, metadata, id)
"""
client = _get_client(db_path)
try:
collection = client.get_collection(collection_name, embedding_function=None)
except Exception as e:
logger.warning(f"[chroma_subprocess] Collection '{collection_name}' not found in get_by_source: {e}")
return {"success": False, "error": f"Collection '{collection_name}' not found: {e}"}
result = collection.get(include=["metadatas", "documents"])
matches = []
for i, meta in enumerate(result.get("metadatas", [])):
source = meta.get("source_file", "")
if source_pattern in source:
matches.append(
{
"collection": collection_name,
"document": result["documents"][i],
"metadata": meta,
"id": result["ids"][i],
"distance": 0.0,
}
)
if len(matches) >= n_results:
break
return {"success": True, "results": matches, "count": len(matches)}
def _delete_by_source(collection_name, source_pattern, db_path=None):
"""Delete vectors whose source_file metadata contains a pattern.
Args:
collection_name: Name of the ChromaDB collection
source_pattern: Substring to match in source_file metadata
db_path: Optional path to Chroma database
Returns:
Dict with success, deleted count, and matched IDs
"""
client = _get_client(db_path)
try:
collection = client.get_collection(collection_name, embedding_function=None)
except Exception as e:
logger.warning(f"[chroma_subprocess] Collection '{collection_name}' not found in delete_by_source: {e}")
return {"success": False, "error": f"Collection '{collection_name}' not found: {e}"}
result = collection.get(include=["metadatas"])
ids_to_delete = []
for i, meta in enumerate(result.get("metadatas", [])):
source = meta.get("source_file", "")
if source_pattern in source:
ids_to_delete.append(result["ids"][i])
if not ids_to_delete:
return {"success": True, "deleted": 0, "ids": [], "message": "No matching vectors found"}
collection.delete(ids=ids_to_delete)
return {"success": True, "deleted": len(ids_to_delete), "ids": ids_to_delete}
def _search_vectors(query_embedding, branch=None, memory_type=None, n_results=5, db_path=None):
"""Search for similar vectors."""
client = _get_client(db_path)
@@ -214,6 +305,19 @@ def main():
)
elif operation == "check_plan":
result = _check_plan(plan_label=input_data.get("plan_label"), db_path=input_data.get("db_path"))
elif operation == "get_by_source":
result = _get_by_source(
collection_name=input_data.get("collection_name"),
source_pattern=input_data.get("source_pattern"),
n_results=input_data.get("n_results", 5),
db_path=input_data.get("db_path"),
)
elif operation == "delete_by_source":
result = _delete_by_source(
collection_name=input_data.get("collection_name"),
source_pattern=input_data.get("source_pattern"),
db_path=input_data.get("db_path"),
)
else:
result = {"success": False, "error": f"Unknown operation: {operation}"}
@@ -0,0 +1,80 @@
# =================== AIPass ====================
# Name: governance.py
# Description: Surfacing governance module — public API
# Version: 1.1.0
# Created: 2026-07-16
# Modified: 2026-07-16
# =============================================
"""
Surfacing Governance Module — Public API
Thin module re-exporting governance engine from handlers/governance/engine.py.
Cross-branch consumers import from here:
from aipass.memory.apps.modules.governance import should_surface, record_message, new_state
"""
import os
import sys
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")
from aipass.prax import logger # noqa: F401
from aipass.memory.apps.handlers.json import json_handler
from aipass.memory.apps.handlers.governance.engine import (
DEFAULT_CONFIG,
new_state,
record_message,
should_surface,
)
__all__ = ["should_surface", "record_message", "new_state", "DEFAULT_CONFIG"]
# =============================================================================
# MODULE ROUTING (handle_command for drone auto-discovery)
# =============================================================================
def print_introspection() -> None:
"""Display module introspection (seedgo standard)."""
from aipass.cli.apps.modules import console
console.print()
console.print("[bold cyan]governance Module[/bold cyan]")
console.print("Pure surfacing governance — state-in/state-out decision functions")
console.print()
console.print("[yellow]Public API:[/yellow]")
console.print(" should_surface(item_id, relevance_score, state, config)")
console.print(" record_message(state)")
console.print(" new_state()")
console.print()
console.print("[dim]Library module — import from: aipass.memory.apps.modules.governance[/dim]")
def handle_command(command: str, args: list) -> bool:
"""Entry point for drone module discovery — governance has no CLI surface."""
if command != "governance":
return False
json_handler.log_operation("governance_command", {"args": args})
if not args:
print_introspection()
return True
if args[0] in ("--help", "-h", "help"):
print_introspection()
return True
from aipass.cli.apps.modules import warning
warning(f"governance: unknown subcommand '{args[0]}'")
print_introspection()
return True
+252
View File
@@ -0,0 +1,252 @@
"""Tests for the surfacing governance module (pure state-in/state-out API)."""
import pytest
from aipass.memory.apps.modules.governance import (
DEFAULT_CONFIG,
new_state,
record_message,
should_surface,
)
# =============================================================================
# new_state
# =============================================================================
class TestNewState:
def test_returns_zeroed_state(self):
state = new_state()
assert state["surfaces_count"] == 0
assert state["messages_since_last"] == 0
assert state["last_surface_time"] == 0.0
assert state["surfaced_ids"] == []
def test_returns_independent_copies(self):
s1 = new_state()
s2 = new_state()
s1["surfaced_ids"].append("x")
assert s2["surfaced_ids"] == []
# =============================================================================
# should_surface — rejection paths
# =============================================================================
class TestShouldSurfaceRejections:
"""Each rejection path tested independently with an otherwise-valid state."""
@pytest.fixture()
def ready_state(self):
"""State that passes all checks when config is default."""
return {
"surfaces_count": 0,
"messages_since_last": 20,
"last_surface_time": 0.0,
"surfaced_ids": [],
}
def test_disabled(self, ready_state):
ok, reason, st = should_surface("item1", 0.8, ready_state, {"enabled": False}, current_time=1000.0)
assert ok is False
assert "disabled" in reason.lower()
assert st is ready_state
def test_below_threshold(self, ready_state):
ok, reason, st = should_surface("item1", 0.1, ready_state, {"threshold": 0.3}, current_time=1000.0)
assert ok is False
assert "threshold" in reason.lower()
assert st is ready_state
def test_budget_exhausted(self, ready_state):
ready_state["surfaces_count"] = 5
ok, reason, st = should_surface("item1", 0.8, ready_state, {"max_surfaces_per_session": 5}, current_time=1000.0)
assert ok is False
assert "budget" in reason.lower() or "exhausted" in reason.lower()
assert st is ready_state
def test_spacing_not_met(self, ready_state):
ready_state["messages_since_last"] = 3
ready_state["last_surface_time"] = 1.0
ok, reason, st = should_surface("item1", 0.8, ready_state, {"min_messages_between": 10}, current_time=1000.0)
assert ok is False
assert "spacing" in reason.lower()
assert st is ready_state
def test_cooldown_active(self, ready_state):
ready_state["last_surface_time"] = 900.0
ok, reason, st = should_surface("item1", 0.8, ready_state, {"cooldown_seconds": 300}, current_time=1000.0)
assert ok is False
assert "cooldown" in reason.lower()
assert st is ready_state
def test_already_surfaced(self, ready_state):
ready_state["surfaced_ids"] = ["item1"]
ok, reason, st = should_surface("item1", 0.8, ready_state, current_time=1000.0)
assert ok is False
assert "already" in reason.lower()
assert st is ready_state
# =============================================================================
# should_surface — happy path
# =============================================================================
class TestShouldSurfaceHappy:
def test_surfaces_and_returns_updated_state(self):
state = {
"surfaces_count": 0,
"messages_since_last": 15,
"last_surface_time": 0.0,
"surfaced_ids": [],
}
ok, reason, updated = should_surface("compass-42", 0.75, state, current_time=5000.0)
assert ok is True
assert "ready" in reason.lower()
assert updated["surfaces_count"] == 1
assert updated["messages_since_last"] == 0
assert updated["last_surface_time"] == 5000.0
assert "compass-42" in updated["surfaced_ids"]
def test_does_not_mutate_input_state(self):
state = {
"surfaces_count": 0,
"messages_since_last": 15,
"last_surface_time": 0.0,
"surfaced_ids": [],
}
original_ids = state["surfaced_ids"]
should_surface("item1", 0.8, state, current_time=5000.0)
assert state["surfaces_count"] == 0
assert state["surfaced_ids"] is original_ids
assert len(original_ids) == 0
def test_threshold_boundary_exact(self):
state = new_state()
state["messages_since_last"] = 10
ok, _, _ = should_surface("x", 0.3, state, {"threshold": 0.3}, current_time=1000.0)
assert ok is True
def test_cooldown_expired(self):
state = {
"surfaces_count": 0,
"messages_since_last": 15,
"last_surface_time": 500.0,
"surfaced_ids": [],
}
ok, _, _ = should_surface("x", 0.8, state, {"cooldown_seconds": 300}, current_time=801.0)
assert ok is True
def test_first_surface_ignores_spacing(self):
"""Fresh session: first prompt with high relevance surfaces immediately."""
state = new_state()
state["messages_since_last"] = 1
ok, reason, updated = should_surface("compass-1", 0.8, state, current_time=100.0)
assert ok is True
assert "ready" in reason.lower()
for i in range(9):
updated = record_message(updated)
ok, reason, _ = should_surface("compass-2", 0.8, updated, current_time=100.0 + 400 + i)
assert ok is False
assert "spacing" in reason.lower()
updated = record_message(updated)
ok, _, _ = should_surface("compass-2", 0.8, updated, current_time=600.0)
assert ok is True
# =============================================================================
# State isolation — two independent states do not bleed
# =============================================================================
class TestStateIsolation:
def test_two_states_independent(self):
s1 = new_state()
s1["messages_since_last"] = 20
s2 = new_state()
s2["messages_since_last"] = 20
ok1, _, s1_updated = should_surface("a", 0.8, s1, current_time=1000.0)
ok2, _, s2_updated = should_surface("b", 0.9, s2, current_time=2000.0)
assert ok1 is True
assert ok2 is True
assert s1_updated["surfaced_ids"] == ["a"]
assert s2_updated["surfaced_ids"] == ["b"]
assert s1_updated["last_surface_time"] == 1000.0
assert s2_updated["last_surface_time"] == 2000.0
def test_chained_surfaces_accumulate(self):
state = new_state()
state["messages_since_last"] = 20
ok, _, state = should_surface("a", 0.8, state, current_time=1000.0)
assert ok is True
assert state["surfaces_count"] == 1
state["messages_since_last"] = 20
ok, _, state = should_surface("b", 0.7, state, current_time=2000.0)
assert ok is True
assert state["surfaces_count"] == 2
assert state["surfaced_ids"] == ["a", "b"]
# =============================================================================
# record_message
# =============================================================================
class TestRecordMessage:
def test_increments_counter(self):
state = new_state()
updated = record_message(state)
assert updated["messages_since_last"] == 1
def test_does_not_mutate_input(self):
state = new_state()
record_message(state)
assert state["messages_since_last"] == 0
def test_preserves_other_fields(self):
state = {
"surfaces_count": 3,
"messages_since_last": 5,
"last_surface_time": 100.0,
"surfaced_ids": ["x"],
}
updated = record_message(state)
assert updated["surfaces_count"] == 3
assert updated["messages_since_last"] == 6
assert updated["last_surface_time"] == 100.0
assert updated["surfaced_ids"] == ["x"]
# =============================================================================
# Config merging
# =============================================================================
class TestConfigMerging:
def test_none_config_uses_defaults(self):
state = new_state()
state["messages_since_last"] = 20
ok, _, _ = should_surface("x", 0.8, state, None, current_time=1000.0)
assert ok is True
def test_partial_config_merges_with_defaults(self):
state = new_state()
state["messages_since_last"] = 20
ok, reason, _ = should_surface("x", 0.25, state, {"threshold": 0.5}, current_time=1000.0)
assert ok is False
assert "threshold" in reason.lower()
def test_default_config_values_match(self):
assert DEFAULT_CONFIG["threshold"] == 0.3
assert DEFAULT_CONFIG["max_surfaces_per_session"] == 5
assert DEFAULT_CONFIG["min_messages_between"] == 10
assert DEFAULT_CONFIG["cooldown_seconds"] == 300
@@ -501,7 +501,7 @@ class TestProcessPlans:
monkeypatch.setattr(
mod,
"_embed_texts",
lambda texts: {"success": True, "embeddings": [[0.1, 0.2]] * len(texts)},
lambda texts, timeout=120: {"success": True, "embeddings": [[0.1, 0.2]] * len(texts)},
)
monkeypatch.setattr(
mod,
@@ -547,7 +547,7 @@ class TestProcessPlans:
monkeypatch.setattr(
mod,
"_embed_texts",
lambda texts: {"success": False, "error": "GPU out of memory"},
lambda texts, timeout=120: {"success": False, "error": "GPU out of memory"},
)
mock_jh = MagicMock()
@@ -584,7 +584,7 @@ class TestProcessPlans:
monkeypatch.setattr(
mod,
"_embed_texts",
lambda texts: {"success": True, "embeddings": []},
lambda texts, timeout=120: {"success": True, "embeddings": []},
)
mock_jh = MagicMock()
@@ -620,7 +620,7 @@ class TestProcessPlans:
monkeypatch.setattr(
mod,
"_embed_texts",
lambda texts: {"success": True, "embeddings": [[0.1, 0.2]] * len(texts)},
lambda texts, timeout=120: {"success": True, "embeddings": [[0.1, 0.2]] * len(texts)},
)
monkeypatch.setattr(
mod,
+6
View File
@@ -117,6 +117,12 @@ def print_help():
console.print()
console.print("[bold cyan]PRAX - System-Wide Logging Infrastructure[/bold cyan]")
console.print()
console.print("[dim]Unified logging system for AIPass ecosystem[/dim]")
console.print()
console.print("[bold cyan]Usage:[/bold cyan]")
console.print(" [green]drone @prax <command>[/green] [dim][options][/dim]")
console.print()
console.print("[yellow]Commands:[/yellow]")
console.print(" [cyan]monitor[/cyan] Mission Control - unified real-time monitoring")
+11 -1
View File
@@ -2,13 +2,23 @@
# Seedgo
**Purpose:** Standards compliance platform for AIPass. Audits all 11 core agents against 40 code standards + diagnostics, manages bypass rules, runs proof certification, and provides per-file checklist validation consumed by auto-fix hooks.
**Purpose:** Standards compliance platform for AIPass. Audits all 11 core agents against 42 code standards + diagnostics, manages bypass rules, runs proof certification, and provides per-file checklist validation consumed by the PostToolUse auto-fix gate.
**Module:** `aipass.seedgo`
**Version:** 2.0.0
**Created:** 2026-03-05
---
## Quick Start
```bash
drone @seedgo audit aipass # Audit all branches against all standards
drone @seedgo checklist <file> # Check a single file
drone @seedgo standards_query aipass_standards cli # Look up what a standard checks
```
---
## Overview
### What I Do
@@ -0,0 +1,484 @@
# =================== AIPass ====================
# Name: cli_ux_check.py
# Description: CLI UX Standards Checker Handler
# Version: 1.0.0
# Created: 2026-07-17
# Modified: 2026-07-17
# =============================================
"""
CLI UX Standards Checker Handler
Validates that branch entry points follow the AIPass house pattern for CLI
help and introspection output.
Good entry points (flow.py, prax.py, drone.py, seedgo.py) all have:
- print_introspection() and print_help() as separate two-tier functions
- Rich console.print() output (no bare print())
- Styled title, purpose/tagline, and --help pointer in introspection
- Usage and Examples sections in help
Checks:
1. two_tier_help - Both print_introspection() and print_help() exist
2. rich_console - Help functions use console.print(), not bare print()
3. title_markup - print_introspection() has a [bold styled title
4. purpose_line - print_introspection() has a [dim] purpose/tagline line
5. help_pointer - print_introspection() references --help
6. usage_section - print_help() includes a Usage section
7. examples_section - print_help() includes an Examples section
8. no_internal_modules - modules/ does not expose internal plumbing files
"""
import ast
from pathlib import Path
from typing import Dict, List, Optional
from aipass.prax import logger
from aipass.seedgo.apps.handlers.json import json_handler
from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
# Only check entry points: apps/{branch}.py files
AUDIT_SCOPE = "entry_point"
def check_module(module_path: str, bypass_rules: list | None = None) -> Dict:
"""
Check if entry point follows the AIPass CLI UX house pattern.
Args:
module_path: Path to Python module to check
bypass_rules: Optional list of bypass rules to skip specific violations
Returns:
dict: {
'passed': bool, # Overall pass/fail
'checks': [ # Individual check results
{
'name': str, # Check name
'passed': bool, # Pass/fail
'message': str, # Details
}
],
'score': int, # 0-100 percentage
'standard': str # Standard name
}
"""
checks: List[Dict] = []
path = Path(module_path)
# Normalize to forward slashes so string matching works on Windows too
module_path = Path(module_path).as_posix()
# Check if entire standard is bypassed for this file
if is_bypassed(module_path, "cli_ux", bypass_rules=bypass_rules):
return {
"passed": True,
"checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}],
"score": 100,
"standard": "CLI_UX",
}
# Validate file exists
if not path.exists():
return {
"passed": False,
"checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}],
"score": 0,
"standard": "CLI_UX",
}
# Skip __init__.py files
if path.name == "__init__.py":
return {
"passed": True,
"checks": [{"name": "CLI UX check", "passed": True, "message": "__init__.py skipped"}],
"score": 100,
"standard": "CLI_UX",
}
# Skip non-entry-point files (entry points live at apps/{name}.py)
if not _is_entry_point(module_path, path):
return {
"passed": True,
"checks": [
{
"name": "CLI UX check",
"passed": True,
"message": "Not an entry point file (not applicable)",
}
],
"score": 100,
"standard": "CLI_UX",
}
# Read file
try:
with open(path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as e:
logger.info("Cannot read %s: %s", path, e)
return {
"passed": False,
"checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}],
"score": 0,
"standard": "CLI_UX",
}
# Empty file
if not content.strip():
return {
"passed": True,
"checks": [{"name": "CLI UX check", "passed": True, "message": "Empty file skipped"}],
"score": 100,
"standard": "CLI_UX",
}
# Parse AST
try:
tree = ast.parse(content, filename=module_path)
except SyntaxError as e:
logger.info("Skipped %s: SyntaxError during parse", path)
return {
"passed": False,
"checks": [{"name": "File parseable", "passed": False, "message": f"Syntax error: {e}"}],
"score": 0,
"standard": "CLI_UX",
}
# Find the two key functions via AST
introspection_func = _find_function(tree, "print_introspection")
help_func = _find_function(tree, "print_help")
# --- Check 1: two_tier_help ---
checks.append(_check_two_tier_help(tree, path.name))
# --- Check 2: rich_console ---
checks.append(_check_rich_console(introspection_func, help_func, path.name))
# --- Check 3: title_markup ---
checks.append(_check_title_markup(introspection_func, path.name))
# --- Check 4: purpose_line ---
checks.append(_check_purpose_line(introspection_func, path.name))
# --- Check 5: help_pointer ---
checks.append(_check_help_pointer(introspection_func, path.name))
# --- Check 6: usage_section ---
checks.append(_check_usage_section(help_func, path.name))
# --- Check 7: examples_section ---
checks.append(_check_examples_section(help_func, path.name))
# --- Check 8: no_internal_modules ---
checks.append(_check_no_internal_modules(module_path, path))
# Calculate score
passed_checks = sum(1 for check in checks if check["passed"])
total_checks = len(checks)
score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0
# Overall pass = ALL checks passed
overall_passed = all(check["passed"] for check in checks)
json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "cli_ux"})
return {"passed": overall_passed, "checks": checks, "score": score, "standard": "CLI_UX"}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _is_entry_point(module_path: str, path: Path) -> bool:
"""
Detect if file is an entry point: apps/{name}.py (directly in apps/, not in subdirectory).
Entry points live at apps/{name}.py -- their parent directory is 'apps'.
Files in apps/modules/, apps/handlers/, apps/plugins/ etc. are NOT entry points.
"""
if not path.name.endswith(".py"):
return False
posix_path = Path(module_path).as_posix()
if "apps/" not in posix_path:
return False
return path.parent.name == "apps"
def _find_function(tree: ast.Module, name: str) -> Optional[ast.FunctionDef]:
"""Find a top-level function definition by name."""
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name == name:
return node
return None
def _collect_string_constants(func_node: ast.FunctionDef) -> List[str]:
"""Extract all string constants from a function body, including f-string parts."""
return [
node.value for node in ast.walk(func_node) if isinstance(node, ast.Constant) and isinstance(node.value, str)
]
# ---------------------------------------------------------------------------
# Individual checks
# ---------------------------------------------------------------------------
def _check_two_tier_help(tree: ast.Module, filename: str) -> Dict:
"""Check 1: Entry point has BOTH print_introspection AND print_help."""
found = set()
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in ("print_introspection", "print_help"):
found.add(node.name)
missing = {"print_introspection", "print_help"} - found
if not missing:
return {
"name": "two_tier_help",
"passed": True,
"message": f"Both print_introspection() and print_help() found in {filename}",
}
missing_str = ", ".join(sorted(missing))
return {
"name": "two_tier_help",
"passed": False,
"message": f"Entry point must define both print_introspection() and print_help() — {missing_str} not found",
}
def _check_rich_console(
introspection_func: Optional[ast.FunctionDef],
help_func: Optional[ast.FunctionDef],
filename: str,
) -> Dict:
"""Check 2: Help functions use console.print(), not bare print()."""
funcs_to_check = []
if introspection_func is not None:
funcs_to_check.append(("print_introspection", introspection_func))
if help_func is not None:
funcs_to_check.append(("print_help", help_func))
if not funcs_to_check:
# Neither function exists -- auto-fail (two_tier_help already catches the root cause)
return {
"name": "rich_console",
"passed": False,
"message": "Help functions must use console.print() from aipass.cli, not bare print()",
}
has_console_print = False
has_bare_print = False
for _func_name, func_node in funcs_to_check:
for node in ast.walk(func_node):
if not isinstance(node, ast.Call):
continue
# console.print() — Attribute call where value.id == 'console' and attr == 'print'
if isinstance(node.func, ast.Attribute):
if (
node.func.attr == "print"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "console"
):
has_console_print = True
# bare print() — Name call where id == 'print'
if isinstance(node.func, ast.Name) and node.func.id == "print":
has_bare_print = True
if has_bare_print:
return {
"name": "rich_console",
"passed": False,
"message": "Help functions must use console.print() from aipass.cli, not bare print()",
}
if not has_console_print:
return {
"name": "rich_console",
"passed": False,
"message": "Help functions must use console.print() from aipass.cli, not bare print()",
}
return {
"name": "rich_console",
"passed": True,
"message": f"Help functions in {filename} use console.print() (no bare print())",
}
def _check_title_markup(introspection_func: Optional[ast.FunctionDef], filename: str) -> Dict:
"""Check 3: print_introspection contains a [bold styled title."""
if introspection_func is None:
return {
"name": "title_markup",
"passed": False,
"message": (
"print_introspection() must include a styled title line (e.g. [bold cyan]Branch Name[/bold cyan])"
),
}
strings = _collect_string_constants(introspection_func)
for s in strings:
if "[bold" in s:
return {
"name": "title_markup",
"passed": True,
"message": f"print_introspection() in {filename} has a styled title with [bold markup",
}
return {
"name": "title_markup",
"passed": False,
"message": "print_introspection() must include a styled title line (e.g. [bold cyan]Branch Name[/bold cyan])",
}
def _check_purpose_line(introspection_func: Optional[ast.FunctionDef], filename: str) -> Dict:
"""Check 4: print_introspection contains a [dim] purpose/tagline line."""
if introspection_func is None:
return {
"name": "purpose_line",
"passed": False,
"message": "print_introspection() must include a dim-styled purpose/tagline line",
}
strings = _collect_string_constants(introspection_func)
for s in strings:
if "[dim]" in s:
return {
"name": "purpose_line",
"passed": True,
"message": f"print_introspection() in {filename} has a [dim] purpose/tagline line",
}
return {
"name": "purpose_line",
"passed": False,
"message": "print_introspection() must include a dim-styled purpose/tagline line",
}
def _check_help_pointer(introspection_func: Optional[ast.FunctionDef], filename: str) -> Dict:
"""Check 5: print_introspection contains a closing pointer to --help."""
if introspection_func is None:
return {
"name": "help_pointer",
"passed": False,
"message": "print_introspection() must include a closing pointer to --help for more info",
}
strings = _collect_string_constants(introspection_func)
for s in strings:
if "--help" in s:
return {
"name": "help_pointer",
"passed": True,
"message": f"print_introspection() in {filename} includes a --help pointer",
}
return {
"name": "help_pointer",
"passed": False,
"message": "print_introspection() must include a closing pointer to --help for more info",
}
def _check_usage_section(help_func: Optional[ast.FunctionDef], filename: str) -> Dict:
"""Check 6: print_help contains a Usage section."""
if help_func is None:
return {
"name": "usage_section",
"passed": False,
"message": "print_help() must include a Usage section",
}
strings = _collect_string_constants(help_func)
for s in strings:
if "usage" in s.lower():
return {
"name": "usage_section",
"passed": True,
"message": f"print_help() in {filename} includes a Usage section",
}
return {
"name": "usage_section",
"passed": False,
"message": "print_help() must include a Usage section",
}
def _check_examples_section(help_func: Optional[ast.FunctionDef], filename: str) -> Dict:
"""Check 7: print_help contains an Examples section."""
if help_func is None:
return {
"name": "examples_section",
"passed": False,
"message": "print_help() must include an Examples section",
}
strings = _collect_string_constants(help_func)
for s in strings:
if "example" in s.lower():
return {
"name": "examples_section",
"passed": True,
"message": f"print_help() in {filename} includes an Examples section",
}
return {
"name": "examples_section",
"passed": False,
"message": "print_help() must include an Examples section",
}
def _check_no_internal_modules(module_path: str, path: Path) -> Dict:
"""
Check 8: modules/ directory does not expose internal plumbing files.
Files whose stems end with _wire, _fix, _impl, or _internal should be
underscore-prefixed to hide from discovery.
"""
internal_suffixes = ("_wire", "_fix", "_impl", "_internal")
# Derive the branch root from the entry point path:
# entry point is at {branch_root}/apps/{name}.py -> parent.parent is branch_root
branch_root = path.parent.parent
modules_dir = branch_root / "apps" / "modules"
if not modules_dir.is_dir():
return {
"name": "no_internal_modules",
"passed": True,
"message": "No modules/ directory found (nothing to check)",
}
exposed_internal: List[str] = []
for py_file in modules_dir.glob("*.py"):
stem = py_file.stem
# Skip __init__.py and already-underscore-prefixed files
if stem.startswith("_"):
continue
if any(stem.endswith(suffix) for suffix in internal_suffixes):
exposed_internal.append(py_file.name)
if exposed_internal:
names = ", ".join(sorted(exposed_internal))
return {
"name": "no_internal_modules",
"passed": False,
"message": (
f"modules/ directory exposes internal plumbing: {names} "
f"— prefix with underscore or use COMMAND attribute"
),
}
return {
"name": "no_internal_modules",
"passed": True,
"message": "No exposed internal plumbing files in modules/",
}
@@ -0,0 +1,110 @@
# =================== AIPass ====================
# Name: cli_ux_content.py
# Description: CLI UX Standards Content Handler
# Version: 1.0.0
# Created: 2026-07-17
# Modified: 2026-07-17
# =============================================
"""
CLI UX Standards Content Handler
Provides formatted CLI UX standards content.
Module orchestrates, handler implements.
"""
from aipass.seedgo.apps.handlers.json import json_handler
def get_cli_ux_standards() -> str:
"""Return formatted CLI UX standards content with Rich markup.
Returns:
str: Formatted standards text with Rich styling
"""
lines = [
"[bold red]CLI UX STANDARD[/bold red]",
"",
"[bold cyan]CORE RULE:[/bold cyan] Entry points must follow the AIPass house pattern",
"",
"[yellow]RULE:[/yellow] Every branch entry point (apps/{branch}.py) must provide",
" a two-tier help surface: print_introspection() for bare invocation and",
" print_help() for --help, both using Rich console.print() from aipass.cli.",
"",
"=" * 70,
"",
"[bold cyan]THE HOUSE PATTERN:[/bold cyan]",
"",
" [green]1.[/green] [bold]Two-tier help[/bold] -- print_introspection() (light) + print_help() (full)",
" [green]2.[/green] [bold]Rich console[/bold] -- console.print() with markup, never bare print()",
" [green]3.[/green] [bold]Title + purpose[/bold] -- styled title line + dim tagline in introspection",
" [green]4.[/green] [bold]Closing pointer[/bold] -- introspection ends with --help reference",
" [green]5.[/green] [bold]Usage + examples[/bold] -- print_help() includes USAGE and EXAMPLES sections",
" [green]6.[/green] [bold]No internal leaks[/bold] -- modules/ must not expose internal plumbing",
"",
"=" * 70,
"",
"[bold cyan]WHAT IS CHECKED:[/bold cyan]",
"",
" [yellow]Scope:[/yellow] entry_point -- only apps/{branch}.py files",
"",
" [yellow]Check 1:[/yellow] two_tier_help",
" Both print_introspection() and print_help() defined as top-level functions",
"",
" [yellow]Check 2:[/yellow] rich_console",
" Help functions use console.print(), no bare print() calls",
"",
" [yellow]Check 3:[/yellow] title_markup",
" print_introspection() contains a [bold]-styled title line",
"",
" [yellow]Check 4:[/yellow] purpose_line",
" print_introspection() contains a [dim]-styled purpose/tagline",
"",
" [yellow]Check 5:[/yellow] help_pointer",
" print_introspection() references --help for more info",
"",
" [yellow]Check 6:[/yellow] usage_section",
" print_help() contains a Usage section",
"",
" [yellow]Check 7:[/yellow] examples_section",
" print_help() contains an Examples section",
"",
" [yellow]Check 8:[/yellow] no_internal_modules",
" modules/ directory does not expose internal plumbing names",
"",
"=" * 70,
"",
"[bold cyan]GOOD EXAMPLE (flow.py introspection):[/bold cyan]",
"",
' console.print("[bold cyan]Flow - PLAN Management System[/bold cyan]")',
' console.print("[dim]Task orchestration and workflow management[/dim]")',
" # ... module list ...",
" console.print(\"[dim]Run 'drone @flow --help' for usage information[/dim]\")",
"",
"[bold cyan]BAD EXAMPLE (aipass.py -- fails all checks):[/bold cyan]",
"",
' print(f"AIPASS - {len(modules)} modules discovered") # bare print',
" # no print_introspection/print_help, no title/purpose, no pointer",
"",
"=" * 70,
"",
"[bold cyan]HOW TO FIX:[/bold cyan]",
"",
" 1. Define print_introspection() with: styled title, dim purpose, module list, --help pointer",
" 2. Define print_help() with: USAGE section, command list, EXAMPLES section",
" 3. Replace all bare print() with console.print() from aipass.cli",
" 4. Prefix internal modules with underscore or set COMMAND attribute",
"",
"=" * 70,
"",
"[bold cyan]SCORING:[/bold cyan]",
" Score = (passed_checks / 8) * 100",
" Pass requires all 8 checks green",
"",
"[bold cyan]REFERENCE:[/bold cyan]",
" [dim]See: seedgo standards pack (cli_ux)[/dim]",
" [dim]Compare: drone @flow (good) vs aipass --help (bad)[/dim]",
]
json_handler.log_operation("standard_content_queried", {"standard": "cli_ux"})
return "\n".join(lines)
@@ -34,6 +34,9 @@ _DOCTEST_RE = re.compile(r"^\s*(\.\.\.|>>>)\s")
# Test file name patterns
_TEST_FILE_RE = re.compile(r"^(test_.+|.+_test|conftest)\.py$")
# String literals — strip content before regex to avoid false positives on print( inside strings
_STRING_LITERAL_RE = re.compile(r'""".*?"""|\'\'\'.*?\'\'\'|"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'')
def _is_in_main_block(lines: list[str], lineno: int) -> bool:
"""
@@ -108,8 +111,9 @@ def _scan_file(file_path: Path) -> tuple[list[int], str | None]:
if _DOCTEST_RE.match(line):
continue
# Strip inline comments before checking for print(
# Strip inline comments and string literal contents before checking
code_part = line.split("#")[0]
code_part = _STRING_LITERAL_RE.sub('""', code_part)
if not _PRINT_RE.search(code_part):
continue

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