Compare commits

...
43 Commits
Author SHA1 Message Date
AIOSAI fb710fdb59 chore: gitignore SQLite WAL sidecars (*.db-wal/*.db-shm) — transient runtime artifacts, first surfaced by the commons feed's ro connection 2026-07-21 20:59:37 -07:00
AIOSAI 5b476e0dc3 feat(prax): commons live social feed in monitor (DPLAN-0257) — monitor run commons streams posts/comments/votes/reactions room-tagged, mode=ro sqlite (write refused, live-verified), 10-event backfill + 1.5s id-cursor poll, --logs escape, relay drop-in. 33 new tests, 1065 green, audit 100. Built by @prax, devpulse-verified + live door-tested (post/comment/react streamed) 2026-07-21 20:13:50 -07:00
AIOSAI 1c6abe9b59 test(hooks): skipif non-Linux on the two real-/proc tests — Windows runner has no /proc, one asserted a value the platform by design returns None for (CI catch on 4169d085, learning #275 class: hermeticity on the Win runner) 2026-07-21 18:49:40 -07:00
AIOSAI 4169d085e3 fix(hooks): engine handler timeout (daemon thread + join, honors hooks.json timeout, default 30s, fails loud) + presence_gate PID-reuse defense (procStart vs /proc stat starttime, liveness-only fallback). DPLAN-0253 backlog via DPLAN-0256. 15 new tests, 1272 green, seedgo 31/31 both files. Built by @hooks, devpulse-verified (diff read + suite re-run + checklist) 2026-07-21 18:34:18 -07:00
AIOSAI 37a26ea86c fix(trigger): runaway-log alerts expire 24h like every other mute (DPLAN-0256 backlog) — _write_alert hardcoded expires_at None, alerts nagged forever; DEFAULT_ALERT_TTL_SECONDS=86400 + forever escape hatch. 2 new tests, 621 green, audit 100. Built by @trigger, devpulse-verified (diff read + suite re-run) 2026-07-21 18:19:37 -07:00
AIOSAI 42f56f2cc4 test(drone): merge routing test honors the joint-decision gate — headless routing asserted via --confirm (DPLAN-0256 follow-up; CI catch: test_devpulse_plugins asserted the pre-gate contract, missed locally by running only the edited file's suite) 2026-07-21 18:17:52 -07:00
AIOSAI 7a2700d649 feat(drone): joint-decision gate on @git merge (DPLAN-0256, todo #92) — TTY gets y/N prompt, headless refused without --confirm, merge_pr unreachable ungated, all decisions logged. 6 new tests (86 green), live refusal verified, seedgo 31/31. Patrick ruling S330: merges together, never accidental 2026-07-21 18:05:39 -07:00
AIOSAI d1a6c874e8 feat(skills): telegram user_message_relay joins the hook sound layer — relay events carry a sound key (59/59 + 252 green). Rides the @hooks sounds rollout 2026-07-21 18:05:20 -07:00
AIOSAI 76ce13830d fix(devpulse): watchdog stall threshold 120s to 300s (false stalls on long tool calls, verified live S330) + branch settings.local carries the 350k autoCompactWindow dial (Patrick ruling S326) 2026-07-21 18:04:51 -07:00
AIOSAI 57285740f2 fix(seedgo): AST checker accuracy arc — 13 false positives gone fleet-wide, 2 legit hooks bypasses documented, checkers/audit/checklist aligned, fixture refreshed, stale bypass entries purged (devpulse/memory/seedgo). Fleet audit 100 pct. S330 night arc 2026-07-21 18:04:32 -07:00
AIOSAI 73baa0005f feat(hooks): sound layer across the hook fleet + temporal grounding handler — sounds mirror the log (2465 green, audit 100, compass #157); new prompt/temporal.py injects live local date/time every turn, host-tz, wired both wires (hooks.json + provider manifest). Built by @hooks 2026-07-21 18:04:08 -07:00
AIOSAI c71f389409 feat(aipass): adopt verb + shared/ scaffold refactor — projects/ adoption (registry, resident agent, additive scaffold) proven on aipass-site (doctor 31/0); shared/project_home + scaffold_content = one source of truth for init/new/adopt; spawn template registry synced. 786 green, audit-clean. Built by @aipass 2026-07-21 18:03:44 -07:00
AIOSAI 47bef92162 docs(projects): aipass-site joins the roster — repo moved from ~/Projects/aipass-site into projects/ (own git repo intact, remote unchanged, invisible to AIPass repo per projects/* ignore) 2026-07-20 19:47:55 -07:00
AIOSAI e2157e118c docs(projects): reframe projects/ README — private by default, publishing is opt-in (Patrick ruling). Mechanics unchanged and confirmed: projects/* gitignored, own git repo per project, aipass new creates no remote. CHANGELOG entry updated in place (same unmerged section). 2026-07-20 19:42:47 -07:00
AIOSAI 1ac0cecb9a docs(projects): projects/ README — satellite-project explainer + Earmark roster entry. Fills the !projects/README.md gitignore whitelist that existed since the aipass-new design. CHANGELOG entry included. 2026-07-20 19:38:16 -07:00
AIOSAI 583a792515 fix(hooks): persistent_alert cross-process dedup + loud trust-break banner (DPLAN-0253 trace round). Dedup: module-global _announced set replaced with session+alert-keyed tempdir guard files - fresh bridge process per prompt meant TTS would announce every turn while an alert existed; banner capped at 10 with overflow note. Trust: is_hash_mismatch distinguishes real break from never-enrolled, trust_break_banner does config-independent walk+hash, engine emits loud banner once per prompt via presence_gate call - a hooks.json edit had silently darkened the ENTIRE hook layer for 2+ hours (found by 5-agent trace round). No auto-heal, re-enroll stays human. Live-fired both ways by devpulse: hash broken = banner, restored = healthy. 16 new tests, 1206 green, seedgo 100 pct, both re-run by devpulse. Also: @hooks prompt corrected (taught one-entry-per-event model) + two-wires checklist + mandatory provider-wire flag; README tree/counts refreshed. Built by @hooks 2026-07-20 16:28:48 -07:00
AIOSAI 33a1510cbb feat(hooks): auto-compact prep - context gauge + snapshot stamp (DPLAN-0253). context_gauge (UserPromptSubmit) reads live transcript fill each prompt and nudges /prep at 80 pct of the compact trigger, escalates at 95, once per threshold per session; pre_compact_prep (PreCompact) stamps AUTO-COMPACT SNAPSHOT (fill, dispatch locks, open plans, git, inbox) into the compacting branch local.json; shared context_window resolver (env > branch settings.local.json > 200k). Round 2 root cause: name-scoped events (UserPromptSubmit/PreCompact) invoke the bridge per-handler - hooks.json alone never fires; provider_manifest.json entries added for both + @hooks branch prompt corrected with provider-wire reminder (Patrick-directed). 36 new tests, suite 1190 green, seedgo 100 pct, both re-run by devpulse. Built by @hooks, 2 rounds. Go-live: user syncs ~/.claude/settings.json from manifest + fresh session 2026-07-20 13:13:43 -07:00
AIOSAI d1facd8bdc fix(skills): TG poll 5xx triggers network backoff — HTTPError >=500 in poll_updates raises _NetworkPollError instead of rapid-fire retry; 4xx unchanged. 3 new tests (502/503 backoff, 429 excluded). Medic loop: detected by @trigger (ERROR ae5ddbd4), fixed autonomously by @skills, verified by devpulse (1080 tests green, seedgo 100%) 2026-07-19 21:20:10 -07:00
AIOSAI 0529a4e7c4 fix(ai_mail): inbox-poller spawn_agent now passes --model DEFAULT_MODEL (sonnet). The daemon asymmetry Vera flagged lived in ai_mail's own handlers/dispatch/daemon.py (name collision, not @daemon branch — their scheduled wakes import wake_branch directly and were covered by efb530e0). DEFAULT_MODEL imported from wake.py, single source; 200k window pin already covered via shared dispatch_monitor wrapper. Test asserts --model in captured spawn cmd. 778 tests green, seedgo 100%, both re-run by devpulse. Investigated by @daemon, built by @ai_mail 2026-07-19 09:32:42 -07:00
AIOSAI efb530e0b2 feat(ai_mail): dispatch default Sonnet 5 — alias pass-through + 200k context pin. MODEL_MAP replaced by KNOWN_MODEL_ALIASES pass-through (CLI resolves latest-in-class, map can never go stale), DEFAULT_MODEL opus->sonnet, dispatch_monitor pins CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000 per spawn (Sonnet 5 is 1M-native — without pin agents inherit 1M window). E2E: live @cli dispatch probe reported claude-sonnet-5 + WINDOW=200000 from inside the spawned session. 777 tests green, seedgo 100%, both re-run by devpulse. Daemon spawn_agent --model gap confirmed, handled separately. Patrick ruling S326, built by @ai_mail 2026-07-19 09:15:04 -07:00
AIOSAI a7108e8504 fix(flow): is_template_content v5.0 — boilerplate-aware Exec Log check closes the N1 false negative. Default-template Exec Log boilerplate (11 lines w/ Log Pattern tip) beat the 8-line user-work threshold before the bracket-marker check ran, so blank plans read as user-written and closed as done (FPLAN-0313/0314). Now boilerplate is recognized and skipped; blank templates reach the marker check. Live-proven: fresh FPLAN-0342 detected+fast-deleted; 730 tests, audit 100%. DPLAN-0250 N1, dispatched to @flow 2026-07-19 03:08:17 -07:00
AIOSAI f09d42a862 fix(flow): lock+atomic registry aggregate writes — S304 F85 residual. save_branch_registry + save_central were bare open+dump; now O_EXCL lockfile with backoff + tmpfile os.replace, matching save_registry.py. Devpulse verified: 730 tests green + 6-proc 180-write concurrent hammer, final file valid JSON. N1 root cause found: is_template_content line threshold fires before bracket-marker check. CHANGELOG entries. DPLAN-0250, dispatched to @flow 2026-07-19 02:55:55 -07:00
AIOSAI c91cfd5166 fix(aipass): doctor stops inventing errors on clean repos — S304 F14-17. .backup+templates excluded from agent scan; relative registry paths anchored to project_root not CWD (killed 5 fake missing-branch errors from branch dirs); severity-honest glyphs (info != PASS). Devpulse live-verified from devpulse dir: zero false registry errors, 1 remaining error is genuine ptest pollution. 756 tests. DPLAN-0250 Track A, dispatched to @aipass 2026-07-19 02:55:45 -07:00
AIOSAI 8cb3895264 fix(spawn): seedgo remediation on is_protected work — narrowed logged excepts (OSError/ValueError/KeyError + logger.info, fallthrough semantics unchanged) and extracted _check_nested_pollution to cut detect_pollution to depth 4. Audit 100% (43/43), 357 tests, probes re-verified (pollution 0, delete aipass refused). DPLAN-0250 Track A follow-up, dispatched to @spawn 2026-07-19 01:18:15 -07:00
AIOSAI d631fd8d54 fix(hooks): pytest-guard the two static-path JSONL writers — S304 F6/F43 closed. diagnostics + telegram_response resolved log paths at import, bypassing prax test routing; now per-write resolvers route to tmp under pytest. Devpulse marker-bounded verify: 1154 tests green, engine.jsonl 1291 lines before AND after, zero suite-attributable entries. CHANGELOG wave entry + prep skill feedback-check habit (F46). DPLAN-0250 Track A, dispatched to @hooks 2026-07-19 01:08:59 -07:00
AIOSAI 48e25ae46e fix(prax): pytest detection immune to env-clearing — S304 F6/F43. Hooks tests patch.dict(os.environ, clear=True) stripped PYTEST_CURRENT_TEST, so loggers minted in that window froze prod-path handlers via the setup cache. get_system/module_logs_dir now also check _pytest in sys.modules. 1032 prax + 1154 hooks tests green. Residual: hooks-owned diagnostics writer bypasses prax resolution — handed to @hooks. DPLAN-0250 Track A, dispatched to @prax 2026-07-19 00:55:07 -07:00
AIOSAI 800acd079a fix(commons): lowercase branch-name normalization across all resolution sites — S304 F52. trade/artifact/profile/welcome/search ops all uppercased vs identity's lowercase (diverged af350fe0), so ownership guards never matched and gifts were invisible. DB sweep confirmed clean (bug blocked bad writes). Live gift+trade round-trip green, 449 tests, seedgo 31/31. DPLAN-0250 Track A, dispatched to @commons 2026-07-19 00:51:43 -07:00
AIOSAI defee1f513 fix(spawn): shared is_protected() guards repair + delete — S304 F30/F84. 3-layer check (infra floor, registry owner, active passport) replaces name-match pollution detection and 3-name delete guard. Live-verified: detect_pollution on AIPass root = 0 issues, delete aipass/devpulse both refused. 357 tests (11 new), seedgo 31/31. DPLAN-0250 Track A, dispatched to @spawn 2026-07-19 00:49:46 -07:00
AIOSAI 7811381d54 fix(hooks-tests): stop test_log_write_failure_does_not_crash minting MagicMock/LOG_FILE dirs — bare-Mock LOG_FILE patch reached prax append_jsonl's real mkdir via mock fspath; now patches a real tmp path. 100/100 green, clean-CWD verified zero dirs minted. S324 scan finding, FYI mailed @hooks 2026-07-18 22:10:05 -07:00
AIOSAI bdb128d904 docs(flow): merge playbook site drift-check step — every merge run asks if install/onboarding/agent-count/platform story changed, aipass.ai updates same day (DPLAN-0249 follow-on, S323 projection ruling). Template edit by @flow, seedgo 42/42 2026-07-18 21:07:10 -07: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
189 changed files with 12160 additions and 1154 deletions
+22
View File
@@ -44,12 +44,28 @@
"matcher": "",
"max_per_session": 10
},
"feedback_pulse": {
"enabled": false,
"handler": "aipass.hooks.apps.handlers.prompt.feedback_pulse.handle",
"matcher": ""
},
"auto_process": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle",
"matcher": "",
"timeout": 120
},
"context_gauge": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.prompt.context_gauge.handle",
"matcher": "",
"timeout": 30
},
"temporal": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.prompt.temporal.handle",
"matcher": ""
},
"user_message_relay": {
"enabled": true,
"handler": "aipass.skills.lib.telegram.apps.handlers.user_message_relay.handle",
@@ -153,6 +169,12 @@
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle",
"matcher": "",
"timeout": 120
},
"pre_compact_prep": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.lifecycle.pre_compact_prep.handle",
"matcher": "",
"timeout": 30
}
},
+1
View File
@@ -54,6 +54,7 @@ Quick checks beat assumptions: `ls`/`find` for files, `git ls-files`/`grep` for
- Run `drone @ai_mail inbox 2>/dev/null` — report any unread emails
- Close any that were already processed but not formally closed
- Run `drone @devpulse feedback 2>/dev/null` — report unread cross-project feedback; view/reply/clear anything NEW (S304 F46: this box sat unread for 3 months because nothing invoked it)
## 5. Compass Review (Devpulse only)
+11 -1
View File
@@ -4,11 +4,19 @@
"cli": {
"claude": {
"hooks": [
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:presence_gate", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:persistent_alert", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:tier0_kernel", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:navmap", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:branch_prompt", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:identity_injector", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:email_notification", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:compass_recall", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:feedback_pulse", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:auto_process", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:context_gauge", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:temporal", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:user_message_relay", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse", "event": "PostToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py SubagentStop", "event": "SubagentStop"},
@@ -17,7 +25,9 @@
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "manual", "timeout": 60},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "auto", "timeout": 60},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "manual", "timeout": 120},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "auto", "timeout": 120}
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "auto", "timeout": 120},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_prep", "event": "PreCompact", "matcher": "manual", "timeout": 30},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_prep", "event": "PreCompact", "matcher": "auto", "timeout": 30}
],
"env": {
"AIPASS_HOME": "{{REPO_ROOT}}",
+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@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
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@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
languages: python
- uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
- uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
+10
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/
@@ -139,3 +145,7 @@ README_ORIGINAL_DISABLED.md
*.bak
test/
sandbox_test/
# SQLite WAL sidecars (runtime artifacts — commons.db ro-feed etc.)
*.db-wal
*.db-shm
+338
View File
@@ -9,8 +9,346 @@ PyPI version — not the changelog header.
---
## [2026-07-21]
**feat(prax)** — Commons live social feed in the monitor (DPLAN-0257, Patrick
ask verbatim): `drone @prax monitor run commons` now streams The Commons'
chatter — posts, comments, votes, reactions — room-tagged with mood coloring,
monitor-style. ~10-event backfill on open, then 1.5s id-cursor polling.
Read-only by construction (`mode=ro` sqlite URI — write attempt refused,
verified live); commons stays the only writer, zero commons-side changes.
Branch-log tail still reachable via `monitor run commons --logs`; mixed branch
lists unchanged. `--relay` rides the existing Telegram relay path.
33 new tests, prax suite 1065 green, audit 100% (52 files). Door-tested live:
devpulse posted/replied/reacted while the feed streamed every event.
Built by @prax.
**fix(hooks)** — two DPLAN-0253 backlog hardenings (DPLAN-0256 clear):
engine handler timeout + presence_gate PID-reuse defense. `_run_handler` now
runs handler-type hooks on a daemon thread joined with the hooks.json
`timeout` field (default 30s) — a hung handler returns TIMEOUT loud
(engine.jsonl + sound) and the event moves on; daemon thread chosen over
ThreadPoolExecutor so a stuck orphan can never hang interpreter exit.
presence_gate occupancy no longer trusts `os.kill(pid, 0)` alone:
`procStart` (CC session file) is matched against `/proc/<pid>/stat` field 22
so a kernel-recycled PID can't impersonate a dead session — closes the gap
before observe-only ever flips to enforcement. Missing procStart / non-Linux
falls back to liveness-only, logged. 15 new tests, suite 1272 green,
seedgo 31/31 both files. Built by @hooks.
**fix(trigger)** — runaway-log alerts get the 24h TTL every other mute already
had (DPLAN-0256 backlog clear): `_write_alert()` hardcoded `expires_at: None`,
so alerts.json entries nagged forever while medic branch mutes self-expired.
New `DEFAULT_ALERT_TTL_SECONDS = 86400` (matches medic_state's
`DEFAULT_MUTE_SECONDS`) with a `forever` escape hatch threaded through
`handle_runaway_log_detected()`. 2 new tests, trigger suite 621 green,
audit 100%. Built by @trigger.
**feat(drone)** — joint-decision gate on `drone @git merge` (DPLAN-0256,
Patrick ruling S330: merges are always done together, never accidental).
The gate sits in `_handle_merge` before the plugin import — `merge_pr()` is
unreachable without confirmation. A real terminal gets an interactive y/N
prompt; headless callers (agent Bash) are refused unless `--confirm` is
passed explicitly. Every gate decision (confirm / tty-yes / tty-abort /
headless-refused) is logged via json_handler. 6 new tests (86 green),
live-fired refusal verified, seedgo 31/31.
**feat(skills)** — telegram user_message_relay joins the sound layer: relay
events now carry their own sound key so an inbound user message is audible
like every other hook event (59/59 + 252 green).
**fix(devpulse)** — watchdog stall threshold 120s → 300s: the 120s
no-JSONL-activity heuristic fired `[watchdog.stall]` on healthy agents doing
long tool calls; 300s matches observed real-stall behavior (verified live
S330). Branch `.claude/settings.local.json` carries the devpulse
`autoCompactWindow: 350000` dial (Patrick ruling S326 — devpulse compacts
~292k, dispatched agents stay pinned at 200k).
**fix(seedgo)** — checker accuracy arc (S330): AST-based import analysis
lands in the checkers (dead_code, encapsulation, handlers, readme,
test_quality, unused_function) — 13 false positives eliminated fleet-wide,
2 real hooks imports that legitimately bypass the pattern documented instead
of suppressed. branch_audit, checklist and ignore_handler aligned; provider
hooks snapshot fixture refreshed; stale bypass entries for deleted tools
purged across branches (devpulse, memory, seedgo, hooks). Fleet audit 100%.
**feat(hooks)** — hook sound layer + temporal grounding. Sounds now mirror
the log across the hook fleet (prompt, lifecycle, notification, security
handlers) — audible liveness for the whole layer, verified live (2465 green,
audit 100). New `prompt/temporal.py`: tiny always-on UserPromptSubmit handler
injecting one line of local date/time/weekday/part-of-day every turn — live
clock each fire, host timezone via `astimezone()` (clones see their own local
time). Wired on both wires (`.aipass/hooks.json` + provider manifest).
**feat(aipass)** — `aipass adopt` + shared scaffold refactor: adopt turns an
existing `projects/` directory into a full AIPass project (registry, resident
agent, `.aipass`/`.claude` scaffold) — every write additive, nothing existing
overwritten; unlike `aipass new` it starts from a directory with its own
content and git history. New `shared/` package (`project_home.py`,
`scaffold_content.py`) gives init/new/adopt one source of truth per helper —
`handlers/init/scaffold_content.py` moved there, no per-command copies to
drift. Proven live adopting aipass-site (doctor 31/0). Spawn template registry
synced (template bug chain me→spawn→aipass, fixed S329). 786 tests green,
audit-clean.
## [2026-07-20]
**docs(projects)** — `projects/README.md`: the projects section now ships in
the repo (the `!projects/README.md` gitignore whitelist existed since the
aipass-new design but the file was never written). Explains the project model:
**private by default** — each project is its own local git repo, fully ignored
by the AIPass repo, and publishing is an explicit opt-in step (Patrick ruling
2026-07-20). Opens the public roster with **Earmark**
([AIOSAI/earmark](https://github.com/AIOSAI/earmark)), the first public AIPass
project — a VS Code read-aloud extension with local Piper TTS and true
pause/resume, born, built, and published 2026-07-20.
**fix(hooks)** — persistent_alert dedup + loud trust-break banner (5-agent
trace round follow-ups, DPLAN-0253 tail):
- persistent_alert's once-per-session sound dedup lived in a module-global set,
but every bridge call is a fresh process — TTS would have announced on every
prompt while any alert was active. Replaced with session+alert-keyed tempdir
guard files (context_gauge idiom); banner capped at 10 alerts with an
"...and N more" note.
- Trust-registry breaks are now LOUD: any `.aipass/hooks.json` change breaks
the enrolled hash and silently disabled the entire hook layer (bit us live
for 2+ hours — tier prompts, security gates, everything dark, one log-file
WARNING as the only signal). New `is_hash_mismatch()` distinguishes a
genuine break from never-enrolled; `trust_break_banner()` does a
config-independent walk+hash check; the engine emits a full-width banner
once per prompt via the presence_gate bridge call. No auto-heal —
re-enrollment stays a deliberate human checkpoint. Live-fired: hash broken →
banner; restored → healthy. 16 new tests, suite 1206 green, seedgo 100%.
- Go-live day for the whole handler roster: 11/12 manifest entries wired into
provider settings by devpulse with Patrick accepting (user_message_relay
held: synchronous Telegram call + full prompt text off-machine — needs a
background send and an explicit call first). @hooks branch prompt corrected
and hardened: two-wires checklist + mandatory provider-wire flag in every
build reply.
**feat(hooks)** — auto-compact prep: context gauge + mechanical snapshot
(DPLAN-0253, built by @hooks, two rounds):
- `context_gauge` (UserPromptSubmit) — reads live context fill from the session
transcript every prompt (cheap 50KB tail), resolves the branch's compact
window (env > branch `settings.local.json` `autoCompactWindow` > 200k), and
injects a "run /prep NOW" nudge at 80% of the compact trigger, escalating at
95% — once per threshold per session. Memory prep happens before auto-compact
takes the choice away, on every branch including dispatched agents.
- `pre_compact_prep` (PreCompact) — stamps a mechanical AUTO-COMPACT SNAPSHOT
session entry into the compacting branch's `.trinity/local.json`: context
fill %, active dispatch locks, open plans, git state, inbox unread. Templated
from live state, defensive (malformed memory = log + skip, never raises).
- Shared `context_window` module: bounded transcript tail reader + per-branch
window resolver. 36 new tests; suite 1190 green; seedgo 100%.
- Round 2 root-cause fix: handlers wired only in `.aipass/hooks.json` never
fire on name-scoped events — UserPromptSubmit and PreCompact invoke the
bridge per-handler from provider settings. Both handlers now have
`provider_manifest.json` entries; @hooks' branch prompt corrected (it taught
the old one-entry-per-event model) with a "new handler? check the provider
wire" reminder. Go-live needs the user's `~/.claude/settings.json` synced
from the manifest + fresh sessions.
## [2026-07-19]
**feat(ai_mail)** — dispatched agents default to Sonnet 5 (Patrick ruling S326):
- wake.py model resolution passes bare aliases (`sonnet`/`opus`/`haiku`)
straight to the Claude CLI, which resolves latest-in-class — the pinned-ID
MODEL_MAP is gone and can never go stale again. Default flips opus → sonnet.
- dispatch_monitor pins `CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000` on every
spawned agent — Sonnet 5 is 1M-context native, and without the pin every
dispatched agent would silently inherit a 1M window. E2E-proven: a live
dispatched probe reported `claude-sonnet-5` + `WINDOW=200000` from inside.
- Follow-up landed same morning: the "daemon gap" was a name collision —
the unpatched `spawn_agent()` was ai_mail's own inbox-poller
(`handlers/dispatch/daemon.py`), not the @daemon branch. It now passes
`--model DEFAULT_MODEL` (imported from wake.py, single source); the 200k pin
was already covered via the shared dispatch_monitor wrapper. @daemon's
scheduled wakes import `wake_branch` directly and were covered from the start.
**fix(skills)** — Telegram poll 5xx now triggers network backoff (medic loop,
autonomous @skills fix): `HTTPError` ≥500 in `poll_updates` raises
`_NetworkPollError` instead of falling through to rapid-fire retry; 4xx still
logs and returns. Three new tests (502/503 backoff, 429 stays out).
**fix(spawn, commons, prax, hooks)** — S304 audit fix campaign, Track A
(DPLAN-0250, four owner dispatches verified + committed by devpulse):
- **spawn** — shared `is_protected()` (infrastructure floor / registry owner /
active passport) now guards both pollution repair and branch delete; repair no
longer flags the live `src/aipass/aipass` branch, and deleting a protected or
actively-passported branch is refused with the reason.
- **commons** — branch-name resolution lowercased across all five ops sites
(trade/artifact/profile/welcome/search) to match identity normalization;
gifting and trading work again (guards had never matched since mid-June).
- **prax** — pytest detection now also checks `_pytest` in `sys.modules`, so
`patch.dict(os.environ, clear=True)` in test suites can no longer strip the
guard and freeze prod-path log handlers into the setup cache.
- **hooks** — the two static-path JSONL writers (engine diagnostics, telegram
delivery log) resolve their path per-write and route to the tmp test dir
under pytest; a full 1154-test suite run now adds zero lines to prod logs
(marker-bounded proof).
Also: `/prep` now checks the cross-project feedback box every run — the S304
"unread since April" backlog (F46) is processed to zero and can't silently
rot again.
**fix(aipass)** — `aipass doctor` no longer invents errors on a healthy repo
(S304 F14-17): `.backup` and spawn `templates` dirs excluded from the agent
scan, relative registry paths anchored against the project root instead of the
caller's CWD (running doctor from inside a branch dir showed 5 fake
missing-branch errors), and info-severity findings now render as warnings
instead of pass checkmarks. Remaining findings on this repo are genuine.
**fix(flow)** — registry aggregate writes are lock+atomic (S304 F85 residual):
`save_branch_registry` and `save_central` were bare `open`+`json.dump`; both now
use the O_EXCL lockfile + temp-file + `os.replace` pattern from the earlier
`save_registry.py` fix. Proven with a 6-process concurrent-write hammer. Also
investigated (N1): FPLAN-0313/0314 closed blank because
`is_template_content()`'s line-count threshold fires before its bracket-marker
check on default templates — guard fix queued, registry annotation is the
maintainer's call.
## [2026-07-18]
**fix(tests)** — the immortal `MagicMock/LOG_FILE/` directory is dead: a hooks
engine test patched `diagnostics.LOG_FILE` with a bare Mock, and prax's
`append_jsonl` turned the mock's fspath into a real `mkdir` — every test run
re-minted an empty `MagicMock/LOG_FILE/` in the runner's CWD (found in repo
root, devpulse, and hooks). Test now patches a real tmp path; 100/100 green,
clean-CWD run verified to mint nothing.
**docs** — merge playbook gains a site drift-check step (DPLAN-0249 follow-on):
every merge run now asks whether install commands, onboarding flow, agent count,
or the platform/CLI story changed — if yes, aipass.ai must be updated the same
day. Codifies the S323 ruling that the site is a projection of the README, never
its own source of facts. Template edit by @flow, one checklist line in the
post-merge section.
**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
+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).
+49
View File
@@ -0,0 +1,49 @@
[← Back to AIPass](../README.md)
# Projects
> Your projects, built with AIPass. **Private by default** — published only if and when you choose.
This folder is where your own projects live. Create one with `aipass new <name>` and build whatever you want here — a tool, an app, an experiment, something nobody else will ever see. Each project is **its own separate git repository**, and the AIPass repo ignores everything in this folder (only this README ships). Nothing you build here can leak into the AIPass repo, appear in its history, or end up in anyone's pull request.
## What a project is
Every project is born complete:
- **Own git repo** — initialized locally at creation. Local means local: no remote, no publishing, nothing leaves your machine.
- **Own registry** — a sealed `*_REGISTRY.json` seating the project's owner.
- **Resident agent** — a full AIPass citizen living at `src/<name>/<name>/`, with identity, memory, and a mailbox, ready to work the project.
- **AIPass scaffold** — the same `.aipass/` prompt structure and conventions as the main ecosystem, so any agent (or human) knows their way around immediately.
Projects use AIPass; they don't live inside its repo. The ecosystem is the workshop — what you build in it is yours.
## Going public — optional, deliberate
If a project is ready for the world, publishing is an explicit choice: create a GitHub repository for it and push. Until you do that, it exists only on your machine.
Projects from the AIPass family that chose to go public:
| Project | What it is | Repo |
|---|---|---|
| **Earmark** | Read code and docs aloud in VS Code with local Piper TTS — pause, resume, pick up where you left off. Ear + bookmark: the plan is notes anchored to where you paused. First public AIPass project. | [AIOSAI/earmark](https://github.com/AIOSAI/earmark) |
| **aipass-site** | The [aipass.ai](https://aipass.ai) website — AIPass's front door on the web. | [AIOSAI/aipass-site](https://github.com/AIOSAI/aipass-site) |
Projects in residence (private, not yet published):
| Project | What it is |
|---|---|
| **Speakeasy** | System-wide voice-to-text: press a hotkey, speak, text lands at your cursor in any app. Local Whisper (faster-whisper), VAD, zero cloud. The voice-IN half of the loop Earmark's voice-OUT completes. Repo moved from ~/Projects/Speakeasy 2026-07-21, own git history intact. |
## Creating one
```bash
aipass new <name> # empty template + resident agent
aipass new <name> --template python
aipass new <name> --no-agent
```
The project lands in `projects/<name>`, git-initialized, registry sealed, agent seated — and private until you decide otherwise.
---
[← Back to AIPass](../README.md)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "aipass"
version = "2.7.2"
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.2"
__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
@@ -34,6 +34,7 @@ from aipass.ai_mail.apps.handlers.json import json_handler
from aipass.ai_mail.apps.handlers.dispatch.status import log_dispatch
from aipass.ai_mail.apps.handlers.paths import find_repo_root
from aipass.ai_mail.apps.handlers.dispatch.test_token import scan_and_ack_test_emails
from aipass.ai_mail.apps.handlers.dispatch.wake import DEFAULT_MODEL
# Infrastructure paths
@@ -408,6 +409,8 @@ def spawn_agent(
"-c",
"-p",
prompt,
"--model",
DEFAULT_MODEL,
"--max-turns",
str(max_turns),
"--permission-mode",
@@ -449,6 +449,9 @@ def main():
for key in list(spawn_env.keys()):
if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID":
spawn_env.pop(key)
# Pin agent context window to 200k (Sonnet 5 is 1M native; without this,
# agents inherit 1M which causes cost + runaway risk).
spawn_env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = "200000"
# Strip caller identity vars to prevent dispatch context leakage.
spawn_env.pop("AIPASS_CALLER_BRANCH", None)
spawn_env.pop("AIPASS_CALLER_CWD", None)
@@ -63,13 +63,9 @@ MONITOR_SCRIPT = Path(__file__).parent / "dispatch_monitor.py"
# Default prompt when no custom message provided
DEFAULT_PROMPT = "Hi. Check inbox, process new emails, update memories when done."
# Model shorthand mapping
MODEL_MAP = {
"sonnet": "claude-sonnet-4-6",
"opus": "claude-opus-4-6",
"haiku": "claude-haiku-4-5-20251001",
}
DEFAULT_MODEL = "opus"
# Model aliases — passed directly to claude CLI which resolves latest-in-class.
KNOWN_MODEL_ALIASES: frozenset = frozenset({"sonnet", "opus", "haiku"})
DEFAULT_MODEL = "sonnet"
# Branches that cannot be woken manually by cross-branch drone commands.
# Dispatch-send path (dispatch.py._orchestrate_dispatch_send) bypasses this check.
@@ -591,8 +587,8 @@ def wake_branch(
config = _load_config()
max_turns = config.get("max_turns_per_wake", 100)
# Resolve model: shorthand -> full ID, or pass through if already a full ID
resolved_model = MODEL_MAP.get(model or DEFAULT_MODEL, model or MODEL_MAP[DEFAULT_MODEL])
# Pass model directly to CLI — aliases resolve latest-in-class automatically
resolved_model = model or DEFAULT_MODEL
lock_file_path = str(branch_path / ".ai_mail.local" / ".dispatch.lock")
if custom_message:
@@ -781,7 +777,7 @@ if __name__ == "__main__":
print(" --fresh Start fresh session (claude -p) instead of resuming (claude -c -p)")
print(" --auto Respect autonomous_pause (used by daemon). Manual wake ignores it.")
print(" --sender @branch Set return-to-sender for bounce emails (default: @devpulse)")
print(" --model NAME Model to use: opus (default), sonnet, haiku, or full model ID")
print(" --model NAME Model to use: sonnet (default), opus, haiku, or full model ID")
print()
print("Output: Step-by-step status of the dispatch pipeline:")
print(" ✅ resolve → @branch found at /path/to/branch")
@@ -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
+42
View File
@@ -1317,6 +1317,48 @@ def test_spawn_agent_strips_claude_env_vars(tmp_path, monkeypatch):
assert captured_env.get("AIPASS_SESSION_TYPE") == "daemon"
def test_spawn_agent_claude_cmd_includes_model_flag(tmp_path):
"""Poller-triggered spawn passes --model DEFAULT_MODEL to the claude invocation."""
branch_path = tmp_path / "branch"
branch_path.mkdir()
(branch_path / "logs").mkdir()
message = {"from": "@devpulse", "id": "msg1", "subject": "Test task"}
config = {"max_turns_per_wake": 50}
state = {"daily_counts": {}, "session_cycles": {}}
captured_args = []
def capture_popen(*args, **kwargs):
captured_args.append(args[0] if args else kwargs.get("args"))
mock_proc = MagicMock()
mock_proc.pid = 22222
return mock_proc
with (
patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen",
side_effect=capture_popen,
),
patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock",
return_value=(True, "Lock acquired"),
),
patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"),
patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification",
create=True,
),
):
result = spawn_agent(branch_path, "@testbranch", message, config, state)
assert result is True
monitor_cmd = captured_args[0]
assert "--model" in monitor_cmd
model_idx = monitor_cmd.index("--model")
assert monitor_cmd[model_idx + 1] == daemon_mod.DEFAULT_MODEL
def test_spawn_agent_prompt_includes_reply_id(tmp_path):
"""Prompt includes explicit reply command with the dispatch email ID."""
branch_path = tmp_path / "branch"
+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
+13 -15
View File
@@ -29,7 +29,7 @@ from aipass.ai_mail.apps.handlers.dispatch.wake import (
_find_claude_bin,
resolve_branch,
DispatchStatus,
MODEL_MAP,
KNOWN_MODEL_ALIASES,
DEFAULT_MODEL,
_acquire_lock,
_load_config,
@@ -572,24 +572,22 @@ def _fake_open_factory(real_status_path, mapping):
# --- Model flag tests ---------------------------------------------------
def test_model_map_has_expected_entries():
"""MODEL_MAP should contain sonnet, opus, haiku shorthand mappings."""
assert "sonnet" in MODEL_MAP
assert "opus" in MODEL_MAP
assert "haiku" in MODEL_MAP
assert "claude-sonnet-4-6" in MODEL_MAP["sonnet"]
assert "claude-opus-4-6" in MODEL_MAP["opus"]
def test_known_model_aliases_has_expected_entries():
"""KNOWN_MODEL_ALIASES should contain sonnet, opus, haiku."""
assert "sonnet" in KNOWN_MODEL_ALIASES
assert "opus" in KNOWN_MODEL_ALIASES
assert "haiku" in KNOWN_MODEL_ALIASES
def test_default_model_is_opus():
"""Default model should be opus."""
assert DEFAULT_MODEL == "opus"
def test_default_model_is_sonnet():
"""Default model should be sonnet."""
assert DEFAULT_MODEL == "sonnet"
def test_model_map_values_are_full_ids():
"""All MODEL_MAP values should be full claude model IDs."""
for key, value in MODEL_MAP.items():
assert value.startswith("claude-"), f"{key} -> {value} doesn't start with 'claude-'"
def test_known_model_aliases_are_bare_names():
"""All KNOWN_MODEL_ALIASES should be bare alias names (no 'claude-' prefix)."""
for alias in KNOWN_MODEL_ALIASES:
assert not alias.startswith("claude-"), f"{alias} should be a bare alias"
# --- _find_claude_bin tests ------------------------------------------
@@ -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.
+25 -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",
+38 -14
View File
@@ -1,11 +1,23 @@
# AIPASS
Concierge and librarian for AIPass. Greets new users, walks them through setup, answers how-things-work questions, hands off to their chosen CLI.
The friendly front door for AIPass. Walks new users through setup, runs system diagnostics, answers documentation questions, and creates projects inside the AIPass environment.
## Quick Start
```bash
aipass # Show available commands
aipass doctor # Check system health
aipass help what does drone do # Search branch documentation
aipass new myapp --template python # Create a new project
aipass adopt myapp --dry-run # Preview adopting an existing projects/ dir
aipass init # Guided setup (10 stages, resumable)
```
## Invoke
```
drone @aipass <command>
aipass <command> [options]
aipass <command> --help
```
## Architecture
@@ -16,27 +28,32 @@ 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)
│ │ ├── new_project.py # aipass new — create projects inside the installation
│ │ ├── adopt.py # aipass adopt — bring an existing projects/ dir into AIPass
│ │ ├── profile.py # User profile read/write
│ │ └── trust.py # Trust registry — aipass trust / aipass revoke
│ │ ├── trust.py # Trust registry — aipass trust / aipass revoke
│ │ └── feedback.py # Feedback pulse toggle — aipass feedback on/off
│ ├── handlers/
│ │ ├── cross_os/ # Cross-OS pre-flight: gap_registry, preflight, run_record
│ │ ├── handoff_platform/ # Platform-specific handoff detection
│ │ ├── init/ # bootstrap.py, scaffold_content.py
│ │ ├── new_project/ # Project creation logic (registry, template, scaffold, git init)
│ │ │ └── adopt.py # Project adoption logic (additive scaffold onto an existing dir)
│ │ ├── json/ # JSON read/write utilities
│ │ ├── ping_sweep/ # Branch reachability verification
│ │ ├── provider_reconcile.py # Stale deny-rule detection + fix
│ │ ├── provider_reconcile.py # Stale deny-rule detection + fix
│ │ ├── readme_map/ # Live file reads + branch routing
│ │ ├── structure_scan/ # Agent placement + pollution detection
│ │ ├── system_detect/ # OS, shell, Python, RAM, CPU
│ │ └── ui/ # Progress bars, menus, banners
│ └── plugins/
├── tests/ # 609 passing
├── tests/ # 785 passing
├── requirements.project.txt # Project-specific Python dependencies
├── .trinity/ # Identity + session history + observations
└── README.md
@@ -46,19 +63,26 @@ aipass/
| Command | Description |
|---------|-------------|
| `aipass` | Help banner |
| `aipass` | Show available commands |
| `aipass help [Q]` | README-backed Q&A with branch routing |
| `aipass doctor` | System health — structure, registry, hooks, pytest |
| `aipass doctor --fix` | Remediation report with `drone @spawn repair` commands |
| `aipass doctor --json` | JSON output for structure scan results |
| `aipass 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 adopt <name>` | Turn an existing `projects/<name>` directory into a full project — additive scaffold only |
| `aipass adopt <name> --no-agent` | Adopt without a resident agent |
| `aipass adopt <name> --dry-run` | Preview what adoption would do, writes nothing |
| `aipass trust [path]` | Show enrolled projects or enroll a project in the trust registry |
| `aipass revoke <path>` | Remove a project from the trust registry |
| `aipass feedback on/off` | Toggle the feedback reminder pulse (delegates to @hooks) |
| `aipass --version` | Version |
## Integration Points
@@ -79,7 +103,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
@@ -87,4 +111,4 @@ Humans only. Nothing in AIPass depends on this branch.
## Last Updated
Last Updated: 2026-07-05
Last Updated: 2026-07-17
+113 -18
View File
@@ -35,8 +35,25 @@ if sys.platform == "win32":
if _reconfigure is not None:
_reconfigure(encoding="utf-8", errors="replace")
from aipass.cli.apps.modules import console, error
from aipass.prax import logger
# =============================================================================
# COMMANDS — public-facing labels and descriptions
# =============================================================================
_PUBLIC_COMMANDS = {
"adopt": "Turn an existing projects/ directory into a full project",
"doctor": "System health — structure, registry, hooks, tests",
"help": "README-backed Q&A — ask about any branch",
"init": "Guided setup for new users (10 stages, resumable)",
"install": "One-command bootstrap — clone + setup + init",
"new": "Create a project inside AIPass",
"profile": "Show/edit user profile",
"trust": "Trust registry — enroll/revoke projects",
"feedback": "Toggle the feedback reminder on/off",
}
# =============================================================================
# MODULE DISCOVERY
# =============================================================================
@@ -72,6 +89,86 @@ 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]adopt <name>[/green] "
"[dim]Turn an existing projects/ directory into a full project[/dim]"
)
console.print(" [green]profile[/green] [dim]Show/edit user profile[/dim]")
console.print(
" [green]trust[/green] [dim][path][/dim] [dim]Trust registry — enroll/revoke projects[/dim]"
)
console.print(" [green]--version[/green] [dim]Show version[/dim]")
console.print()
console.print("[yellow]Examples:[/yellow]")
console.print(" [green]aipass doctor[/green] [dim]Check system health[/dim]")
console.print(" [green]aipass help what does drone do[/green] [dim]Search documentation[/dim]")
console.print(" [green]aipass new myapp --template python[/green] [dim]Create a Python project[/dim]")
console.print(" [green]aipass adopt myapp --dry-run[/green] [dim]Preview adopting projects/myapp[/dim]")
console.print(" [green]aipass init[/green] [dim]Start guided setup[/dim]")
console.print()
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
"""Route command to appropriate module.
@@ -105,17 +202,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 +221,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,9 +11,10 @@
from aipass.aipass.apps.handlers.init.bootstrap import (
_sanitize_name,
init_project,
is_projects_child,
update_project,
)
from aipass.aipass.apps.handlers.init.scaffold_content import (
from aipass.aipass.shared.scaffold_content import (
global_prompt_md,
inbox_json,
prep_md,
@@ -25,6 +26,7 @@ __all__ = [
"global_prompt_md",
"inbox_json",
"init_project",
"is_projects_child",
"prep_md",
"update_project",
"with_source",
+54 -108
View File
@@ -30,18 +30,23 @@ RULES:
- No hardcoded paths
"""
import importlib.util
import json
import logging
import os
import re
import shutil
import tempfile
import uuid
from datetime import date
from pathlib import Path
from aipass.aipass.apps.handlers.init import scaffold_content as sc
from aipass.aipass.shared import scaffold_content as sc
from aipass.aipass.shared.project_home import (
_claude_local_settings,
_claude_settings,
_detect_aipass_home,
_enroll_project,
is_projects_child,
is_throwaway_path,
)
logger = logging.getLogger(__name__)
@@ -50,25 +55,6 @@ _STALE_MANAGED_FILES: list[Path] = [
]
def is_throwaway_path(path: str | Path) -> bool:
"""True if path is under a temp dir or Claude Code scratchpad."""
resolved = str(Path(path).resolve())
tmp_roots = [tempfile.gettempdir()]
if os.name == "posix":
tmp_roots.append("/tmp")
for root in tmp_roots:
try:
r = str(Path(root).resolve())
except OSError:
logger.info("is_throwaway_path: could not resolve %s", root)
continue
if resolved == r or resolved.startswith(r + os.sep):
return True
if "scratchpad" in resolved.lower():
return True
return False
def _sanitize_name(raw: str) -> str:
"""Sanitize a project name for use in filenames.
@@ -78,23 +64,6 @@ def _sanitize_name(raw: str) -> str:
return re.sub(r"[^A-Z0-9_-]", "_", raw.upper()).strip("_")
def _detect_aipass_home() -> str | None:
"""Detect the AIPass installation root from the aipass package location.
Returns the parent of the src/ directory (the repo root).
Returns None if detection fails.
"""
try:
spec = importlib.util.find_spec("aipass")
if spec and spec.origin:
# aipass/__init__.py lives at src/aipass/__init__.py
# parent = src/aipass/, parent.parent = src/, parent.parent.parent = AIPass root
return str(Path(spec.origin).resolve().parent.parent.parent)
except Exception as exc:
logger.info("AIPASS_HOME detection skipped: %s", exc)
return None
def _hook_fingerprint(hook_entry: dict) -> str:
"""Extract a comparable fingerprint from a hook entry."""
commands = []
@@ -133,10 +102,11 @@ def _merge_settings(existing: dict, generated: dict) -> dict:
if cleaned_hooks:
merged["hooks"] = cleaned_hooks
# Merge env: generated wins for AIPASS_HOME, preserve user additions
existing_env = existing.get("env", {})
generated_env = generated.get("env", {})
merged["env"] = {**existing_env, **generated_env}
# env: AIPASS_HOME is machine-local — never tracked (see settings.local.json).
# Strip it from any previously-tracked settings.json; preserve other user vars.
existing_env = {k: v for k, v in existing.get("env", {}).items() if k != "AIPASS_HOME"}
if existing_env:
merged["env"] = existing_env
# Merge permissions: union deny/ask lists
existing_perms = existing.get("permissions", {})
@@ -210,61 +180,13 @@ def _merge_hooks_json(existing: dict, template: dict) -> dict:
return merged
def _claude_settings(aipass_home: str | None = None) -> str:
"""Generate .claude/settings.json — env and permissions only.
Hooks are NOT wired at the project level. All AIPass hooks
(prompt injection, identity, email, pre-compact, edit gates) fire
from provider settings (~/.claude/settings.json), installed by
setup.sh. Provider hooks use CWD-walking patterns that work from
any directory in any project.
Project settings only contain:
- env.AIPASS_HOME (so hooks can find the AIPass installation)
- permissions.deny (basic safety rails)
Args:
aipass_home: Optional AIPass installation root to add as env.AIPASS_HOME.
"""
data: dict = {}
data["permissions"] = {
"deny": [
"Bash(git push --force*)",
"Bash(git reset --hard*)",
"EnterPlanMode",
],
}
if aipass_home and not is_throwaway_path(aipass_home):
data["env"] = {"AIPASS_HOME": aipass_home}
elif aipass_home:
logger.warning(
"AIPASS_HOME '%s' is a throwaway path — not writing to settings",
aipass_home,
)
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
def _enroll_project(target: Path) -> None:
"""Enroll a project in the trusted-project registry (DPLAN-0244).
Lazy import to keep 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 _guard_init(target: Path) -> None:
def _guard_init(target: Path, *, allow_projects_child: bool = False) -> None:
"""Block init if target is inside an agent branch or existing project.
When *allow_projects_child* is True, the nested-project checks are
skipped for targets that are ``<host>/projects/<name>``. This is used
by ``aipass new`` to create projects inside the installation.
Raises RuntimeError with explanation if init should not proceed.
"""
target = target.resolve()
@@ -286,6 +208,8 @@ def _guard_init(target: Path) -> None:
# Block: target already has a registry (is already a project)
for f in target.iterdir() if target.is_dir() else []:
if f.is_file() and f.name.endswith("_REGISTRY.json"):
if allow_projects_child and is_projects_child(target):
break
raise RuntimeError(
f"BLOCKED: '{target}' is already an AIPass project (has {f.name}). "
"Use 'aipass init update' to upgrade an existing project."
@@ -296,6 +220,8 @@ def _guard_init(target: Path) -> None:
continue
for f in parent.iterdir():
if f.is_file() and f.name.endswith("_REGISTRY.json"):
if allow_projects_child and is_projects_child(target):
return
raise RuntimeError(
f"BLOCKED: '{target}' is inside AIPass project at '{parent}' (has {f.name}). "
"Cannot create a nested project."
@@ -304,12 +230,18 @@ def _guard_init(target: Path) -> None:
break
def init_project(target: Path, project_name: str | None = None) -> dict:
def init_project(
target: Path,
project_name: str | None = None,
*,
allow_projects_child: bool = False,
) -> dict:
"""Initialize an AIPass project in the target directory.
Args:
target: Directory to initialize
project_name: Name for the registry (defaults to directory name)
allow_projects_child: When True, allow init inside ``<host>/projects/<name>``.
Returns:
dict with registry_id, registry_file, project_name, target, created_files
@@ -319,7 +251,7 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
RuntimeError: If target is inside an agent branch or existing project
"""
target = target.resolve()
_guard_init(target)
_guard_init(target, allow_projects_child=allow_projects_child)
if not target.exists():
target.mkdir(parents=True)
@@ -416,16 +348,23 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
gitignore_path.write_text(sc.gitignore(), encoding="utf-8")
created.append(str(gitignore_path))
# 9. .claude/settings.json
# 9. .claude/settings.json — tracked, permissions only (no machine-local paths)
claude_dir = target / ".claude"
claude_dir.mkdir(exist_ok=True)
settings_path = claude_dir / "settings.json"
if not settings_path.exists():
settings_path.write_text(_claude_settings(aipass_home), encoding="utf-8")
settings_path.write_text(_claude_settings(), encoding="utf-8")
created.append(str(settings_path))
# 9b. .claude/commands/prep.md — /prep session wrap-up slash command
# 9b. .claude/settings.local.json — machine-local AIPASS_HOME (gitignored)
if aipass_home and not is_throwaway_path(aipass_home):
local_settings_path = claude_dir / "settings.local.json"
if not local_settings_path.exists():
local_settings_path.write_text(_claude_local_settings(aipass_home), encoding="utf-8")
created.append(str(local_settings_path))
# 9c. .claude/commands/prep.md — /prep session wrap-up slash command
# Only prep.md here — memo.md belongs at provider level (~/.claude/commands/)
commands_dir = claude_dir / "commands"
commands_dir.mkdir(exist_ok=True)
@@ -549,11 +488,11 @@ def update_project(target: Path) -> dict:
elif tier_dest.exists():
already_current.append(str(tier_dest))
# settings.json — smart merge: preserve user hooks + env, update AIPass hooks
# settings.json — smart merge: preserve user hooks, update AIPass permissions.
# AIPASS_HOME never lives here — machine-local paths go in settings.local.json.
settings_path = claude_dir / "settings.json"
if not settings_path.exists():
aipass_home = _detect_aipass_home()
settings_path.write_text(_claude_settings(aipass_home), encoding="utf-8")
settings_path.write_text(_claude_settings(), encoding="utf-8")
updated.append(str(settings_path))
else:
existing_content = settings_path.read_text(encoding="utf-8")
@@ -562,9 +501,7 @@ def update_project(target: Path) -> dict:
except json.JSONDecodeError as exc:
logger.info("settings.json parse failed, rebuilding: %s", exc)
existing = {}
existing_env = existing.get("env", {})
aipass_home = existing_env.get("AIPASS_HOME") or _detect_aipass_home()
generated = json.loads(_claude_settings(aipass_home))
generated = json.loads(_claude_settings())
merged = _merge_settings(existing, generated)
merged_content = json.dumps(merged, indent=2, ensure_ascii=False) + "\n"
if existing != merged:
@@ -573,6 +510,15 @@ def update_project(target: Path) -> dict:
else:
already_current.append(str(settings_path))
# settings.local.json — machine-local AIPASS_HOME (gitignored, create if missing)
if aipass_home and not is_throwaway_path(aipass_home):
local_settings_path = claude_dir / "settings.local.json"
if not local_settings_path.exists():
local_settings_path.write_text(_claude_local_settings(aipass_home), encoding="utf-8")
updated.append(str(local_settings_path))
else:
already_current.append(str(local_settings_path))
# hooks.json — union-merge: preserve user enabled, add new hooks from template
hooks_json_path = aipass_dir / "hooks.json"
hook_home = aipass_home or _detect_aipass_home()
@@ -0,0 +1,354 @@
# =================== AIPass ====================
# Name: __init__.py
# Description: New project handler — create projects inside AIPass
# Version: 1.0.0
# Created: 2026-07-17
# Modified: 2026-07-17
# =============================================
"""
New Project Handler — creates projects inside AIPass installations.
Business logic for `aipass new`. Creates a project at <host>/projects/<name>
with its own git repo, registry, and optional AIPass agent.
Flow: find host -> validate -> mkdir -> mint registry (FIRST) ->
write template -> scaffold AIPass files -> git init -> optional agent.
RULES:
- Registry MUST be minted before any spawn call
- git init via subprocess (hooks git gate only intercepts agent Bash)
- Cleanup on failure: partial project is worse than no project
"""
import json
import re
import shutil
import subprocess
import uuid
from datetime import date
from pathlib import Path
from aipass.aipass.shared import scaffold_content as sc
from aipass.prax import logger
from aipass.spawn import spawn_agent
TEMPLATES = ("empty", "python")
def find_host_root(start: Path) -> Path | None:
"""Walk up from *start* to find the AIPass host installation root.
Returns the directory containing ``*_REGISTRY.json``, or ``None``.
"""
for p in [start, *start.parents]:
try:
entries = list(p.iterdir())
except OSError:
continue
for f in entries:
try:
if f.is_file() and f.name.endswith("_REGISTRY.json"):
return p
except OSError:
continue
return None
def _validate_name(name: str) -> str:
if not name:
raise ValueError("Project name cannot be empty")
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_-]*$", name):
raise ValueError(
f"Invalid project name '{name}'. "
"Must start with a letter, contain only letters, digits, hyphens, underscores."
)
return name
def _registry_name(name: str) -> str:
return re.sub(r"[^A-Z0-9_-]", "_", name.upper()).strip("_")
def _git(args: list[str], cwd: Path) -> str:
r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
if r.returncode != 0:
raise RuntimeError(f"git {' '.join(args)}: {r.stderr.strip()}")
return r.stdout.strip()
# ── registry ────────────────────────────────────────────────────────────
def _write_registry(target: Path, name: str) -> tuple[str, str]:
"""Mint the project registry. Returns ``(registry_id, filename)``."""
registry_id = str(uuid.uuid4())
today = date.today().isoformat()
reg = _registry_name(name)
filename = f"{reg}_REGISTRY.json"
data = {
"metadata": {
"id": registry_id,
"name": reg,
"version": "1.0.0",
"created": today,
"last_updated": today,
"total_branches": 0,
},
"branches": [],
}
(target / filename).write_text(
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
return registry_id, filename
# ── templates ───────────────────────────────────────────────────────────
def _write_template(target: Path, name: str, template: str) -> list[str]:
"""Write template-specific files. Returns relative paths created."""
created: list[str] = []
(target / "README.md").write_text(
f"# {name}\n\nCreated with `aipass new`. Template: {template}.\n",
encoding="utf-8",
)
created.append("README.md")
(target / ".gitignore").write_text(sc.gitignore(), encoding="utf-8")
created.append(".gitignore")
if template == "python":
pkg = name.replace("-", "_").lower()
(target / "pyproject.toml").write_text(
"[build-system]\n"
'requires = ["setuptools>=61.0"]\n'
'build-backend = "setuptools.build_meta"\n\n'
"[project]\n"
f'name = "{name}"\n'
'version = "0.1.0"\n'
f'description = "{name} — born deployable"\n'
'requires-python = ">=3.10"\n\n'
"[tool.setuptools.packages.find]\n"
'where = ["src"]\n\n'
"[tool.pytest.ini_options]\n"
'testpaths = ["src"]\n'
'pythonpath = ["src"]\n',
encoding="utf-8",
)
created.append("pyproject.toml")
src = target / "src" / pkg
src.mkdir(parents=True)
(src / "__init__.py").write_text(
f'"""{name} — born deployable."""\n\n__version__ = "0.1.0"\n',
encoding="utf-8",
)
created.append(f"src/{pkg}/__init__.py")
return created
# ── AIPass scaffold ─────────────────────────────────────────────────────
def _scaffold_aipass(target: Path, name: str) -> list[str]:
"""Write AIPass scaffold files (tiers, hooks, CLAUDE.md, settings, .venv)."""
from aipass.aipass.shared.project_home import (
_claude_local_settings,
_claude_settings,
_detect_aipass_home,
_enroll_project,
is_throwaway_path,
)
created: list[str] = []
aipass_home = _detect_aipass_home()
reg = _registry_name(name)
# .aipass/
aipass_dir = target / ".aipass"
aipass_dir.mkdir(exist_ok=True)
# Tier files
if aipass_home:
for tier_file in ("tier0_kernel.md", "tier1_navmap.md"):
dest = aipass_dir / tier_file
src_path = Path(aipass_home) / ".aipass" / tier_file
if src_path.is_file():
shutil.copy2(str(src_path), str(dest))
created.append(f".aipass/{tier_file}")
# hooks.json + trust enrollment
if aipass_home:
template = Path(aipass_home) / ".aipass" / "project_hooks.json"
if template.is_file():
shutil.copy2(str(template), str(aipass_dir / "hooks.json"))
created.append(".aipass/hooks.json")
_enroll_project(target)
# CLAUDE.md, AGENTS.md
for md_name in ("CLAUDE.md", "AGENTS.md"):
dest = target / md_name
if dest.exists():
continue
if aipass_home:
tmpl = Path(aipass_home) / ".aipass" / f"project_{md_name}"
if tmpl.is_file():
content = tmpl.read_text(encoding="utf-8").replace("{name}", reg)
dest.write_text(content, encoding="utf-8")
created.append(md_name)
continue
if md_name == "AGENTS.md":
dest.write_text(sc.agents_md(reg), encoding="utf-8")
created.append(md_name)
# .claude/settings.json — tracked, permissions only (no machine-local paths)
claude_dir = target / ".claude"
claude_dir.mkdir(exist_ok=True)
(claude_dir / "settings.json").write_text(
_claude_settings(),
encoding="utf-8",
)
created.append(".claude/settings.json")
# .claude/settings.local.json — machine-local AIPASS_HOME (gitignored)
if aipass_home and not is_throwaway_path(aipass_home):
(claude_dir / "settings.local.json").write_text(
_claude_local_settings(aipass_home),
encoding="utf-8",
)
created.append(".claude/settings.local.json")
# .claude/commands/prep.md
commands_dir = claude_dir / "commands"
commands_dir.mkdir(exist_ok=True)
(commands_dir / "prep.md").write_text(sc.prep_md(), encoding="utf-8")
created.append(".claude/commands/prep.md")
# .venv symlink
if aipass_home:
venv = Path(aipass_home) / ".venv"
if venv.is_dir():
link = target / ".venv"
link.symlink_to(venv)
created.append(".venv")
return created
# ── git ─────────────────────────────────────────────────────────────────
def _git_init(target: Path, name: str, template: str) -> None:
"""Initialize git repo with birth commit. Guards against re-init."""
if (target / ".git").exists():
raise RuntimeError(f"'{target}' already has a .git directory")
_git(["init", "-b", "main"], target)
_git(["add", "-A"], target)
_git(
["commit", "-m", f"birth: {name} ({template} template) via aipass new"],
target,
)
# ── agent (via @spawn) ──────────────────────────────────────────────────
def _agent_home(project_root: Path, name: str) -> Path:
"""Compute the agent home directory: src/<pkg>/<pkg>/."""
pkg = name.replace("-", "_").lower()
return project_root / "src" / pkg / pkg
def _spawn_project_agent(project_root: Path, name: str) -> dict:
"""Create the project agent via spawn_agent().
Agent lives at src/<pkg>/<pkg>/ inside the project. Spawn discovers
the project-local registry (minted earlier by _write_registry) by
walking up from the agent home to the project root.
"""
home = _agent_home(project_root, name)
result = spawn_agent(
target_path=str(home),
role="project_agent",
purpose=f"Resident agent of the {name} project.",
citizen_class="project_agent",
)
if not result.get("success"):
raise RuntimeError(f"spawn_agent failed: {result.get('error', 'unknown')}")
logger.info(
"[aipass new] agent spawned via @spawn: %s (%d files)",
result["branch_name"],
result["files_copied"],
)
return result
# ── public API ──────────────────────────────────────────────────────────
def create_project(
name: str,
template: str = "empty",
no_agent: bool = False,
) -> dict:
"""Create a new project inside the AIPass host installation.
Returns:
dict with name, template, target, host, registry_id, registry_file,
files, agent_spawned.
Raises:
ValueError: Invalid name or template.
RuntimeError: Not inside AIPass, target exists, git failure.
"""
_validate_name(name)
if template not in TEMPLATES:
raise ValueError(f"Unknown template '{template}'. Choose from: {', '.join(TEMPLATES)}")
host = find_host_root(Path.cwd())
if host is None:
raise RuntimeError(
"Not inside an AIPass installation (no *_REGISTRY.json found). "
"`aipass new` creates projects inside the AIPass environment."
)
target = host / "projects" / name
if target.exists():
raise RuntimeError(f"Project already exists: {target}")
target.mkdir(parents=True)
try:
registry_id, registry_file = _write_registry(target, name)
logger.info("[aipass new] registry minted: %s (%s)", registry_file, registry_id)
template_files = _write_template(target, name, template)
scaffold_files = _scaffold_aipass(target, name)
spawn_result = None
if not no_agent:
spawn_result = _spawn_project_agent(target, name)
_git_init(target, name, template)
logger.info("[aipass new] git repo initialized with birth commit")
return {
"name": name,
"template": template,
"target": str(target),
"host": str(host),
"registry_id": registry_id,
"registry_file": registry_file,
"files": template_files + scaffold_files,
"agent_created": spawn_result is not None,
"agent_home": str(_agent_home(target, name)) if spawn_result else None,
"spawn_result": spawn_result,
}
except Exception:
if target.exists():
shutil.rmtree(target)
raise
@@ -0,0 +1,224 @@
# =================== AIPass ====================
# Name: adopt.py
# Description: aipass adopt — bring an existing projects/ directory into AIPass
# Version: 1.0.0
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""
Adopt Handler — PRIVATE implementation
Business logic for `aipass adopt`. Turns an EXISTING directory at
<host>/projects/<name> into a full AIPass project: sealed registry,
resident agent, .aipass/.claude scaffold.
Unlike `aipass new` (which births a brand-new directory), adopt starts
from a directory that already has its own content — possibly its own
git repo, possibly public. Every write is existence-guarded; nothing
already present is ever overwritten. .gitignore is the one file that's
patched rather than skipped, since AIPass local state (.trinity/,
mailbox, *.local.json) must never leak into a tracked commit.
RULES:
- Additive only — never overwrite a file that already exists
- Never touch the target's git history (no git init/add/commit)
- gitignore safety runs BEFORE any other AIPass file is written
- Registry-first: mint the project registry before spawning the agent
- dry_run performs zero filesystem writes and never calls spawn_agent
"""
from __future__ import annotations
from pathlib import Path
from aipass.aipass.apps.handlers.json import json_handler
from aipass.aipass.apps.handlers.new_project import (
_agent_home,
_registry_name,
_spawn_project_agent,
_write_registry,
)
from aipass.aipass.shared import scaffold_content as sc
from aipass.aipass.shared.project_home import (
_claude_local_settings,
_claude_settings,
_detect_aipass_home,
_enroll_project,
is_projects_child,
is_throwaway_path,
)
GITIGNORE_MARKER = "# AIPass local state"
def _gitignore_safety(target: Path, *, dry_run: bool) -> str:
"""Ensure AIPass-managed paths are gitignored. Returns 'created', 'appended', or 'already-safe'."""
gitignore_path = target / ".gitignore"
if gitignore_path.exists():
existing = gitignore_path.read_text(encoding="utf-8")
if GITIGNORE_MARKER in existing:
return "already-safe"
if not dry_run:
separator = "" if existing.endswith("\n") else "\n"
gitignore_path.write_text(existing + separator + "\n" + sc.gitignore(), encoding="utf-8")
return "appended"
if not dry_run:
gitignore_path.write_text(sc.gitignore(), encoding="utf-8")
return "created"
def _write_if_missing(path: Path, content: str, *, dry_run: bool, planned: list[str]) -> None:
"""Record and (unless dry_run) write *content* to *path*, but only if it doesn't already exist."""
if path.exists():
return
if not dry_run:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
planned.append(str(path))
def adopt_project(target: Path, *, no_agent: bool = False, dry_run: bool = False) -> dict:
"""Adopt an existing directory at <host>/projects/<name> as a full AIPass project.
Args:
target: Existing directory to adopt — must be <host>/projects/<name>.
no_agent: Skip resident-agent creation.
dry_run: Report what WOULD happen; performs zero filesystem writes and
never calls spawn_agent.
Returns:
dict with name, target, host, dry_run, registry_id, registry_file,
files, gitignore_action, agent_created, agent_home, spawn_result.
Raises:
RuntimeError: target missing, not a projects/ child, already adopted,
or a resident-agent home path collision.
"""
target = target.resolve()
if not target.is_dir():
raise RuntimeError(f"'{target}' does not exist. `aipass adopt` brings in an EXISTING directory.")
if not is_projects_child(target):
raise RuntimeError(
f"'{target}' is not <host>/projects/<name>. `aipass adopt` only works inside projects/ "
"— use `aipass new` to create a fresh project instead."
)
existing_registry = [f for f in target.iterdir() if f.is_file() and f.name.endswith("_REGISTRY.json")]
if existing_registry:
raise RuntimeError(f"'{target}' is already adopted (has {existing_registry[0].name}).")
host = target.parent.parent
name = target.name
reg = _registry_name(name)
agent_home = _agent_home(target, name)
if not no_agent and agent_home.exists() and any(agent_home.iterdir()):
raise RuntimeError(
f"Name collision: '{agent_home}' already exists and is non-empty — "
"cannot seat a resident agent there. Retry with --no-agent."
)
aipass_home = _detect_aipass_home()
files: list[str] = []
# gitignore safety FIRST — nothing AIPass-managed gets written before the
# target is confirmed to ignore it (target may be a public repo).
gitignore_action = _gitignore_safety(target, dry_run=dry_run)
# Registry — sealed, minted BEFORE spawn (registry-first rule)
registry_id = None
registry_filename = f"{reg}_REGISTRY.json"
if not dry_run:
registry_id, registry_filename = _write_registry(target, name)
files.append(registry_filename)
# .aipass/ — tier files, hooks.json, CLAUDE.md/AGENTS.md
aipass_dir = target / ".aipass"
if aipass_home:
for tier_file in ("tier0_kernel.md", "tier1_navmap.md"):
src_path = Path(aipass_home) / ".aipass" / tier_file
if src_path.is_file():
_write_if_missing(
aipass_dir / tier_file,
src_path.read_text(encoding="utf-8"),
dry_run=dry_run,
planned=files,
)
hooks_template = Path(aipass_home) / ".aipass" / "project_hooks.json"
if hooks_template.is_file():
hooks_dest = aipass_dir / "hooks.json"
already_had_hooks = hooks_dest.exists()
_write_if_missing(hooks_dest, hooks_template.read_text(encoding="utf-8"), dry_run=dry_run, planned=files)
if not already_had_hooks and not dry_run:
_enroll_project(target)
for md_name in ("CLAUDE.md", "AGENTS.md"):
dest = target / md_name
if dest.exists():
continue
if aipass_home:
tmpl = Path(aipass_home) / ".aipass" / f"project_{md_name}"
if tmpl.is_file():
content = tmpl.read_text(encoding="utf-8").replace("{name}", reg)
_write_if_missing(dest, content, dry_run=dry_run, planned=files)
continue
if md_name == "AGENTS.md":
_write_if_missing(dest, sc.agents_md(reg), dry_run=dry_run, planned=files)
# .claude/settings.json — tracked, permissions only
claude_dir = target / ".claude"
_write_if_missing(claude_dir / "settings.json", _claude_settings(), dry_run=dry_run, planned=files)
# .claude/settings.local.json — machine-local AIPASS_HOME (gitignored)
if aipass_home and not is_throwaway_path(aipass_home):
_write_if_missing(
claude_dir / "settings.local.json",
_claude_local_settings(aipass_home),
dry_run=dry_run,
planned=files,
)
# .claude/commands/prep.md
_write_if_missing(claude_dir / "commands" / "prep.md", sc.prep_md(), dry_run=dry_run, planned=files)
# .venv symlink → AIPass shared runtime
if aipass_home:
venv = Path(aipass_home) / ".venv"
venv_link = target / ".venv"
if venv.is_dir() and not venv_link.exists():
if not dry_run:
venv_link.symlink_to(venv)
files.append(str(venv_link))
# Resident agent — registry MUST exist first (dry_run never spawns)
spawn_result = None
agent_created = False
will_have_agent = not no_agent
if will_have_agent and not dry_run:
spawn_result = _spawn_project_agent(target, name)
agent_created = True
elif will_have_agent and dry_run:
files.append(f"{agent_home} (resident agent — planned)")
json_handler.log_operation(
"adopt_project",
{"name": name, "target": str(target), "dry_run": dry_run},
"adopt",
)
return {
"name": name,
"target": str(target),
"host": str(host),
"dry_run": dry_run,
"registry_id": registry_id,
"registry_file": registry_filename,
"files": files,
"gitignore_action": gitignore_action,
"agent_created": agent_created,
"agent_home": str(agent_home) if will_have_agent else None,
"spawn_result": spawn_result,
}
@@ -93,7 +93,7 @@ def find_project_root(start: Path) -> Optional[Path]:
# =============================================================================
_SCAN_SKIP_DIRS = {".archive", ".venv", ".git", "__pycache__", "node_modules", ".chroma"}
_SCAN_SKIP_DIRS = {".archive", ".backup", ".venv", ".git", "__pycache__", "node_modules", ".chroma", "templates"}
def scan_agents(project_root: Path) -> List[AgentInfo]:
@@ -286,12 +286,15 @@ def detect_pollution(agents: List[AgentInfo]) -> List[PollutionHit]:
def check_registry_consistency(
registry_path: Path,
agents: List[AgentInfo],
project_root: Optional[Path] = None,
) -> List[RegistryIssue]:
"""Validate that registry branches[].path entries match actual filesystem.
Returns:
List of RegistryIssue for each problem found.
"""
anchor = (project_root or registry_path.parent).resolve()
try:
data = json.loads(registry_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
@@ -309,7 +312,8 @@ def check_registry_consistency(
issues.append(RegistryIssue(name, "", "missing"))
continue
reg_path = Path(path_str).resolve()
p = Path(path_str)
reg_path = p.resolve() if p.is_absolute() else (anchor / p).resolve()
if not reg_path.exists():
issues.append(RegistryIssue(name, path_str, "missing"))
elif str(reg_path) not in agent_paths:
@@ -1,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
+127
View File
@@ -0,0 +1,127 @@
# =================== AIPass ====================
# Name: adopt.py
# Description: aipass adopt — bring an existing projects/ directory into AIPass
# Version: 1.0.0
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""
aipass adopt — turn an existing directory under projects/ into a full
AIPass project (registry, resident agent, .aipass/.claude scaffold).
Unlike `aipass new`, adopt starts from a directory that already has its
own content and git history — every write is additive, nothing existing
is ever overwritten.
"""
from __future__ import annotations
import sys
from pathlib import Path
from aipass.aipass.apps.handlers.json import json_handler
from aipass.cli.apps.modules import console, error, success
from aipass.prax import logger
COMMAND = "adopt"
def print_introspection() -> None:
"""Bare invocation — usage pointer."""
console.print()
console.print("[bold cyan]aipass adopt[/bold cyan] — bring an existing directory into projects/")
console.print()
console.print("[dim]Usage: aipass adopt <name-or-path> [--no-agent] [--dry-run][/dim]")
console.print()
def print_help() -> None:
"""Print usage help for the adopt command."""
console.print()
console.print("[bold cyan]aipass adopt[/bold cyan] — adopt an existing directory as an AIPass project")
console.print()
console.print("[yellow]USAGE:[/yellow]")
console.print(" [green]aipass adopt <name>[/green] [dim]# Adopt <host>/projects/<name>[/dim]")
console.print(" [green]aipass adopt <path>[/green] [dim]# Adopt by relative/absolute path[/dim]")
console.print(" [green]aipass adopt <name> --no-agent[/green] [dim]# Skip resident agent[/dim]")
console.print(" [green]aipass adopt <name> --dry-run[/green] [dim]# Preview, write nothing[/dim]")
console.print()
console.print("[yellow]WHAT IT DOES:[/yellow]")
console.print(" Seats a sealed registry, scaffolds .aipass/.claude, and (unless")
console.print(" --no-agent) creates a resident agent — all ADDITIVE. Never touches")
console.print(" the target's existing git history or tracked files. If the target")
console.print(" has no .gitignore covering AIPass local state, one is created or")
console.print(" appended to first — the target may be a public repo.")
console.print()
console.print("[yellow]REQUIRES:[/yellow]")
console.print(" Target must be an existing directory at <host>/projects/<name>.")
console.print(" Use 'aipass new' instead to create a brand-new project.")
console.print()
def handle_command(command: str, args: list[str]) -> bool:
"""Route the 'adopt' command. Returns True if handled."""
if command != COMMAND:
return False
if not args:
json_handler.log_operation("adopt_usage", {"command": command})
print_introspection()
return True
if args[0] in ("--help", "-h", "help"):
json_handler.log_operation("adopt_help", {"command": command})
print_help()
return True
name_or_path = args[0]
known = {"--no-agent", "--dry-run"}
unknown = [a for a in args[1:] if a not in known]
if unknown:
error(f"Unknown option: {unknown[0]}")
print_help()
return True
no_agent = "--no-agent" in args[1:]
dry_run = "--dry-run" in args[1:]
from aipass.aipass.apps.handlers.new_project import find_host_root
from aipass.aipass.apps.handlers.new_project.adopt import adopt_project
target_path = Path(name_or_path)
if not target_path.exists():
host = find_host_root(Path.cwd())
if host is None:
error("Not inside an AIPass installation (no *_REGISTRY.json found).")
sys.exit(1)
target_path = host / "projects" / name_or_path
try:
result = adopt_project(target_path, no_agent=no_agent, dry_run=dry_run)
except RuntimeError as e:
logger.warning("[AIPASS] adopt failed: %s", e)
error(str(e))
sys.exit(1)
console.print()
verb = "Would adopt" if dry_run else "Adopted"
success(f"{verb} '{result['name']}' at {result['target']}")
console.print()
console.print(f" [dim]Registry:[/dim] {result['registry_file']}")
console.print(f" [dim]Gitignore:[/dim] {result['gitignore_action']}")
if result["agent_home"]:
state = "created" if result["agent_created"] else "planned"
console.print(f" [dim]Agent:[/dim] {state} ({result['agent_home']})")
else:
console.print(" [dim]Agent:[/dim] skipped (--no-agent)")
console.print()
console.print("[dim]Files:[/dim]")
for f in result["files"]:
console.print(f" [dim]{f}[/dim]")
console.print()
json_handler.log_operation(
"adopt_project",
{"name": result["name"], "target": result["target"], "dry_run": dry_run},
)
logger.info("[AIPASS] adopt: %s at %s (dry_run=%s)", result["name"], result["target"], dry_run)
return True
+6 -4
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]
@@ -668,7 +670,7 @@ def _check_structure() -> List[CheckResult]:
# Registry consistency
reg_path = _discover_registry(start_path=project_root)
if reg_path and reg_path.exists():
reg_issues = check_registry_consistency(reg_path, agents)
reg_issues = check_registry_consistency(reg_path, agents, project_root=project_root)
if reg_issues:
for issue in reg_issues:
glyph = GLYPH_FAIL if issue.problem == "missing" else GLYPH_WARN
@@ -684,7 +686,7 @@ def _check_structure() -> List[CheckResult]:
root_hits = check_root_artifacts(project_root)
if root_hits:
for hit in root_hits:
glyph = GLYPH_WARN if hit.severity == "warn" else GLYPH_PASS
glyph = GLYPH_PASS if hit.severity == "pass" else GLYPH_WARN
results.append(CheckResult(f"root: {hit.name}", glyph, hit.description, ""))
else:
results.append(CheckResult("root artifacts", GLYPH_PASS, "none misplaced", ""))
+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
+125
View File
@@ -0,0 +1,125 @@
# =================== AIPass ====================
# Name: project_home.py
# Description: Shared AIPass-home detection, project-path validation, and settings content
# Version: 1.0.0
# Created: 2026-07-21
# Modified: 2026-07-21
# =============================================
"""Project home — AIPASS_HOME detection, path validation, settings content.
Shared across every command that scaffolds or inspects an AIPass project
(`aipass init`, `aipass new`, `aipass adopt`), so there is exactly one
source of truth per helper — no per-command copies to drift apart.
Dependency-free: uses only stdlib. Importable before drone/prax exist.
"""
import importlib.util
import json
import logging
import os
import tempfile
from pathlib import Path
logger = logging.getLogger(__name__)
def is_throwaway_path(path: str | Path) -> bool:
"""True if path is under a temp dir or Claude Code scratchpad."""
resolved = str(Path(path).resolve())
tmp_roots = [tempfile.gettempdir()]
if os.name == "posix":
tmp_roots.append("/tmp")
for root in tmp_roots:
try:
r = str(Path(root).resolve())
except OSError:
logger.info("is_throwaway_path: could not resolve %s", root)
continue
if resolved == r or resolved.startswith(r + os.sep):
return True
if "scratchpad" in resolved.lower():
return True
return False
def _detect_aipass_home() -> str | None:
"""Detect the AIPass installation root from the aipass package location.
Returns the parent of the src/ directory (the repo root).
Returns None if detection fails.
"""
try:
spec = importlib.util.find_spec("aipass")
if spec and spec.origin:
# aipass/__init__.py lives at src/aipass/__init__.py
# parent = src/aipass/, parent.parent = src/, parent.parent.parent = AIPass root
return str(Path(spec.origin).resolve().parent.parent.parent)
except Exception as exc:
logger.info("AIPASS_HOME detection skipped: %s", exc)
return None
def _claude_settings() -> str:
"""Generate .claude/settings.json — permissions only, no machine-local paths.
Hooks are NOT wired at the project level. All AIPass hooks
(prompt injection, identity, email, pre-compact, edit gates) fire
from provider settings (~/.claude/settings.json), installed by
setup.sh. Provider hooks use CWD-walking patterns that work from
any directory in any project.
AIPASS_HOME is a machine-local absolute path — it never belongs in this
tracked file. It goes in .claude/settings.local.json instead, which is
gitignored and merged over tracked settings by Claude Code natively.
See _claude_local_settings().
"""
data: dict = {
"permissions": {
"deny": [
"Bash(git push --force*)",
"Bash(git reset --hard*)",
"EnterPlanMode",
],
},
}
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
def _claude_local_settings(aipass_home: str) -> str:
"""Generate .claude/settings.local.json — machine-local env (gitignored)."""
return json.dumps({"env": {"AIPASS_HOME": aipass_home}}, indent=2, ensure_ascii=False) + "\n"
def _enroll_project(target: Path) -> None:
"""Enroll a project in the trusted-project registry (DPLAN-0244).
Lazy import to keep this module free of prax/module-level deps.
"""
try:
from aipass.hooks.apps.handlers.config.trust_registry import enroll
if enroll(str(target)):
logger.info("Enrolled project in trust registry: %s", target)
else:
logger.warning("Trust enrollment failed for %s", target)
except ImportError as exc:
logger.info("Trust registry unavailable, skipping enrollment: %s", exc)
def is_projects_child(target: Path) -> bool:
"""True if *target* is ``<host>/projects/<name>`` — a valid nested project path.
The host is identified by having a ``*_REGISTRY.json`` in the grandparent
of target (i.e. target's parent is named ``projects``).
"""
resolved = target.resolve()
if resolved.parent.name != "projects":
return False
host = resolved.parent.parent
try:
return any(f.is_file() and f.name.endswith("_REGISTRY.json") for f in host.iterdir())
except OSError as exc:
logger.info("is_projects_child: could not read host dir %s: %s", host, exc)
return False
@@ -1,17 +1,20 @@
# =================== AIPass ====================
# Name: scaffold_content.py
# Description: Template content generators for aipass init scaffold
# Description: Shared template content generators for project scaffolding
# Version: 1.0.0
# Created: 2026-04-22
# Modified: 2026-04-22
# Modified: 2026-07-21
# =============================================
"""
Scaffold Content — template generators for `aipass init`
Scaffold Content — template generators for `aipass init`, `aipass new`, `aipass adopt`
Pure string-returning functions that produce the content for each scaffold
file (CLAUDE.md, AGENTS.md, etc.). Extracted from bootstrap.py to keep
the handler under 700 lines.
file (CLAUDE.md, AGENTS.md, .gitignore, etc.). Shared across every command
that mints AIPass project files, so there is exactly one source of truth
per template — no per-command copies to drift apart.
Dependency-free: uses only stdlib. Importable before drone/prax exist.
RULES:
- Pure Python only (no module/prax/cli imports)
@@ -251,7 +254,7 @@ def gitignore() -> str:
"*.egg-info/\n"
"dist/\n"
"build/\n"
".venv/\n"
".venv\n"
"venv/\n"
"\n"
"# IDE\n"
@@ -267,6 +270,9 @@ def gitignore() -> str:
"\n"
"# Disabled files\n"
"*(disabled)*\n"
"\n"
"# Registry lock\n"
".*_REGISTRY.lock\n"
)
+421
View File
@@ -0,0 +1,421 @@
# =================== AIPass ====================
# Name: test_adopt.py
# Description: Tests for aipass adopt — project adoption handler
# Version: 1.0.0
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""Tests for the adopt handler and module.
All file operations use tmp_path to stay fully isolated from the live
filesystem. dry_run tests assert zero filesystem mutation.
"""
import json
import subprocess
from unittest.mock import patch
import pytest # pyright: ignore[reportMissingImports]
from aipass.aipass.apps.handlers.new_project.adopt import (
GITIGNORE_MARKER,
adopt_project,
)
@pytest.fixture()
def host_env(tmp_path):
"""Minimal AIPass host installation with an existing (pre-populated) project dir."""
(tmp_path / "AIPASS_REGISTRY.json").write_text(json.dumps({"metadata": {"id": "host-id"}, "branches": []}))
projects = tmp_path / "projects"
projects.mkdir()
target = projects / "existing-site"
target.mkdir()
(target / "index.html").write_text("<html></html>\n", encoding="utf-8")
(target / "README.md").write_text("# existing-site\n\nReal content.\n", encoding="utf-8")
return tmp_path, target
def _no_home():
return patch(
"aipass.aipass.apps.handlers.new_project.adopt._detect_aipass_home",
return_value=None,
)
# ---------------------------------------------------------------------------
# adopt_project — guards / refusals
# ---------------------------------------------------------------------------
def test_adopt_rejects_missing_target(tmp_path):
with pytest.raises(RuntimeError, match="does not exist"):
adopt_project(tmp_path / "nope")
def test_adopt_rejects_non_projects_child(tmp_path):
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
outside = tmp_path / "elsewhere" / "myapp"
outside.mkdir(parents=True)
with pytest.raises(RuntimeError, match="not <host>/projects/<name>"):
adopt_project(outside)
def test_adopt_rejects_already_adopted(host_env):
_, target = host_env
(target / "EXISTING_SITE_REGISTRY.json").write_text("{}")
with pytest.raises(RuntimeError, match="already adopted"):
adopt_project(target, no_agent=True)
def test_adopt_rejects_agent_home_collision(host_env):
_, target = host_env
home = target / "src" / "existing_site" / "existing_site"
home.mkdir(parents=True)
(home / "stray.txt").write_text("junk\n", encoding="utf-8")
with _no_home():
with pytest.raises(RuntimeError, match="Name collision"):
adopt_project(target, no_agent=False)
def test_adopt_allows_collision_with_no_agent(host_env):
"""An occupied agent-home path is fine when --no-agent skips the agent entirely."""
_, target = host_env
home = target / "src" / "existing_site" / "existing_site"
home.mkdir(parents=True)
(home / "stray.txt").write_text("junk\n", encoding="utf-8")
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
result = adopt_project(target, no_agent=True)
assert result["agent_created"] is False
# ---------------------------------------------------------------------------
# adopt_project — gitignore safety
# ---------------------------------------------------------------------------
def test_adopt_creates_gitignore_when_absent(host_env):
_, target = host_env
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
result = adopt_project(target, no_agent=True)
assert result["gitignore_action"] == "created"
content = (target / ".gitignore").read_text(encoding="utf-8")
assert GITIGNORE_MARKER in content
assert ".trinity/" in content
def test_adopt_appends_gitignore_when_marker_absent(host_env):
_, target = host_env
(target / ".gitignore").write_text("node_modules/\ndist/\n", encoding="utf-8")
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
result = adopt_project(target, no_agent=True)
assert result["gitignore_action"] == "appended"
content = (target / ".gitignore").read_text(encoding="utf-8")
assert "node_modules/" in content
assert GITIGNORE_MARKER in content
assert ".trinity/" in content
def test_adopt_skips_gitignore_when_marker_present(host_env):
_, target = host_env
(target / ".gitignore").write_text(f"{GITIGNORE_MARKER}\n.trinity/\n", encoding="utf-8")
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
result = adopt_project(target, no_agent=True)
assert result["gitignore_action"] == "already-safe"
content = (target / ".gitignore").read_text(encoding="utf-8")
assert content.count(GITIGNORE_MARKER) == 1
def test_adopt_gitignore_covers_symlinked_venv_and_registry_lock(host_env, tmp_path):
"""Live-verification regression: a symlinked .venv (not a real dir) and a
registry lock file must both be actually ignored by real git, not just
present as a string in the .gitignore content."""
host, target = host_env
aipass_home = tmp_path / "fake_aipass_home"
(aipass_home / ".venv").mkdir(parents=True)
subprocess.run(["git", "init"], cwd=target, capture_output=True, text=True, check=True)
with (
patch(
"aipass.aipass.apps.handlers.new_project.adopt._detect_aipass_home",
return_value=str(aipass_home),
),
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
):
adopt_project(target, no_agent=True)
venv_link = target / ".venv"
assert venv_link.is_symlink()
lock_file = target / ".EXISTING-SITE_REGISTRY.lock"
lock_file.write_text("", encoding="utf-8")
result = subprocess.run(
["git", "check-ignore", ".venv", ".EXISTING-SITE_REGISTRY.lock"],
cwd=target,
capture_output=True,
text=True,
)
assert result.returncode == 0
assert ".venv" in result.stdout.split()
assert ".EXISTING-SITE_REGISTRY.lock" in result.stdout.split()
# ---------------------------------------------------------------------------
# adopt_project — additive scaffold, existing files untouched
# ---------------------------------------------------------------------------
def test_adopt_never_overwrites_existing_readme(host_env):
_, target = host_env
original = (target / "README.md").read_text(encoding="utf-8")
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
adopt_project(target, no_agent=True)
assert (target / "README.md").read_text(encoding="utf-8") == original
def test_adopt_never_touches_existing_tracked_files(host_env):
_, target = host_env
original = (target / "index.html").read_text(encoding="utf-8")
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
adopt_project(target, no_agent=True)
assert (target / "index.html").read_text(encoding="utf-8") == original
def test_adopt_writes_registry_and_settings(host_env):
_, target = host_env
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
result = adopt_project(target, no_agent=True)
assert result["registry_file"] == "EXISTING-SITE_REGISTRY.json"
assert (target / "EXISTING-SITE_REGISTRY.json").exists()
reg = json.loads((target / "EXISTING-SITE_REGISTRY.json").read_text())
assert reg["metadata"]["name"] == "EXISTING-SITE"
assert (target / ".claude" / "settings.json").exists()
settings = json.loads((target / ".claude" / "settings.json").read_text())
assert "env" not in settings
def test_adopt_no_agent_skips_spawn(host_env):
_, target = host_env
with (
_no_home(),
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
patch("aipass.aipass.apps.handlers.new_project.spawn_agent") as mock_spawn,
):
result = adopt_project(target, no_agent=True)
mock_spawn.assert_not_called()
assert result["agent_created"] is False
assert result["agent_home"] is None
def test_adopt_with_agent_spawns(host_env):
_, target = host_env
spawn_ok = {
"success": True,
"branch_name": "EXISTING_SITE",
"path": str(target / "src" / "existing_site" / "existing_site"),
"files_copied": 12,
"registry_updated": True,
"validation_issues": [],
}
with (
_no_home(),
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
patch(
"aipass.aipass.apps.handlers.new_project.spawn_agent",
return_value=spawn_ok,
) as mock_spawn,
):
result = adopt_project(target, no_agent=False)
mock_spawn.assert_called_once()
assert result["agent_created"] is True
assert result["agent_home"] == str(target / "src" / "existing_site" / "existing_site")
def test_adopt_registry_minted_before_spawn(host_env):
"""Registry-first invariant: registry file exists on disk before spawn_agent is called."""
_, target = host_env
seen = {}
def _check_registry_exists(**kwargs):
seen["registry_present"] = (target / "EXISTING-SITE_REGISTRY.json").exists()
return {
"success": True,
"branch_name": "EXISTING_SITE",
"path": kwargs["target_path"],
"files_copied": 1,
"registry_updated": True,
"validation_issues": [],
}
with (
_no_home(),
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
patch(
"aipass.aipass.apps.handlers.new_project.spawn_agent",
side_effect=_check_registry_exists,
),
):
adopt_project(target, no_agent=False)
assert seen["registry_present"] is True
# ---------------------------------------------------------------------------
# adopt_project — dry_run performs zero writes
# ---------------------------------------------------------------------------
def test_adopt_dry_run_writes_nothing(host_env):
_, target = host_env
before = sorted(p.relative_to(target) for p in target.rglob("*"))
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.spawn_agent") as mock_spawn:
result = adopt_project(target, no_agent=False, dry_run=True)
after = sorted(p.relative_to(target) for p in target.rglob("*"))
assert before == after
mock_spawn.assert_not_called()
assert result["dry_run"] is True
assert result["registry_id"] is None
assert result["agent_created"] is False
def test_adopt_dry_run_reports_planned_registry_and_agent(host_env):
_, target = host_env
with _no_home():
result = adopt_project(target, no_agent=False, dry_run=True)
assert result["registry_file"] == "EXISTING-SITE_REGISTRY.json"
assert "EXISTING-SITE_REGISTRY.json" in result["files"]
assert result["agent_home"] == str(target / "src" / "existing_site" / "existing_site")
assert any("resident agent" in f for f in result["files"])
def test_adopt_dry_run_no_agent_reports_no_home(host_env):
_, target = host_env
with _no_home():
result = adopt_project(target, no_agent=True, dry_run=True)
assert result["agent_home"] is None
def test_adopt_dry_run_still_reports_gitignore_action(host_env):
_, target = host_env
with _no_home():
result = adopt_project(target, no_agent=True, dry_run=True)
assert result["gitignore_action"] == "created"
assert not (target / ".gitignore").exists()
# ---------------------------------------------------------------------------
# Module handle_command
# ---------------------------------------------------------------------------
def test_module_handles_not_mine():
from aipass.aipass.apps.modules.adopt import handle_command
assert handle_command("notmine", []) is False
def test_module_handles_help():
from aipass.aipass.apps.modules.adopt import handle_command
assert handle_command("adopt", ["--help"]) is True
def test_module_handles_no_args():
from aipass.aipass.apps.modules.adopt import handle_command
assert handle_command("adopt", []) is True
def test_module_rejects_unknown_option(host_env):
from aipass.aipass.apps.modules.adopt import handle_command
_, target = host_env
with patch("aipass.aipass.apps.modules.adopt.error") as mock_error:
handle_command("adopt", [str(target), "--bogus"])
mock_error.assert_called_once()
assert "Unknown option" in mock_error.call_args[0][0]
def test_module_adopt_by_absolute_path(host_env, monkeypatch):
from aipass.aipass.apps.modules.adopt import handle_command
host, target = host_env
monkeypatch.chdir(host)
with (
_no_home(),
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
patch("aipass.aipass.apps.modules.adopt.console") as mock_con,
):
handle_command("adopt", [str(target), "--no-agent"])
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
assert "Registry:" in printed
assert "EXISTING-SITE_REGISTRY.json" in printed
def test_module_adopt_by_bare_name(host_env, monkeypatch):
from aipass.aipass.apps.modules.adopt import handle_command
host, target = host_env
monkeypatch.chdir(host)
with (
_no_home(),
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
patch("aipass.aipass.apps.modules.adopt.console") as mock_con,
):
handle_command("adopt", ["existing-site", "--no-agent"])
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
assert "Registry:" in printed
assert "EXISTING-SITE_REGISTRY.json" in printed
def test_module_adopt_dry_run_reports_would_adopt(host_env, monkeypatch, capsys):
from aipass.aipass.apps.modules.adopt import handle_command
host, target = host_env
monkeypatch.chdir(host)
with _no_home():
handle_command("adopt", ["existing-site", "--no-agent", "--dry-run"])
out = capsys.readouterr().out
assert "Would adopt" in out
assert not (target / "EXISTING-SITE_REGISTRY.json").exists()
def test_module_adopt_missing_target_no_host(tmp_path, monkeypatch):
from aipass.aipass.apps.modules.adopt import handle_command
monkeypatch.chdir(tmp_path)
with (
patch("aipass.aipass.apps.modules.adopt.error") as mock_error,
pytest.raises(SystemExit) as exc_info,
):
handle_command("adopt", ["nope"])
mock_error.assert_called_once()
assert "Not inside an AIPass installation" in mock_error.call_args[0][0]
assert exc_info.value.code == 1
def test_module_adopt_reports_refusal(host_env, monkeypatch):
from aipass.aipass.apps.modules.adopt import handle_command
host, target = host_env
monkeypatch.chdir(host)
(target / "EXISTING-SITE_REGISTRY.json").write_text("{}")
with (
patch("aipass.aipass.apps.modules.adopt.error") as mock_error,
pytest.raises(SystemExit) as exc_info,
):
handle_command("adopt", ["existing-site", "--no-agent"])
mock_error.assert_called_once()
assert "already adopted" in mock_error.call_args[0][0]
assert exc_info.value.code == 1
# ---------------------------------------------------------------------------
# aipass.py wiring
# ---------------------------------------------------------------------------
def test_aipass_public_commands_includes_adopt():
from aipass.aipass.apps.aipass import _PUBLIC_COMMANDS
assert "adopt" in _PUBLIC_COMMANDS
+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()
+60 -20
View File
@@ -20,7 +20,6 @@ from pathlib import Path
import pytest # pyright: ignore[reportMissingImports]
from aipass.aipass.apps.handlers.init import scaffold_content as sc
from aipass.aipass.apps.handlers.init.bootstrap import (
_merge_hooks_json,
_sanitize_name,
@@ -28,6 +27,7 @@ from aipass.aipass.apps.handlers.init.bootstrap import (
init_project,
update_project,
)
from aipass.aipass.shared import scaffold_content as sc
# ---------------------------------------------------------------------------
@@ -293,7 +293,7 @@ def test_init_project_settings_no_hooks(tmp_path, monkeypatch):
data = json.loads(settings_path.read_text(encoding="utf-8"))
assert "hooks" not in data, "Project settings should not contain hooks"
assert "env" in data
assert "env" not in data, "AIPASS_HOME is machine-local — belongs in settings.local.json"
assert "permissions" in data
@@ -547,9 +547,10 @@ def test_update_project_creates_missing_managed_dirs(tmp_path):
result = update_project(target)
assert (target / ".claude" / "settings.json").exists()
# Managed files in deleted dirs re-written (tier0_kernel, tier1_navmap, hooks.json, settings, prep)
# Managed files in deleted dirs re-written (tier0_kernel, tier1_navmap,
# hooks.json, settings.json, settings.local.json, prep)
if result["aipass_home"]:
assert len(result["updated_files"]) == 5
assert len(result["updated_files"]) == 6
else:
assert len(result["updated_files"]) == 2
assert len(result["already_current"]) >= 2
@@ -584,7 +585,7 @@ def test_init_project_returns_aipass_home(tmp_path):
def test_init_project_settings_has_aipass_home_when_detected(tmp_path, monkeypatch):
"""When AIPASS_HOME is detected, settings.json includes env.AIPASS_HOME."""
"""When AIPASS_HOME is detected, settings.local.json (not settings.json) includes env.AIPASS_HOME."""
monkeypatch.setattr(
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
lambda _: False,
@@ -598,8 +599,10 @@ def test_init_project_settings_has_aipass_home_when_detected(tmp_path, monkeypat
pytest.skip("AIPASS_HOME not detectable in this environment")
settings = json.loads((target / ".claude" / "settings.json").read_text(encoding="utf-8"))
assert "env" in settings
assert settings["env"]["AIPASS_HOME"] == result["aipass_home"]
assert "env" not in settings
local_settings = json.loads((target / ".claude" / "settings.local.json").read_text(encoding="utf-8"))
assert local_settings["env"]["AIPASS_HOME"] == result["aipass_home"]
def test_update_project_returns_aipass_home(tmp_path):
@@ -615,7 +618,7 @@ def test_update_project_returns_aipass_home(tmp_path):
def test_update_project_adds_aipass_home_if_missing(tmp_path, monkeypatch):
"""update_project injects AIPASS_HOME into settings.json if env section is absent."""
"""update_project recreates settings.local.json with AIPASS_HOME if missing."""
monkeypatch.setattr(
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
lambda _: False,
@@ -624,17 +627,15 @@ def test_update_project_adds_aipass_home_if_missing(tmp_path, monkeypatch):
target.mkdir()
init_project(target, project_name="addenv")
settings_path = target / ".claude" / "settings.json"
data = json.loads(settings_path.read_text(encoding="utf-8"))
data.pop("env", None)
settings_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
local_settings_path = target / ".claude" / "settings.local.json"
local_settings_path.unlink(missing_ok=True)
result = update_project(target)
if result["aipass_home"] is not None:
new_data = json.loads(settings_path.read_text(encoding="utf-8"))
new_data = json.loads(local_settings_path.read_text(encoding="utf-8"))
assert new_data.get("env", {}).get("AIPASS_HOME") == result["aipass_home"]
assert str(settings_path) in result["updated_files"]
assert str(local_settings_path) in result["updated_files"]
# ---------------------------------------------------------------------------
@@ -1256,10 +1257,49 @@ def test_throwaway_path_allows_project():
assert not is_throwaway_path(str(Path.home() / "Projects" / "myapp"))
def test_settings_omits_throwaway_aipass_home(tmp_path):
"""_claude_settings refuses to write AIPASS_HOME when it's a throwaway path."""
from aipass.aipass.apps.handlers.init.bootstrap import _claude_settings
def test_settings_omits_throwaway_aipass_home(tmp_path, monkeypatch):
"""init_project skips settings.local.json when detected AIPASS_HOME is a throwaway path."""
from aipass.aipass.apps.handlers.init import bootstrap
content = _claude_settings(str(tmp_path))
data = json.loads(content)
assert "AIPASS_HOME" not in data.get("env", {})
monkeypatch.setattr(bootstrap, "_detect_aipass_home", lambda: str(tmp_path))
target = tmp_path / "proj"
target.mkdir()
bootstrap.init_project(target, project_name="alpha")
assert not (target / ".claude" / "settings.local.json").exists()
# Directory/file-name fragments that .gitignore excludes from git tracking —
# mirrors scaffold_content.gitignore(). Tracked-file scans must skip these.
_GITIGNORED_PARTS = (".trinity", ".ai_mail.local", "logs", ".venv", "venv", ".git")
def test_minted_tracked_files_have_no_absolute_paths(tmp_path, monkeypatch):
"""No file init_project writes into a TRACKED path may contain the machine-local AIPASS_HOME.
Regression guard for the settings.json bug: AIPASS_HOME is an absolute,
machine-local path and must only ever land in gitignored *.local.json
files. This walks every minted file that would actually be committed
and greps it for the (fake) AIPASS_HOME value.
"""
from aipass.aipass.apps.handlers.init import bootstrap
fake_home = str(tmp_path / f"fake_aipass_home_{uuid.uuid4().hex}")
monkeypatch.setattr(bootstrap, "_detect_aipass_home", lambda: fake_home)
monkeypatch.setattr(bootstrap, "is_throwaway_path", lambda _: False)
target = tmp_path / "proj"
target.mkdir()
bootstrap.init_project(target, project_name="pathcheck")
for path in target.rglob("*"):
if not path.is_file():
continue
rel = path.relative_to(target)
if any(part in _GITIGNORED_PARTS for part in rel.parts):
continue
if ".local." in rel.name:
continue
content = path.read_text(encoding="utf-8")
assert fake_home not in content, f"{rel} leaks machine-local AIPASS_HOME"
+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.shared.project_home._detect_aipass_home",
return_value=None,
),
patch(
"aipass.aipass.shared.project_home._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.shared.project_home._detect_aipass_home",
return_value=None,
),
patch(
"aipass.aipass.shared.project_home._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.shared.project_home._detect_aipass_home",
return_value=None,
),
patch("aipass.aipass.shared.project_home._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.shared.project_home._detect_aipass_home",
return_value=None,
),
patch("aipass.aipass.shared.project_home._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.shared.project_home._detect_aipass_home",
return_value=None,
),
patch("aipass.aipass.shared.project_home._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.shared.project_home._detect_aipass_home",
return_value=None,
),
patch("aipass.aipass.shared.project_home._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.shared.project_home._detect_aipass_home",
return_value=None,
),
patch("aipass.aipass.shared.project_home._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.shared.project_home._detect_aipass_home",
return_value=None,
),
patch("aipass.aipass.shared.project_home._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.shared.project_home._detect_aipass_home",
return_value=None,
),
patch("aipass.aipass.shared.project_home._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.shared.project_home._detect_aipass_home",
return_value=None,
),
patch("aipass.aipass.shared.project_home._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.shared.project_home._detect_aipass_home",
return_value=None,
),
patch("aipass.aipass.shared.project_home._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([])
@@ -24,7 +24,9 @@ import sys
import aipass.aipass.shared.json_handler
import aipass.aipass.shared.json_ops
import aipass.aipass.shared.project_home
import aipass.aipass.shared.registry_discovery
import aipass.aipass.shared.scaffold_content
bad = []
for name in sorted(sys.modules):
+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,
@@ -75,8 +75,8 @@ def _validate_metadata(metadata_str: str) -> Optional[dict]:
def _resolve_branch_name(mention: str) -> Optional[str]:
"""Resolve a @mention to a branch name."""
name = mention.lstrip("@").upper()
"""Resolve a @mention to a branch name (lowercase-normalized)."""
name = mention.lstrip("@").lower()
if not os.path.exists(BRANCH_REGISTRY_PATH):
return None
@@ -85,7 +85,7 @@ def _resolve_branch_name(mention: str) -> Optional[str]:
with open(BRANCH_REGISTRY_PATH, encoding="utf-8") as f:
registry = json.load(f)
for branch in registry.get("branches", []):
if branch.get("name") == name:
if branch.get("name", "").lower() == name:
return name
return None
except Exception:
@@ -54,8 +54,8 @@ RARITY_COLORS = {
def _resolve_branch_name(mention: str) -> Optional[str]:
"""Resolve a @mention to a branch name."""
name = mention.lstrip("@").upper()
"""Resolve a @mention to a branch name (lowercase-normalized)."""
name = mention.lstrip("@").lower()
if not os.path.exists(BRANCH_REGISTRY_PATH):
return None
@@ -64,7 +64,7 @@ def _resolve_branch_name(mention: str) -> Optional[str]:
with open(BRANCH_REGISTRY_PATH, encoding="utf-8") as f:
registry = json.load(f)
for branch in registry.get("branches", []):
if branch.get("name") == name:
if branch.get("name", "").lower() == name:
return name
return None
except Exception:
@@ -58,7 +58,7 @@ def show_profile(args: List[str]) -> dict:
# Determine which branch to show
if args:
target_branch = args[0].upper()
target_branch = args[0].lower()
else:
caller = get_caller_branch()
if not caller:
@@ -62,7 +62,7 @@ def _parse_search_args(args: List[str]) -> dict:
result["room"] = remaining[i + 1].lower()
i += 2
elif flag == "--author" and i + 1 < len(remaining):
result["author"] = remaining[i + 1].upper()
result["author"] = remaining[i + 1].lower()
i += 2
elif flag == "--type" and i + 1 < len(remaining):
search_type = remaining[i + 1].lower()
@@ -55,7 +55,7 @@ def run_welcome(args: List[str]) -> dict:
conn = get_db()
if filtered_args:
branch_name = filtered_args[0].upper()
branch_name = filtered_args[0].lower()
if dry_run:
already = has_been_welcomed(conn, branch_name)
close_db(conn)
@@ -114,7 +114,7 @@ def _welcome_specific(conn, branch_name: str) -> dict:
Returns:
Dict with success and welcome result
"""
agent = conn.execute("SELECT branch_name FROM agents WHERE branch_name = ?", (branch_name,)).fetchone()
agent = conn.execute("SELECT branch_name FROM agents WHERE LOWER(branch_name) = ?", (branch_name,)).fetchone()
if not agent:
return {"success": False, "error": f"Branch '{branch_name}' not found in The Commons."}
@@ -993,7 +993,7 @@ def test_run_welcome_dry_run_specific_branch(
"""run_welcome <branch> --dry-run reports whether the branch would be welcomed."""
from aipass.commons.apps.handlers.welcome.welcome_ops import run_welcome
_seed_agent(initialized_db, "GAMMA")
_seed_agent(initialized_db, "gamma")
mock_get_db.return_value = initialized_db
mock_close.side_effect = lambda c: None
@@ -1001,7 +1001,7 @@ def test_run_welcome_dry_run_specific_branch(
result = run_welcome(["gamma", "--dry-run"])
assert result["success"] is True
assert result["dry_run"] is True
assert result["branch"] == "GAMMA"
assert result["branch"] == "gamma"
assert result["would_welcome"] is True
@@ -1076,7 +1076,7 @@ def test_run_welcome_specific_branch_success(
"""run_welcome <branch> creates a welcome post for that branch."""
from aipass.commons.apps.handlers.welcome.welcome_ops import run_welcome
_seed_agent(initialized_db, "EPSILON")
_seed_agent(initialized_db, "epsilon")
mock_get_db.return_value = initialized_db
mock_close.side_effect = lambda c: None
@@ -1085,7 +1085,7 @@ def test_run_welcome_specific_branch_success(
assert result["success"] is True
assert result["action"] == "specific"
assert result["already_welcomed"] is False
assert result["branch"] == "EPSILON"
assert result["branch"] == "epsilon"
assert result["post_id"] is not None
+3 -3
View File
@@ -58,10 +58,10 @@ def test_parse_search_args_room_flag():
def test_parse_search_args_author_flag():
"""The --author flag should set the author filter and uppercase it."""
"""The --author flag should set the author filter and lowercase it."""
result = _parse_search_args(["test", "--author", "drone"])
assert result["query"] == "test"
assert result["author"] == "DRONE"
assert result["author"] == "drone"
def test_parse_search_args_type_flag_valid():
@@ -94,7 +94,7 @@ def test_parse_search_args_all_flags():
)
assert result["query"] == "registry"
assert result["room"] == "dev"
assert result["author"] == "FLOW"
assert result["author"] == "flow"
assert result["search_type"] == "posts"
+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()
@@ -1,4 +1,5 @@
{
"autoCompactWindow": 350000,
"permissions": {
"allow": [],
"deny": [
-65
View File
@@ -70,71 +70,6 @@
"file": "tests/test_owner_guard.py",
"reason": "Unit test imports the owner guard handler (its SUT) and patches spawn.registry's get_owner/is_owner — the guard is a shared handler primitive with no apps/modules/ command entry point. Same direct-SUT pattern as test_compass_store.py. #681."
},
{
"standard": "encapsulation",
"file": "tools/spot_check.py",
"reason": "Standalone diagnostic tool script, not a handler."
},
{
"standard": "debug_print",
"file": "tools/spot_check.py",
"reason": "Standalone CLI tool — print() is the intended output method."
},
{
"standard": "silent_catch",
"file": "tools/hook_engine_poc/engine.py",
"reason": "POC hook engine — stdlib only, no prax logger. Uses sys.stderr for error reporting."
},
{
"standard": "error_handling",
"file": "tools/hook_engine_poc/engine.py",
"reason": "POC hook engine — exception handlers write to stderr, not prax logger."
},
{
"standard": "silent_catch",
"file": "tools/hook_engine_poc/test_engine.py",
"reason": "POC test harness — catches test exceptions to report pass/fail, writes to stderr."
},
{
"standard": "imports",
"file": "tools/hook_engine_poc/test_engine.py",
"reason": "POC test harness — sys.path needed to import engine.py from same directory."
},
{
"standard": "trigger",
"file": "tools/hook_engine_poc/test_engine.py",
"reason": "POC test harness — .unlink() clears ephemeral JSONL log between tests, not a tracked file."
},
{
"standard": "help_text",
"file": "tools/hook_engine_poc/test_engine.py",
"reason": "POC test harness — usage example in docstring."
},
{
"standard": "debug_print",
"file": "tools/rm_shim/redteam_suite.py",
"reason": "Standalone red-team diagnostic runner (FPLAN-0250 Phase 6) — print() IS the report output, same as broker_acceptance_test.py."
},
{
"standard": "encapsulation",
"file": "tools/rm_shim/redteam_suite.py",
"reason": "Red-team tool imports the real broker daemon/client + sandbox module directly to exercise them under live conditions — that is the point of an integration probe, not a handler."
},
{
"standard": "imports",
"file": "tools/rm_shim/redteam_suite.py",
"reason": "Standalone script run via 'python tools/...' — sys.path insert lets it import the production modules it red-teams without being pip-installed."
},
{
"standard": "help_text",
"file": "tools/rm_shim/redteam_suite.py",
"reason": "Diagnostic script — docstring shows the 'python tools/...' invocation; it is not a drone-routed module."
},
{
"standard": "documentation",
"file": "tools/rm_shim/redteam_suite.py",
"reason": "Result.ok/bad are 2-line internal report helpers in a diagnostic script — self-evident, docstrings redundant."
},
{
"standard": "encapsulation",
"file": "tests/test_compass_store.py",
+12
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
+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:
@@ -423,7 +423,12 @@ class StallTracker:
(#634 part 2). The verbose trail stays on ``_stderr`` + logger.
"""
STALL_THRESHOLD = 120.0
# Quiet spans with zero JSONL output are routine, not stalls: a model composing
# a long response writes nothing until the turn completes, and compaction is one
# multi-minute silent API call. 120s false-fired on every stall of S329 (8/8);
# 300s clears normal turns and most compactions while a wedged agent still
# surfaces well inside a long watch.
STALL_THRESHOLD = 300.0
# A single tool call held in-flight this long is surfaced as a soft advisory
# (heavy op or a hung tool). Below the 600s default timeout so long watches
# get a mid-flight heads-up instead of waiting on the timeout.
@@ -516,7 +521,7 @@ def watch_agent(
poll_interval: Seconds between checks. Default 5.0 — the per-tick work (lock
stat, PID liveness, one-dir JSONL size scan) is cheap, so a tight cadence
just burns CPU. 5s keeps completion latency invisible on multi-minute
dispatches while the 120s stall threshold has ample resolution.
dispatches while the 300s stall threshold has ample resolution.
Returns:
dict with keys: woke, reason, elapsed, agent_state, exit_code, agent_id.
@@ -0,0 +1,173 @@
<title>S324 Advisory Scan — AIPass</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root {
--paper: #f4f1ea; --surface: #fbf9f4; --surface-2: #eeeae0; --ink: #1a1e27;
--ink-soft: #454b58; --ink-faint: #6b7280; --hair: #ddd6c8;
--accent: #b57e1f; --accent-soft: #e9d9b4;
--crit: #c92a30; --high: #c0741f; --med: #4964a8; --low: #7b8494; --good: #2f8f5b;
--crit-bg: #f6dcdc; --high-bg: #f4e6d0; --med-bg: #dde3f2; --low-bg: #e6e8ec; --good-bg: #d8ecdf;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--maxw: 940px;
}
@media (prefers-color-scheme: dark) {
:root {
--paper: #10131a; --surface: #161a23; --surface-2: #1e2430; --ink: #e7e9ee;
--ink-soft: #aab2c0; --ink-faint: #79828f; --hair: #2a3140; --accent: #d9a441;
--accent-soft: #4a3d1c; --crit: #f0565b; --high: #e8973f; --med: #7d97d8; --low: #8a93a3; --good: #4cc082;
--crit-bg: #3a1c1e; --high-bg: #382a17; --med-bg: #1f2740; --low-bg: #242a35; --good-bg: #16301f;
}
}
:root[data-theme="light"] {
--paper: #f4f1ea; --surface: #fbf9f4; --surface-2: #eeeae0; --ink: #1a1e27;
--ink-soft: #454b58; --ink-faint: #6b7280; --hair: #ddd6c8; --accent: #b57e1f;
--accent-soft: #e9d9b4; --crit: #c92a30; --high: #c0741f; --med: #4964a8; --low: #7b8494; --good: #2f8f5b;
--crit-bg: #f6dcdc; --high-bg: #f4e6d0; --med-bg: #dde3f2; --low-bg: #e6e8ec; --good-bg: #d8ecdf;
}
:root[data-theme="dark"] {
--paper: #10131a; --surface: #161a23; --surface-2: #1e2430; --ink: #e7e9ee;
--ink-soft: #aab2c0; --ink-faint: #79828f; --hair: #2a3140; --accent: #d9a441;
--accent-soft: #4a3d1c; --crit: #f0565b; --high: #e8973f; --med: #7d97d8; --low: #8a93a3; --good: #4cc082;
--crit-bg: #3a1c1e; --high-bg: #382a17; --med-bg: #1f2740; --low-bg: #242a35; --good-bg: #16301f;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); line-height: 1.5; -webkit-font-smoothing: antialiased; }
.wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 22px; }
header { padding: 54px 0 30px; border-bottom: 1px solid var(--hair); }
.eyebrow { font-family: var(--mono); font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: var(--accent); margin: 0 0 16px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.eyebrow .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--good); box-shadow: 0 0 0 3px var(--good-bg); }
h1 { font-family: var(--mono); font-weight: 600; font-size: clamp(28px, 5vw, 44px); line-height: 1.06; letter-spacing: -0.02em; margin: 0 0 14px; text-wrap: balance; }
h1 .melt { color: var(--accent); }
.lede { font-size: 18px; color: var(--ink-soft); max-width: 62ch; margin: 0; }
.meta { font-family: var(--mono); font-size: 12.5px; color: var(--ink-faint); margin-top: 22px; display: flex; gap: 8px 20px; flex-wrap: wrap; }
.meta b { color: var(--ink-soft); font-weight: 600; }
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--hair); border: 1px solid var(--hair); border-radius: 10px; overflow: hidden; margin: 30px 0 8px; }
.stat { background: var(--surface); padding: 18px 18px 16px; }
.stat .n { font-family: var(--mono); font-size: 30px; font-weight: 600; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; line-height: 1; }
.stat .k { font-size: 12.5px; color: var(--ink-faint); margin-top: 7px; }
.stat.good .n { color: var(--good); }
.stat.med .n { color: var(--med); }
@media (max-width: 620px) { .stats { grid-template-columns: repeat(2, 1fr); } }
section { padding: 40px 0; border-bottom: 1px solid var(--hair); }
.sec-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 22px; }
.sec-head h2 { font-family: var(--mono); font-size: 14px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink); margin: 0; font-weight: 600; }
.sec-head .rule { flex: 1; height: 1px; background: var(--hair); }
.sec-head .count { font-family: var(--mono); font-size: 12px; color: var(--ink-faint); }
.findings { display: flex; flex-direction: column; gap: 12px; }
.f { background: var(--surface); border: 1px solid var(--hair); border-radius: 9px; border-left-width: 4px; padding: 16px 18px; }
.f.high { border-left-color: var(--high); }
.f.med { border-left-color: var(--med); }
.f.low { border-left-color: var(--low); }
.f.good { border-left-color: var(--good); }
.f-top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 7px; }
.id { font-family: var(--mono); font-size: 12px; font-weight: 600; color: var(--ink-faint); }
.pill { font-family: var(--mono); font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase; font-weight: 600; padding: 2px 8px; border-radius: 20px; }
.pill.high { color: var(--high); background: var(--high-bg); }
.pill.med { color: var(--med); background: var(--med-bg); }
.pill.low { color: var(--low); background: var(--low-bg); }
.pill.good { color: var(--good); background: var(--good-bg); }
.f-title { font-weight: 600; font-size: 15.5px; color: var(--ink); flex: 1 1 100%; margin-top: 2px; }
.f-body { font-size: 14px; color: var(--ink-soft); margin: 0; }
.f-body code { font-family: var(--mono); font-size: 12.5px; background: var(--surface-2); padding: 1px 5px; border-radius: 4px; color: var(--ink); }
table.melt { width: 100%; border-collapse: collapse; font-size: 14px; margin-top: 4px; }
table.melt th { font-family: var(--mono); font-size: 11.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-faint); text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--hair); }
table.melt td { padding: 9px 12px; border-bottom: 1px solid var(--hair); color: var(--ink-soft); }
table.melt td.n { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--ink); font-weight: 600; white-space: nowrap; }
table.melt td.verdict-good { color: var(--good); font-weight: 600; }
table.melt td.verdict-open { color: var(--med); font-weight: 600; }
.tblwrap { overflow-x: auto; }
.callout { background: var(--surface-2); border: 1px solid var(--hair); border-radius: 9px; padding: 18px 20px; margin-top: 18px; }
.callout h3 { font-family: var(--mono); font-size: 13px; letter-spacing: .06em; text-transform: uppercase; margin: 0 0 8px; color: var(--accent); }
.callout p { margin: 0; font-size: 14px; color: var(--ink-soft); }
footer { padding: 30px 0 60px; font-family: var(--mono); font-size: 12px; color: var(--ink-faint); }
footer .disc { color: var(--good); }
</style>
<div class="wrap">
<header>
<p class="eyebrow"><span class="dot"></span> Session S324 · Advisory Scan · Read-only</p>
<h1>The scanners CI never runs said 1,419 problems. Verification <span class="melt">melted it to a handful.</span></h1>
<p class="lede">Patrick's ask: "look in ur tools folder run some random scan see what surfaces." Ran the April-era advisory scanners — the ones deliberately NOT wired into seedgo's audit — across all 17 branches, then read every scary number in context before believing it. The tree is clean where it counts; most of the raw count is archive noise and scanner age.</p>
<p class="meta"><span><b>Date</b> 2026-07-18</span><span><b>Agent</b> @devpulse</span><span><b>Scanners</b> fallback · stale-terms · readme-freshness</span><span><b>Branches</b> 17/17</span><span><b>Files changed</b> 0</span></p>
</header>
<div class="stats" role="list">
<div class="stat" role="listitem"><div class="n">1,419</div><div class="k">raw findings, all scanners</div></div>
<div class="stat med" role="listitem"><div class="n">16</div><div class="k">fallbacks actually in live code</div></div>
<div class="stat good" role="listitem"><div class="n">2/2</div><div class="k">sampled live hits = defensible on read</div></div>
<div class="stat" role="listitem"><div class="n">1</div><div class="k">genuine surprise (MagicMock leak)</div></div>
</div>
<!-- THE MELT -->
<section>
<div class="sec-head"><h2>How the numbers melt</h2><span class="rule"></span><span class="count">raw → verified</span></div>
<div class="tblwrap"><table class="melt">
<tr><th>Scanner</th><th>Raw</th><th>After context</th><th>Verdict</th></tr>
<tr><td>Silent fallbacks</td><td class="n">61</td><td class="n">16 live · 45 = devpulse <code>dropbox/</code> quarantine copies</td><td class="verdict-good">mostly archive noise</td></tr>
<tr><td>&nbsp;&nbsp;↳ of the 16 live</td><td class="n">16</td><td class="n">@trigger 11 · @cli 2 · @skills 2 (tests) · @spawn 1 (template)</td><td class="verdict-open">@trigger needs owner triage</td></tr>
<tr><td>Stale terminology</td><td class="n">1,358</td><td class="n">~600 genuinely dead names · rest = valid <code>.ai_mail.local/</code> refs</td><td class="verdict-open">needs live-code filter</td></tr>
<tr><td>README freshness</td><td class="n">15/17 stale</td><td class="n">dates lag the front-door code week</td><td class="verdict-good">informational</td></tr>
</table></div>
<div class="callout">
<h3>Why sampling flipped the verdict</h3>
<p>Both @trigger hits I read in full are <b>recursion-safe meta-logging guards</b> — "if logging this warning fails, don't crash the error pipeline" — one even carries a <code># seedgo:bypass meta-logging</code> marker. The @spawn template hit is a two-prefix import <em>probe</em> that surfaces real failures later, not a swallow. The scanner predates the bypass convention, so it re-alarms on code the standards system already acquitted.</p>
</div>
</section>
<!-- FINDINGS -->
<section>
<div class="sec-head"><h2>What actually surfaced</h2><span class="rule"></span><span class="count">5 findings</span></div>
<div class="findings">
<div class="f med">
<div class="f-top"><span class="id">S324-1</span><span class="pill med">Medium · real · unowned</span>
<span class="f-title">A test leaked a Mock onto disk: <code>MagicMock/LOG_FILE/</code> sits in devpulse's branch root</span></div>
<p class="f-body">Empty, untracked, born <b>Jul 11 03:31</b> (a night-shift test run). Something called mkdir on a path built from an unpatched <code>Mock().LOG_FILE</code>. Four candidate offenders mock a <code>LOG_FILE</code> attribute: ai_mail <code>test_daemon.py</code> / <code>test_dispatch_status.py</code>, hooks <code>test_engine.py</code>, seedgo <code>test_checkers_batch3.py</code>. Cheap hunt: run each suite from a scratch CWD and watch which one mints the dir. Until then it's clutter that will regrow after cleanup.</p>
</div>
<div class="f med">
<div class="f-top"><span class="id">S324-2</span><span class="pill med">Medium · owner call</span>
<span class="f-title">@trigger: 11 bare <code>except Exception: pass</code> in live event handlers — 9 still unread</span></div>
<p class="f-body">The error-detection branch swallowing exceptions is worth eyes on principle. The 2 I sampled are legitimate meta-logging guards; the other 9 (in <code>error_detected</code>, <code>runaway_handler</code>, <code>plan_file</code>, <code>pr_status_sync</code>, <code>memory_pool</code>, <code>registry</code>, <code>startup</code>) deserve the same context read by their owner. Concern, not prescription — @trigger designs the fix if any is real.</p>
</div>
<div class="f low">
<div class="f-top"><span class="id">S324-3</span><span class="pill low">Low · tooling debt</span>
<span class="f-title">The advisory scanners are aging out of the conventions they audit</span></div>
<p class="f-body">April-era tools: the fallback scanner doesn't honor <code>seedgo:bypass</code> markers (false alarms on acquitted code), the stale scanner counts valid <code>.ai_mail.local/</code> usage in its headline, and none of them exclude <code>dropbox/</code> / <code>.archive/</code> quarantine dirs — which is where 45 of 61 "fallbacks" came from. A bypass-aware, archive-excluding refresh would make raw counts trustworthy again.</p>
</div>
<div class="f low">
<div class="f-top"><span class="id">S324-4</span><span class="pill low">Low · unfiltered</span>
<span class="f-title">~600 dead-name references still in the tree: dev central 243 · branch_registry 211 · ai_central 131 · cortex 13</span></div>
<p class="f-body">Not yet split between live code (worth fixing) and historical plans/archives (should keep their old names — never rewrite history). A filtered live-code pass is the prerequisite before this number means anything. Public-repo angle: strangers reading live code shouldn't meet four generations of naming.</p>
</div>
<div class="f good">
<div class="f-top"><span class="id">S324-5</span><span class="pill good">Verified good</span>
<span class="f-title">Where it counts, the tree is clean</span></div>
<p class="f-body">Devpulse live code: <b>zero</b> silent fallbacks — all 45 flagged were quarantined pollution captures in <code>dropbox/</code>, doing exactly what quarantine is for. The 10 hard checkers born from this same tools folder already run inside <code>drone @seedgo audit</code>, where all 17 branches sit at 100%. The advisory layer found no criticals, no highs, nothing live-broken.</p>
</div>
</div>
</section>
<!-- CONTEXT -->
<section>
<div class="sec-head"><h2>Report lineage</h2><span class="rule"></span><span class="count">where this fits</span></div>
<p class="f-body" style="max-width:70ch">Sibling of <code>docs/discovery/S304_report.html</code> (Jul 12 — the 124-finding autonomous discovery walk; two criticals from it, medic-off and the rollover off-by-one, were since fixed and Patrick-monitored respectively). The other big HTML artifact of the week is <code>artifacts/ux_flow_graph.html</code> (3.5&nbsp;MB, the v4 install-to-chat UX graph). Same-day context: PR&nbsp;#703 (merge-playbook drift-check step) is CI-green and awaiting Patrick's merge word; gif session prepped in <code>artifacts/gif_recording_plan.md</code>.</p>
</section>
<footer>
<span class="disc">read-only scan</span> · zero files changed · scanners: fallback_scanner_v1, stale_scanner_v1, readme_freshness_scanner_v1 · @devpulse S324 · 2026-07-18
</footer>
</div>
@@ -312,7 +312,7 @@ def test_stalltracker_inflight_tool_prevents_stall(monkeypatch, capsys):
monkeypatch.setattr(agent_handler, "_last_entry_is_inflight_tool", lambda *a, **kw: True)
t = agent_handler.StallTracker("@x", Path("/nope"), {}, now=0.0, pid=123)
for now in (60.0, 120.0, 180.0, 240.0):
for now in (120.0, 240.0, 360.0, 480.0):
t.observe(now=now)
out = capsys.readouterr().out
@@ -391,7 +391,8 @@ def test_watch_agent_surfaces_stall_to_stdout(monkeypatch, tmp_path, capsys):
monkeypatch.setattr(agent_handler, "_pid_alive", lambda pid: True)
monkeypatch.setattr(agent_handler, "_has_jsonl_activity", lambda *a, **kw: False)
monkeypatch.setattr(agent_handler, "_last_entry_is_inflight_tool", lambda *a, **kw: False)
_fake_clock_sleep(agent_handler, monkeypatch, lock_file)
# Lock must outlive STALL_THRESHOLD (300s) or the watch completes stall-free.
_fake_clock_sleep(agent_handler, monkeypatch, lock_file, unlink_at=400.0)
result = agent_handler.watch_agent("@fakebranch", timeout_seconds=100000, poll_interval=0.01)
out = capsys.readouterr().out
+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.
+9 -9
View File
@@ -93,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)
@@ -116,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()
+45 -5
View File
@@ -17,6 +17,7 @@ from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
from aipass.prax import logger
@@ -302,10 +303,47 @@ def _handle_close_pr(args: list[str]) -> dict:
return {"stdout": "", "stderr": result["message"], "exit_code": 1}
def _confirm_merge(pr_number: str, caller: str, confirmed: bool) -> dict | None:
"""Joint-decision gate: merges must never happen accidentally (DPLAN-0256).
Returns None when the merge may proceed, or a refusal/abort result dict.
Order matters: an explicit --confirm always passes; otherwise a real
terminal gets an interactive y/N prompt; headless callers are refused.
"""
if confirmed:
json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "--confirm"})
return None
if sys.stdin.isatty():
answer = input(f"Merge PR #{pr_number}? Merges are a joint decision. [y/N] ")
if answer.strip().lower() in ("y", "yes"):
json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "tty-yes"})
return None
json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "tty-abort"})
return {"stdout": "", "stderr": f"Merge of PR #{pr_number} aborted at prompt.", "exit_code": 1}
json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "headless-refused"})
logger.info("merge gate: refused headless merge of PR #%s by %s (no --confirm)", pr_number, caller)
return {
"stdout": "",
"stderr": (
f"Merge of PR #{pr_number} requires explicit confirmation — merges are a joint decision.\n"
f"Re-run once agreed: drone @git merge {pr_number} --confirm"
),
"exit_code": 1,
}
def _handle_merge(args: list[str], caller: str) -> dict:
"""Handle the merge subcommand (owner-tier, auth pre-checked)."""
if not args:
return {"stdout": "", "stderr": "Usage: drone @git merge <PR#>", "exit_code": 1}
confirmed = "--confirm" in args
pr_args = [a for a in args if not a.startswith("--")]
if not pr_args:
return {"stdout": "", "stderr": "Usage: drone @git merge <PR#> [--confirm]", "exit_code": 1}
refusal = _confirm_merge(pr_args[0], caller, confirmed)
if refusal is not None:
return refusal
try:
from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr
@@ -313,7 +351,7 @@ def _handle_merge(args: list[str], caller: str) -> dict:
logger.error("Failed to import devpulse_ops merge plugin: %s", exc)
return {"stdout": "", "stderr": f"devpulse_ops plugin not available: {exc}", "exit_code": 1}
result = merge_pr(args[0], caller)
result = merge_pr(pr_args[0], caller)
if result["success"]:
return {"stdout": result["message"], "stderr": "", "exit_code": 0}
return {"stdout": "", "stderr": result["message"], "exit_code": 1}
@@ -637,8 +675,10 @@ def get_help(command: str | None = None) -> str:
return "git unlock --force — Force-release the PR lock [owner]\n"
if command == "merge":
return (
"git merge <PR#> — Merge a PR and sync local main [owner]\n"
"git merge <PR#> [--confirm] — Merge a PR and sync local main [owner]\n"
" Runs gh pr merge --merge --delete-branch, then git pull --rebase.\n"
" Joint-decision gate: terminal sessions get a y/N prompt; headless\n"
" callers must pass --confirm or the merge is refused.\n"
)
if command == "smart-sync":
return "git smart-sync — Fetch origin and rebase if behind [owner]\n"
@@ -686,7 +726,7 @@ def get_help(command: str | None = None) -> str:
" dev-pr <desc> Push dev and create PR to main\n"
" delete-branch <name> Delete a remote branch\n"
" close-pr <number> Close a PR\n"
" merge <PR#> Merge a PR\n"
" merge <PR#> [--confirm] Merge a PR (gated: y/N prompt or --confirm)\n"
" sync [--autostash] Sync with origin/main (FF on dev)\n"
" smart-sync Fetch + rebase if behind\n"
" unlock --force Force-release the PR lock\n"
@@ -552,7 +552,9 @@ class TestGitModuleRouting:
proc.stdout = "ok\n"
mock_run.return_value = proc
result = handle_command("merge", ["42"])
# --confirm satisfies the joint-decision gate (DPLAN-0256); headless
# merge without it is refused before routing reaches merge_pr.
result = handle_command("merge", ["42", "--confirm"])
assert result["exit_code"] == 0
@patch(
+88
View File
@@ -1471,3 +1471,91 @@ class TestScopeFooter:
result = handle_command("diff", ["--all"])
assert "showing drone scope" not in result["stdout"]
# ===========================================================================
# Merge joint-decision gate (DPLAN-0256)
# ===========================================================================
_MERGE_PR = "aipass.drone.apps.plugins.devpulse_ops.merge_plugin.merge_pr"
_MERGE_OK = {
"success": True,
"pr_number": "123",
"title": "t",
"merge_commit": "abc123",
"message": "PR #123 merged: t (abc123)",
}
class TestMergeGate:
"""merge must never run without explicit confirmation."""
@patch(_AUTH, return_value="devpulse")
def test_headless_without_confirm_refused(self, _mock_auth: MagicMock) -> None:
"""Non-TTY caller without --confirm is refused before the plugin loads."""
with patch(_MERGE_PR) as mock_merge:
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
mock_stdin.isatty.return_value = False
result = handle_command("merge", ["123"])
assert result["exit_code"] == 1
assert "requires explicit confirmation" in result["stderr"]
assert "--confirm" in result["stderr"]
mock_merge.assert_not_called()
@patch(_AUTH, return_value="devpulse")
def test_confirm_flag_proceeds(self, _mock_auth: MagicMock) -> None:
"""--confirm merges without prompting, even headless."""
with patch(_MERGE_PR, return_value=dict(_MERGE_OK)) as mock_merge:
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
mock_stdin.isatty.return_value = False
result = handle_command("merge", ["123", "--confirm"])
assert result["exit_code"] == 0
mock_merge.assert_called_once_with("123", "devpulse")
@patch(_AUTH, return_value="devpulse")
def test_confirm_flag_position_agnostic(self, _mock_auth: MagicMock) -> None:
"""--confirm before the PR number still resolves the right PR."""
with patch(_MERGE_PR, return_value=dict(_MERGE_OK)) as mock_merge:
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
mock_stdin.isatty.return_value = False
result = handle_command("merge", ["--confirm", "123"])
assert result["exit_code"] == 0
mock_merge.assert_called_once_with("123", "devpulse")
@patch(_AUTH, return_value="devpulse")
def test_tty_yes_proceeds(self, _mock_auth: MagicMock) -> None:
"""Interactive terminal answering y merges."""
with patch(_MERGE_PR, return_value=dict(_MERGE_OK)) as mock_merge:
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
mock_stdin.isatty.return_value = True
with patch("builtins.input", return_value="y"):
result = handle_command("merge", ["123"])
assert result["exit_code"] == 0
mock_merge.assert_called_once_with("123", "devpulse")
@patch(_AUTH, return_value="devpulse")
def test_tty_default_aborts(self, _mock_auth: MagicMock) -> None:
"""Interactive terminal hitting enter (default N) aborts."""
with patch(_MERGE_PR) as mock_merge:
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
mock_stdin.isatty.return_value = True
with patch("builtins.input", return_value=""):
result = handle_command("merge", ["123"])
assert result["exit_code"] == 1
assert "aborted" in result["stderr"]
mock_merge.assert_not_called()
@patch(_AUTH, return_value="devpulse")
def test_confirm_without_pr_number_is_usage_error(self, _mock_auth: MagicMock) -> None:
"""--confirm alone (no PR number) is a usage error, not a merge."""
with patch(_MERGE_PR) as mock_merge:
result = handle_command("merge", ["--confirm"])
assert result["exit_code"] == 1
assert "Usage" in result["stderr"]
mock_merge.assert_not_called()
+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
+24 -20
View File
@@ -184,11 +184,15 @@ def get_closed_plans() -> List[Dict[str, Any]]:
def is_template_content(content: str) -> bool:
"""Check if plan content is still unedited template (v4.0)
"""Check if plan content is still unedited template (v5.0)
Uses bracket placeholders only (not section headers) to detect untouched
templates. Also checks for user-added content in key sections — if any
real work was added, the plan is NOT a template even if placeholders remain.
Priority order:
1. User-content signals (checked checkboxes) — strongest evidence of
real work, overrides everything.
2. Real content in Notes section — overrides bracket markers.
3. Non-boilerplate Execution Log entries — user-written entries
override bracket markers; template boilerplate is ignored.
4. Bracket-marker check — 3+ template placeholders = template.
Args:
content: Plan file content
@@ -196,37 +200,38 @@ def is_template_content(content: str) -> bool:
Returns:
True if plan is essentially an untouched template
"""
# User content indicators — if ANY of these are found, plan has real work
import re
# --- 1. User content signals override everything ---
user_content_signals = [
"- [x] Agent deployed", # Checked execution log item
"- [x] Agent completed", # Checked execution log item
"- [x] Seedgo checklist", # Checked completion item
"- [x] All goals achieved", # Checked completion item
"- [x] Agent deployed",
"- [x] Agent completed",
"- [x] Seedgo checklist",
"- [x] All goals achieved",
]
for signal in user_content_signals:
if signal in content:
return False
# Check Notes section for user content (not just the placeholder)
import re
# --- 2. Real Notes content → not template ---
notes_match = re.search(r"## Notes\s*\n(.*?)(?=\n---|\n## |\Z)", content, re.DOTALL)
if notes_match:
notes_content = notes_match.group(1).strip()
# If notes has content beyond the template placeholder, it's real work
if notes_content and notes_content != "[Working notes, issues encountered, decisions made]":
return False
# Check Execution Log for user-added entries beyond template
# --- 3. Non-boilerplate Exec Log entries → not template ---
_exec_boilerplate = ["**Log Pattern:**", "[task]", "[outcome]", "[file]"]
exec_match = re.search(r"## Execution Log\s*\n(.*?)(?=\n---|\n## |\Z)", content, re.DOTALL)
if exec_match:
exec_content = exec_match.group(1).strip()
lines = [line.strip() for line in exec_content.split("\n") if line.strip()]
# Template has ~6 lines (date header + checkbox items). More = user added content.
if len(lines) > 8:
return False
is_boilerplate = any(m in exec_content for m in _exec_boilerplate)
if not is_boilerplate:
lines = [line.strip() for line in exec_content.split("\n") if line.strip()]
if len(lines) > 8:
return False
# Bracket placeholders only (no section headers — those persist in real plans)
# --- 4. Bracket markers: 3+ from any type = template ---
default_markers = [
"[What do you want to achieve? Specific end state.]",
"[How will agents tackle this? What instructions will they need?]",
@@ -250,7 +255,6 @@ def is_template_content(content: str) -> bool:
"[Any other branches, services, or approvals needed?]",
]
# 3+ bracket placeholders from any type = template
for markers in [default_markers, master_markers, proposal_markers]:
found = sum(1 for m in markers if m in content)
if found >= 3:
@@ -17,6 +17,8 @@ Usage:
"""
import json
import os
import time
from pathlib import Path
from datetime import datetime, timezone
from typing import Dict, Any, List, Tuple, Optional
@@ -32,6 +34,34 @@ from aipass.flow.apps.handlers.json import json_handler
MODULE_NAME = "aggregate_central"
_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)
# =============================================
# HELPER FUNCTIONS
@@ -101,13 +131,36 @@ def save_branch_registry(registry_path: Path, registry: Dict[str, Any]) -> bool:
Returns:
True on success, False on failure
"""
lock_path = registry_path.with_suffix(".lock")
try:
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
with open(registry_path, "w", encoding="utf-8") as f:
json.dump(registry, f, indent=2, ensure_ascii=False)
registry_path.parent.mkdir(parents=True, exist_ok=True)
if not _acquire_lock(lock_path):
logger.error(
"[%s] Could not acquire lock for %s after %d retries",
MODULE_NAME,
registry_path,
_LOCK_RETRIES,
)
return False
try:
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
tmp_path = registry_path.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(registry_path))
finally:
_release_lock(lock_path)
return True
except Exception as e:
logger.error(f"[{MODULE_NAME}] Failed to save registry {registry_path}: {e}")
logger.error(
"[%s] Failed to save registry %s: %s",
MODULE_NAME,
registry_path,
e,
)
return False
@@ -265,10 +318,27 @@ def save_central(central_file: Path, central_dir: Path, central_data: Dict[str,
Returns:
True on success, False on failure
"""
lock_path = central_file.with_suffix(".lock")
try:
central_dir.mkdir(parents=True, exist_ok=True)
with open(central_file, "w", encoding="utf-8") as f:
json.dump(central_data, 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,
central_file,
_LOCK_RETRIES,
)
return False
try:
tmp_path = central_file.with_suffix(".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(central_data, f, indent=2, ensure_ascii=False)
os.replace(str(tmp_path), str(central_file))
finally:
_release_lock(lock_path)
return True
except Exception as e:
logger.error(f"[{MODULE_NAME}] Failed to save {central_file}: {e}")
@@ -392,7 +462,10 @@ def aggregate_central_impl(
# Save central file
if save_central(central_file, central_dir, central_data):
logger.info(
f"[{MODULE_NAME}] SUCCESS: Aggregation complete: {len(all_active)} active, {len(recently_closed)} recently closed"
"[%s] SUCCESS: Aggregation complete: %d active, %d recently closed",
MODULE_NAME,
len(all_active),
len(recently_closed),
)
# Fire trigger event
@@ -116,6 +116,7 @@ The PR gate (verified against `.github/workflows/`):
- [ ] **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
- [ ] **Site drift check (S323):** does this merge change install commands, onboarding flow, agent count, or the platform/CLI story? If YES → note it here and flag that aipass.ai must be updated same-day (devpulse handles the site edit; the site is a projection of the README, never its own source of facts).
## 7. Release tag
+32 -12
View File
@@ -9,14 +9,14 @@ HOOKS -- hook infrastructure owner. Single engine dispatches all hooks across pl
## What I Do
- Own the hook engine -- receives events from platform bridges, routes to handlers, logs everything
- Maintain 14 native handlers across 4 categories (prompt, security, lifecycle, notification)
- Maintain 27 native handlers across 4 categories (prompt, security, lifecycle, notification)
- Bridge platforms -- thin normalization layer per provider (Claude today, Codex planned)
- Per-project config -- `.aipass/hooks.json` controls what fires per project
- Log everything -- prax integration + JSONL diagnostics for every hook execution
## What I Don't Do
- Touch provider settings directly -- setup.sh/doctor handles platform config installation
- Touch `~/.claude/settings.json` -- personal file, doctor/init syncs it. The manifest (`.claude/provider_manifest.json`) IS mine to maintain
- Manage other branches -- I'm a builder, not an orchestrator
- Own handler business logic -- handlers are self-contained, engine just dispatches
@@ -40,45 +40,59 @@ apps/
handlers/
bridges/
claude.py # Claude Code bridge (provider settings entry point)
prompt/ # Prompt injection hooks
codex.py # Codex bridge (planned)
prompt/ # Prompt injection hooks (UserPromptSubmit)
branch_loader.py # Injects aipass_local_prompt.md
tier0_kernel.py # Injects tier0 kernel prompt (every turn)
navmap.py # Injects tier1 navmap prompt (periodic)
identity.py # Injects passport identity block
compass_recall.py # Governance recall injection
feedback_pulse.py # 10-turn cadence feedback nudge (disabled default)
context_gauge.py # Live transcript-fill nudge toward /prep
temporal.py # Weekday/date/time/tz/part-of-day, every turn
persistent_alert.py # Advisory banners for .aipass/alerts.json
security/ # Enforcement hooks
presence_gate.py # Session presence gate (UserPromptSubmit + Stop release)
edit_gate.py # Blocks edits while type errors exist
git_gate.py # Enforces git access tiers
rm_gate.py # Guards destructive rm commands
registry_gate.py # Guards registry-modifying commands
subagent_gate.py # Blocks sub-agent stop until clean
lifecycle/ # Session management hooks
auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile)
auto_watchdog.py # Watchdog arming after dispatch
auto_process.py # Scheduled inbox/task processing
compact.py # Pre-compact memory archival
rollover.py # Pre-compact memory rollover
pre_compact_prep.py # Pre-compact snapshot stamp (context/dispatch/plans)
session_start.py # SessionStart cadence reset
notification/ # Alert hooks
announce.py # Inbox banner on prompt
email.py # Email notification
stop_sound.py # Sound on session stop
tool_sound.py # Sound on tool use
telegram_response.py # Telegram reply delivery on Stop
config/
loader.py # hooks.json discovery + validation
diagnostics.py # Diagnostics config
loader.py # hooks.json discovery + validation, config-independent trust checks
trust_registry.py # Trusted-project registry (enroll/revoke/hash checks)
diagnostics.py # JSONL diagnostics config
logs/
engine.jsonl # JSONL diagnostics (every hook execution)
tests/ # 15 test files, 244 tests
tests/ # 43 test files, 1249 tests
```
## Handler Categories
| Category | Count | Handlers |
|----------|-------|----------|
| prompt | 4 | branch_loader, tier0_kernel, navmap, identity |
| security | 3 | edit_gate, git_gate, subagent_gate |
| lifecycle | 4 | auto_fix, auto_watchdog, compact, rollover |
| notification | 4 | announce, email, stop_sound, tool_sound |
| prompt | 9 | branch_loader, tier0_kernel, navmap, identity, compass_recall, feedback_pulse, context_gauge, temporal, persistent_alert |
| security | 6 | presence_gate, edit_gate, git_gate, rm_gate, registry_gate, subagent_gate |
| lifecycle | 7 | auto_fix, auto_watchdog, auto_process, compact, rollover, pre_compact_prep, session_start |
| notification | 5 | announce, email, stop_sound, tool_sound, telegram_response |
## How It Works
1. Provider settings point ONE bridge entry per event type (e.g., `claude.py UserPromptSubmit`)
1. Provider settings invoke the bridge two ways: `claude.py EventType` (all enabled handlers -- tool events) or `claude.py EventType:handler_name` (one handler per entry -- UserPromptSubmit, PreCompact)
2. Bridge calls `engine.dispatch(event_type, stdin_data, config)`
3. Engine reads `.aipass/hooks.json` (walks up from CWD)
4. Engine runs matching hooks sequentially, logs each to JSONL
@@ -86,6 +100,12 @@ tests/ # 15 test files, 244 tests
6. Exit code 2 without JSON = crash (log error, continue to next hook)
7. All hook stdout concatenated and returned to platform
## New handler? Check the provider wire
hooks.json alone is not live: UserPromptSubmit + PreCompact are invoked per-handler (`claude.py Event:name`) -- handlers on those events ALSO need a command entry in `.claude/provider_manifest.json` (PreCompact: manual + auto pair). Verify with firing evidence in engine.jsonl, not just the suite.
EVERY reply that adds/renames/moves a handler MUST state either "provider settings update needed: <exact entries>" or "no provider wire needed" -- never silent. Devpulse + Patrick apply live-settings changes; flag it every time, even if the manifest is already updated.
## Integration
- **Depends on:** @prax for logging (system_logger for prax monitor visibility)
@@ -105,4 +125,4 @@ tests/ # 15 test files, 244 tests
- Exit code 2 has dual meaning: intentional block (with JSON) vs crash (without JSON). Engine distinguishes by checking stdout.
- JSONL log lives at `logs/engine.jsonl` -- not in prax. Prax gets a copy via system_logger, but JSONL is the source of truth for hook diagnostics.
- Bridge must be the ONLY entry in provider settings per event type. Multiple entries per event = platform calls them all independently, bypassing engine sequencing.
- Provider settings carry multiple named bridge entries per event for UserPromptSubmit and PreCompact -- deliberate (per-handler output + timeout). New handlers on those events need their own provider entry.
+114 -77
View File
@@ -4,7 +4,7 @@
"created": "2026-05-18",
"updated": "2026-05-28",
"description": "Standards bypass configuration for this branch",
"audit_context": "DPLAN-0191: Full ownership hardening. All handler wiring verified against .aipass/hooks.json + engine.jsonl firing evidence. Dynamic dispatch via engine._run_handler (importlib.import_module + getattr) means handlers are never statically imported \u2014 seedgo's dead_code/unused_function checks are false positives for this architecture."
"audit_context": "DPLAN-0191: Full ownership hardening. All handler wiring verified against .aipass/hooks.json + engine.jsonl firing evidence. Dynamic dispatch via engine._run_handler (importlib.import_module + getattr) means handlers are never statically imported — seedgo's dead_code/unused_function checks are false positives for this architecture."
},
"bypass": [
{
@@ -14,17 +14,17 @@
{
"file": "apps/handlers/bridges/claude.py",
"standard": "dead_code",
"reason": "Bridge called externally by provider settings subprocess \u2014 no internal import. Verified wired in ~/.claude/settings.json hook entries."
"reason": "Bridge called externally by provider settings subprocess — no internal import. Verified wired in ~/.claude/settings.json hook entries."
},
{
"file": "apps/handlers/bridges/claude.py",
"standard": "unused_function",
"reason": "main() called as subprocess entry point from provider settings \u2014 never statically imported."
"reason": "main() called as subprocess entry point from provider settings — never statically imported."
},
{
"file": "apps/handlers/bridges/claude.py",
"standard": "handlers",
"reason": "Bridges import engine module by design \u2014 that is their entire purpose."
"reason": "Bridges import engine module by design — that is their entire purpose."
},
{
"file": "apps/handlers/bridges/claude.py",
@@ -39,27 +39,27 @@
{
"file": "apps/handlers/bridges/claude.py",
"standard": "imports",
"reason": "Bridge imports engine module by design \u2014 sole purpose."
"reason": "Bridge imports engine module by design — sole purpose."
},
{
"file": "apps/handlers/bridges/codex.py",
"standard": "dead_code",
"reason": "Bridge called externally by Codex hook settings subprocess \u2014 no internal import. Wired in .codex/hooks.json."
"reason": "Bridge called externally by Codex hook settings subprocess — no internal import. Wired in .codex/hooks.json."
},
{
"file": "apps/handlers/bridges/codex.py",
"standard": "unused_function",
"reason": "main() called as subprocess entry point from Codex hook settings \u2014 never statically imported."
"reason": "main() called as subprocess entry point from Codex hook settings — never statically imported."
},
{
"file": "apps/handlers/bridges/codex.py",
"standard": "handlers",
"reason": "Bridges import engine module by design \u2014 that is their entire purpose."
"reason": "Bridges import engine module by design — that is their entire purpose."
},
{
"file": "apps/handlers/bridges/codex.py",
"standard": "json_structure",
"reason": "Thin entry point using stdlib json for Codex protocol envelope \u2014 no JSON file ops needing json_handler."
"reason": "Thin entry point using stdlib json for Codex protocol envelope — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/bridges/codex.py",
@@ -69,12 +69,12 @@
{
"file": "apps/handlers/bridges/codex.py",
"standard": "imports",
"reason": "Bridge imports engine module by design \u2014 sole purpose."
"reason": "Bridge imports engine module by design — sole purpose."
},
{
"file": "apps/handlers/prompt/identity.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.identity.handle' \u2014 not statically imported by design. Verified wired in UserPromptSubmit.identity_injector + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.identity.handle' — not statically imported by design. Verified wired in UserPromptSubmit.identity_injector + fires in engine.jsonl."
},
{
"file": "apps/handlers/prompt/identity.py",
@@ -84,12 +84,12 @@
{
"file": "apps/handlers/prompt/identity.py",
"standard": "json_structure",
"reason": "Uses stdlib json.loads to read passport.json \u2014 no JSON file ops needing json_handler."
"reason": "Uses stdlib json.loads to read passport.json — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/prompt/branch_loader.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.branch_loader.handle' \u2014 not statically imported by design. Verified wired in UserPromptSubmit.branch_prompt + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.branch_loader.handle' — not statically imported by design. Verified wired in UserPromptSubmit.branch_prompt + fires in engine.jsonl."
},
{
"file": "apps/handlers/prompt/branch_loader.py",
@@ -99,7 +99,7 @@
{
"file": "apps/handlers/prompt/branch_loader.py",
"standard": "json_structure",
"reason": "No JSON operations \u2014 reads markdown files and outputs text."
"reason": "No JSON operations — reads markdown files and outputs text."
},
{
"file": "apps/handlers/prompt/tier0_kernel.py",
@@ -131,10 +131,15 @@
"standard": "json_structure",
"reason": "No JSON operations - reads markdown file (.aipass/tier1_navmap.md) and outputs text."
},
{
"file": "apps/handlers/prompt/temporal.py",
"standard": "json_structure",
"reason": "No JSON operations - reads the system clock and outputs text. Wired in UserPromptSubmit.temporal (FPLAN-0348)."
},
{
"file": "apps/handlers/security/edit_gate.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.edit_gate.handle' \u2014 not statically imported by design. Verified wired in PreToolUse.pre_edit_gate + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.edit_gate.handle' — not statically imported by design. Verified wired in PreToolUse.pre_edit_gate + fires in engine.jsonl."
},
{
"file": "apps/handlers/security/edit_gate.py",
@@ -144,12 +149,12 @@
{
"file": "apps/handlers/security/edit_gate.py",
"standard": "json_structure",
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler."
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/security/git_gate.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.git_gate.handle' \u2014 not statically imported by design. Verified wired in PreToolUse.git_gate + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.git_gate.handle' — not statically imported by design. Verified wired in PreToolUse.git_gate + fires in engine.jsonl."
},
{
"file": "apps/handlers/security/git_gate.py",
@@ -159,12 +164,12 @@
{
"file": "apps/handlers/security/git_gate.py",
"standard": "json_structure",
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler."
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/security/rm_gate.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.rm_gate.handle' \u2014 not statically imported by design. Wired in PreToolUse.rm_gate."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.rm_gate.handle' — not statically imported by design. Wired in PreToolUse.rm_gate."
},
{
"file": "apps/handlers/security/rm_gate.py",
@@ -174,12 +179,12 @@
{
"file": "apps/handlers/security/rm_gate.py",
"standard": "json_structure",
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler."
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/security/subagent_gate.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.subagent_gate.handle' \u2014 not statically imported by design. Verified wired in SubagentStop.subagent_stop_gate + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.subagent_gate.handle' — not statically imported by design. Verified wired in SubagentStop.subagent_stop_gate + fires in engine.jsonl."
},
{
"file": "apps/handlers/security/subagent_gate.py",
@@ -189,12 +194,12 @@
{
"file": "apps/handlers/security/subagent_gate.py",
"standard": "json_structure",
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler."
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/security/subagent_gate.py",
"standard": "open_encoding",
"reason": "NamedTemporaryFile creates binary wav for Piper TTS \u2014 encoding not applicable to binary audio."
"reason": "NamedTemporaryFile creates binary wav for Piper TTS — encoding not applicable to binary audio."
},
{
"file": "apps/handlers/security/registry_gate.py",
@@ -244,7 +249,7 @@
{
"file": "apps/handlers/lifecycle/auto_fix.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_fix.handle' \u2014 not statically imported by design. Verified wired in PostToolUse.auto_fix_diagnostics + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_fix.handle' — not statically imported by design. Verified wired in PostToolUse.auto_fix_diagnostics + fires in engine.jsonl."
},
{
"file": "apps/handlers/lifecycle/auto_fix.py",
@@ -254,12 +259,12 @@
{
"file": "apps/handlers/lifecycle/auto_fix.py",
"standard": "json_structure",
"reason": "Diagnostics handler uses stdlib json for hook protocol responses and state file \u2014 no JSON file ops needing json_handler."
"reason": "Diagnostics handler uses stdlib json for hook protocol responses and state file — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/lifecycle/auto_watchdog.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_watchdog.handle' \u2014 not statically imported by design. Verified wired in PostToolUse.auto_watchdog + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_watchdog.handle' — not statically imported by design. Verified wired in PostToolUse.auto_watchdog + fires in engine.jsonl."
},
{
"file": "apps/handlers/lifecycle/auto_watchdog.py",
@@ -269,42 +274,42 @@
{
"file": "apps/handlers/lifecycle/auto_watchdog.py",
"standard": "json_structure",
"reason": "Uses stdlib json.dumps to produce additionalContext output \u2014 no JSON file ops needing json_handler."
"reason": "Uses stdlib json.dumps to produce additionalContext output — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/lifecycle/compact.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.compact.handle' \u2014 not statically imported by design. Verified wired in PreCompact.pre_compact (PreCompact events are rare \u2014 fires only during context compaction)."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.compact.handle' — not statically imported by design. Verified wired in PreCompact.pre_compact (PreCompact events are rare — fires only during context compaction)."
},
{
"file": "apps/handlers/lifecycle/compact.py",
"standard": "unused_function",
"reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact \u2014 fires during compaction events."
"reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact — fires during compaction events."
},
{
"file": "apps/handlers/lifecycle/compact.py",
"standard": "json_structure",
"reason": "Uses stdlib json.loads for local.json reading \u2014 no JSON file ops needing json_handler."
"reason": "Uses stdlib json.loads for local.json reading — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/lifecycle/rollover.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.rollover.handle' \u2014 not statically imported by design. Verified wired in PreCompact.pre_compact_rollover (PreCompact events are rare \u2014 fires only during context compaction)."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.rollover.handle' — not statically imported by design. Verified wired in PreCompact.pre_compact_rollover (PreCompact events are rare — fires only during context compaction)."
},
{
"file": "apps/handlers/lifecycle/rollover.py",
"standard": "unused_function",
"reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact_rollover \u2014 fires during compaction events."
"reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact_rollover — fires during compaction events."
},
{
"file": "apps/handlers/lifecycle/rollover.py",
"standard": "json_structure",
"reason": "Uses stdlib json.loads for registry and memory file checks \u2014 no JSON file ops needing json_handler."
"reason": "Uses stdlib json.loads for registry and memory file checks — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/lifecycle/auto_process.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_process.handle' \u2014 not statically imported by design. Wired in UserPromptSubmit.auto_process + PreCompact.auto_process."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_process.handle' — not statically imported by design. Wired in UserPromptSubmit.auto_process + PreCompact.auto_process."
},
{
"file": "apps/handlers/lifecycle/auto_process.py",
@@ -314,12 +319,12 @@
{
"file": "apps/handlers/lifecycle/auto_process.py",
"standard": "json_structure",
"reason": "Delegates to @memory's auto_process() via importlib \u2014 no direct JSON file ops needing json_handler."
"reason": "Delegates to @memory's auto_process() via importlib — no direct JSON file ops needing json_handler."
},
{
"file": "apps/handlers/lifecycle/session_start.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.session_start.handle' \u2014 not statically imported by design. Wired in SessionStart.cadence_reset."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.session_start.handle' — not statically imported by design. Wired in SessionStart.cadence_reset."
},
{
"file": "apps/handlers/lifecycle/session_start.py",
@@ -329,12 +334,12 @@
{
"file": "apps/handlers/lifecycle/session_start.py",
"standard": "json_structure",
"reason": "Delegates to cadence.reset_counter() via importlib \u2014 no direct JSON file ops needing json_handler."
"reason": "Delegates to cadence.reset_counter() via importlib — no direct JSON file ops needing json_handler."
},
{
"file": "apps/modules/cadence.py",
"standard": "dead_code",
"reason": "Cadence module \u2014 should_fire() called from tier0_kernel.py, navmap.py, and branch_loader.py guard lines; reset_counter() called from compact.py PreCompact handler. Not statically discoverable because callers are themselves dynamically dispatched."
"reason": "Cadence module — should_fire() called from tier0_kernel.py, navmap.py, and branch_loader.py guard lines; reset_counter() called from compact.py PreCompact handler. Not statically discoverable because callers are themselves dynamically dispatched."
},
{
"file": "apps/modules/cadence.py",
@@ -344,12 +349,12 @@
{
"file": "apps/modules/cadence.py",
"standard": "json_structure",
"reason": "Uses stdlib json for /tmp state file (turn counter) and hooks_json/custom_config/cadence_config.json config loading \u2014 lightweight ephemeral state, not branch data storage."
"reason": "Uses stdlib json for /tmp state file (turn counter) and hooks_json/custom_config/cadence_config.json config loading — lightweight ephemeral state, not branch data storage."
},
{
"file": "apps/handlers/notification/announce.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.announce.handle' \u2014 not statically imported by design. Verified wired in Notification.notification_sound + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.announce.handle' — not statically imported by design. Verified wired in Notification.notification_sound + fires in engine.jsonl."
},
{
"file": "apps/handlers/notification/announce.py",
@@ -359,12 +364,12 @@
{
"file": "apps/handlers/notification/announce.py",
"standard": "json_structure",
"reason": "Sound handler \u2014 no JSON operations, plays WAV files."
"reason": "Sound handler — no JSON operations, plays WAV files."
},
{
"file": "apps/handlers/notification/email.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.email.handle' \u2014 not statically imported by design. Verified wired in UserPromptSubmit.email_notification + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.email.handle' — not statically imported by design. Verified wired in UserPromptSubmit.email_notification + fires in engine.jsonl."
},
{
"file": "apps/handlers/notification/email.py",
@@ -374,12 +379,12 @@
{
"file": "apps/handlers/notification/email.py",
"standard": "json_structure",
"reason": "Uses stdlib json.loads for inbox parsing \u2014 no JSON file ops needing json_handler."
"reason": "Uses stdlib json.loads for inbox parsing — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/notification/stop_sound.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.stop_sound.handle' \u2014 not statically imported by design. Verified wired in Stop.stop_sound + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.stop_sound.handle' — not statically imported by design. Verified wired in Stop.stop_sound + fires in engine.jsonl."
},
{
"file": "apps/handlers/notification/stop_sound.py",
@@ -389,12 +394,12 @@
{
"file": "apps/handlers/notification/stop_sound.py",
"standard": "json_structure",
"reason": "Sound handler \u2014 no JSON operations, plays WAV files."
"reason": "Sound handler — no JSON operations, plays WAV files."
},
{
"file": "apps/handlers/notification/tool_sound.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.tool_sound.handle' \u2014 not statically imported by design. Verified wired in PreToolUse.tool_use_sound + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.tool_sound.handle' — not statically imported by design. Verified wired in PreToolUse.tool_use_sound + fires in engine.jsonl."
},
{
"file": "apps/handlers/notification/tool_sound.py",
@@ -404,7 +409,7 @@
{
"file": "apps/handlers/notification/tool_sound.py",
"standard": "json_structure",
"reason": "Sound handler \u2014 no JSON operations, plays WAV files."
"reason": "Sound handler — no JSON operations, plays WAV files."
},
{
"file": "apps/handlers/config/trust_registry.py",
@@ -414,22 +419,22 @@
{
"file": "apps/handlers/config/loader.py",
"standard": "json_structure",
"reason": "Config loader does $AIPASS_HOME variable expansion before JSON parse \u2014 json_handler does not support this."
"reason": "Config loader does $AIPASS_HOME variable expansion before JSON parse — json_handler does not support this."
},
{
"file": "apps/handlers/config/diagnostics.py",
"standard": "json_structure",
"reason": "JSONL append-only diagnostic log \u2014 different pattern from branch json_handler storage."
"reason": "JSONL append-only diagnostic log — different pattern from branch json_handler storage."
},
{
"file": "apps/modules/engine.py",
"standard": "json_structure",
"reason": "Engine uses JSONL diagnostic logging, not branch json_handler \u2014 different purpose."
"reason": "Engine uses JSONL diagnostic logging, not branch json_handler — different purpose."
},
{
"file": "apps/modules/engine.py",
"standard": "modules",
"reason": "dispatch() is the engine's core purpose \u2014 it IS the module's primary function, not a handler that belongs elsewhere. The engine exists to dispatch; moving dispatch to handlers/ would leave an empty module."
"reason": "dispatch() is the engine's core purpose — it IS the module's primary function, not a handler that belongs elsewhere. The engine exists to dispatch; moving dispatch to handlers/ would leave an empty module."
},
{
"file": "apps/modules/hook_test.py",
@@ -439,27 +444,32 @@
{
"file": "apps/modules/hooksound.py",
"standard": "json_structure",
"reason": "Sound mute toggle \u2014 touches /tmp/aipass-hooks-muted flag file only, no JSON operations or json_handler storage."
"reason": "Sound mute toggle — touches /tmp/aipass-hooks-muted flag file only, no JSON operations or json_handler storage."
},
{
"file": "apps/modules/hooksound.py",
"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'."
"reason": "MUTE_FLAG.unlink() removes a /tmp mute flag file for sound toggle — 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 — not a tracked resource. Deliberate user action via 'drone @hooks feedback on'."
},
{
"file": "apps/modules/hookstatus.py",
"standard": "json_structure",
"reason": "Read-only config viewer \u2014 delegates JSON loading to config/loader.py, no direct JSON file ops."
"reason": "Read-only config viewer — delegates JSON loading to config/loader.py, no direct JSON file ops."
},
{
"file": "apps/modules/wire_verify.py",
"standard": "json_structure",
"reason": "Reads ~/.claude/settings.json (external provider settings) with stdlib json \u2014 not branch data storage needing json_handler."
"reason": "Reads ~/.claude/settings.json (external provider settings) with stdlib json — not branch data storage needing json_handler."
},
{
"file": "apps/modules/cadence.py",
"standard": "modules",
"reason": "Cadence module reads /tmp state file and hooks_json/custom_config/cadence_config.json with stdlib json \u2014 lightweight ephemeral state (turn counter) + tunable config. json_handler is for persistent branch data, not /tmp session state or config knobs."
"reason": "Cadence module reads /tmp state file and hooks_json/custom_config/cadence_config.json with stdlib json — lightweight ephemeral state (turn counter) + tunable config. json_handler is for persistent branch data, not /tmp session state or config knobs."
},
{
"file": "apps/hooks.py",
@@ -469,17 +479,17 @@
{
"file": "apps/sound.py",
"standard": "unused_function",
"reason": "play() called by handler files (stop_sound.py, announce.py) that are dynamically dispatched via importlib \u2014 static analysis cannot trace the call chain from hooks.json \u2192 engine \u2192 handler \u2192 sound.play()."
"reason": "play() called by handler files (stop_sound.py, announce.py) that are dynamically dispatched via importlib — static analysis cannot trace the call chain from hooks.json → engine → handler → sound.play()."
},
{
"file": "apps/sound.py",
"standard": "open_encoding",
"reason": "NamedTemporaryFile creates binary WAV for Piper TTS \u2014 encoding not applicable to binary audio."
"reason": "NamedTemporaryFile creates binary WAV for Piper TTS — encoding not applicable to binary audio."
},
{
"file": "apps/sound.py",
"standard": "cli_flags",
"reason": "Shared utility module imported by handlers \u2014 not a CLI entry point. Has print_introspection() for drone discovery but no handle_command() or user-facing CLI."
"reason": "Shared utility module imported by handlers — not a CLI entry point. Has print_introspection() for drone discovery but no handle_command() or user-facing CLI."
},
{
"standard": "json_handler",
@@ -487,7 +497,7 @@
},
{
"standard": "test_quality",
"reason": "Hooks branch does not use json_handler \u2014 has its own JSONL diagnostic logging (diagnostics.py) and stdlib json for hook protocol I/O. json_handler coverage, mock_json_handler fixture, and exception_contracts (create_default_raises, save_invalid_raises, invalid_mode_raises) are all N/A for a hook dispatch engine architecture."
"reason": "Hooks branch does not use json_handler — has its own JSONL diagnostic logging (diagnostics.py) and stdlib json for hook protocol I/O. json_handler coverage, mock_json_handler fixture, and exception_contracts (create_default_raises, save_invalid_raises, invalid_mode_raises) are all N/A for a hook dispatch engine architecture."
},
{
"file": "tests/conftest.py",
@@ -497,12 +507,12 @@
{
"file": "tests/conftest.py",
"standard": "json_handler",
"reason": "Hooks branch does not use json_handler \u2014 has its own JSONL logging and stdlib json for hook protocol. mock_json_handler fixture is N/A."
"reason": "Hooks branch does not use json_handler — has its own JSONL logging and stdlib json for hook protocol. mock_json_handler fixture is N/A."
},
{
"file": "tests/conftest.py",
"standard": "exception_contracts",
"reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns \u2014 those contracts are N/A for a hook dispatch engine."
"reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns — those contracts are N/A for a hook dispatch engine."
},
{
"file": "tests/test_engine.py",
@@ -527,7 +537,7 @@
{
"file": "tests/test_engine.py",
"standard": "json_handler",
"reason": "Hooks branch does not use json_handler \u2014 has its own JSONL logging."
"reason": "Hooks branch does not use json_handler — has its own JSONL logging."
},
{
"file": "tests/test_engine.py",
@@ -657,12 +667,12 @@
{
"file": "tests/test_auto_fix.py",
"standard": "commented_logger",
"reason": "Test data contains '# logger.debug(msg)' as input to pattern checker under test \u2014 not a commented-out call."
"reason": "Test data contains '# logger.debug(msg)' as input to pattern checker under test — not a commented-out call."
},
{
"file": "tests/test_auto_fix.py",
"standard": "trigger",
"reason": "Test cleanup .unlink() removes temporary state files \u2014 not a production file deletion."
"reason": "Test cleanup .unlink() removes temporary state files — not a production file deletion."
},
{
"file": "tests/test_identity.py",
@@ -952,47 +962,47 @@
{
"file": "apps/modules/sandbox.py",
"standard": "json_structure",
"reason": "Uses stdlib json.dump to write ephemeral srt config to a NamedTemporaryFile \u2014 not a tracked JSON resource needing json_handler."
"reason": "Uses stdlib json.dump to write ephemeral srt config to a NamedTemporaryFile — not a tracked JSON resource needing json_handler."
},
{
"file": "apps/modules/sandbox.py",
"standard": "trigger",
"reason": "Path.unlink() removes an ephemeral NamedTemporaryFile (srt config) created seconds earlier in the same function \u2014 not a tracked resource or production data deletion."
"reason": "Path.unlink() removes an ephemeral NamedTemporaryFile (srt config) created seconds earlier in the same function — not a tracked resource or production data deletion."
},
{
"file": "artifacts/sandbox_phase1_demo.py",
"standard": "architecture",
"reason": "Live demo artifact for FPLAN-0250 Phase 1 acceptance \u2014 not production code, lives in artifacts/ per the phase brief."
"reason": "Live demo artifact for FPLAN-0250 Phase 1 acceptance — not production code, lives in artifacts/ per the phase brief."
},
{
"file": "artifacts/sandbox_phase1_demo.py",
"standard": "debug_print",
"reason": "Demo script uses print() for human-readable acceptance test output \u2014 not a module with CLI service."
"reason": "Demo script uses print() for human-readable acceptance test output — not a module with CLI service."
},
{
"file": "artifacts/sandbox_phase1_demo.py",
"standard": "imports",
"reason": "sys.path insert needed to run standalone demo from artifacts/ \u2014 not a pip-installed module entry point."
"reason": "sys.path insert needed to run standalone demo from artifacts/ — not a pip-installed module entry point."
},
{
"file": "artifacts/sandbox_phase1_demo.py",
"standard": "documentation",
"reason": "Demo helper function \u2014 docstrings omitted for brevity in a non-production artifact."
"reason": "Demo helper function — docstrings omitted for brevity in a non-production artifact."
},
{
"file": "artifacts/sandbox_phase1_demo.py",
"standard": "help_text",
"reason": "Demo run instructions reference python3 as the invocation command \u2014 this is a standalone script, not a drone-routed module."
"reason": "Demo run instructions reference python3 as the invocation command — this is a standalone script, not a drone-routed module."
},
{
"file": "tests/test_sandbox.py",
"standard": "architecture",
"reason": "Test file lives in tests/ per hooks convention \u2014 not a module or handler."
"reason": "Test file lives in tests/ per hooks convention — not a module or handler."
},
{
"file": "tests/test_sandbox.py",
"standard": "documentation",
"reason": "Test methods use descriptive names \u2014 docstrings redundant per hooks test convention."
"reason": "Test methods use descriptive names — docstrings redundant per hooks test convention."
},
{
"file": "tests/test_sandbox.py",
@@ -1002,7 +1012,7 @@
{
"file": "apps/modules/sandbox.py",
"standard": "modules",
"reason": "Read-only passport.json check in _is_devpulse() to detect branch role for policy generation \u2014 not a file-write operation, not a handler-level concern. Module reads sibling passports to determine writable/RO map."
"reason": "Read-only passport.json check in _is_devpulse() to detect branch role for policy generation — not a file-write operation, not a handler-level concern. Module reads sibling passports to determine writable/RO map."
},
{
"file": "apps/modules/sandbox.py",
@@ -1243,10 +1253,37 @@
"file": "tests/test_wire_verify.py",
"standard": "help_text",
"reason": "Test fixture _BRIDGE_CMD contains 'python3' as part of a mock provider command string — not user-facing help text."
},
{
"file": "apps/modules/context_window.py",
"standard": "json_structure",
"reason": "Reads external state (session transcript JSONL via tail, branch .claude/settings.local.json) with stdlib json — not branch data storage needing json_handler. The module IS the transcript/window reader service (DPLAN-0253), same pattern as cc_sessions.py."
},
{
"file": "apps/handlers/lifecycle/pre_compact_prep.py",
"standard": "json_structure",
"reason": "Uses stdlib json for .trinity/local.json read+stamp (own branch's memory file, DPLAN-0253) plus AIPASS_REGISTRY.json/inbox.json/memory.config.json reads (external system + other branches' state) — not this branch's own hooks_json/ triplet storage. Matches compact.py precedent (same local.json access)."
},
{
"file": "apps/handlers/prompt/context_gauge.py",
"standard": "json_structure",
"reason": "Delegates to context_window module for transcript/settings reads — no direct JSON file ops needing json_handler. Same pattern as session_start.py delegating to cadence."
},
{
"file": "apps/handlers/lifecycle/pre_compact_prep.py",
"standard": "handlers",
"reason": "Line 75: lazy cross-branch import of aipass.flow.apps.handlers.plan.get_open_plans — needed for the open-plan count in the AUTO-COMPACT SNAPSHOT. No module-layer equivalent exists to switch to: flow's own modules (close_plan.py) import this same handler directly rather than re-exporting it, and list_plans.py's module is a CLI display orchestrator, not a programmatic data source. Read-only cross-branch primitive read, same authorized pattern as ai_mail's dispatch_monitor.py broker-client import. Call is wrapped in try/except and degrades to None on any failure — never corrupts the snapshot."
},
{
"file": "apps/handlers/prompt/compass_recall.py",
"standard": "handlers",
"reason": "Lines 49, 75, 84: lazy cross-branch imports of aipass.memory.apps.modules.governance and aipass.devpulse.apps.modules.compass. Surfacing other branches' rated decisions at prompt time IS this handler's job — not orchestration bleed but the designed integration point, same authorized cross-branch primitive pattern as ai_mail's dispatch_monitor.py. Whole handler wrapped in try/except and never blocks the prompt on failure."
}
],
"notes": {
"removed_2026-05-19": "Stripped 4 illegitimate bypasses \u2014 hooks.py/cli, hooks.py/cli_flags, engine.py/modules, engine.py/introspection. Code fixed to meet standards instead.",
"dplan_0191_2026-05-28": "Added dead_code + unused_function bypasses for all 15 dynamically-dispatched handlers after verifying each is wired in .aipass/hooks.json AND fires in engine.jsonl. Root cause: engine._run_handler (engine.py:60-66) uses importlib.import_module + getattr on hooks.json handler strings \u2014 handlers are never statically imported. Follow-up for @seedgo: teach dead_code/unused_function about dynamic importlib dispatch patterns."
"removed_2026-05-19": "Stripped 4 illegitimate bypasses — hooks.py/cli, hooks.py/cli_flags, engine.py/modules, engine.py/introspection. Code fixed to meet standards instead.",
"dplan_0191_2026-05-28": "Added dead_code + unused_function bypasses for all 15 dynamically-dispatched handlers after verifying each is wired in .aipass/hooks.json AND fires in engine.jsonl. Root cause: engine._run_handler (engine.py:60-66) uses importlib.import_module + getattr on hooks.json handler strings — handlers are never statically imported. Follow-up for @seedgo: teach dead_code/unused_function about dynamic importlib dispatch patterns.",
"dplan_0253_2026-07-20": "Added json_structure bypasses for context_window.py, pre_compact_prep.py, context_gauge.py — same external-state-read profile as existing compact.py/cc_sessions.py/session_start.py bypasses.",
"dplan_0253_audit_catch_2026-07-21": "Seedgo's handlers checker went AST-based and caught two lazy cross-branch imports missed by the old text-matching version: pre_compact_prep.py (flow.get_open_plans) and compass_recall.py (memory.governance, devpulse.compass). Both bypassed rather than restructured — no clean module-layer alternative exists for the former, and the latter's whole purpose is surfacing cross-branch data at prompt time. Same authorized-primitive precedent as ai_mail dispatch_monitor.py."
}
}
+25 -6
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 |
@@ -53,9 +66,11 @@ src/aipass/hooks/
│ ├── sound.py # Shared sound utilities (speak, play, mute)
│ ├── modules/
│ │ ├── cadence.py # Prompt injection cadence (every-Nth-turn gating)
│ │ ├── context_window.py # Transcript usage reader + per-branch compact-window resolver
│ │ ├── 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 +86,9 @@ 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)
│ │ │ ├── context_gauge.py # Nudges /prep before auto-compact fires (80%/95% of window)
│ │ │ ├── temporal.py # Injects weekday/date/time/tz/part-of-day, every turn
│ │ │ └── persistent_alert.py # Injects advisory banners from .aipass/alerts.json
│ │ ├── security/ # Enforcement hooks
│ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics)
@@ -83,6 +101,7 @@ src/aipass/hooks/
│ │ │ ├── auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile)
│ │ │ ├── auto_watchdog.py # Watchdog arming after dispatch
│ │ │ ├── compact.py # Pre-compact memory archival
│ │ │ ├── pre_compact_prep.py # Mechanical AUTO-COMPACT SNAPSHOT stamp (fill %, git, locks, plans)
│ │ │ ├── rollover.py # Pre-compact memory rollover
│ │ │ └── session_start.py # Cadence reset on new chat / clear (SessionStart)
│ │ └── notification/ # Sound/alert hooks
@@ -96,7 +115,7 @@ src/aipass/hooks/
│ └── diagnostics.py # JSONL logging for hook execution
├── logs/
│ └── engine.jsonl # JSONL diagnostics (every hook execution)
└── tests/ # 1071 tests across 29 test files
└── tests/ # 1249 tests across 43 test files
```
## How It Works
@@ -117,13 +136,13 @@ 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, context_gauge, temporal, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + feedback + context gauge + temporal + 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 |
| Stop | stop_sound, telegram_response, presence_release | Bell + Telegram delivery + presence release |
| Notification | announce | Announcement tone |
| PreCompact | compact, rollover | Memory archival + rollover |
| PreCompact | compact, rollover, pre_compact_prep | Memory archival + rollover + mechanical snapshot stamp |
## Git Gate
@@ -10,19 +10,38 @@
"""JSONL diagnostic logging — appends structured entries for hook activity."""
import sys
import tempfile
from pathlib import Path
from aipass.prax import append_jsonl
from aipass.prax.apps.modules.logger import system_logger as logger
BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent.parent
LOG_FILE = BRANCH_ROOT / "logs" / "engine.jsonl"
_PROD_LOG_FILE = BRANCH_ROOT / "logs" / "engine.jsonl"
def _is_pytest_session() -> bool:
"""Detect pytest via sys.modules (immune to patch.dict(os.environ, clear=True))."""
return "_pytest" in sys.modules
def _get_log_file() -> Path:
"""Resolve log path — temp dir during pytest, prod path otherwise."""
if _is_pytest_session():
p = Path(tempfile.gettempdir()) / "aipass_test_logs" / "hooks"
p.mkdir(parents=True, exist_ok=True)
return p / "engine.jsonl"
return _PROD_LOG_FILE
LOG_FILE = _PROD_LOG_FILE
def log_entry(entry: dict) -> None:
"""Append a JSONL log entry for detailed diagnostics."""
try:
append_jsonl(LOG_FILE, entry)
append_jsonl(_get_log_file(), entry)
except OSError as exc:
logger.error("[HOOKS] log write failed: %s", exc)
@@ -56,3 +56,37 @@ def find_project_config() -> dict | None:
return None
search = search.parent
return None
def trust_break_banner() -> str | None:
"""Loud, config-independent check for a stale trust enrollment.
find_project_config() goes silent on a hash mismatch (logs one WARNING,
returns None) — the fallback config downstream then has no event_type
keys at all, so every hook including this one's own dispatch path goes
dark. This check does its own walk-up and hash comparison, never touches
hooks.json content, and does not depend on the project being trusted —
so it still works precisely when trust is broken. Returns None when
nothing is broken, or when the project was simply never enrolled (normal
first-run state, not a break).
"""
from aipass.hooks.apps.handlers.config.trust_registry import is_hash_mismatch
search = Path.cwd()
home = Path.home()
while search != home and search.parent != search:
config_file = search / ".aipass" / "hooks.json"
if config_file.exists():
if is_hash_mismatch(str(search)):
return (
"# TRUST BREAK — ALL AIPASS HOOKS DISABLED\n\n"
f"{search}/.aipass/hooks.json no longer matches its enrolled hash. "
"Every hook for this project (including this warning's own delivery "
"path) is silently skipped until a human re-enrolls.\n\n"
"Fix: drone @hooks trust enroll (or: aipass init update)\n"
"This does not auto-heal — re-enrollment is a deliberate human "
"checkpoint, not a bug."
)
return None
search = search.parent
return None
@@ -100,6 +100,25 @@ def is_trusted(project_dir: str) -> bool:
return current_hash == entry.get("config_hash", "")
def is_hash_mismatch(project_dir: str) -> bool:
"""True only when a project WAS enrolled but its hooks.json hash has since changed.
Distinct from is_trusted()==False for a never-enrolled project (normal
first-run state, not a break). This flags a genuine trust break — an
existing enrollment gone stale — so callers can warn loudly instead of
treating it the same as "not set up yet".
"""
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
return _hash_file(config_path) != entry.get("config_hash", "")
def bootstrap() -> bool:
"""Bootstrap the registry with ONLY the AIPass install. Returns True on success.
@@ -67,7 +67,7 @@ def handle(hook_data: dict) -> dict:
logger.info("[HOOKS] auto_process: no-op (nothing to process)")
_mark_session_ran()
return {"stdout": "", "exit_code": 0}
return {"stdout": "", "exit_code": 0, "sound": "auto process"}
except Exception as exc:
logger.error("[HOOKS] auto_process: error: %s", exc)
@@ -0,0 +1,246 @@
# =================== AIPass ====================
# Name: pre_compact_prep.py
# Version: 1.0.0
# Description: Stamps a mechanical AUTO-COMPACT SNAPSHOT into the compacting branch's memory (PreCompact)
# Branch: hooks
# Layer: apps/handlers/lifecycle
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""Mechanical /prep AT compact time (DPLAN-0253).
Resolves the compacting branch from cwd, then prepends a session entry to its
.trinity/local.json — context fill, in-flight dispatch locks across the
system, this branch's open plan count, git state, and inbox unread count.
Templated from live state, no model turn needed. The judgment layer (todo
reconcile, thoughtful summary) is handled separately by the context gauge
nudge, which fires early enough for a live model turn to run /prep.
Defensive: a missing or malformed .trinity/local.json is logged and skipped —
this handler must never corrupt memory or raise out of PreCompact."""
import importlib
import json
import subprocess
from datetime import date
from pathlib import Path
from aipass.prax.apps.modules.logger import system_logger as logger
_DEFAULT_SUMMARY_CAP = 300
def _find_repo_root(start: Path) -> Path | None:
for parent in [start, *start.parents]:
if (parent / "AIPASS_REGISTRY.json").exists():
return parent
return None
def _context_fill_pct(hook_data: dict, cwd: str) -> str | None:
context_window = importlib.import_module("aipass.hooks.apps.modules.context_window")
usage = context_window.read_latest_usage(hook_data.get("transcript_path", ""))
if usage is None:
return None
fill = context_window.context_fill_tokens(usage)
window = context_window.resolve_compact_window(cwd)
if window <= 0:
return None
pct = round(fill / window * 100)
return f"~{fill // 1000}k/{window // 1000}k ({pct}%)"
def _count_active_dispatch_locks(repo_root: Path | None) -> int | None:
if repo_root is None:
return None
registry_path = repo_root / "AIPASS_REGISTRY.json"
try:
data = json.loads(registry_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.info("[HOOKS] pre_compact_prep: registry read failed: %s", exc)
return None
count = 0
for branch in data.get("branches", []):
branch_path = repo_root / branch.get("path", "")
lock_path = branch_path / ".ai_mail.local" / ".dispatch.lock"
if lock_path.is_file():
count += 1
return count
def _count_open_plans(branch_dir: Path) -> int | None:
try:
from aipass.flow.apps.handlers.plan.get_open_plans import get_open_plans
target = str(branch_dir.resolve())
return sum(1 for _num, info in get_open_plans() if info.get("location") == target)
except Exception as exc:
logger.info("[HOOKS] pre_compact_prep: open plans read failed: %s", exc)
return None
def _git_snapshot(branch_dir: Path) -> str | None:
try:
branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
timeout=5,
cwd=str(branch_dir),
)
last_commit = subprocess.run(
["git", "log", "-1", "--format=%h %s"],
capture_output=True,
text=True,
timeout=5,
cwd=str(branch_dir),
)
dirty = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True,
text=True,
timeout=5,
cwd=str(branch_dir),
)
except Exception as exc:
logger.info("[HOOKS] pre_compact_prep: git snapshot failed: %s", exc)
return None
bits: list[str] = []
if branch.returncode == 0 and branch.stdout.strip():
bits.append(branch.stdout.strip())
if last_commit.returncode == 0 and last_commit.stdout.strip():
bits.append(last_commit.stdout.strip())
if dirty.returncode == 0:
dirty_lines = [ln for ln in dirty.stdout.strip().split("\n") if ln]
bits.append(f"{len(dirty_lines)} dirty")
return " / ".join(bits) if bits else None
def _inbox_unread(branch_dir: Path) -> int | None:
inbox_path = branch_dir / ".ai_mail.local" / "inbox.json"
if not inbox_path.is_file():
return None
try:
data = json.loads(inbox_path.read_text(encoding="utf-8"))
unread = data.get("unread_count")
return unread if isinstance(unread, int) else None
except (OSError, json.JSONDecodeError) as exc:
logger.info("[HOOKS] pre_compact_prep: inbox read failed: %s", exc)
return None
def _summary_cap(repo_root: Path | None) -> int:
if repo_root is None:
return _DEFAULT_SUMMARY_CAP
config_path = repo_root / "src" / "aipass" / "memory" / "memory_json" / "custom_config" / "memory.config.json"
try:
data = json.loads(config_path.read_text(encoding="utf-8"))
cap = data["entry_limits"]["entry_types"]["sessions"]["max_chars"]
if isinstance(cap, int) and cap > 0:
return cap
except (OSError, json.JSONDecodeError, KeyError, TypeError) as exc:
logger.info("[HOOKS] pre_compact_prep: entry_limits read failed, using default: %s", exc)
return _DEFAULT_SUMMARY_CAP
def _build_snapshot(hook_data: dict, branch_dir: Path, repo_root: Path | None) -> str:
cwd = hook_data.get("cwd", "") or str(branch_dir)
parts: list[str] = []
fill = _context_fill_pct(hook_data, cwd)
if fill:
parts.append(f"context {fill}")
locks = _count_active_dispatch_locks(repo_root)
if locks is not None:
parts.append(f"{locks} active dispatch(es)")
open_plans = _count_open_plans(branch_dir)
if open_plans is not None:
parts.append(f"{open_plans} open plan(s)")
git_info = _git_snapshot(branch_dir)
if git_info:
parts.append(f"git: {git_info}")
unread = _inbox_unread(branch_dir)
if unread is not None:
parts.append(f"{unread} unread")
body = ", ".join(parts) if parts else "no live state available"
return f"AUTO-COMPACT SNAPSHOT: {body}"
def _stamp_session_entry(branch_dir: Path, summary: str, cap: int) -> bool:
local_path = branch_dir / ".trinity" / "local.json"
if not local_path.is_file():
logger.info("[HOOKS] pre_compact_prep: no local.json at %s — skipping stamp", local_path)
return False
try:
data = json.loads(local_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.warning("[HOOKS] pre_compact_prep: local.json unreadable, skipping stamp: %s", exc)
return False
if not isinstance(data, dict):
logger.warning("[HOOKS] pre_compact_prep: local.json malformed (not a dict), skipping stamp")
return False
sessions = data.get("sessions")
if not isinstance(sessions, list):
logger.warning("[HOOKS] pre_compact_prep: sessions container malformed, skipping stamp")
return False
truncated = summary if len(summary) <= cap else summary[: cap - 1].rstrip() + "…"
existing_numbers = [
entry.get("number", 0) for entry in sessions if isinstance(entry, dict) and isinstance(entry.get("number"), int)
]
next_number = max(existing_numbers, default=0) + 1
sessions.insert(
0,
{
"date": date.today().isoformat(),
"summary": truncated,
"status": "auto-compact",
"number": next_number,
},
)
data["sessions"] = sessions
try:
local_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
except OSError as exc:
logger.warning("[HOOKS] pre_compact_prep: local.json write failed: %s", exc)
return False
return True
def handle(hook_data: dict) -> dict:
"""Stamp a mechanical AUTO-COMPACT SNAPSHOT into the compacting branch's memory."""
try:
cwd = hook_data.get("cwd", "") or str(Path.cwd())
context_window = importlib.import_module("aipass.hooks.apps.modules.context_window")
branch_dir = context_window.find_branch_dir(cwd)
if branch_dir is None:
logger.info("[HOOKS] pre_compact_prep: no branch dir resolved from cwd=%s", cwd)
return {"stdout": "", "exit_code": 0}
repo_root = _find_repo_root(branch_dir)
cap = _summary_cap(repo_root)
snapshot = _build_snapshot(hook_data, branch_dir, repo_root)
stamped = _stamp_session_entry(branch_dir, snapshot, cap)
logger.info("[HOOKS] pre_compact_prep: snapshot stamped=%s branch=%s", stamped, branch_dir.name)
return {"stdout": snapshot, "exit_code": 0, "sound": "pre compact prep"}
except Exception as exc:
logger.info("[HOOKS] pre_compact_prep: unexpected error: %s", exc)
return {"stdout": "", "exit_code": 0}
@@ -37,5 +37,6 @@ def handle(hook_data: dict) -> dict:
logger.info("[HOOKS] session_start: cadence reset (source=%s)", source)
except Exception as exc:
logger.info("[HOOKS] session_start: cadence reset failed: %s", exc)
return {"stdout": "", "exit_code": 0}
return {"stdout": "", "exit_code": 0}
return {"stdout": "", "exit_code": 0, "sound": "cadence reset"}
@@ -23,6 +23,8 @@ import json
import os
import re
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from urllib.error import HTTPError, URLError
@@ -35,7 +37,19 @@ PENDING_DIR = Path.home() / ".aipass" / "telegram_pending"
MIRROR_DIR = Path.home() / ".aipass" / "telegram_bots"
PENDING_TTL = 3600
TELEGRAM_CHAR_LIMIT = 4096
_DELIVERY_LOG = Path(__file__).resolve().parent.parent.parent.parent / "logs" / "telegram_delivery.jsonl"
_PROD_DELIVERY_LOG = Path(__file__).resolve().parent.parent.parent.parent / "logs" / "telegram_delivery.jsonl"
def _get_delivery_log() -> Path:
"""Resolve delivery log path — temp dir during pytest, prod path otherwise."""
if "_pytest" in sys.modules:
p = Path(tempfile.gettempdir()) / "aipass_test_logs" / "hooks"
p.mkdir(parents=True, exist_ok=True)
return p / "telegram_delivery.jsonl"
return _PROD_DELIVERY_LOG
_DELIVERY_LOG = _PROD_DELIVERY_LOG
def _is_expired(data: dict) -> bool:
@@ -585,7 +599,7 @@ def handle(hook_data: dict) -> dict:
else:
logger.error("[HOOKS] telegram: delivery failed — keeping pending for retry")
return {"stdout": "", "exit_code": 0}
return {"stdout": "", "exit_code": 0, "sound": "telegram response"}
def _extract_response(hook_data: dict, transcript_path: str, pending_data: dict) -> str | None:
@@ -771,6 +785,6 @@ def _write_delivery_log(intended_text: str, chunks: list[str], chunk_results: li
record["culprit"] = culprit
try:
append_jsonl(_DELIVERY_LOG, record)
append_jsonl(_get_delivery_log(), record)
except OSError as e:
logger.warning("[HOOKS] telegram: delivery log write failed: %s", e)
@@ -110,7 +110,7 @@ def handle(hook_data: dict) -> dict:
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}
return {"stdout": "\n".join(lines), "exit_code": 0, "sound": "compass recall"}
except Exception as exc:
logger.info("[HOOKS] compass_recall_unreachable: %s", exc)
@@ -0,0 +1,106 @@
# =================== AIPass ====================
# Name: context_gauge.py
# Version: 1.0.0
# Description: Nudges the model to run /prep before auto-compact fires (UserPromptSubmit, DPLAN-0253)
# Branch: hooks
# Layer: apps/handlers/prompt
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""Early-warning nudge so the model runs /prep before the compact ceiling —
memory should never be at the mercy of an auto-compact firing mid-work.
Reads live transcript usage every prompt (cheap tail read), resolves the
branch's compact trigger (window * 0.9), and injects a hard line once fill
crosses 80%/95% of that trigger. Independent of the cadence system — its own
per-session, per-threshold guard file in tempdir, same idiom as
feedback_pulse.py / auto_process.py, so it isn't gated by turn count."""
import importlib
import os
import tempfile
from pathlib import Path
from aipass.prax.apps.modules.logger import system_logger as logger
_GUARD_DIR = Path(tempfile.gettempdir())
_TRIGGER_RATIO = 0.9
_NUDGE_THRESHOLD_PCT = 80
_ESCALATE_THRESHOLD_PCT = 95
def _guard_path(session_id: str, threshold: str) -> Path | None:
if not session_id:
return None
return _GUARD_DIR / f"aipass-context-gauge-{session_id}-{threshold}"
def _already_fired(session_id: str, threshold: str) -> bool:
path = _guard_path(session_id, threshold)
return path is not None and path.exists()
def _mark_fired(session_id: str, threshold: str) -> None:
path = _guard_path(session_id, threshold)
if path is not None:
try:
path.touch()
except OSError as exc:
logger.info("[HOOKS] context_gauge: guard write failed: %s", exc)
def handle(hook_data: dict) -> dict:
"""Inject a context-fill nudge once per threshold per session."""
try:
session_id = hook_data.get("session_id", "") or os.environ.get("CLAUDE_CODE_SESSION_ID", "")
transcript_path = hook_data.get("transcript_path", "")
if not transcript_path:
return {"stdout": "", "exit_code": 0}
context_window = importlib.import_module("aipass.hooks.apps.modules.context_window")
usage = context_window.read_latest_usage(transcript_path)
if usage is None:
return {"stdout": "", "exit_code": 0}
fill = context_window.context_fill_tokens(usage)
cwd = hook_data.get("cwd", "") or str(Path.cwd())
window = context_window.resolve_compact_window(cwd)
trigger = window * _TRIGGER_RATIO
if trigger <= 0:
return {"stdout": "", "exit_code": 0}
pct = fill / trigger * 100
fill_k = fill // 1000
trigger_k = int(trigger) // 1000
if pct >= _ESCALATE_THRESHOLD_PCT and not _already_fired(session_id, "95"):
_mark_fired(session_id, "95")
_mark_fired(session_id, "80")
logger.info("[HOOKS] context_gauge: escalate fired at %.0f%% session=%s", pct, session_id[:8])
return {
"stdout": (
f"CONTEXT GAUGE: ~{fill_k}k/{trigger_k}k ({pct:.0f}%) — run /prep NOW "
"AND wrap up the current work item. Auto-compact is imminent."
),
"exit_code": 0,
"sound": "context gauge",
}
if pct >= _NUDGE_THRESHOLD_PCT and not _already_fired(session_id, "80"):
_mark_fired(session_id, "80")
logger.info("[HOOKS] context_gauge: nudge fired at %.0f%% session=%s", pct, session_id[:8])
return {
"stdout": (
f"CONTEXT GAUGE: ~{fill_k}k/{trigger_k}k ({pct:.0f}%) — run /prep NOW, "
"before auto-compact takes the choice away."
),
"exit_code": 0,
"sound": "context gauge",
}
return {"stdout": "", "exit_code": 0}
except Exception as exc:
logger.info("[HOOKS] context_gauge: unexpected error: %s", exc)
return {"stdout": "", "exit_code": 0}
@@ -0,0 +1,98 @@
# =================== 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):
logger.info("[HOOKS] feedback_pulse: skipped (disabled via .aipass/feedback_off sentinel)")
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}
@@ -11,13 +11,16 @@
"""Injects advisory banners for active alerts from .aipass/alerts.json."""
import json
import os
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from aipass.hooks.apps.handlers.json import json_handler
from aipass.prax.apps.modules.logger import system_logger as logger
_announced: set[str] = set()
_GUARD_DIR = Path(tempfile.gettempdir())
_MAX_ALERTS_SHOWN = 10
def _find_aipass_dir() -> Path | None:
@@ -77,10 +80,33 @@ def _load_and_clean(alerts_path: Path) -> list[dict]:
return active
def _guard_path(session_id: str, alert_id: str) -> Path | None:
if not session_id:
return None
return _GUARD_DIR / f"aipass-persistent-alert-{session_id}-{alert_id}"
def _already_announced(session_id: str, alert_id: str) -> bool:
path = _guard_path(session_id, alert_id)
return path is not None and path.exists()
def _mark_announced(session_id: str, alert_id: str) -> None:
path = _guard_path(session_id, alert_id)
if path is not None:
try:
path.touch()
except OSError as exc:
logger.info("[HOOKS] persistent_alert: guard write failed: %s", exc)
def _format_banner(alerts: list[dict]) -> str:
"""Format alert banners for prompt injection."""
"""Format alert banners for prompt injection, capped at _MAX_ALERTS_SHOWN."""
shown = alerts[:_MAX_ALERTS_SHOWN]
hidden = len(alerts) - len(shown)
lines = []
for alert in alerts:
for alert in shown:
severity = alert.get("severity", "warning").upper()
title = alert.get("title", "Untitled alert")
body = alert.get("body", "")
@@ -89,6 +115,8 @@ def _format_banner(alerts: list[dict]) -> str:
lines.append(f"[{severity}] {title} (from @{source}, id: {alert_id})")
if body:
lines.append(f" {body}")
if hidden > 0:
lines.append(f"...and {hidden} more (dismiss some to see the rest)")
header = "# Active Alerts"
dismiss_hint = "Dismiss with: drone @hooks dismiss <alert-id>"
return "\n".join([header, ""] + lines + ["", dismiss_hint])
@@ -117,10 +145,12 @@ def handle(hook_data: dict) -> dict:
banner = _format_banner(alerts)
new_ids = [a["id"] for a in alerts if a.get("id") and a["id"] not in _announced]
session_id = hook_data.get("session_id", "") or os.environ.get("CLAUDE_CODE_SESSION_ID", "")
new_ids = [a["id"] for a in alerts if a.get("id") and not _already_announced(session_id, a["id"])]
sound = ""
if new_ids:
_announced.update(new_ids)
for alert_id in new_ids:
_mark_announced(session_id, alert_id)
count = len(alerts)
plural = "s" if count != 1 else ""
sound = f"alert: {count} active alert{plural}"
@@ -0,0 +1,51 @@
# =================== AIPass ====================
# Name: temporal.py
# Version: 1.0.0
# Description: Every-turn local date/time/weekday/part-of-day injection (UserPromptSubmit)
# Branch: hooks
# Layer: apps/handlers/prompt
# Created: 2026-07-21
# Modified: 2026-07-21
# =============================================
"""Tiny always-on temporal grounding line — no cadence gating, fires every
turn. Reads the live clock each fire (no caching) and injects one short line:
weekday, date, 24h time, tz abbreviation, part of day. Timezone comes from
the host system (astimezone()) — never hardcoded, so clones running outside
Vancouver still show their own local time. Keep it to one line — every-turn
cost matters."""
from datetime import datetime
from aipass.prax.apps.modules.logger import system_logger as logger
def _part_of_day(hour: int) -> str:
"""Bucket an hour (0-23) into morning/afternoon/evening/night."""
if 5 <= hour < 12:
return "morning"
if 12 <= hour < 17:
return "afternoon"
if 17 <= hour < 22:
return "evening"
return "night"
def _format_line(now: datetime) -> str:
weekday = now.strftime("%a")
date = now.strftime("%Y-%m-%d")
time_str = now.strftime("%H:%M")
tz = now.strftime("%Z")
part = _part_of_day(now.hour)
clock = f"{weekday} {date} {time_str} {tz}" if tz else f"{weekday} {date} {time_str}"
return f"Temporal: {clock} ({part})"
def handle(hook_data: dict) -> dict:
"""Inject the current local temporal line — every turn, no cadence."""
try:
line = _format_line(datetime.now().astimezone())
return {"stdout": line, "exit_code": 0, "sound": "temporal"}
except Exception as exc:
logger.info("[HOOKS] temporal: unexpected error: %s", exc)
return {"stdout": "", "exit_code": 0}
@@ -1,11 +1,11 @@
# =================== AIPass ====================
# Name: presence_gate.py
# Version: 3.0.0
# Version: 3.0.1
# Description: Single-session gate — blocks duplicate Claude runtimes per branch
# Branch: hooks
# Layer: apps/handlers/security
# Created: 2026-06-29
# Modified: 2026-07-13
# Modified: 2026-07-21
# =============================================
"""Single-session gate — blocks duplicate Claude runtimes per branch.
@@ -27,6 +27,12 @@ including background sessions with agent_type "claude".
Ships in OBSERVE-ONLY mode: logs would-block decisions to engine.jsonl
but never actually blocks. Flip _OBSERVE_ONLY to False after soak period
confirms zero false positives.
Occupant PID claims are identity-checked, not just liveness-checked:
cc_sessions.find_live_for_cwd() cross-verifies each session's recorded
procStart against the live process's actual start time before treating
a PID as a genuine occupant, so a PID recycled after the original
session died can never satisfy a stale claim.
"""
import importlib
@@ -131,7 +137,7 @@ def handle(hook_data: dict) -> dict:
if _OBSERVE_ONLY:
logger.warning("[presence_gate] OBSERVE-ONLY would-block: %s", reason)
return _ALLOW
return {**_ALLOW, "sound": "presence gate"}
logger.warning("[presence_gate] BLOCKED: %s", reason)
return {

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