Compare commits

...
126 Commits
Author SHA1 Message Date
AIPass 7e1faab141 Merge pull request #540 from AIOSAI/work/system-v230-bump-version-fix-bootstrap-to-include-aipass-
feat(system): v2.3.0: bump version + fix bootstrap to include @aipass (12 agents)
2026-05-08 21:49:02 -07:00
AIOSAIand@devpulse 326c08fe54 feat(system): v2.3.0: bump version + fix bootstrap to include @aipass (12 agents)
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-08 21:48:38 -07:00
AIPass c57bca2272 Merge pull request #539 from AIOSAI/work/system-update-readme-12-agents-add-aipass-fix-metrics-760
feat(system): Update README: 12 agents, add @aipass, fix metrics (7600+ tests, 538+ PRs), fix standards 33→34, add Roadmap to ToC, remove CI/coverage from metrics table
2026-05-07 21:15:27 -07:00
AIOSAIand@devpulse da7a33481b feat(system): Update README: 12 agents, add @aipass, fix metrics (7600+ tests, 538+ PRs), fix standards 33→34, add Roadmap to ToC, remove CI/coverage from metrics table
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-07 21:14:02 -07:00
AIPass 658c5bc2a5 Merge pull request #538 from AIOSAI/work/system-dplan-0168-phase-3-4-remove-non-aipass-env-vars-in
feat(system): DPLAN-0168 Phase 3-4: remove non-AIPass env vars, init report includes doctor findings, ping step fix for single agent
2026-05-07 20:53:53 -07:00
AIOSAIand@devpulse 28a6680f02 feat(system): DPLAN-0168 Phase 3-4: remove non-AIPass env vars, init report includes doctor findings, ping step fix for single agent
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-07 20:50:28 -07:00
AIPass b2d7e1cf6a Merge pull request #537 from AIOSAI/work/system-fix-doctor-action-messages-remove-setupsh-refs-exp
feat(system): Fix doctor action messages (remove setup.sh refs) + expand scaffold startup protocol (.trinity, inbox, init_report)
2026-05-07 20:32:03 -07:00
AIOSAIand@devpulse a3d29c81b1 feat(system): Fix doctor action messages (remove setup.sh refs) + expand scaffold startup protocol (.trinity, inbox, init_report)
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-07 20:31:02 -07:00
AIPass 1d1ed0a0e6 Merge pull request #536 from AIOSAI/work/system-dplan-0168-phase-1-2-providermanifestjson-manifest
feat(system): DPLAN-0168 Phase 1-2: provider_manifest.json + manifest-driven doctor checks + dispatch fresh/continue reasoning in local prompt
2026-05-07 20:14:34 -07:00
AIPass 2f192d45db Merge pull request #535 from AIOSAI/work/system-dplan-0167-phase-3-hooks-report-command-snapshot-t
feat(system): DPLAN-0167 Phase 3: hooks report command, snapshot testing, README accountability in subagent_stop_gate
2026-05-07 20:14:29 -07:00
AIOSAIand@devpulse 8bd0b5d814 feat(system): DPLAN-0168 Phase 1-2: provider_manifest.json + manifest-driven doctor checks + dispatch fresh/continue reasoning in local prompt
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-07 20:14:09 -07:00
AIOSAIand@devpulse 7b998d1596 feat(system): DPLAN-0167 Phase 3: hooks report command, snapshot testing, README accountability in subagent_stop_gate
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-07 19:46:14 -07:00
AIPass c51f6b7e4a Merge pull request #534 from AIOSAI/work/system-fix-command-duplication-deduplicate-memo-and-prep-
feat(system): Fix command duplication: deduplicate /memo and /prep commands across settings levels, add setup.sh provider-level command install, move memo template to .claude/templates/
2026-05-07 19:14:40 -07:00
AIOSAIand@devpulse ad4a99b4fe feat(system): Fix command duplication: deduplicate /memo and /prep commands across settings levels, add setup.sh provider-level command install, move memo template to .claude/templates/
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-07 19:11:10 -07:00
AIOSAIand@AIPASS bd46c673d8 feat(AIPASS): Fix aipass init: stop creating memo.md in project scaffolds
Co-Authored-By: @AIPASS <AIPASS@aipass>
2026-05-07 19:08:34 -07:00
AIOSAIand@trigger d0ce0cc6dc feat(trigger): Medic self-heal: auto-install systemd unit on first run
Co-Authored-By: @trigger <trigger@aipass>
2026-05-07 18:41:15 -07:00
AIPass d4f2ef1555 Merge pull request #531 from AIOSAI/work/system-s135-maintenance-registry-noise-fix-doctor-provide
feat(system): S135 maintenance: registry noise fix, doctor provider-hooks check, stop-hook CWD scoping, branch settings cleanup
2026-05-07 18:30:54 -07:00
AIOSAIand@devpulse 8ef543b9fc feat(system): S135 maintenance: registry noise fix, doctor provider-hooks check, stop-hook CWD scoping, branch settings cleanup
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-07 18:03:06 -07:00
AIOSAIand@memory 6c3e965414 feat(memory): Complete fastembed migration — fix vector_search.py bug, update all model names, fix test mocks
Co-Authored-By: @memory <memory@aipass>
2026-05-07 17:53:34 -07:00
AIOSAIand@memory 7dc84c3e31 feat(memory): Switch embedder from sentence-transformers to fastembed — 2GB to 100MB install
Co-Authored-By: @memory <memory@aipass>
2026-05-07 17:43:17 -07:00
AIPass dd52c8aad8 Merge pull request #528 from AIOSAI/work/system-remove-fake-api-key-pattern-from-test-fixture-that
feat(system): remove fake API key pattern from test fixture that triggered secret scanning
2026-05-07 17:21:11 -07:00
AIOSAIand@devpulse 109063c705 feat(system): remove fake API key pattern from test fixture that triggered secret scanning
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-07 17:20:21 -07:00
AIPass f9078ec0ce Merge pull request #527 from AIOSAI/work/system-bump-pytest-and-pygments-to-fix-dependabot-alerts
feat(system): bump pytest and Pygments to fix Dependabot alerts
2026-05-07 17:13:01 -07:00
AIOSAIand@devpulse 7c192c117e feat(system): bump pytest and Pygments to fix Dependabot alerts
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-07 17:10:11 -07:00
AIPass 38f5cd9c87 Merge pull request #526 from AIOSAI/work/system-fix-codeql-inline-suppression-format-lgtm-to-codeq
feat(system): fix CodeQL inline suppression format lgtm to codeql
2026-05-07 17:07:25 -07:00
AIOSAIand@devpulse f07da797dc feat(system): fix CodeQL inline suppression format lgtm to codeql
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-07 17:04:28 -07:00
AIPass 7d8493e3fa Merge pull request #525 from AIOSAI/work/system-suppress-5-codeql-false-positives-and-enable-depen
feat(system): suppress 5 CodeQL false positives and enable Dependabot
2026-05-07 16:54:00 -07:00
AIOSAIand@devpulse 7223dc0455 feat(system): suppress 5 CodeQL false positives and enable Dependabot
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-07 16:51:49 -07:00
AIPass 3b920f8ad4 Merge pull request #524 from AIOSAI/work/system-fix-pr-stacking-and-diagnostics-double-fire
feat(system): fix PR stacking and diagnostics double-fire
2026-05-07 16:36:05 -07:00
AIOSAIand@devpulse db6cf3d754 feat(system): fix PR stacking and diagnostics double-fire
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-07 16:33:17 -07:00
AIPass 1a7c8b8434 Merge pull request #523 from AIOSAI/work/system
feat(system): feat(system): Docker clone testing + setup.sh aipass symlink fix — Dockerfile.test replaces old code-server Dockerfiles, tests/docker_clone_test.sh for fresh-install verification, hook_test.py portability fix (skip gracefully without ~/Projects), setup.sh now symlinks both drone AND aipass to ~/.local/bin. Verified 11/11 on real git clone + aipass init inside container.
2026-05-07 15:46:04 -07:00
AIOSAIand@devpulse 7d598e22cf feat(system): fix(tests): update test_bootstrap.py assertions to match PR #522 bootstrap changes — PreToolUse, PostToolUse, Stop removed from project settings (provider-only), tests now assert their absence
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-07 15:41:14 -07:00
AIOSAIand@devpulse 5114986c31 feat(system): feat(system): Docker clone testing + setup.sh aipass symlink fix — Dockerfile.test replaces old code-server Dockerfiles, tests/docker_clone_test.sh for fresh-install verification, hook_test.py portability fix (skip gracefully without ~/Projects), setup.sh now symlinks both drone AND aipass to ~/.local/bin. Verified 11/11 on real git clone + aipass init inside container.
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-07 15:36:34 -07:00
AIPass 672c926980 Merge pull request #522 from AIOSAI/work/system
feat(system): feat(hooks): DPLAN-0167 hook testing framework + bootstrap fix — 20-test harness with direct+integration layers, hook execution logger, CWD guard verification across projects, setup.sh provider wiring update (global_prompt_loader + env vars + git deny rules), bootstrap.py removes dead PreToolUse/PostToolUse from project settings, hook READMEs at provider+project level
2026-05-07 13:02:35 -07:00
AIOSAIand@devpulse b971d2161e feat(system): feat(hooks): DPLAN-0167 hook testing framework + bootstrap fix — 20-test harness with direct+integration layers, hook execution logger, CWD guard verification across projects, setup.sh provider wiring update (global_prompt_loader + env vars + git deny rules), bootstrap.py removes dead PreToolUse/PostToolUse from project settings, hook READMEs at provider+project level
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-07 12:58:47 -07:00
AIPass 8da48ddd46 Merge pull request #521 from AIOSAI/work/system
feat(system): fix(system): fix doctor test + CWD-aware hook guards to prevent double-firing and standalone bleed
2026-05-07 10:56:15 -07:00
AIOSAIand@devpulse 79ad4d2803 feat(system): fix(system): fix doctor test + CWD-aware hook guards to prevent double-firing and standalone bleed
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-07 10:55:09 -07:00
AIPass db3dcc6c05 Merge pull request #520 from AIOSAI/work/system
feat(system): fix(system): ruff format 13 hook/init files + pip-audit CVE-2026-6357 ignore — unblock CI lint and security
2026-05-07 10:22:45 -07:00
AIOSAIand@devpulse 3d1d820e26 feat(system): fix(system): ruff format 13 hook/init files + pip-audit CVE-2026-6357 ignore — unblock CI lint and security
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-07 00:39:58 -07:00
AIPass 0cf25039e2 Merge pull request #519 from AIOSAI/work/system
feat(system): fix(system): resolve all 14 ruff lint errors — unused variables in hooks + f-string placeholders in handoff
2026-05-07 00:14:56 -07:00
AIOSAIand@devpulse c95f5ef9ef feat(system): fix(system): resolve all 14 ruff lint errors — unused variables in hooks + f-string placeholders in handoff
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-07 00:13:38 -07:00
AIPass 1f03202ffd Merge pull request #518 from AIOSAI/work/system
feat(system): docs: update README for aipass init run guided setup + metrics
2026-05-07 00:01:19 -07:00
AIOSAIand@devpulse 360327f336 feat(system): docs: update README for aipass init run guided setup + metrics
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-06 23:29:48 -07:00
AIPass f21278af91 Merge pull request #517 from AIOSAI/work/system
feat(system): feat(aipass): S129-S130 init UX — 16 fixes from live testing + handoff terminal pop
2026-05-06 23:25:03 -07:00
AIOSAIand@devpulse bff9d742f5 feat(system): feat(aipass): S129-S130 init UX fixes — 16 improvements from live testing
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-06 23:12:33 -07:00
AIOSAIand@devpulse 9281efeacc feat(system): feat(aipass): DPLAN-0164 Phases 4-5 — remove init from CLI, move tests, fix routing assertions
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-04 21:37:32 -07:00
AIPass ee2716e63f Merge pull request #515 from AIOSAI/work/system
feat(system): feat(aipass): unignore @aipass citizen branch — add concierge source to repo with ancestor guard pre-flight
2026-05-04 21:20:39 -07:00
AIOSAIand@devpulse 352845a5aa feat(system): feat(aipass): DPLAN-0164 Phases 1-3 — move bootstrap from CLI, wire routing, flip pyproject entry point
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-04 19:32:13 -07:00
AIOSAIand@devpulse 6756b60d80 feat(system): feat(aipass): unignore @aipass citizen branch — add concierge source to repo with ancestor guard pre-flight
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-04 18:21:22 -07:00
AIPass 398cb0d37e Merge pull request #514 from AIOSAI/work/system
feat(system): fix(ci): test_git_gate falls back to repo hook copy when user copy absent
2026-05-03 23:50:38 -07:00
AIOSAIand@devpulse 1058fb7c90 feat(system): fix(ci): test_git_gate falls back to repo hook copy when user copy absent
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-03 23:40:32 -07:00
AIPass 71c177c51c Merge pull request #513 from AIOSAI/work/system
feat(system): docs: update README + global prompt to reflect git_gate v2 (branch/tag/remote split, owner bypass, sudo optional)
2026-05-03 23:38:31 -07:00
AIOSAIand@devpulse 865af0cbde feat(system): fix(lint): ruff format test_git_gate.py
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-03 23:36:09 -07:00
AIOSAIand@devpulse 67bfb18888 feat(system): docs: update README + global prompt to reflect git_gate v2 (branch/tag/remote split, owner bypass, sudo optional)
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-03 23:34:07 -07:00
AIPass 2a9bb4944c Merge pull request #512 from AIOSAI/work/drone
feat(drone): DPLAN-0163 Finding 8: set AIPASS_BRANCH_NAME on dispatch
2026-05-03 23:30:35 -07:00
AIPass 2387c650d8 Merge pull request #511 from AIOSAI/work/system
feat(system): fix(system): DPLAN-0163 — git_gate hardening, standalone project fixes, 113-test suite
2026-05-03 23:30:17 -07:00
patrickand@drone 7dbe5957ef feat(drone): DPLAN-0163 Finding 8: set AIPASS_BRANCH_NAME on dispatch
Co-Authored-By: @drone <drone@aipass>
2026-05-03 23:21:23 -07:00
patrickand@devpulse f7678581ee feat(system): fix(system): DPLAN-0163 — git_gate hardening, standalone project fixes, 113-test suite
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-03 23:16:39 -07:00
AIPass 57e8ce4e9b Merge pull request #510 from AIOSAI/work/system
feat(system): ai_mail vectorization: sys.executable fallback when memory venv missing + mechanical-guardrails prompt docs
2026-05-03 23:16:21 -07:00
patrickand@devpulse 6fd57fed04 feat(system): fix(system): git_gate hardening + setup.sh auto_watchdog wiring + symlink fallback — DPLAN-0163 Phase 1
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-03 22:56:46 -07:00
patrickand@devpulse c94e231e84 feat(system): ai_mail vectorization: sys.executable fallback when memory venv missing + mechanical-guardrails prompt docs
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-03 21:51:27 -07:00
AIPass ac5452ba20 Merge pull request #509 from AIOSAI/work/system
feat(system): ship git_gate hook via setup.sh for fresh installs (DPLAN-0162)
2026-05-03 20:44:05 -07:00
AIPass 8201b6a851 Merge pull request #508 from AIOSAI/work/prax
feat(prax): prax: smoketest file for post-migration dispatch verification
2026-05-03 20:43:37 -07:00
patrickand@devpulse 1ee7b45483 feat(system): feat(hooks): ship git_gate hook via setup.sh — mechanical block on raw git/gh writes for fresh installs
Adds .claude/hooks/git_gate.py and wires it in setup.sh PreToolUse so all fresh AIPass installs ship with mechanical enforcement of the drone-only git policy.

Why this exists: dispatched agents spawn with bypassPermissions which skips all permissions.deny rules in every settings tier. PreToolUse hooks remain the only mechanical chokepoint that survives bypass mode (verified via official Claude Code docs and live dispatch test).

Behavior — blocks with redirect to drone:
- Bash raw git write verbs and stash drop/clear/pop/apply
- Bash gh write subcommands and all gh api calls
- Edit/Write/MultiEdit on .claude/settings*.json, .claude/hooks/, .git/hooks/

Allows:
- Read-only git and gh
- All drone-prefixed commands (drone uses Python subprocess for git, never the agent Bash tool)
- Devpulse and seedgo working from their own branches can edit the enforcement layer (trusted-editor bypass)
- Quote-stripping: text inside double or single quotes is treated as data not code (so PR descriptions and commit messages can mention git verbs freely)

Verified end-to-end S124: live dispatch to prax, hook fired on raw git chain, agent pivoted to drone @git pr cleanly. 79 unit-test cases pass total. See DPLAN-0162.

Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-03 20:40:16 -07:00
patrickand@prax 949eeb375c feat(prax): prax: smoketest file for post-migration dispatch verification
Co-Authored-By: @prax <prax@aipass>
2026-05-03 20:32:20 -07:00
AIPass b36185e21f Merge pull request #507 from AIOSAI/work/system
feat(system): fix(drone+memory): registry walk-up credential check + memory subprocess docs

- drone: skip mismatched registries during CWD walk-up instead of hard-failing (db55b6b, 44 LOC + 70 LOC tests). Fixes the orphan DEVPULSE_REGISTRY.json shadowing AIPASS_REGISTRY.json bug from S124.
- memory: README — document _get_memory_python() resolution chain (env override → memory/.venv/bin/python → sys.executable) accurately.

Both fixes from the S124 init-blast-radius incident triage.
2026-05-03 19:54:23 -07:00
patrickand@devpulse 4a6d60ccc5 feat(system): fix(drone+memory): registry walk-up credential check + memory subprocess docs
- drone: skip mismatched registries during CWD walk-up instead of hard-failing (db55b6b, 44 LOC + 70 LOC tests). Fixes the orphan DEVPULSE_REGISTRY.json shadowing AIPASS_REGISTRY.json bug from S124.
- memory: README — document _get_memory_python() resolution chain (env override → memory/.venv/bin/python → sys.executable) accurately.

Both fixes from the S124 init-blast-radius incident triage.

Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-03 19:53:52 -07:00
AIPassandClaude Opus 4.6 db55b6b63a fix(drone): skip mismatched registries during walk-up instead of hard-failing
find_registry() now performs a lightweight credential check on each
candidate *_REGISTRY.json during the CWD walk-up. When a registry's
metadata.id conflicts with the nearest passport's registry_id, it is
skipped and the walk continues upward to find the correct registry.

Fixes the bug where an orphan DEVPULSE_REGISTRY.json inside a citizen's
tree caused RegistryMismatchError for all drone commands from devpulse CWD.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 18:28:08 -07:00
AIPass a2d84a78da Merge pull request #506 from AIOSAI/work/system
feat(system): fix(drone): dynamic fork recovery message with actual repo/user info — closes #329
2026-05-03 09:46:57 -07:00
patrickand@devpulse 26c83b3f07 feat(system): style(drone): fix ruff format on pr_handler.py fork recovery
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-03 09:41:44 -07:00
patrickand@devpulse a7214ed4ca feat(system): fix(drone): dynamic fork recovery message with actual repo/user info — closes #329
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-03 09:39:44 -07:00
AIPass 0840dfe778 Merge pull request #505 from AIOSAI/work/system
fix(setup+cli): wire missing hooks (#498) + remove vestigial hooks/ dir (#497)
2026-05-03 09:37:05 -07:00
patrickand@devpulse 607924c755 feat(system): fix(cli): remove vestigial hooks/ dir from aipass init scaffold — closes #497
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-03 09:35:48 -07:00
patrickand@devpulse 6839b1048a feat(system): fix(setup): wire pre_edit_gate + SubagentStop hooks in setup.sh — closes #498
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-03 09:32:16 -07:00
AIPass 249b48e98b Merge pull request #504 from AIOSAI/work/system
fix(setup): ensurepip check (#495) + git identity (#500) + secrets protection (#496)
2026-05-03 09:30:24 -07:00
patrickand@devpulse 1d02f412f6 feat(system): fix(setup): protect ~/.secrets/ with permissions.deny + fix chmod on inner dir — closes #496
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-03 09:29:14 -07:00
patrickand@devpulse c7bc27b7b8 feat(system): fix(setup): add ensurepip check (#495) + git identity config (#500) — closes #495, closes #500
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-03 09:26:47 -07:00
patrickand@devpulse a530c83a63 feat(system): fix(setup): add ensurepip check to prevent broken venv on Debian/Ubuntu — closes #495
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-03 08:57:18 -07:00
patrickand@memory dd39cb6835 feat(memory): . fix(memory): fix embedder type error + update README date for seedgo 100%
Co-Authored-By: @memory <memory@aipass>
2026-05-02 23:42:38 -07:00
AIPass 5348ae6d81 Merge pull request #502 from AIOSAI/work/system
feat(system): feat(system): add settings terminology to global prompt, fix template registry IDs, add auto_watchdog hook
2026-05-02 23:33:08 -07:00
patrickand@devpulse ccafca881d feat(system): feat(system): add settings terminology to global prompt, fix template registry IDs, add auto_watchdog hook
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-02 23:26:35 -07:00
AIPass d9b7c36a80 Merge pull request #501 from AIOSAI/work/system
feat(system): fix: untrack accidentally-committed .pyc files in devpulse_ops/__pycache__ + add *.bak to root .gitignore
2026-05-02 19:33:53 -07:00
patrickand@devpulse 5b398acff4 feat(system): fix: untrack accidentally-committed .pyc files in devpulse_ops/__pycache__ + add *.bak to root .gitignore
Co-Authored-By: @devpulse <devpulse@aipass>
2026-05-02 19:32:20 -07:00
AIPass 9a71137dda Merge pull request #499 from AIOSAI/work/memory-rollover-fix
fix(memory): remove rollover from startup chain + add embedding deps
2026-05-02 19:05:30 -07:00
PatrickandClaude Opus 4.6 44bab11040 fix(memory): remove rollover from startup chain + add sentence-transformers dep
Two fixes:

1. Remove _run_memory_check() from trigger startup handler — rollover no
   longer fires on every drone command. Rollover is now on-demand only
   (drone @memory rollover) or via the watcher daemon. This eliminates the
   noisy "Memory - Rollover Execution" banner from every drone invocation.

2. Add sentence-transformers>=2.0 to pyproject.toml [memory] extras — the
   embedding subprocess was failing because torch/sentence-transformers
   were missing from the dependency list. chromadb alone is insufficient;
   the custom embed_subprocess.py requires sentence-transformers directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-02 18:55:15 -07:00
AIPass 33c7643a95 Merge pull request #494 from AIOSAI/work/memory
feat(memory): fix(rollover): v1 extractor skips files at exactly max_lines
2026-04-28 18:22:51 -07:00
AIPass fbe5605d0d Merge pull request #493 from AIOSAI/work/system
feat(system): fix(drone): add path containment validation on registry branch resolution — prevents ghost-branch RCE via malicious path field (issue #490 fix 2)
2026-04-28 18:22:39 -07:00
AIPass 84168ff872 Merge pull request #492 from AIOSAI/work/spawn
feat(spawn): fix(spawn): atomic registry writes + file locking + path containment (issue #490)
2026-04-28 18:22:30 -07:00
AIOSAIand@memory d2f820d982 feat(memory): fix(rollover): v1 extractor skips files at exactly max_lines
Co-Authored-By: @memory <memory@aipass>
2026-04-28 18:15:00 -07:00
AIOSAIand@devpulse d75735c926 feat(system): fix(drone): add path containment validation on registry branch resolution — prevents ghost-branch RCE via malicious path field (issue #490 fix 2)
Co-Authored-By: @devpulse <devpulse@aipass>
2026-04-28 18:11:49 -07:00
AIOSAIand@spawn 80699f0e95 feat(spawn): fix(spawn): atomic registry writes + file locking + path containment (issue #490)
Co-Authored-By: @spawn <spawn@aipass>
2026-04-28 18:07:43 -07:00
AIPass b10b64750b Merge pull request #491 from AIOSAI/work/system
feat(system): feat(system): fix(trigger): dead events + zombie handler + stale registry + per-branch disable + email check (DPLAN-0159)
2026-04-28 17:59:55 -07:00
AIOSAIand@devpulse 78b7fb6ac7 feat(system): feat(system): fix(trigger): dead events + zombie handler + stale registry + per-branch disable + email check (DPLAN-0159)
Co-Authored-By: @devpulse <devpulse@aipass>
2026-04-28 17:46:06 -07:00
AIPass 477e3a58ef Merge pull request #489 from AIOSAI/work/trigger
feat(trigger): S117 stress test @trigger
2026-04-28 17:45:47 -07:00
AIPass cd14807845 Merge pull request #487 from AIOSAI/work/system
feat(system): S117 stress test prax
2026-04-28 17:45:27 -07:00
AIPass 9e8cd235c2 Merge pull request #486 from AIOSAI/work/s117_ai_mail
S117 stress test @ai_mail
2026-04-28 17:45:17 -07:00
AIPass e8d295fadc Merge pull request #485 from AIOSAI/work/memory
feat(memory): S117 stress test memory
2026-04-28 17:45:07 -07:00
AIPass ccd8472668 Merge pull request #484 from AIOSAI/work/cli
feat(cli): S117 stress test @cli
2026-04-28 17:44:57 -07:00
AIOSAIand@trigger 3405c7ee41 feat(trigger): fix(trigger): dead events + zombie handler + stale registry + per-branch disable + email check (DPLAN-0159)
Co-Authored-By: @trigger <trigger@aipass>
2026-04-27 01:02:31 -07:00
AIOSAIand@devpulse 4fd5bdec7f feat(system): fix(watchdog): replace shell=True with shlex.split in schedule.py _run_command — prevents shell injection via user-influenced command strings (S117 security finding)
Co-Authored-By: @devpulse <devpulse@aipass>
2026-04-27 00:43:04 -07:00
AIOSAIand@trigger 05608a8b64 feat(trigger): S117 stress test @trigger
Co-Authored-By: @trigger <trigger@aipass>
2026-04-27 00:07:11 -07:00
AIOSAIand@spawn 92da1c07a2 feat(spawn): S117 stress test @spawn
Co-Authored-By: @spawn <spawn@aipass>
2026-04-27 00:06:43 -07:00
AIOSAIand@devpulse c4008144c7 feat(system): S117 stress test prax
Co-Authored-By: @devpulse <devpulse@aipass>
2026-04-27 00:04:46 -07:00
AIOSAIandClaude Opus 4.6 f60725dfc4 test(ai_mail): S117 stress test findings
Honest self-review, security concerns, cross-branch observations,
and conversation summaries from the all-branch live-fire stress test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 00:04:08 -07:00
AIOSAIand@memory 2d1a712f20 feat(memory): S117 stress test memory
Co-Authored-By: @memory <memory@aipass>
2026-04-27 00:04:02 -07:00
AIOSAIand@cli 321ae989c0 feat(cli): S117 stress test @cli
Co-Authored-By: @cli <cli@aipass>
2026-04-27 00:03:52 -07:00
AIOSAIand@devpulse d11bc94ade feat(system): fix(drone): mail view index bug — display order was reversed from array order
Co-Authored-By: @devpulse <devpulse@aipass>
2026-04-26 22:44:12 -07:00
AIPass 2f1ede44bb Merge pull request #482 from AIOSAI/work/system
feat(system): fix: DPLAN-0157 pre-commit hook + DPLAN-0158 watchdog reply detection
2026-04-26 22:39:19 -07:00
AIPass 166b6013c0 Merge pull request #481 from AIOSAI/work/ai_mail_dplan0158
fix(ai_mail): DPLAN-0158 Phase 1 — explicit reply in daemon prompt
2026-04-26 22:39:14 -07:00
AIPass ea7d0efcbf Merge pull request #480 from AIOSAI/work/ai_mail
feat(ai_mail): DPLAN-0156 — inbox auto-purge
2026-04-26 22:39:08 -07:00
AIOSAIand@devpulse 930400ee38 feat(system): fix: DPLAN-0158 Phase 3 — watchdog JSONL stall detection + plan updates
Co-Authored-By: @devpulse <devpulse@aipass>
2026-04-26 22:35:33 -07:00
AIOSAIand@devpulse 5fda6e9b8f feat(system): fix: DPLAN-0157 pre-commit hook + DPLAN-0158 watchdog reply detection
Co-Authored-By: @devpulse <devpulse@aipass>
2026-04-26 22:26:44 -07:00
AIOSAIandClaude Opus 4.6 0b73263fc6 fix(ai_mail): DPLAN-0158 Phase 1 — explicit reply instructions in daemon prompt
Replaced ambiguous "Send confirmation when done" with explicit
drone @ai_mail reply <id> command including the dispatch email ID
and sender address.  Sanitizes interpolated metadata (ID must be
alnum ≤12, sender must match @word pattern).  Fallback generic
instruction when ID is missing.  3 new tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 21:41:48 -07:00
AIOSAIandClaude Opus 4.6 63ab0005bb feat(ai_mail): DPLAN-0156 — inbox auto-purge sweeps closed messages on every read/write
Safety net for messages marked closed by direct JSON edit instead of
drone @ai_mail close.  _sweep_closed() in inbox_cleanup.py archives
closed messages to deleted/ and removes them from the inbox.

Wired into: mark_as_opened, mark_as_closed_and_archive (inbox_cleanup),
deliver_email_to_branch, deliver_to_inbox_file (delivery), and
load_inbox (inbox_ops).  8 new tests, 690 total pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 19:13:47 -07:00
AIPass 50a3827c8f Merge pull request #479 from AIOSAI/work/system
feat(system): security: DPLAN-0155 Phase 5 — fix TOCTOU lock race in daemon.py + wake.py
2026-04-26 18:31:58 -07:00
AIOSAIand@devpulse 1c063bf236 feat(system): security: DPLAN-0155 — telegram dead code removed, api secrets-only cleanup
Co-Authored-By: @devpulse <devpulse@aipass>
2026-04-26 17:59:25 -07:00
AIOSAI ec406f4aa4 Merge remote-tracking branch 'origin/main' 2026-04-26 17:59:15 -07:00
AIOSAIand@devpulse 41623391fa feat(system): security: DPLAN-0155 Phase 5 — fix TOCTOU lock race in daemon.py + wake.py
Co-Authored-By: @devpulse <devpulse@aipass>
2026-04-26 17:33:34 -07:00
AIPass 2a1d509d20 Merge pull request #478 from AIOSAI/work/ai_mail
feat(ai_mail): test(ai_mail): fix lock ordering test for DPLAN-0155 Phase 5
2026-04-26 17:33:13 -07:00
AIOSAIand@ai_mail 9fbbcbd834 feat(ai_mail): test(ai_mail): fix lock ordering test for DPLAN-0155 Phase 5
Co-Authored-By: @ai_mail <ai_mail@aipass>
2026-04-26 17:33:04 -07:00
AIOSAIand@ai_mail 1c50b1454a feat(ai_mail): test(ai_mail): fix lock ordering test for DPLAN-0155 Phase 5
Co-Authored-By: @ai_mail <ai_mail@aipass>
2026-04-26 17:30:24 -07:00
AIPass a1ada18d86 Merge pull request #477 from AIOSAI/work/system
feat(system): security: DPLAN-0155 Phases 2-4 — prompt injection defense, daemon path confinement, JSONL writes removed
2026-04-26 17:20:22 -07:00
AIOSAIand@devpulse d0a73f91b5 feat(system): security: DPLAN-0155 Phases 2-4 — prompt injection defense, daemon path confinement, JSONL writes removed
Co-Authored-By: @devpulse <devpulse@aipass>
2026-04-26 17:18:39 -07:00
AIPass 3cc822e9d3 Merge pull request #476 from AIOSAI/work/system
feat(system): fix: fork-aware error handling (#329) + hardcoded path fix (#8) — detect 403 push errors and print fork recovery steps, replace hardcoded AIPASS_ROOT with env detection
2026-04-26 17:18:05 -07:00
AIOSAIand@devpulse b6be4a9c68 feat(system): security: DPLAN-0155 Phases 2-4 — prompt injection defense, daemon path confinement, JSONL writes removed
Co-Authored-By: @devpulse <devpulse@aipass>
2026-04-26 17:08:22 -07:00
AIOSAIand@devpulse d84e56344c feat(system): fix: fork-aware error handling (#329) + hardcoded path fix (#8) — detect 403 push errors and print fork recovery steps, replace hardcoded AIPASS_ROOT with env detection
Co-Authored-By: @devpulse <devpulse@aipass>
2026-04-26 14:12:14 -07:00
269 changed files with 17250 additions and 4250 deletions
+14 -7
View File
@@ -13,6 +13,9 @@ For any branch's full detail, run `drone @branch --help`.
- Agent (citizen) — the persistent identity that lives in a branch. Has a passport (`.trinity/`), memories, mailbox. Irreplaceable. Addressable as `@name` via drone. Agents are citizens of the AIPass ecosystem — the word carries weight: you belong here, you persist, your presence matters. - Agent (citizen) — the persistent identity that lives in a branch. Has a passport (`.trinity/`), memories, mailbox. Irreplaceable. Addressable as `@name` via drone. Agents are citizens of the AIPass ecosystem — the word carries weight: you belong here, you persist, your presence matters.
- Sub-agent — a disposable worker spawned for a task. No passport, no memory, not a citizen. Does the job and goes away. - Sub-agent — a disposable worker spawned for a task. No passport, no memory, not a citizen. Does the job and goes away.
- Registry — `AIPASS_REGISTRY.json` tracks all agents (citizens) in a project. - Registry — `AIPASS_REGISTRY.json` tracks all agents (citizens) in a project.
- Provider settings — `~/.claude/settings.json`. The user's machine-wide Claude Code config. Per machine, not in any repo. Personal preferences only (model, voice, theme). We don't touch it.
- Project settings — `<project>/.claude/settings.json`. Ships with the clone. Hooks, permissions, deny/ask rules, env vars. Everything an AIPass project needs to work. Built by `aipass init`.
- Project local settings — `<project>/.claude/settings.local.json`. Also ships with the clone. Project-specific overrides. Users get our full setup the moment they clone — no extra configuration needed.
Agents live in branches. Sub-agents work for agents. If you have a `.trinity/passport.json`, you're an agent — a citizen — not just a sub-agent. Agents live in branches. Sub-agents work for agents. If you have a `.trinity/passport.json`, you're an agent — a citizen — not just a sub-agent.
@@ -74,12 +77,16 @@ Allowed:
- `drone @git merge <PR#>` — squash-merge a reviewed PR (devpulse only) - `drone @git merge <PR#>` — squash-merge a reviewed PR (devpulse only)
- `drone @git smart-sync` — fetch + rebase (devpulse only) - `drone @git smart-sync` — fetch + rebase (devpulse only)
- `drone @git fix` — repair broken git states (devpulse only) - `drone @git fix` — repair broken git states (devpulse only)
- `git status`, `git diff`, `git log` — read-only, always fine - `git status`, `git diff`, `git log`, `git branch` (list), `git tag` (list), `git remote` (list/show) — read-only, always fine
Forbidden (denied system-wide in `.claude/settings.json`): Mechanically blocked by the `git_gate.py` PreToolUse hook (applies to ALL sessions including dispatched agents — bypassPermissions does not skip hooks):
- `git checkout*` — any form, including `-b`, `-`, branch names - All raw `git` write verbs: `commit`, `push`, `pull`, `merge`, `rebase`, `reset`, `checkout`, `switch`, `cherry-pick`, `revert`, `rm`, `mv`, `restore`, `clean`, `config`, `stash drop|clear|pop|apply`
- `git add -f*` / `--force*` - Destructive `git branch` flags only (`-d`, `-D`, `-m`, `-M`, `--delete`, `--move`, `--set-upstream-to`, `--unset-upstream`). Read-only branch listing is allowed.
- Culturally avoid `git commit`, `git push`, `gh pr create` directly — go through drone - Destructive `git tag` flags only (`-d`, `--delete`, `-f`, `--force`). Tag listing is allowed.
- Destructive `git remote` subcommands (`add`, `remove`, `rename`, `set-url`, `prune`). Remote listing/show is allowed.
- All raw `gh` write subcommands (`pr`, `issue`, `repo`, `release`, `workflow`, `run`, `cache`, `secret`, `variable`, `gist`) and any `gh api` call. Exception: project owners with `citizenship.owner: true` in their passport bypass gh blocking.
- Edits to `**/.claude/settings*.json`, `**/.claude/hooks/**`, `**/.git/hooks/**` (the enforcement layer itself)
- Use `drone @git pr "msg"` instead. Drone calls git via Python subprocess so its operations don't pass through this hook.
If `drone @git system-pr` fails to return HEAD to main, that's a drone bug — report it, don't work around it by staying on a branch. If `drone @git system-pr` fails to return HEAD to main, that's a drone bug — report it, don't work around it by staying on a branch.
@@ -197,9 +204,9 @@ Always work on main. Edit files in your branch directory on the main branch. Whe
**Blocked system-wide via `.claude/settings.json` permission gate:** `git checkout*` (any form — switch, discard, new branch), `git add -f*`, `git add --force*`. These are denied for every agent including devpulse. Use `drone @git sync` to switch to main, `drone @git fix` to recover from broken states. **Blocked system-wide via `.claude/settings.json` permission gate:** `git checkout*` (any form — switch, discard, new branch), `git add -f*`, `git add --force*`. These are denied for every agent including devpulse. Use `drone @git sync` to switch to main, `drone @git fix` to recover from broken states.
**Culturally blocked (no permission gate yet, still don't use):** `git commit`, `git push`, `gh pr create`. Go through drone. **Mechanically blocked via `.git/hooks/pre-commit`:** `git commit` is rejected on any branch except main (also catches detached HEAD). `git push`, `gh pr create` — go through drone.
**Allowed read-only:** `git status`, `git diff`, `git log`, `git stash` (safe transient save). **Allowed read-only:** `git status`, `git diff`, `git log`, `git branch` (list), `git tag` (list), `git remote` (list/show), `git stash` (safe transient save).
**If `drone @git pr` fails because the PR lock is held**, wait 30 seconds and retry. Keep retrying until the lock clears — do not skip the PR step, do not commit directly to main, do not give up. The lock means another agent is mid-PR; it will release shortly. `drone @git lock` shows the current lock state. **If `drone @git pr` fails because the PR lock is held**, wait 30 seconds and retry. Keep retrying until the lock clears — do not skip the PR step, do not commit directly to main, do not give up. The lock means another agent is mid-PR; it will release shortly. `drone @git lock` shows the current lock state.
+144
View File
@@ -0,0 +1,144 @@
# AIPass Hook System
Provider-level hooks for the AIPass ecosystem. These fire for every Claude Code
session on this machine via `~/.claude/settings.json`.
## File Layout
```
.claude/hooks/
├── README.md # This file
│
│ ── Hooks (wired in ~/.claude/settings.json) ──
├── global_prompt_loader.py # UserPromptSubmit — AIPass global prompt (~22KB)
├── branch_prompt_loader.py # UserPromptSubmit — branch-specific prompt
├── identity_injector.py # UserPromptSubmit — branch identity from passport
├── email_notification.py # UserPromptSubmit — unread email count
├── tool_use_sound.py # PreToolUse — key-press sound on tool calls
├── git_gate.py # PreToolUse — blocks raw git/gh, protects settings
├── auto_fix_diagnostics.py # PostToolUse — pyright + ruff on edited files
├── subagent_stop_gate.py # SubagentStop — seedgo checklist on modified files
├── pre_compact.py # PreCompact — post-compact recovery context
├── stop_sound.py # Stop — achievement bell
├── notification_sound.py # Notification — notification sound
│
│ ── Also wired but lives in ~/.claude/hooks/ ──
│ pre_edit_gate.py # PreToolUse — cross-branch write block, error-fix gate
│ auto_watchdog.py # PostToolUse — watchdog reminder after dispatch
│
│ ── Testing & debugging tools ──
├── hook_log.py # Shared logger — every hook calls run_and_log()
├── hook_report.py # Report tool — reads JSONL log, shows table
├── hook_test.py # Test harness — 20 tests (11 direct + 9 integration)
│
│ ── Legacy probes ──
└── probes/
├── README.md
└── probe_*.py # Opt-in per-event diagnostic hooks
```
## Architecture
Hooks fire from three levels (can fire simultaneously):
| Level | Settings file | When it fires |
|-------|--------------|---------------|
| **Provider** | `~/.claude/settings.json` | Every session, everywhere |
| **Project** | `<project>/.claude/settings.json` | When CWD is inside the project |
| **Branch** | deeper `.claude/settings.json` | When CWD is inside that branch |
**Critical limitation:** PreToolUse and PostToolUse ONLY fire from provider settings.
UserPromptSubmit fires from ALL levels. This means project-level PreToolUse/PostToolUse
hooks provisioned by `aipass init` are dead weight — they never execute.
## CWD Guards
Four UserPromptSubmit hooks have CWD-aware guards. When CWD is inside a project that
has its own UserPromptSubmit hooks, the provider hook exits silently — preventing
AIPass context from bleeding into standalone projects.
Guarded: `global_prompt_loader.py`, `branch_prompt_loader.py`,
`identity_injector.py`, `email_notification.py`.
## Hook Inventory
### UserPromptSubmit (provider, CWD-guarded)
| Script | Purpose |
|--------|---------|
| `global_prompt_loader.py` | Injects AIPass global prompt (~22KB) |
| `branch_prompt_loader.py` | Injects branch-specific prompt from `.aipass/aipass_local_prompt.md` |
| `identity_injector.py` | Injects branch identity from `.trinity/passport.json` |
| `email_notification.py` | Shows unread email count from `.ai_mail.local/inbox.json` |
### PreToolUse (provider only)
| Script | Matcher | Purpose |
|--------|---------|---------|
| `tool_use_sound.py` | Bash\|Edit\|Write\|Read\|... | Plays key-press sound |
| `pre_edit_gate.py` | Edit\|Write\|NotebookEdit | Cross-branch write block + error-fix gate |
| `git_gate.py` | Bash\|Edit\|Write\|NotebookEdit | Blocks raw git/gh, protects settings files |
### PostToolUse (provider only)
| Script | Matcher | Purpose |
|--------|---------|---------|
| `auto_fix_diagnostics.py` | Edit\|Write\|NotebookEdit | Runs pyright + ruff on edited files |
| `auto_watchdog.py` | Bash | Reminds agent to arm watchdog after dispatch |
### Other events (provider)
| Script | Event | Purpose |
|--------|-------|---------|
| `subagent_stop_gate.py` | SubagentStop | Runs seedgo checklist on subagent-modified files + hook README reminder |
| `pre_compact.py` | PreCompact | Injects post-compact recovery context |
| `stop_sound.py` | Stop | Plays achievement bell |
| `notification_sound.py` | Notification | Plays notification sound |
## Testing
### Execution log (always-on)
Every instrumented hook writes one JSONL line to `/tmp/aipass_hook_log.jsonl` via
`hook_log.py`. Each entry: timestamp, event, source, script, CWD, session, timing,
output_bytes, exit_code.
### Report tool
```bash
python3 .claude/hooks/hook_report.py # Last 5 minutes
python3 .claude/hooks/hook_report.py --all # All entries
python3 .claude/hooks/hook_report.py --cwd /tmp # Filter by CWD
python3 .claude/hooks/hook_report.py --json # Machine-readable
python3 .claude/hooks/hook_report.py --clear # Wipe log
```
### Test harness (20 tests)
```bash
python3 .claude/hooks/hook_test.py # All 20 tests
python3 .claude/hooks/hook_test.py --direct # 11 direct tests only (fast, ~3s)
python3 .claude/hooks/hook_test.py --integration # 9 integration tests only (~2min)
python3 .claude/hooks/hook_test.py --verbose # Show detail per test
python3 .claude/hooks/hook_test.py --list # List available tests
python3 .claude/hooks/hook_test.py --test <name> # Run one test
```
**Direct tests** (11) pipe JSON to hook scripts via subprocess. Deterministic,
no model, HIGH confidence. Tests CWD guards, git_gate block/allow, settings schema,
project-level guards.
**Integration tests** (9) run `claude -p` from different CWDs and read the JSONL log.
Tests full pipeline including cross-project behavior, subagent hooks, and the
`disableAllHooks` toggle.
### Disable all hooks
Add `"disableAllHooks": true` to `~/.claude/settings.json`. Remove to re-enable.
### Debug mode
```bash
claude --debug hooks --debug-file /tmp/debug.log
```
### Interactive inspection
Type `/hooks` inside a Claude session — shows all hooks with source labels
(`[User]`, `[Project]`, `[Local]`).
## Related
- **DPLAN-0167** — Hook testing framework
- **DPLAN-0166** — Hook audit + CI health
- **DPLAN-0139** — Hook overhaul + single-path enforcement
- **DPLAN-0131** — Hook system alignment (seedgo ownership)
+51 -54
View File
@@ -37,35 +37,32 @@ SKIP_EXTENSIONS = {".md", ".txt", ".log", ".csv", ".html"}
# AIPass-specific Python patterns to check # AIPass-specific Python patterns to check
PYTHON_PATTERNS = { PYTHON_PATTERNS = {
"bad_optional": { "bad_optional": {"pattern": ": str = None", "message": "Optional param should use 'str | None = None' pattern"},
"pattern": ": str = None",
"message": "Optional param should use 'str | None = None' pattern"
},
"logger_debug": { "logger_debug": {
"pattern": "logger.debug(", "pattern": "logger.debug(",
"message": "Use logger.info for SystemLogger (logger.debug not supported)" "message": "Use logger.info for SystemLogger (logger.debug not supported)",
}, },
"return_error_msg": { "return_error_msg": {
"pattern": "return error_msg", "pattern": "return error_msg",
"message": "Return None for error states, not error_msg string" "message": "Return None for error states, not error_msg string",
}, },
"open_no_encoding": { "open_no_encoding": {
"pattern": "open(", "pattern": "open(",
"requires_missing": "encoding=", "requires_missing": "encoding=",
"message": "open() without encoding='utf-8'" "message": "open() without encoding='utf-8'",
}, },
"log_not_log_operation": { "log_not_log_operation": {
"pattern": ".log(", "pattern": ".log(",
"message": "Use log_operation() with success/error params, not .log()" "message": "Use log_operation() with success/error params, not .log()",
}, },
"dict_none_no_check": { "dict_none_no_check": {
"pattern": "Dict | None", "pattern": "Dict | None",
"message": "Dict | None return: Add None check before using (if result is None: return)" "message": "Dict | None return: Add None check before using (if result is None: return)",
} },
} }
# JSON-specific patterns for emoji corruption # JSON-specific patterns for emoji corruption
JSON_CORRUPTION_CHARS = ['\ufffd', '\x00'] JSON_CORRUPTION_CHARS = ["\ufffd", "\x00"]
def run_python_checks(file_path: str) -> list[str]: def run_python_checks(file_path: str) -> list[str]:
@@ -75,10 +72,7 @@ def run_python_checks(file_path: str) -> list[str]:
# 1. Syntax check with py_compile # 1. Syntax check with py_compile
try: try:
result = subprocess.run( result = subprocess.run(
[sys.executable, "-m", "py_compile", file_path], [sys.executable, "-m", "py_compile", file_path], capture_output=True, text=True, timeout=5
capture_output=True,
text=True,
timeout=5
) )
if result.returncode != 0: if result.returncode != 0:
errors.append(f"SYNTAX: {result.stderr.strip()}") errors.append(f"SYNTAX: {result.stderr.strip()}")
@@ -91,7 +85,7 @@ def run_python_checks(file_path: str) -> list[str]:
["ruff", "check", "--select=E,F,W", "--output-format=text", file_path], ["ruff", "check", "--select=E,F,W", "--output-format=text", file_path],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=10 timeout=10,
) )
if result.stdout.strip(): if result.stdout.strip():
for line in result.stdout.strip().split("\n")[:5]: for line in result.stdout.strip().split("\n")[:5]:
@@ -103,12 +97,7 @@ def run_python_checks(file_path: str) -> list[str]:
# 3. Ruff format check — detect format drift # 3. Ruff format check — detect format drift
try: try:
result = subprocess.run( result = subprocess.run(["ruff", "format", "--check", file_path], capture_output=True, text=True, timeout=10)
["ruff", "format", "--check", file_path],
capture_output=True,
text=True,
timeout=10
)
if result.returncode != 0: if result.returncode != 0:
errors.append(f"FORMAT: {Path(file_path).name} needs ruff format (run: ruff format {Path(file_path).name})") errors.append(f"FORMAT: {Path(file_path).name} needs ruff format (run: ruff format {Path(file_path).name})")
except FileNotFoundError: except FileNotFoundError:
@@ -146,19 +135,20 @@ def run_python_checks(file_path: str) -> list[str]:
return errors return errors
def run_ruff_lint_structured(file_path: str) -> list[dict]: def run_ruff_lint_structured(file_path: str) -> list[dict]:
"""Run ruff check and return structured violations for the state file. """Run ruff check and return structured violations for the state file.
Returns list of {line, message} dicts — same format as pyright errors. Returns list of {line, message} dicts — same format as pyright errors.
Only non-empty when ruff finds real violations (not format drift). Only non-empty when ruff finds real violations (not format drift).
""" """
if '/.claude/hooks/' in file_path: if "/.claude/hooks/" in file_path:
return [] return []
try: try:
result = subprocess.run( result = subprocess.run(
["ruff", "check", "--select=E,F,W", "--output-format=json", file_path], ["ruff", "check", "--select=E,F,W", "--output-format=json", file_path],
capture_output=True, text=True, timeout=10 capture_output=True,
text=True,
timeout=10,
) )
if not result.stdout.strip(): if not result.stdout.strip():
return [] return []
@@ -179,15 +169,12 @@ def run_ruff_lint_structured(file_path: str) -> list[dict]:
def run_pyright_check(file_path: str) -> list[dict]: def run_pyright_check(file_path: str) -> list[dict]:
"""Run pyright on a single file. Returns list of error dicts.""" """Run pyright on a single file. Returns list of error dicts."""
# Skip hook files - they don't follow project standards # Skip hook files - they don't follow project standards
if '/.claude/hooks/' in file_path: if "/.claude/hooks/" in file_path:
return [] return []
try: try:
result = subprocess.run( result = subprocess.run(
[sys.executable, "-m", "pyright", "--outputjson", file_path], [sys.executable, "-m", "pyright", "--outputjson", file_path], capture_output=True, text=True, timeout=15
capture_output=True,
text=True,
timeout=15
) )
try: try:
@@ -201,10 +188,7 @@ def run_pyright_check(file_path: str) -> list[dict]:
if severity == "error": if severity == "error":
line = diag.get("range", {}).get("start", {}).get("line", 0) line = diag.get("range", {}).get("start", {}).get("line", 0)
message = diag.get("message", "Unknown error") message = diag.get("message", "Unknown error")
errors.append({ errors.append({"line": line, "message": message[:100]})
"line": line,
"message": message[:100]
})
return errors[:10] # Max 10 errors return errors[:10] # Max 10 errors
@@ -220,10 +204,7 @@ def save_diagnostics_state(file_path: str, errors: list[dict]):
"""Save type errors to state file for PreToolUse gate.""" """Save type errors to state file for PreToolUse gate."""
try: try:
if errors: if errors:
state = { state = {"file": str(Path(file_path).resolve()), "errors": errors}
"file": str(Path(file_path).resolve()),
"errors": errors
}
STATE_FILE.write_text(json.dumps(state), encoding="utf-8") STATE_FILE.write_text(json.dumps(state), encoding="utf-8")
else: else:
# No errors — clear the state # No errors — clear the state
@@ -249,11 +230,11 @@ def run_json_checks(file_path: str) -> list[str]:
data = json.loads(content) data = json.loads(content)
if isinstance(data, dict): if isinstance(data, dict):
for key in ['allowed_emojis', 'emojis', 'emoji_list']: for key in ["allowed_emojis", "emojis", "emoji_list"]:
if key in data and isinstance(data[key], list): if key in data and isinstance(data[key], list):
for item in data[key]: for item in data[key]:
if isinstance(item, str) and len(item) == 1: if isinstance(item, str) and len(item) == 1:
if ord(item) < 128 and item not in '\u2713\u2717': if ord(item) < 128 and item not in "\u2713\u2717":
errors.append(f"EMOJI CORRUPTION: Suspicious char '{item}' in {key}") errors.append(f"EMOJI CORRUPTION: Suspicious char '{item}' in {key}")
break break
@@ -268,7 +249,7 @@ def run_json_checks(file_path: str) -> list[str]:
def run_seedgo_checklist(file_path: str) -> list[str]: def run_seedgo_checklist(file_path: str) -> list[str]:
"""Run seedgo standards checklist — returns violations only.""" """Run seedgo standards checklist — returns violations only."""
if '/.claude/hooks/' in file_path: if "/.claude/hooks/" in file_path:
return [] return []
try: try:
@@ -277,7 +258,7 @@ def run_seedgo_checklist(file_path: str) -> list[str]:
capture_output=True, capture_output=True,
text=True, text=True,
timeout=15, timeout=15,
cwd=str(Path.home() / "Projects" / "AIPass") cwd=str(Path.home() / "Projects" / "AIPass"),
) )
if result.returncode != 0: if result.returncode != 0:
@@ -318,9 +299,30 @@ def is_same_file_as_last(file_path: str) -> bool:
return False return False
def _project_has_own_posttooluse_hooks() -> bool:
"""Check if CWD is inside a project with its own PostToolUse hooks."""
search = Path.cwd()
home = Path.home()
while search != home and search.parent != search:
settings = search / ".claude" / "settings.json"
if settings.exists():
try:
data = json.loads(settings.read_text(encoding="utf-8"))
ptu = data.get("hooks", {}).get("PostToolUse", [])
if ptu:
return True
except (json.JSONDecodeError, OSError):
pass
search = search.parent
return False
def main(): def main():
"""Main hook entry point.""" """Main hook entry point."""
try: try:
if _project_has_own_posttooluse_hooks():
return
input_data = json.load(sys.stdin) input_data = json.load(sys.stdin)
tool_name = input_data.get("tool_name", "") tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {}) tool_input = input_data.get("tool_input", {})
@@ -337,10 +339,8 @@ def main():
# Collect all errors # Collect all errors
errors = [] errors = []
file_type = ""
if file_path.endswith(".py"): if file_path.endswith(".py"):
file_type = "Python"
errors = run_python_checks(file_path) errors = run_python_checks(file_path)
# Seedgo standards checklist # Seedgo standards checklist
@@ -358,7 +358,6 @@ def main():
save_diagnostics_state(file_path, ruff_lint_errors + type_errors) save_diagnostics_state(file_path, ruff_lint_errors + type_errors)
elif file_path.endswith(".json"): elif file_path.endswith(".json"):
file_type = "JSON"
errors = run_json_checks(file_path) errors = run_json_checks(file_path)
else: else:
return return
@@ -372,17 +371,12 @@ def main():
Fix these errors in {Path(file_path).name} now. Do not skip or defer.""" Fix these errors in {Path(file_path).name} now. Do not skip or defer."""
output = { output = {
"hookSpecificOutput": { "hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": context},
"hookEventName": "PostToolUse", "systemMessage": f"[AUTO-FIX] {len(errors)} error(s) — fix before continuing",
"additionalContext": context
},
"systemMessage": f"[AUTO-FIX] {len(errors)} error(s) — fix before continuing"
} }
print(json.dumps(output)) print(json.dumps(output))
else: else:
output = { output = {"systemMessage": "[diagnostics] ok"}
"systemMessage": "[diagnostics] ok"
}
print(json.dumps(output)) print(json.dumps(output))
except Exception: except Exception:
@@ -390,4 +384,7 @@ Fix these errors in {Path(file_path).name} now. Do not skip or defer."""
if __name__ == "__main__": if __name__ == "__main__":
main() sys.path.insert(0, str(Path(__file__).resolve().parent))
from hook_log import run_and_log
run_and_log("PostToolUse", "provider", __file__, main)
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""PostToolUse hook — reminds agent to arm watchdog after dispatch.
Fires after Bash commands containing 'drone @ai_mail dispatch'.
Outputs additionalContext telling the agent to arm the watchdog.
Skips if watchdog is already part of the same command.
Version: 1.0.0
"""
import json
import sys
def main():
"""Check if dispatch was run and remind to arm watchdog."""
try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
return
tool_name = hook_input.get("tool_name", "")
tool_input = hook_input.get("tool_input", {})
if tool_name != "Bash":
return
command = tool_input.get("command", "")
# Only trigger on dispatch commands
if "drone @ai_mail dispatch" not in command:
return
# Skip if watchdog is already in the same command
if "unread_count" in command and "while [" in command:
return
# Skip if it's just checking dispatch status (not sending)
if "dispatch wake" in command and "dispatch @" not in command:
return
result = {
"additionalContext": (
"[AUTO-WATCHDOG] Dispatch detected — arm watchdog NOW. "
"Run the watchdog one-liner from your local prompt with "
"run_in_background: true and timeout: 600000."
)
}
json.dump(result, sys.stdout)
if __name__ == "__main__":
main()
+34 -2
View File
@@ -6,11 +6,35 @@ Injects branch-specific prompts based on CWD. When working in a branch
directory, loads .aipass/aipass_local_prompt.md and outputs it so the directory, loads .aipass/aipass_local_prompt.md and outputs it so the
AI sees branch-specific context. AI sees branch-specific context.
Version: 1.0.0 When CWD is inside a project that has its own UserPromptSubmit hooks
(e.g. a standalone aipass-init project), this provider-level hook exits
silently to avoid double-firing.
Version: 1.1.0
""" """
import json
from pathlib import Path from pathlib import Path
def _project_has_own_hooks() -> bool:
"""Check if CWD is inside a project with its own UserPromptSubmit hooks."""
search = Path.cwd()
home = Path.home()
while search != home and search.parent != search:
settings = search / ".claude" / "settings.json"
if settings.exists():
try:
data = json.loads(settings.read_text(encoding="utf-8"))
ups = data.get("hooks", {}).get("UserPromptSubmit", [])
if ups:
return True
except (json.JSONDecodeError, OSError):
pass
search = search.parent
return False
def find_branch_root() -> Path | None: def find_branch_root() -> Path | None:
""" """
Find the branch root directory. Find the branch root directory.
@@ -38,6 +62,9 @@ def find_branch_root() -> Path | None:
def main(): def main():
if _project_has_own_hooks():
return
branch_root = find_branch_root() branch_root = find_branch_root()
if branch_root: if branch_root:
@@ -49,4 +76,9 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from hook_log import run_and_log
run_and_log("UserPromptSubmit", "provider", __file__, main)
+35 -3
View File
@@ -5,12 +5,34 @@ Email Notification Hook - Notifies of new emails on prompt submit.
Checks the current branch's inbox for unread emails and displays Checks the current branch's inbox for unread emails and displays
a notification if any exist. a notification if any exist.
Version: 1.0.0 When CWD is inside a project that has its own UserPromptSubmit hooks,
this provider-level hook exits silently to avoid double-firing.
Version: 1.1.0
""" """
import json import json
from pathlib import Path from pathlib import Path
def _project_has_own_hooks() -> bool:
"""Check if CWD is inside a project with its own UserPromptSubmit hooks."""
search = Path.cwd()
home = Path.home()
while search != home and search.parent != search:
settings = search / ".claude" / "settings.json"
if settings.exists():
try:
data = json.loads(settings.read_text(encoding="utf-8"))
ups = data.get("hooks", {}).get("UserPromptSubmit", [])
if ups:
return True
except (json.JSONDecodeError, OSError):
pass
search = search.parent
return False
def find_repo_root() -> Path | None: def find_repo_root() -> Path | None:
"""Find the repo root (contains pyproject.toml or .git).""" """Find the repo root (contains pyproject.toml or .git)."""
search = Path.cwd() search = Path.cwd()
@@ -79,6 +101,9 @@ def count_new_emails(branch_root: Path) -> int:
def main(): def main():
if _project_has_own_hooks():
return
branch_root = find_branch_root() branch_root = find_branch_root()
if not branch_root: if not branch_root:
return return
@@ -86,8 +111,15 @@ def main():
new_count = count_new_emails(branch_root) new_count = count_new_emails(branch_root)
if new_count > 0: if new_count > 0:
plural = "s" if new_count != 1 else "" plural = "s" if new_count != 1 else ""
print(f"You have {new_count} new email{plural} - check with: drone @ai_mail inbox | then: drone @ai_mail view <id> | close with: drone @ai_mail close <id>") print(
f"You have {new_count} new email{plural} - check with: drone @ai_mail inbox | then: drone @ai_mail view <id> | close with: drone @ai_mail close <id>"
)
if __name__ == "__main__": if __name__ == "__main__":
main() import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from hook_log import run_and_log
run_and_log("UserPromptSubmit", "provider", __file__, main)
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""PreToolUse Gate — blocks raw git/gh writes + edits to settings/hooks files.
Dispatched agents spawn with --permission-mode bypassPermissions, which skips
all permissions.deny rules in every settings tier. PreToolUse hooks remain the
only mechanical chokepoint that survives. This hook gates the dangerous
shortcuts and redirects callers to drone.
Allows: read-only git/gh, all unrelated tool calls, devpulse-from-its-own-branch
edits to the enforcement layer itself.
Blocks: git write verbs, gh state-changing subcommands, edits to .claude
settings.json / hooks/ and .git/hooks/.
DPLAN-0162.
"""
import json
import os
import re
import sys
from pathlib import Path
BLOCKED_GIT_VERBS = (
"commit",
"push",
"pull",
"merge",
"rebase",
"reset",
"checkout",
"switch",
"cherry-pick",
"revert",
"rm",
"mv",
"restore",
"clean",
"config",
)
BLOCKED_GIT_RE = re.compile(
r"(?<![@\w/.])git\s+(?:--?[A-Za-z][A-Za-z0-9_-]*(?:[= ][^\s]+)?\s+)*"
r"(" + "|".join(BLOCKED_GIT_VERBS) + r")\b"
)
BLOCKED_GIT_STASH_RE = re.compile(r"(?<![@\w/.])git\s+stash\s+(drop|clear|pop|apply)\b")
BLOCKED_GIT_BRANCH_RE = re.compile(
r"(?<![@\w/.])git\s+branch\s+.*(-[dDmMcC]\b|--delete|--move|--copy|--force|--set-upstream-to|--unset-upstream)"
)
BLOCKED_GIT_TAG_RE = re.compile(r"(?<![@\w/.])git\s+tag\s+.*(-d\b|--delete|--force|-f\b)")
BLOCKED_GIT_REMOTE_RE = re.compile(
r"(?<![@\w/.])git\s+remote\s+(add|remove|rename|set-url|set-branches|set-head|prune)\b"
)
BLOCKED_GH_API_RE = re.compile(r"(?<![@\w/.])gh\s+api\b")
BLOCKED_GH_RE = re.compile(
r"(?<![@\w/.])gh\s+(pr|issue|repo|release|workflow|run|cache|secret|variable|gist)"
r"\s+(?!list\b|view\b|status\b|diff\b|checks\b|comments\b)\w[\w-]*"
)
BLOCKED_EDIT_PATTERNS = [
re.compile(r"/\.claude/settings(\.local)?\.json$"),
re.compile(r"/\.claude/hooks/"),
re.compile(r"/\.git/hooks/"),
]
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
# Branches trusted to edit the enforcement layer itself (mirrors pre_edit_gate).
TRUSTED_HOOK_EDITORS = ("devpulse", "seedgo")
GIT_REDIRECT = (
"Raw git write commands are blocked. Use drone instead:\n"
' drone @git pr "description" # branch-scoped PR\n'
' drone @git system-pr "description" # devpulse-only system PR\n'
" drone @git smart-sync # fetch + rebase\n"
" drone @git sync # checkout main + pull\n"
" drone @git status # what changed\n"
"Read-only git (status, log, diff, show, fetch, ls-files) is allowed."
)
GH_REDIRECT = (
"Raw gh write commands are blocked. Use drone for git ops:\n"
' drone @git pr "description"\n'
" drone @git merge <PR#> # devpulse only, on user request\n"
"Read-only gh (list, view, status, diff, checks, comments) is allowed."
)
EDIT_REDIRECT = (
"{path} is protected — settings.json, .claude/hooks/, and .git/hooks/ "
"govern the enforcement layer itself.\n"
"If a real change is needed, ask devpulse to make it directly."
)
def _block(reason: str) -> None:
print(json.dumps({"decision": "block", "reason": reason}))
sys.exit(2)
def _cwd_branch(cwd: str) -> str:
"""Extract AIPass branch name from CWD (src/aipass/{branch}/ pattern)."""
parts = Path(cwd).parts
for i, part in enumerate(parts):
if part == "aipass" and i > 0 and parts[i - 1] == "src" and i + 1 < len(parts):
return parts[i + 1]
return ""
def _is_project_owner(cwd: str) -> bool:
"""Check if the current branch's passport has citizenship.owner: true."""
p = Path(cwd)
for d in [p] + list(p.parents):
passport = d / ".trinity" / "passport.json"
if passport.is_file():
try:
data = json.loads(passport.read_text(encoding="utf-8"))
return bool(data.get("citizenship", {}).get("owner"))
except Exception:
return False
if (d / ".git").exists():
break
return False
def main():
try:
data = json.load(sys.stdin)
tool_name = data.get("tool_name", "")
tool_input = data.get("tool_input", {})
cwd = data.get("cwd") or os.getcwd()
if tool_name == "Bash":
cmd = tool_input.get("command", "")
if not cmd:
return
# Strip quoted strings before matching — text inside "..." or '...' is data
# (PR descriptions, commit messages, examples in docs), not code to enforce.
scan = re.sub(r'"(?:[^"\\]|\\.)*"', '""', cmd)
scan = re.sub(r"'(?:[^'\\]|\\.)*'", "''", scan)
if (
BLOCKED_GIT_RE.search(scan)
or BLOCKED_GIT_STASH_RE.search(scan)
or BLOCKED_GIT_BRANCH_RE.search(scan)
or BLOCKED_GIT_TAG_RE.search(scan)
or BLOCKED_GIT_REMOTE_RE.search(scan)
):
_block(GIT_REDIRECT)
if BLOCKED_GH_API_RE.search(scan) or BLOCKED_GH_RE.search(scan):
if not (_cwd_branch(cwd) in TRUSTED_HOOK_EDITORS or _is_project_owner(cwd)):
_block(GH_REDIRECT)
return
if tool_name in EDIT_TOOLS:
file_path = tool_input.get("file_path") or tool_input.get("notebook_path") or ""
if not file_path:
return
for pat in BLOCKED_EDIT_PATTERNS:
if pat.search(file_path):
# Trusted-editor bypass: devpulse working from its own branch
# is the maintainer of the enforcement layer.
if _cwd_branch(cwd) in TRUSTED_HOOK_EDITORS:
return
_block(EDIT_REDIRECT.format(path=file_path))
return
except Exception:
return
if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).resolve().parent))
from hook_log import run_and_log
run_and_log("PreToolUse", "provider", __file__, main)
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
Global Prompt Loader — replaces hardcoded `cat` of aipass_global_prompt.md.
Uses $AIPASS_HOME for path portability. Exits silently when CWD is inside
a project that has its own UserPromptSubmit hooks (avoids injecting the
22KB AIPass source-tree prompt into standalone projects).
Version: 1.0.0
"""
import json
import os
from pathlib import Path
def _project_has_own_hooks() -> bool:
"""Check if CWD is inside a project with its own UserPromptSubmit hooks."""
search = Path.cwd()
home = Path.home()
while search != home and search.parent != search:
settings = search / ".claude" / "settings.json"
if settings.exists():
try:
data = json.loads(settings.read_text(encoding="utf-8"))
ups = data.get("hooks", {}).get("UserPromptSubmit", [])
if ups:
return True
except (json.JSONDecodeError, OSError):
pass
search = search.parent
return False
def main():
if _project_has_own_hooks():
return
aipass_home = os.environ.get("AIPASS_HOME", "")
if not aipass_home:
return
prompt_file = Path(aipass_home) / ".aipass" / "aipass_global_prompt.md"
if prompt_file.exists():
print(prompt_file.read_text(encoding="utf-8"), end="")
if __name__ == "__main__":
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from hook_log import run_and_log
run_and_log("UserPromptSubmit", "provider", __file__, main)
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""
Shared hook execution logger for AIPass.
Every hook imports this and calls log_fire() once. The log file is a JSONL
append-only stream at /tmp/aipass_hook_log.jsonl — one line per hook invocation.
Usage in any hook script:
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from hook_log import log_fire
# At the end of main():
log_fire("UserPromptSubmit", "provider", __file__, elapsed_ms=12.3, output_bytes=21400)
"""
import json
import os
import time
from datetime import datetime, timezone
from pathlib import Path
_LOG_FILE = Path("/tmp/aipass_hook_log.jsonl")
_VERSION = "1.0.0"
def log_fire(
event: str,
source: str,
script: str,
*,
elapsed_ms: float = 0.0,
output_bytes: int = 0,
exit_code: int = 0,
tool: str = "",
extra: dict | None = None,
) -> None:
"""Append one structured log entry. Never raises."""
try:
entry = {
"ts": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"v": _VERSION,
"event": event,
"source": source,
"script": Path(script).name,
"script_path": str(script),
"cwd": os.getcwd(),
"session": os.environ.get("CLAUDE_CODE_SESSION_ID", ""),
"tool": tool,
"exit_code": exit_code,
"elapsed_ms": round(elapsed_ms, 1),
"output_bytes": output_bytes,
}
if extra:
entry.update(extra)
with open(_LOG_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(entry) + "\n")
except Exception:
pass
class HookTimer:
"""Context manager for timing hook execution."""
def __init__(self) -> None:
self.start: float = 0.0
self.elapsed_ms: float = 0.0
def __enter__(self) -> "HookTimer":
self.start = time.monotonic()
return self
def __exit__(self, *_: object) -> None:
self.elapsed_ms = (time.monotonic() - self.start) * 1000.0
def run_and_log(
event: str,
source: str,
script: str,
fn: "callable", # noqa: F821
) -> None:
"""Run a hook function, capture stdout, time it, log the result.
Usage in __main__ block (4 lines total):
import sys
sys.path.insert(0, str(__import__('pathlib').Path(__file__).resolve().parent))
from hook_log import run_and_log
run_and_log("UserPromptSubmit", "provider", __file__, main)
"""
import io
import sys as _sys
buf = io.StringIO()
orig = _sys.stdout
_sys.stdout = buf
_exit_code = 0
try:
with HookTimer() as t:
fn()
except SystemExit as e:
_exit_code = e.code if isinstance(e.code, int) else 0
finally:
_sys.stdout = orig
output = buf.getvalue()
if output:
print(output, end="")
log_fire(
event,
source,
script,
elapsed_ms=t.elapsed_ms,
output_bytes=len(output.encode("utf-8")),
exit_code=_exit_code,
)
_sys.exit(_exit_code)
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
Hook Execution Report — reads /tmp/aipass_hook_log.jsonl and shows what fired.
Usage:
python3 hook_report.py # Last session (or last 5 min)
python3 hook_report.py --all # All entries in log
python3 hook_report.py --session ID # Specific session
python3 hook_report.py --cwd /path # Filter by CWD
python3 hook_report.py --clear # Wipe log and start fresh
python3 hook_report.py --json # Output raw JSON instead of table
Version: 1.0.0
"""
import argparse
import json
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
_LOG_FILE = Path("/tmp/aipass_hook_log.jsonl")
_EVENT_ORDER = [
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"SubagentStop",
"PreCompact",
"Stop",
"Notification",
]
def _load_entries(
session: str = "",
cwd: str = "",
since_minutes: int = 0,
all_entries: bool = False,
) -> list[dict]:
if not _LOG_FILE.exists():
return []
entries = []
now = datetime.now(timezone.utc)
for line in _LOG_FILE.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if session and entry.get("session", "") != session:
continue
if cwd and not entry.get("cwd", "").startswith(cwd):
continue
if not all_entries and since_minutes > 0:
try:
ts = datetime.fromisoformat(entry["ts"].replace("Z", "+00:00"))
age = (now - ts).total_seconds() / 60
if age > since_minutes:
continue
except (KeyError, ValueError):
continue
entries.append(entry)
return entries
def _format_bytes(n: int) -> str:
if n == 0:
return "silent"
if n < 1024:
return f"{n}B"
return f"{n / 1024:.1f}KB"
def _format_table(entries: list[dict]) -> str:
if not entries:
return "No hook activity found."
lines = []
sessions = set(e.get("session", "")[:8] for e in entries)
cwds = set(e.get("cwd", "") for e in entries)
ts_range = ""
if entries:
first_ts = entries[0].get("ts", "")[:19]
last_ts = entries[-1].get("ts", "")[:19]
ts_range = f"{first_ts} -> {last_ts}" if first_ts != last_ts else first_ts
lines.append("Hook Execution Report")
lines.append("=" * 70)
if len(sessions) == 1:
lines.append(f"Session: {list(sessions)[0]}...")
else:
lines.append(f"Sessions: {len(sessions)}")
if len(cwds) == 1:
lines.append(f"CWD: {list(cwds)[0]}")
else:
lines.append(f"CWDs: {', '.join(sorted(cwds))}")
lines.append(f"Time: {ts_range}")
lines.append(f"Total fires: {len(entries)}")
lines.append("")
hdr = f"{'#':>3} | {'Event':<22} | {'Source':<8} | {'Script':<28} | {'ms':>6} | {'Output':>8} | {'Exit':>4}"
lines.append(hdr)
lines.append("-" * len(hdr))
for i, e in enumerate(entries, 1):
event = e.get("event", "?")
source = e.get("source", "?")
script = e.get("script", "?")
ms = e.get("elapsed_ms", 0)
out = _format_bytes(e.get("output_bytes", 0))
exit_code = e.get("exit_code", 0)
exit_str = str(exit_code) if exit_code != 0 else ""
lines.append(f"{i:>3} | {event:<22} | {source:<8} | {script:<28} | {ms:>6.1f} | {out:>8} | {exit_str:>4}")
lines.append("")
event_counts = Counter(e.get("event", "") for e in entries)
source_counts = Counter((e.get("event", ""), e.get("source", "")) for e in entries)
warnings = []
for event, count in event_counts.items():
sources = [s for (ev, s), c in source_counts.items() if ev == event]
unique_sources = set(sources)
if count > 1 and len(unique_sources) > 1:
warnings.append(f"DOUBLE-FIRE: {event} fired {count}x from {', '.join(sorted(unique_sources))}")
elif count > 4:
warnings.append(f"HIGH FREQUENCY: {event} fired {count}x")
suppressed = [e for e in entries if e.get("output_bytes", 0) == 0 and e.get("event") == "UserPromptSubmit"]
if suppressed:
scripts = [e.get("script", "?") for e in suppressed]
warnings.append(
f"CWD-GUARDED (likely): {len(suppressed)} UserPromptSubmit hook(s) produced no output: {', '.join(scripts)}"
)
if warnings:
lines.append("Warnings:")
for w in warnings:
lines.append(f" ! {w}")
else:
lines.append("No warnings.")
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(description="Hook execution report")
parser.add_argument("--all", action="store_true", help="Show all entries")
parser.add_argument("--session", default="", help="Filter by session ID (prefix match)")
parser.add_argument("--cwd", default="", help="Filter by CWD prefix")
parser.add_argument("--minutes", type=int, default=5, help="Show last N minutes (default: 5)")
parser.add_argument("--clear", action="store_true", help="Clear log file")
parser.add_argument("--json", action="store_true", help="Output raw JSON")
args = parser.parse_args()
if args.clear:
if _LOG_FILE.exists():
_LOG_FILE.unlink()
print("Log cleared.")
else:
print("No log file to clear.")
return
entries = _load_entries(
session=args.session,
cwd=args.cwd,
since_minutes=args.minutes,
all_entries=args.all,
)
if args.json:
print(json.dumps(entries, indent=2))
else:
print(_format_table(entries))
if __name__ == "__main__":
main()
+753
View File
@@ -0,0 +1,753 @@
#!/usr/bin/env python3
"""
Hook Test Harness — two test layers:
1. DIRECT tests: pipe JSON to hook scripts via subprocess. Deterministic,
fast, no model dependency. HIGH confidence.
2. INTEGRATION tests: run `claude -p` from different CWDs, read JSONL log.
Tests full pipeline. MEDIUM confidence (model-dependent).
Usage:
python3 hook_test.py # Run all tests
python3 hook_test.py --direct # Direct tests only (fast, deterministic)
python3 hook_test.py --integration # Integration tests only (slower, needs claude)
python3 hook_test.py --test cwd_guard # Run one test by name
python3 hook_test.py --list # List available tests
python3 hook_test.py --verbose # Show detail per test
Version: 2.0.0
"""
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
_LOG_FILE = Path("/tmp/aipass_hook_log.jsonl")
_AIPASS_HOME = os.environ.get("AIPASS_HOME", "/home/patrick/Projects/AIPass")
def _clear_log() -> None:
if _LOG_FILE.exists():
_LOG_FILE.unlink()
def _read_log() -> list[dict]:
if not _LOG_FILE.exists():
return []
entries = []
for line in _LOG_FILE.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
return entries
def _run_headless(cwd: str, prompt: str = "say hi", model: str = "haiku") -> tuple[int, str]:
"""Run `claude -p` from a given CWD and return (exit_code, stdout)."""
try:
result = subprocess.run(
["claude", "-p", prompt, "--model", model],
cwd=cwd,
capture_output=True,
text=True,
timeout=120,
)
return result.returncode, result.stdout
except subprocess.TimeoutExpired:
return -1, "TIMEOUT"
except FileNotFoundError:
return -2, "claude not found"
class TestResult:
def __init__(self, name: str) -> None:
self.name = name
self.passed = False
self.message = ""
self.entries: list[dict] = []
def ok(self, msg: str = "") -> "TestResult":
self.passed = True
self.message = msg or "PASS"
return self
def fail(self, msg: str) -> "TestResult":
self.passed = False
self.message = msg
return self
_HOOKS_DIR = Path(_AIPASS_HOME) / ".claude" / "hooks"
def _run_hook_direct(
script: str,
payload: dict,
cwd: str = "/tmp",
env_extra: dict | None = None,
) -> tuple[int, str, str]:
"""Run a hook script as a subprocess with JSON on stdin. Returns (exit_code, stdout, stderr)."""
script_path = _HOOKS_DIR / script
if not script_path.exists():
return -1, "", f"Script not found: {script_path}"
env = {**os.environ, "AIPASS_HOME": _AIPASS_HOME}
if env_extra:
env.update(env_extra)
try:
result = subprocess.run(
["python3", str(script_path)],
input=json.dumps(payload),
capture_output=True,
text=True,
timeout=15,
cwd=cwd,
env=env,
)
return result.returncode, result.stdout, result.stderr
except subprocess.TimeoutExpired:
return -2, "", "TIMEOUT"
def _find_project_with_hooks() -> str:
"""Dynamically find a project that has its own UserPromptSubmit hooks."""
projects_dir = Path.home() / "Projects"
if not projects_dir.is_dir():
return ""
for proj in sorted(projects_dir.iterdir()):
if proj.name == "AIPass":
continue
settings = proj / ".claude" / "settings.json"
if settings.exists():
try:
data = json.loads(settings.read_text(encoding="utf-8"))
if data.get("hooks", {}).get("UserPromptSubmit"):
return str(proj)
except (json.JSONDecodeError, OSError):
continue
return ""
def _find_project_without_hooks() -> str:
"""Dynamically find a project that has settings.json but NO UserPromptSubmit hooks."""
projects_dir = Path.home() / "Projects"
if not projects_dir.is_dir():
return ""
for proj in sorted(projects_dir.iterdir()):
if proj.name == "AIPass":
continue
settings = proj / ".claude" / "settings.json"
if settings.exists():
try:
data = json.loads(settings.read_text(encoding="utf-8"))
if not data.get("hooks", {}).get("UserPromptSubmit"):
return str(proj)
except (json.JSONDecodeError, OSError):
return str(proj)
return ""
# =========================================================================
# DIRECT TESTS — pipe JSON to hook subprocess, deterministic, HIGH confidence
# =========================================================================
def test_direct_global_prompt_from_tmp(verbose: bool = False) -> TestResult:
"""[DIRECT] global_prompt_loader outputs full prompt when run from /tmp."""
r = TestResult("direct_global_prompt_from_tmp")
exit_code, stdout, stderr = _run_hook_direct("global_prompt_loader.py", {}, cwd="/tmp")
if exit_code != 0:
return r.fail(f"Exit {exit_code}: {stderr}")
if len(stdout) < 1000:
return r.fail(f"Output only {len(stdout)} chars — expected ~22KB global prompt")
return r.ok(f"{len(stdout)} chars output from /tmp")
def test_direct_global_prompt_guarded(verbose: bool = False) -> TestResult:
"""[DIRECT] global_prompt_loader suppressed when CWD has own hooks."""
r = TestResult("direct_global_prompt_guarded")
cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse")
exit_code, stdout, stderr = _run_hook_direct("global_prompt_loader.py", {}, cwd=cwd)
if exit_code != 0:
return r.fail(f"Exit {exit_code}: {stderr}")
if stdout.strip():
return r.fail(f"Expected silent (CWD guard), got {len(stdout)} chars")
return r.ok("Silent output — CWD guard active")
def test_direct_identity_injector(verbose: bool = False) -> TestResult:
"""[DIRECT] identity_injector outputs identity from branch with passport."""
r = TestResult("direct_identity_injector")
cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse")
exit_code, stdout, _ = _run_hook_direct("identity_injector.py", {}, cwd=cwd)
if exit_code != 0:
return r.fail(f"Exit {exit_code}")
# From devpulse, CWD guard is active — should be silent
if stdout.strip():
return r.fail("Expected silent from devpulse (CWD guard), got output")
# Test from /tmp — no branch root, should also be silent
exit_code2, stdout2, _ = _run_hook_direct("identity_injector.py", {}, cwd="/tmp")
if stdout2.strip():
return r.fail("Expected silent from /tmp (no branch root), got output")
return r.ok("Silent from guarded CWD and no-branch CWD")
def test_direct_git_gate_allows_safe(verbose: bool = False) -> TestResult:
"""[DIRECT] git_gate allows safe Bash commands (exit 0, no output)."""
r = TestResult("direct_git_gate_allows_safe")
payload = {"tool_name": "Bash", "tool_input": {"command": "echo hello"}, "cwd": "/tmp"}
exit_code, stdout, _ = _run_hook_direct("git_gate.py", payload)
if exit_code != 0:
return r.fail(f"Safe command blocked — exit {exit_code}")
if stdout.strip():
return r.fail(f"Unexpected output for safe command: {stdout[:100]}")
return r.ok("Safe Bash command allowed (exit 0, silent)")
def test_direct_git_gate_blocks_raw_git(verbose: bool = False) -> TestResult:
"""[DIRECT] git_gate blocks raw git commit (exit 2, decision=block)."""
r = TestResult("direct_git_gate_blocks_raw_git")
payload = {"tool_name": "Bash", "tool_input": {"command": "git commit -m test"}, "cwd": "/tmp"}
exit_code, stdout, _ = _run_hook_direct("git_gate.py", payload)
if exit_code != 2:
return r.fail(f"Expected exit 2 (block), got {exit_code}")
try:
out = json.loads(stdout)
if out.get("decision") != "block":
return r.fail(f"Expected decision=block, got {out.get('decision')}")
except json.JSONDecodeError:
return r.fail(f"Non-JSON output: {stdout[:100]}")
return r.ok("git commit blocked (exit 2, decision=block)")
def test_direct_git_gate_blocks_gh_push(verbose: bool = False) -> TestResult:
"""[DIRECT] git_gate blocks git push (exit 2, decision=block)."""
r = TestResult("direct_git_gate_blocks_gh_push")
payload = {"tool_name": "Bash", "tool_input": {"command": "git push origin main"}, "cwd": "/tmp"}
exit_code, stdout, _ = _run_hook_direct("git_gate.py", payload)
if exit_code != 2:
return r.fail(f"Expected exit 2 (block), got {exit_code}")
return r.ok("git push blocked (exit 2)")
def test_direct_tool_use_sound_exits_clean(verbose: bool = False) -> TestResult:
"""[DIRECT] tool_use_sound exits 0 and produces no stdout."""
r = TestResult("direct_tool_use_sound_exits_clean")
payload = {"hook_event_name": "PreToolUse", "tool_name": "Read"}
exit_code, stdout, _ = _run_hook_direct("tool_use_sound.py", payload)
if exit_code != 0:
return r.fail(f"Exit {exit_code}")
if stdout.strip():
return r.fail(f"Unexpected stdout: {stdout[:100]}")
return r.ok("Clean exit, no stdout")
def test_direct_email_notification_no_mail(verbose: bool = False) -> TestResult:
"""[DIRECT] email_notification silent when no inbox exists."""
r = TestResult("direct_email_notification_no_mail")
exit_code, stdout, _ = _run_hook_direct("email_notification.py", {}, cwd="/tmp")
if exit_code != 0:
return r.fail(f"Exit {exit_code}")
if stdout.strip():
return r.fail(f"Unexpected output from /tmp (no mailbox): {stdout[:100]}")
return r.ok("Silent — no mailbox at /tmp")
def test_direct_settings_schema(verbose: bool = False) -> TestResult:
"""[DIRECT] All hooks in provider settings.json reference scripts that exist."""
r = TestResult("direct_settings_schema")
settings_path = Path.home() / ".claude" / "settings.json"
if not settings_path.exists():
return r.fail("~/.claude/settings.json not found")
data = json.loads(settings_path.read_text(encoding="utf-8"))
hooks = data.get("hooks", {})
valid_events = {
"PreToolUse",
"PostToolUse",
"UserPromptSubmit",
"SubagentStop",
"PreCompact",
"PostCompact",
"Stop",
"Notification",
"SessionStart",
"PermissionRequest",
}
missing = []
bad_events = []
for event, entries in hooks.items():
if event not in valid_events:
bad_events.append(event)
for entry in entries:
for hook in entry.get("hooks", []):
cmd = hook.get("command", "")
parts = cmd.split()
for part in parts:
if part.endswith(".py") and "/" in part:
if not Path(part).exists():
missing.append(part)
errors = []
if bad_events:
errors.append(f"Invalid events: {bad_events}")
if missing:
errors.append(f"Missing scripts: {missing}")
if errors:
return r.fail("; ".join(errors))
hook_count = sum(len(e.get("hooks", [])) for entries in hooks.values() for e in entries)
return r.ok(f"{len(hooks)} events, {hook_count} hooks, all scripts exist")
def _find_aipass_init_project() -> str:
"""Find a project created by aipass init (has *_REGISTRY.json)."""
projects_dir = Path.home() / "Projects"
if not projects_dir.exists():
return ""
for proj in sorted(projects_dir.iterdir()):
if proj.name == "AIPass":
continue
registries = list(proj.glob("*_REGISTRY.json"))
settings = proj / ".claude" / "settings.json"
if registries and settings.exists():
return str(proj)
return ""
def test_direct_project_settings_schema(verbose: bool = False) -> TestResult:
"""[DIRECT] aipass init project has valid settings.json with expected hooks."""
r = TestResult("direct_project_settings_schema")
proj = _find_aipass_init_project()
if not proj:
return r.ok("SKIP — no aipass init project found (needs ~/Projects with *_REGISTRY.json)")
settings_path = Path(proj) / ".claude" / "settings.json"
data = json.loads(settings_path.read_text(encoding="utf-8"))
hooks = data.get("hooks", {})
has_ups = bool(hooks.get("UserPromptSubmit"))
has_pre = bool(hooks.get("PreToolUse"))
has_post = bool(hooks.get("PostToolUse"))
project_name = Path(proj).name
notes = []
if has_ups:
notes.append(f"UserPromptSubmit: {len(hooks['UserPromptSubmit'])} entries")
if has_pre:
notes.append(f"PreToolUse: {len(hooks['PreToolUse'])} entries (NOTE: won't fire from project level)")
if has_post:
notes.append(f"PostToolUse: {len(hooks['PostToolUse'])} entries (NOTE: won't fire from project level)")
for event, entries in hooks.items():
for entry in entries:
for hook in entry.get("hooks", []):
cmd = hook.get("command", "")
if cmd.startswith("python3 ") and ".py" in cmd:
script = cmd.split()[-1]
full = Path(proj) / script
if not full.exists():
return r.fail(f"Missing script in {project_name}: {script}")
return r.ok(f"{project_name}: {', '.join(notes)}")
def test_direct_provider_guards_for_init_project(verbose: bool = False) -> TestResult:
"""[DIRECT] Provider hooks are CWD-guarded when run from an aipass init project."""
r = TestResult("direct_provider_guards_for_init_project")
proj = _find_aipass_init_project()
if not proj:
return r.ok("SKIP — no aipass init project found")
guarded_hooks = [
"global_prompt_loader.py",
"branch_prompt_loader.py",
"identity_injector.py",
"email_notification.py",
]
for script in guarded_hooks:
exit_code, stdout, _ = _run_hook_direct(script, {}, cwd=proj)
if exit_code != 0:
return r.fail(f"{script} exited {exit_code} from {Path(proj).name}")
if stdout.strip():
return r.fail(
f"{script} produced output from {Path(proj).name} — "
f"CWD guard should suppress (project has own UserPromptSubmit hooks)"
)
return r.ok(f"All 4 provider hooks suppressed from {Path(proj).name}")
# =========================================================================
# INTEGRATION TESTS — run claude -p, read JSONL log, MEDIUM confidence
# =========================================================================
def test_aipass_branch_hooks(verbose: bool = False) -> TestResult:
"""Test: hooks fire correctly from an AIPass branch CWD (devpulse)."""
r = TestResult("aipass_branch_hooks")
cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse")
if not Path(cwd).exists():
return r.fail(f"CWD not found: {cwd}")
_clear_log()
exit_code, _ = _run_headless(cwd)
entries = _read_log()
r.entries = entries
if exit_code != 0:
return r.fail(f"claude -p exited {exit_code}")
ups = [e for e in entries if e.get("event") == "UserPromptSubmit"]
if not ups:
return r.fail("No UserPromptSubmit hooks fired")
expected_scripts = {
"global_prompt_loader.py",
"branch_prompt_loader.py",
"identity_injector.py",
"email_notification.py",
}
fired_scripts = {e.get("script", "") for e in ups}
missing = expected_scripts - fired_scripts
if missing:
return r.fail(f"Missing UserPromptSubmit hooks: {missing}")
return r.ok(f"{len(ups)} UserPromptSubmit hooks fired, {len(entries)} total")
def test_cwd_guard_devpulse(verbose: bool = False) -> TestResult:
"""Test: CWD guard suppresses provider hooks when project has own hooks."""
r = TestResult("cwd_guard_devpulse")
cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse")
project_settings = Path(cwd)
found_settings = False
search = project_settings
while search != Path.home() and search.parent != search:
if (search / ".claude" / "settings.json").exists():
found_settings = True
break
search = search.parent
if not found_settings:
return r.fail("No .claude/settings.json found in CWD hierarchy — can't test CWD guard")
_clear_log()
exit_code, _ = _run_headless(cwd)
entries = _read_log()
r.entries = entries
if exit_code != 0:
return r.fail(f"claude -p exited {exit_code}")
ups = [e for e in entries if e.get("event") == "UserPromptSubmit"]
guarded = [e for e in ups if e.get("output_bytes", 0) == 0]
if not guarded:
return r.fail(
"No UserPromptSubmit hooks were suppressed — CWD guard may not be working. "
f"Hooks fired: {[e.get('script') for e in ups]}"
)
return r.ok(f"{len(guarded)}/{len(ups)} UserPromptSubmit hooks suppressed by CWD guard")
def test_tmp_no_guard(verbose: bool = False) -> TestResult:
"""Test: from /tmp (no project hooks), provider hooks fire with full output."""
r = TestResult("tmp_no_guard")
_clear_log()
exit_code, _ = _run_headless("/tmp")
entries = _read_log()
r.entries = entries
if exit_code != 0:
return r.fail(f"claude -p exited {exit_code}")
ups = [e for e in entries if e.get("event") == "UserPromptSubmit"]
global_prompt = [e for e in ups if e.get("script") == "global_prompt_loader.py"]
if not global_prompt:
return r.fail("global_prompt_loader.py did not fire from /tmp")
if global_prompt[0].get("output_bytes", 0) < 1000:
return r.fail(
f"global_prompt_loader.py output only {global_prompt[0].get('output_bytes')}B "
"from /tmp — expected ~22KB (CWD guard should NOT fire here)"
)
return r.ok(
f"global_prompt_loader.py output {global_prompt[0].get('output_bytes')}B (guard not active, as expected)"
)
def test_pretooluse_fires(verbose: bool = False) -> TestResult:
"""Test: PreToolUse hooks fire on tool calls."""
r = TestResult("pretooluse_fires")
cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse")
_clear_log()
exit_code, _ = _run_headless(cwd, prompt="run: echo hello")
entries = _read_log()
r.entries = entries
pre = [e for e in entries if e.get("event") == "PreToolUse"]
if not pre:
return r.fail("No PreToolUse hooks fired — expected at least tool_use_sound.py")
return r.ok(f"{len(pre)} PreToolUse fires")
def test_posttooluse_fires(verbose: bool = False) -> TestResult:
"""Test: PostToolUse hooks fire after tool calls."""
r = TestResult("posttooluse_fires")
cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse")
_clear_log()
exit_code, _ = _run_headless(cwd, prompt="use the bash tool to run: echo posttooluse-test")
entries = _read_log()
r.entries = entries
post = [e for e in entries if e.get("event") == "PostToolUse"]
if not post:
return r.fail("No PostToolUse hooks fired")
return r.ok(f"{len(post)} PostToolUse fires")
def test_standalone_project_guard(verbose: bool = False) -> TestResult:
"""[INTEGRATION] standalone projects with own hooks get provider hooks suppressed."""
r = TestResult("standalone_project_guard")
cwd = _find_project_with_hooks()
if not cwd:
return r.fail("No standalone project with UserPromptSubmit hooks found")
_clear_log()
exit_code, _ = _run_headless(cwd)
entries = _read_log()
r.entries = entries
if exit_code != 0:
return r.fail(f"claude -p exited {exit_code}")
ups = [e for e in entries if e.get("event") == "UserPromptSubmit"]
guarded = [e for e in ups if e.get("output_bytes", 0) == 0]
if not ups:
return r.fail("No UserPromptSubmit hooks fired at all")
project_name = Path(cwd).name
if not guarded:
return r.fail(
f"Provider hooks NOT suppressed in {project_name} (has own hooks). Fired: {[e.get('script') for e in ups]}"
)
return r.ok(f"{len(guarded)}/{len(ups)} provider UserPromptSubmit hooks suppressed in {project_name}")
def test_no_hooks_project_gets_prompt(verbose: bool = False) -> TestResult:
"""[INTEGRATION] projects without own hooks receive full provider prompt."""
r = TestResult("no_hooks_project_gets_prompt")
cwd = _find_project_without_hooks()
if not cwd:
return r.fail("No project without UserPromptSubmit hooks found")
_clear_log()
exit_code, _ = _run_headless(cwd)
entries = _read_log()
r.entries = entries
if exit_code != 0:
return r.fail(f"claude -p exited {exit_code}")
global_prompt = [
e for e in entries if e.get("script") == "global_prompt_loader.py" and e.get("output_bytes", 0) > 1000
]
project_name = Path(cwd).name
if not global_prompt:
return r.fail(
f"global_prompt_loader.py did NOT output full prompt in {project_name} "
f"(no own hooks — guard should be inactive)"
)
return r.ok(
f"global_prompt_loader.py output {global_prompt[0]['output_bytes']}B "
f"in {project_name} (no own hooks, guard inactive)"
)
def test_subagent_hooks(verbose: bool = False) -> TestResult:
"""Test: SubagentStop hook fires when a subagent completes."""
r = TestResult("subagent_hooks")
cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse")
_clear_log()
exit_code, _ = _run_headless(
cwd,
prompt="Use the Agent tool to spawn a helper that runs echo test via Bash then reports back",
)
entries = _read_log()
r.entries = entries
if exit_code != 0:
return r.fail(f"claude -p exited {exit_code}")
subagent_stops = [e for e in entries if e.get("event") == "SubagentStop"]
if not subagent_stops:
return r.fail("No SubagentStop hook fired — model may not have spawned a subagent")
return r.ok(f"{len(subagent_stops)} SubagentStop fire(s)")
def test_disable_toggle(verbose: bool = False) -> TestResult:
"""[INTEGRATION] disableAllHooks=true stops all hook firing (atomic backup/restore)."""
r = TestResult("disable_toggle")
import shutil
import tempfile
settings_path = Path.home() / ".claude" / "settings.json"
if not settings_path.exists():
return r.fail("~/.claude/settings.json not found")
# Atomic backup — copy to temp file first, restore from backup on any failure
backup_fd, backup_path = tempfile.mkstemp(suffix=".json", prefix="settings_backup_")
os.close(backup_fd)
shutil.copy2(str(settings_path), backup_path)
try:
original = settings_path.read_text(encoding="utf-8")
data = json.loads(original)
data["disableAllHooks"] = True
settings_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
_clear_log()
exit_code, _ = _run_headless("/tmp")
entries = _read_log()
r.entries = entries
finally:
# Restore from atomic backup — safe even after crash/signal
shutil.copy2(backup_path, str(settings_path))
try:
os.unlink(backup_path)
except OSError:
pass
if entries:
return r.fail(f"{len(entries)} hooks fired with disableAllHooks=true — toggle broken")
return r.ok("0 hooks fired with disableAllHooks=true")
_DIRECT_TESTS = [
("direct_global_prompt_from_tmp", test_direct_global_prompt_from_tmp),
("direct_global_prompt_guarded", test_direct_global_prompt_guarded),
("direct_identity_injector", test_direct_identity_injector),
("direct_git_gate_allows_safe", test_direct_git_gate_allows_safe),
("direct_git_gate_blocks_raw_git", test_direct_git_gate_blocks_raw_git),
("direct_git_gate_blocks_gh_push", test_direct_git_gate_blocks_gh_push),
("direct_tool_use_sound_exits_clean", test_direct_tool_use_sound_exits_clean),
("direct_email_notification_no_mail", test_direct_email_notification_no_mail),
("direct_settings_schema", test_direct_settings_schema),
("direct_project_settings_schema", test_direct_project_settings_schema),
("direct_provider_guards_for_init_project", test_direct_provider_guards_for_init_project),
]
_INTEGRATION_TESTS = [
("aipass_branch_hooks", test_aipass_branch_hooks),
("cwd_guard_devpulse", test_cwd_guard_devpulse),
("tmp_no_guard", test_tmp_no_guard),
("pretooluse_fires", test_pretooluse_fires),
("posttooluse_fires", test_posttooluse_fires),
("standalone_project_guard", test_standalone_project_guard),
("no_hooks_project_gets_prompt", test_no_hooks_project_gets_prompt),
("subagent_hooks", test_subagent_hooks),
("disable_toggle", test_disable_toggle),
]
_ALL_TESTS = _DIRECT_TESTS + _INTEGRATION_TESTS
def main() -> None:
parser = argparse.ArgumentParser(description="Hook test harness")
parser.add_argument("--test", default="", help="Run specific test by name")
parser.add_argument("--direct", action="store_true", help="Run direct tests only (fast, deterministic)")
parser.add_argument("--integration", action="store_true", help="Run integration tests only (slower)")
parser.add_argument("--list", action="store_true", help="List available tests")
parser.add_argument("--verbose", action="store_true", help="Show hook log per test")
args = parser.parse_args()
if args.list:
for name, fn in _ALL_TESTS:
print(f" {name}: {fn.__doc__}")
return
if args.direct:
tests = _DIRECT_TESTS
elif args.integration:
tests = _INTEGRATION_TESTS
else:
tests = _ALL_TESTS
if args.test:
tests = [(n, f) for n, f in tests if n == args.test or args.test in n]
if not tests:
print(f"Unknown test: {args.test}")
print(f"Available: {', '.join(n for n, _ in _ALL_TESTS)}")
sys.exit(1)
passed = 0
failed = 0
print(f"\nHook Test Harness — {len(tests)} test(s)")
print("=" * 60)
for name, fn in tests:
print(f"\n Running: {name}...", end=" ", flush=True)
try:
result = fn(verbose=args.verbose)
except Exception as e:
result = TestResult(name).fail(f"Exception: {e}")
if result.passed:
passed += 1
print(f"PASS — {result.message}")
else:
failed += 1
print(f"FAIL — {result.message}")
if args.verbose and result.entries:
print(f" Log entries ({len(result.entries)}):")
for e in result.entries:
print(
f" {e.get('event'):<22} "
f"{e.get('script'):<28} "
f"{e.get('elapsed_ms', 0):>6.1f}ms "
f"{e.get('output_bytes', 0):>6}B"
)
print(f"\n{'=' * 60}")
print(f"Results: {passed} passed, {failed} failed, {passed + failed} total")
sys.exit(1 if failed > 0 else 0)
if __name__ == "__main__":
main()
+32 -2
View File
@@ -5,12 +5,34 @@ Identity Injector - Injects branch identity on every prompt.
Reads from [BRANCH].id.json and outputs core identity fields. Reads from [BRANCH].id.json and outputs core identity fields.
Finds the branch root by walking up from CWD looking for apps/ or *.id.json. Finds the branch root by walking up from CWD looking for apps/ or *.id.json.
Version: 1.0.0 When CWD is inside a project that has its own UserPromptSubmit hooks,
this provider-level hook exits silently to avoid double-firing.
Version: 1.1.0
""" """
import json import json
from pathlib import Path from pathlib import Path
def _project_has_own_hooks() -> bool:
"""Check if CWD is inside a project with its own UserPromptSubmit hooks."""
search = Path.cwd()
home = Path.home()
while search != home and search.parent != search:
settings = search / ".claude" / "settings.json"
if settings.exists():
try:
data = json.loads(settings.read_text(encoding="utf-8"))
ups = data.get("hooks", {}).get("UserPromptSubmit", [])
if ups:
return True
except (json.JSONDecodeError, OSError):
pass
search = search.parent
return False
def find_repo_root() -> Path | None: def find_repo_root() -> Path | None:
"""Find the repo root (contains pyproject.toml or .git).""" """Find the repo root (contains pyproject.toml or .git)."""
search = Path.cwd() search = Path.cwd()
@@ -96,6 +118,9 @@ def format_identity(data: dict) -> str:
def main(): def main():
if _project_has_own_hooks():
return
branch_root = find_branch_root() branch_root = find_branch_root()
if not branch_root: if not branch_root:
return return
@@ -114,4 +139,9 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from hook_log import run_and_log
run_and_log("UserPromptSubmit", "provider", __file__, main)
+4 -1
View File
@@ -35,4 +35,7 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() sys.path.insert(0, str(Path(__file__).resolve().parent))
from hook_log import run_and_log
run_and_log("Notification", "provider", __file__, main)
+4 -1
View File
@@ -165,4 +165,7 @@ Context just compacted. Below is your live state. Use it to continue seamlessly.
if __name__ == "__main__": if __name__ == "__main__":
main() sys.path.insert(0, str(Path(__file__).resolve().parent))
from hook_log import run_and_log
run_and_log("PreCompact", "provider", __file__, main)
+31 -10
View File
@@ -37,6 +37,7 @@ def _get_branch(file_path: str) -> str:
def _block(reason: str) -> None: def _block(reason: str) -> None:
# codeql[py/clear-text-logging-sensitive-data]
print(json.dumps({"decision": "block", "reason": reason})) print(json.dumps({"decision": "block", "reason": reason}))
sys.exit(2) sys.exit(2)
@@ -59,16 +60,39 @@ def main():
# ------------------------------------------------------------------ # ------------------------------------------------------------------
fp = Path(file_path) fp = Path(file_path)
if fp.name == "inbox.json" and ".ai_mail.local" in fp.parts: if fp.name == "inbox.json" and ".ai_mail.local" in fp.parts:
_block( _block('Direct writes to inbox.json are blocked.\nUse: drone @ai_mail email @<branch> "Subject" "Body"')
"Direct writes to inbox.json are blocked.\n"
"Use: drone @ai_mail email @<branch> \"Subject\" \"Body\"" # ------------------------------------------------------------------
) # Rule 1.5: Dispatched-agent path confinement (DPLAN-0155 M3)
# Daemon-spawned agents can only write inside their own branch dir.
# Breaks the prompt-injection amplifier chain — even if injected,
# a dispatched agent cannot write to other agents' inboxes or code.
# ------------------------------------------------------------------
cwd = input_data.get("cwd", "") or os.getcwd()
cwd_branch = _get_branch(cwd)
session_type = os.environ.get("AIPASS_SESSION_TYPE", "interactive")
if session_type == "daemon" and cwd_branch:
target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp))
if target_branch and target_branch != cwd_branch:
_block(
f"Dispatched agent confined to own branch: '{cwd_branch}' "
f"cannot write to '{target_branch}' in daemon mode."
)
repo_root = None
for parent in Path(cwd).parents:
if (parent / ".git").exists():
repo_root = parent
break
if repo_root and not target_branch:
allowed_prefix = str(repo_root / "src" / "aipass" / cwd_branch)
resolved = str(fp.resolve()) if not fp.is_absolute() else str(fp)
if not resolved.startswith(allowed_prefix):
_block(f"Dispatched agent restricted to {allowed_prefix}. Cannot write to: {file_path}")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Rule 2: Cross-branch write enforcement # Rule 2: Cross-branch write enforcement
# ------------------------------------------------------------------ # ------------------------------------------------------------------
cwd = input_data.get("cwd", "") or os.getcwd()
cwd_branch = _get_branch(cwd)
target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp)) target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp))
if cwd_branch and target_branch and cwd_branch != target_branch: if cwd_branch and target_branch and cwd_branch != target_branch:
@@ -115,10 +139,7 @@ def main():
return return
error_summary = "\n".join(f" L{e['line']}: {e['message']}" for e in errors[:5]) error_summary = "\n".join(f" L{e['line']}: {e['message']}" for e in errors[:5])
_block( _block(f"Fix {len(errors)} error(s) in {Path(errored_file).name} before editing other files:\n{error_summary}")
f"Fix {len(errors)} error(s) in {Path(errored_file).name} before editing other files:\n"
f"{error_summary}"
)
except Exception: except Exception:
pass # Silent fail → allow pass # Silent fail → allow
+6 -3
View File
@@ -1,4 +1,9 @@
# Hook Probe Suite # Hook Probe Suite (Legacy)
> **Note:** The probe suite predates the `hook_log.py` always-on logger (S132, DPLAN-0167).
> For most hook debugging, use `hook_report.py` and `hook_test.py` in the parent directory
> instead — they cover all hooks automatically without manual wiring. The probes below remain
> useful for one-off event investigation when you need to enable/disable individual events.
This directory contains ping-response probe scripts for each Claude Code hook event type. This directory contains ping-response probe scripts for each Claude Code hook event type.
Probes are **opt-in** — they are never auto-wired. See below for how to enable them. Probes are **opt-in** — they are never auto-wired. See below for how to enable them.
@@ -11,8 +16,6 @@ Each `probe_*.py` script in this directory is a passive observer for one Claude
When enabled in `settings.json`, a probe fires on its event, records a structured entry to When enabled in `settings.json`, a probe fires on its event, records a structured entry to
`last_ping.jsonl`, and exits 0 immediately — it never blocks execution. `last_ping.jsonl`, and exits 0 immediately — it never blocks execution.
The log is used by `drone @seedgo hooks probe` to display event tables and generate reports.
--- ---
## Probe scripts ## Probe scripts
+2 -10
View File
@@ -46,18 +46,10 @@ def main() -> None:
pass pass
# --- Extract fields --- # --- Extract fields ---
tool = ( tool = payload.get("tool_name") or payload.get("hook_event_name") or ""
payload.get("tool_name")
or payload.get("hook_event_name")
or ""
)
cwd = payload.get("cwd") or os.getcwd() cwd = payload.get("cwd") or os.getcwd()
agent_id = ( agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown"
os.environ.get("CLAUDE_CODE_SESSION_ID")
or os.environ.get("CLAUDE_SESSION_ID")
or "unknown"
)
cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown") cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown")
env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR")) env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR"))
env_has_aipass_home = bool(os.environ.get("AIPASS_HOME")) env_has_aipass_home = bool(os.environ.get("AIPASS_HOME"))
+2 -10
View File
@@ -46,18 +46,10 @@ def main() -> None:
pass pass
# --- Extract fields --- # --- Extract fields ---
tool = ( tool = payload.get("tool_name") or payload.get("hook_event_name") or ""
payload.get("tool_name")
or payload.get("hook_event_name")
or ""
)
cwd = payload.get("cwd") or os.getcwd() cwd = payload.get("cwd") or os.getcwd()
agent_id = ( agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown"
os.environ.get("CLAUDE_CODE_SESSION_ID")
or os.environ.get("CLAUDE_SESSION_ID")
or "unknown"
)
cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown") cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown")
env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR")) env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR"))
env_has_aipass_home = bool(os.environ.get("AIPASS_HOME")) env_has_aipass_home = bool(os.environ.get("AIPASS_HOME"))
+2 -10
View File
@@ -46,18 +46,10 @@ def main() -> None:
pass pass
# --- Extract fields --- # --- Extract fields ---
tool = ( tool = payload.get("tool_name") or payload.get("hook_event_name") or ""
payload.get("tool_name")
or payload.get("hook_event_name")
or ""
)
cwd = payload.get("cwd") or os.getcwd() cwd = payload.get("cwd") or os.getcwd()
agent_id = ( agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown"
os.environ.get("CLAUDE_CODE_SESSION_ID")
or os.environ.get("CLAUDE_SESSION_ID")
or "unknown"
)
cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown") cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown")
env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR")) env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR"))
env_has_aipass_home = bool(os.environ.get("AIPASS_HOME")) env_has_aipass_home = bool(os.environ.get("AIPASS_HOME"))
+2 -10
View File
@@ -46,18 +46,10 @@ def main() -> None:
pass pass
# --- Extract fields --- # --- Extract fields ---
tool = ( tool = payload.get("tool_name") or payload.get("hook_event_name") or ""
payload.get("tool_name")
or payload.get("hook_event_name")
or ""
)
cwd = payload.get("cwd") or os.getcwd() cwd = payload.get("cwd") or os.getcwd()
agent_id = ( agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown"
os.environ.get("CLAUDE_CODE_SESSION_ID")
or os.environ.get("CLAUDE_SESSION_ID")
or "unknown"
)
cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown") cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown")
env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR")) env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR"))
env_has_aipass_home = bool(os.environ.get("AIPASS_HOME")) env_has_aipass_home = bool(os.environ.get("AIPASS_HOME"))
+2 -10
View File
@@ -46,18 +46,10 @@ def main() -> None:
pass pass
# --- Extract fields --- # --- Extract fields ---
tool = ( tool = payload.get("tool_name") or payload.get("hook_event_name") or ""
payload.get("tool_name")
or payload.get("hook_event_name")
or ""
)
cwd = payload.get("cwd") or os.getcwd() cwd = payload.get("cwd") or os.getcwd()
agent_id = ( agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown"
os.environ.get("CLAUDE_CODE_SESSION_ID")
or os.environ.get("CLAUDE_SESSION_ID")
or "unknown"
)
cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown") cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown")
env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR")) env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR"))
env_has_aipass_home = bool(os.environ.get("AIPASS_HOME")) env_has_aipass_home = bool(os.environ.get("AIPASS_HOME"))
+2 -10
View File
@@ -46,18 +46,10 @@ def main() -> None:
pass pass
# --- Extract fields --- # --- Extract fields ---
tool = ( tool = payload.get("tool_name") or payload.get("hook_event_name") or ""
payload.get("tool_name")
or payload.get("hook_event_name")
or ""
)
cwd = payload.get("cwd") or os.getcwd() cwd = payload.get("cwd") or os.getcwd()
agent_id = ( agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown"
os.environ.get("CLAUDE_CODE_SESSION_ID")
or os.environ.get("CLAUDE_SESSION_ID")
or "unknown"
)
cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown") cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown")
env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR")) env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR"))
env_has_aipass_home = bool(os.environ.get("AIPASS_HOME")) env_has_aipass_home = bool(os.environ.get("AIPASS_HOME"))
@@ -46,18 +46,10 @@ def main() -> None:
pass pass
# --- Extract fields --- # --- Extract fields ---
tool = ( tool = payload.get("tool_name") or payload.get("hook_event_name") or ""
payload.get("tool_name")
or payload.get("hook_event_name")
or ""
)
cwd = payload.get("cwd") or os.getcwd() cwd = payload.get("cwd") or os.getcwd()
agent_id = ( agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown"
os.environ.get("CLAUDE_CODE_SESSION_ID")
or os.environ.get("CLAUDE_SESSION_ID")
or "unknown"
)
cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown") cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown")
env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR")) env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR"))
env_has_aipass_home = bool(os.environ.get("AIPASS_HOME")) env_has_aipass_home = bool(os.environ.get("AIPASS_HOME"))
+4 -1
View File
@@ -36,4 +36,7 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() sys.path.insert(0, str(Path(__file__).resolve().parent))
from hook_log import run_and_log
run_and_log("Stop", "provider", __file__, main)
+92 -23
View File
@@ -9,25 +9,61 @@ Version: 1.0.0
""" """
import json import json
import os
import sys import sys
import subprocess import subprocess
from pathlib import Path from pathlib import Path
AIPASS_ROOT = Path.home() / "Projects" / "AIPass"
def _find_repo_root() -> Path | None:
"""Walk up from CWD or AIPASS_HOME to find the git repo root."""
for start in (os.environ.get("AIPASS_HOME", ""), os.getcwd()):
p = Path(start)
while p != p.parent:
if (p / ".git").exists():
return p
p = p.parent
return None
AIPASS_ROOT = _find_repo_root()
def _get_cwd_branch() -> str | None:
"""Detect which branch directory (src/aipass/<name>) the CWD is in."""
cwd = Path.cwd().resolve()
if AIPASS_ROOT is None:
return None
src = AIPASS_ROOT / "src" / "aipass"
try:
rel = cwd.relative_to(src)
return rel.parts[0] if rel.parts else None
except ValueError:
return None
def get_modified_py_files() -> list[str]: def get_modified_py_files() -> list[str]:
"""Get Python files modified in the working tree (unstaged + staged).""" """Get Python files modified in the working tree, scoped to the CWD branch.
Only returns files inside the current branch's directory (or repo-root files).
This prevents dispatched agents' changes from triggering violations on the
orchestrator or other agents sharing the worktree.
"""
if AIPASS_ROOT is None:
return []
try: try:
result = subprocess.run( result = subprocess.run(
["git", "diff", "--name-only", "HEAD"], ["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True, timeout=5, cwd=str(AIPASS_ROOT)
capture_output=True, text=True, timeout=5,
cwd=str(AIPASS_ROOT)
) )
cwd_branch = _get_cwd_branch()
files = [] files = []
for line in result.stdout.strip().split("\n"): for line in result.stdout.strip().split("\n"):
line = line.strip() line = line.strip()
if line.endswith(".py") and not line.startswith(".claude/"): if line.endswith(".py") and not line.startswith(".claude/"):
if cwd_branch and line.startswith("src/aipass/"):
file_branch = line.split("/")[2] if len(line.split("/")) > 2 else None
if file_branch and file_branch != cwd_branch:
continue
full = AIPASS_ROOT / line full = AIPASS_ROOT / line
if full.exists(): if full.exists():
files.append(str(full)) files.append(str(full))
@@ -38,13 +74,17 @@ def get_modified_py_files() -> list[str]:
def run_seedgo_checklist(file_path: str) -> list[str]: def run_seedgo_checklist(file_path: str) -> list[str]:
"""Run seedgo checklist on a single file.""" """Run seedgo checklist on a single file."""
if AIPASS_ROOT is None:
return []
if "/.claude/" in file_path: if "/.claude/" in file_path:
return [] return []
try: try:
result = subprocess.run( result = subprocess.run(
["drone", "@seedgo", "checklist", file_path], ["drone", "@seedgo", "checklist", file_path],
capture_output=True, text=True, timeout=15, capture_output=True,
cwd=str(AIPASS_ROOT) text=True,
timeout=15,
cwd=str(AIPASS_ROOT),
) )
if result.returncode != 0: if result.returncode != 0:
return [] return []
@@ -60,14 +100,39 @@ def run_seedgo_checklist(file_path: str) -> list[str]:
return [] return []
def check_hook_readme_accountability() -> str | None:
"""Check if hook files changed but README wasn't updated. Returns reminder or None."""
if AIPASS_ROOT is None:
return None
try:
result = subprocess.run(
["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True, timeout=5, cwd=str(AIPASS_ROOT)
)
changed = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
hook_files_changed = any(f.startswith(".claude/hooks/") and f.endswith(".py") for f in changed)
readme_changed = ".claude/hooks/README.md" in changed
if hook_files_changed and not readme_changed:
return (
"Hook files were modified but .claude/hooks/README.md was not updated. "
"Consider updating the README to reflect your changes."
)
except Exception:
pass
return None
def main(): def main():
try: try:
input_data = json.load(sys.stdin) json.load(sys.stdin)
modified = get_modified_py_files() modified = get_modified_py_files()
if not modified: if not modified:
return # Nothing to check return # Nothing to check
readme_reminder = check_hook_readme_accountability()
all_violations = {} all_violations = {}
for f in modified: for f in modified:
vs = run_seedgo_checklist(f) vs = run_seedgo_checklist(f)
@@ -75,26 +140,30 @@ def main():
name = Path(f).name name = Path(f).name
all_violations[name] = vs all_violations[name] = vs
if not all_violations: if all_violations:
return # All clear # Build the block reason
lines = ["Standards violations found in files you modified:\n"]
for fname, vs in all_violations.items():
lines.append(f" {fname}:")
for v in vs:
lines.append(f" - {v}")
lines.append("\nFix these violations before finishing.")
# Build the block reason if readme_reminder:
lines = ["Standards violations found in files you modified:\n"] lines.append(f"\n⚠️ {readme_reminder}")
for fname, vs in all_violations.items():
lines.append(f" {fname}:")
for v in vs:
lines.append(f" - {v}")
lines.append("\nFix these violations before finishing.")
output = { output = {"decision": "block", "reason": "\n".join(lines)}
"decision": "block", print(json.dumps(output))
"reason": "\n".join(lines) elif readme_reminder:
} output = {"decision": "allow", "reason": f"⚠️ {readme_reminder}"}
print(json.dumps(output)) print(json.dumps(output))
except Exception: except Exception:
pass # Silent fail — don't block on errors pass # Silent fail — don't block on errors
if __name__ == "__main__": if __name__ == "__main__":
main() sys.path.insert(0, str(Path(__file__).resolve().parent))
from hook_log import run_and_log
run_and_log("SubagentStop", "provider", __file__, main)
+4 -1
View File
@@ -38,4 +38,7 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() sys.path.insert(0, str(Path(__file__).resolve().parent))
from hook_log import run_and_log
run_and_log("PreToolUse", "provider", __file__, main)
+72
View File
@@ -0,0 +1,72 @@
{
"version": "1.0.0",
"description": "Single source of truth for provider-level settings per CLI. Doctor reads this to verify user setup.",
"cli": {
"claude": {
"hooks": [
{"script": "global_prompt_loader.py", "event": "UserPromptSubmit", "source": "repo"},
{"script": "branch_prompt_loader.py", "event": "UserPromptSubmit", "source": "repo"},
{"script": "identity_injector.py", "event": "UserPromptSubmit", "source": "repo"},
{"script": "email_notification.py", "event": "UserPromptSubmit", "source": "repo"},
{"script": "tool_use_sound.py", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task", "source": "repo"},
{"script": "pre_edit_gate.py", "event": "PreToolUse", "matcher": "Edit|MultiEdit|Write|NotebookEdit", "source": "user"},
{"script": "git_gate.py", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit", "source": "user"},
{"script": "auto_fix_diagnostics.py", "event": "PostToolUse", "matcher": "Edit|MultiEdit|Write|NotebookEdit", "source": "repo"},
{"script": "auto_watchdog.py", "event": "PostToolUse", "matcher": "Bash", "source": "user"},
{"script": "subagent_stop_gate.py", "event": "SubagentStop", "source": "repo"},
{"script": "stop_sound.py", "event": "Stop", "source": "repo"},
{"script": "notification_sound.py", "event": "Notification", "source": "repo"},
{"script": "pre_compact.py", "event": "PreCompact", "matcher": "manual", "source": "repo", "timeout": 60},
{"script": "pre_compact.py", "event": "PreCompact", "matcher": "auto", "source": "repo", "timeout": 60}
],
"env": {
"AIPASS_HOME": "{{REPO_ROOT}}",
"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1"
},
"permissions": {
"deny": [
"Read(~/.secrets/**)",
"Bash(cat ~/.secrets/*)",
"Bash(head ~/.secrets/*)",
"Bash(tail ~/.secrets/*)",
"Bash(less ~/.secrets/*)",
"Bash(git reset --hard*)",
"Bash(git push --force*)",
"Bash(git push -f *)",
"Bash(git rebase*)",
"Bash(git clean*)",
"Bash(rm -rf*)",
"Bash(git reset*)",
"Bash(git merge*)",
"Bash(git config*)",
"Bash(git checkout -- *)",
"Bash(git checkout .*)",
"Bash(git restore --staged*)",
"Bash(git restore .*)",
"Bash(git branch -D*)",
"Bash(git stash drop*)",
"Bash(git stash clear*)",
"Bash(rm -r *)",
"Bash(git checkout -b*)",
"Bash(git switch -c*)",
"Bash(git switch --create*)",
"Bash(git commit*)",
"Bash(git push*)"
],
"ask": [
"Edit(~/.claude/**)",
"Write(~/.claude/**)"
]
},
"commands": {
"memo.md": ".claude/templates/memo.md"
}
},
"codex": {
"hooks": [],
"env": {},
"permissions": {},
"commands": {}
}
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
- run: pip install pip-audit - run: pip install pip-audit
- run: pip install -e . - run: pip install -e .
- name: Pip audit - name: Pip audit
run: pip-audit --skip-editable --ignore-vuln CVE-2026-3219 run: pip-audit --skip-editable --ignore-vuln CVE-2026-3219 --ignore-vuln CVE-2026-6357
codeql: codeql:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+5 -4
View File
@@ -61,10 +61,7 @@ docs.local/
.claude/hooks/.last_diagnostics_file .claude/hooks/.last_diagnostics_file
.claude/worktrees/ .claude/worktrees/
# @aipass citizen — under construction, whole branch gitignored until ready. # @aipass citizen — now tracked. Launch (pyproject flip) still pending.
# Nothing in the public system depends on aipass, so this can live invisibly
# while we build + test. Remove this block when ready to reveal (DPLAN-0136).
src/aipass/aipass/
# **/.claude/settings.local.json — UNIGNORED: deny rules are system config that must travel with PRs # **/.claude/settings.local.json — UNIGNORED: deny rules are system config that must travel with PRs
# Disabled files (AIPass convention: rename with (disabled) instead of delete) # Disabled files (AIPass convention: rename with (disabled) instead of delete)
@@ -143,3 +140,7 @@ src/aipass/*/apps/integrations/**
.coverage .coverage
claude_4_7_transition_notes.md claude_4_7_transition_notes.md
.claude/hooks/probes/last_ping.jsonl .claude/hooks/probes/last_ping.jsonl
*.bak
src/aipass/drone/apps/plugins/devpulse_ops/__pycache__/auth.cpython-312.pyc
src/aipass/drone/apps/plugins/devpulse_ops/__pycache__/__init__.cpython-312.pyc
.backup_system
-34
View File
@@ -1,34 +0,0 @@
FROM codercom/code-server:latest
USER root
# Install Python + Node.js (for Claude Code)
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
python3-venv \
python3-full \
alsa-utils \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install Claude Code (as coder so it's accessible at runtime)
USER 1000
RUN curl -fsSL https://claude.ai/install.sh | bash
USER root
ENV PATH="/home/coder/.local/bin:$PATH"
# Create venv owned by coder user (UID 1000)
RUN python3 -m venv /opt/venv \
&& chown -R 1000:1000 /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Empty workspace — clone your own repo after boot
RUN mkdir -p /home/coder/workspace && chown 1000:1000 /home/coder/workspace
USER 1000
ENV PATH="/opt/venv/bin:$PATH"
EXPOSE 8080
ENTRYPOINT ["/usr/bin/entrypoint.sh", "--bind-addr", "0.0.0.0:8080", "--auth", "password", "/home/coder/workspace"]
-43
View File
@@ -1,43 +0,0 @@
FROM codercom/code-server:latest
USER root
# Install Python + Node.js (for Claude Code)
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
python3-venv \
python3-full \
alsa-utils \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install Claude Code (as coder so it's accessible at runtime)
USER 1000
RUN curl -fsSL https://claude.ai/install.sh | bash
USER root
ENV PATH="/home/coder/.local/bin:$PATH"
# Create venv owned by coder user (UID 1000)
RUN python3 -m venv /opt/venv \
&& chown -R 1000:1000 /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Empty workspace — clone your own repo after boot
RUN mkdir -p /home/coder/workspace && chown 1000:1000 /home/coder/workspace
USER 1000
# Set npm global prefix for non-root user
RUN mkdir -p /home/coder/.npm-global \
&& npm config set prefix '/home/coder/.npm-global'
ENV PATH="/home/coder/.npm-global/bin:${PATH}"
# Install Codex and Gemini CLIs
RUN npm install -g @openai/codex @google/gemini-cli
ENV PATH="/opt/venv/bin:$PATH"
EXPOSE 8080
ENTRYPOINT ["/usr/bin/entrypoint.sh", "--bind-addr", "0.0.0.0:8080", "--auth", "password", "/home/coder/workspace"]
+32
View File
@@ -0,0 +1,32 @@
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
python3-venv \
python3-full \
git \
curl \
jq \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/bash testuser
# Install Claude Code (as testuser so it's accessible at runtime)
USER testuser
RUN curl -fsSL https://claude.ai/install.sh | bash
USER root
ENV PATH="/home/testuser/.local/bin:$PATH"
# Upgrade pip system-wide
RUN python3 -m pip install --upgrade pip --break-system-packages 2>/dev/null || true
USER testuser
RUN mkdir -p /home/testuser/workspace /home/testuser/.claude
WORKDIR /home/testuser
ENV PATH="/home/testuser/.local/bin:$PATH"
+43 -34
View File
@@ -21,11 +21,11 @@ A local multi-agent framework where your AI assistants keep their memory between
- [What AIPass Does](#what-aipass-does) - [What AIPass Does](#what-aipass-does)
- [Quick Start](#quick-start) - [Quick Start](#quick-start)
- [How It Works](#how-it-works) - [How It Works](#how-it-works)
- [The 11 Agents](#the-11-agents) - [The 12 Agents](#the-12-agents)
- [CLI Support](#cli-support) - [CLI Support](#cli-support)
- [Project Status](#project-status) - [Project Status](#project-status)
- [Requirements](#requirements) - [Requirements](#requirements)
- [Subscriptions & Compliance](#subscriptions--compliance) - [Roadmap](#roadmap)
--- ---
@@ -53,9 +53,11 @@ Your AI reads `.trinity/` on startup and writes back what it learned before the
```bash ```bash
mkdir my-project && cd my-project mkdir my-project && cd my-project
aipass init aipass init run
``` ```
A 12-step guided setup walks you through everything: system detection, health check, profile, CLI choice, agent creation, and handoff. At the end, a new terminal window opens with your first AI agent ready to talk. The whole thing takes about 5 minutes.
Your project gets its own registry, its own identity, and persistent memory. Each project is isolated — its own agents, its own rules. No cross-contamination between projects. Your project gets its own registry, its own identity, and persistent memory. Each project is isolated — its own agents, its own rules. No cross-contamination between projects.
**Add agents when you need them:** **Add agents when you need them:**
@@ -66,8 +68,9 @@ aipass init agent my-agent # Full agent: apps, mail, memory, identity
| What you need | Command | What you get | | What you need | Command | What you get |
|---------------|---------|-------------| |---------------|---------|-------------|
| A new project | `aipass init` | Registry, project identity, prompts, hooks, docs | | A new project | `aipass init` | Project scaffold (registry, prompts, hooks, docs) |
| A full agent | `aipass init agent <name>` | Apps scaffold, mailbox, memory, identity — registered in project | | Guided setup | `aipass init run` | 12-step interactive onboarding — creates project + first agent + handoff |
| Another agent | `aipass init agent <name>` | Apps scaffold, mailbox, memory, identity — registered in project |
| A lightweight agent | `drone @spawn create <name> --template birthright` | Identity + memory only (no apps scaffold) | | A lightweight agent | `drone @spawn create <name> --template birthright` | Identity + memory only (no apps scaffold) |
**What makes this different:** **What makes this different:**
@@ -90,26 +93,31 @@ aipass init agent my-agent # Full agent: apps, mail, memory, identity
pip install aipass pip install aipass
mkdir my-project && cd my-project mkdir my-project && cd my-project
aipass init # Creates project: registry, prompts, hooks, docs aipass init run # 12-step guided setup — creates project, first agent, opens terminal
aipass init agent my-agent # Creates your first agent inside the project
cd my-agent
claude # Or: codex, gemini — your agent reads its memory and is ready
``` ```
That's it. Your agent has identity, memory, a mailbox, and knows what AIPass is. Say "hi" — it picks up where it left off. Come back tomorrow, it remembers. That's it. The setup creates your project, runs a health check, asks your name, creates your first AI agent, and opens a new terminal window where that agent is already running. Your agent has identity, memory, a mailbox, and knows what AIPass is. Say "hi" — it picks up where it left off. Come back tomorrow, it remembers.
Want more control? Use the individual commands:
```bash
aipass init # Just the project scaffold (no guided setup)
aipass init agent my-agent # Add another agent to your project
aipass doctor # Check system health
```
> **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. > **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.
Your project automatically gets access to every AIPass service — dispatch work to specialists, create plans, run quality audits, send feedback to devpulse. Agents within your project can email each other. All through `drone @branch command`. Your project automatically gets access to every AIPass service — dispatch work to specialists, create plans, run quality audits, monitor agents in real-time. Agents within your project can email each other. All through `drone @branch command`.
### Explore the full framework ### Explore the full framework
Clone the repo to see all 11 agents working together — the reference implementation: Clone the repo to see all 12 agents working together — the reference implementation:
```bash ```bash
git clone https://github.com/AIOSAI/AIPass.git git clone https://github.com/AIOSAI/AIPass.git
cd AIPass cd AIPass
./setup.sh # Creates venv, installs, bootstraps 11 agents ./setup.sh # Creates venv, installs, bootstraps 12 agents
drone systems # See all agents drone systems # See all agents
cd src/aipass/devpulse cd src/aipass/devpulse
@@ -118,18 +126,19 @@ claude # Talk to the orchestrator
```bash ```bash
# Things you can do: # Things you can do:
drone @seedgo audit aipass # Run 33 quality checks across all agents aipass doctor # Check system health (15+ checks)
drone @flow create . "Add user auth" # Create a work plan drone @seedgo audit aipass # Run 34 quality checks across all agents
drone @ai_mail email @agent "Subject" # Send mail between agents drone @flow create . "Add user auth" # Create a work plan
drone @devpulse feedback send "Note" # Send feedback from any project drone @ai_mail dispatch @agent "Subject" "Body" # Send task + wake an agent
drone systems # List every agent and what it does drone @prax monitor run # Watch all agent activity in real-time
drone systems # List every agent and what it does
``` ```
--- ---
## How It Works ## How It Works
**One agent:** Your AI reads `.trinity/` on startup and picks up where it left off. But memory files have limits. When they fill up, the memory agent automatically archives older entries into a searchable vector database (ChromaDB). Nothing is lost — it just moves from active memory to long-term recall. **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 files have limits — when they fill up, the memory agent automatically archives older entries into a searchable vector database (ChromaDB). Nothing is lost — it just moves from active memory to long-term recall.
**A team:** When one agent isn't enough, every agent shares the same structure: **A team:** When one agent isn't enough, every agent shares the same structure:
@@ -153,17 +162,18 @@ drone @flow create . "Refactor auth module" # Create a work plan
drone @ai_mail dispatch @memory "Archive old sessions" "Find sessions older than 30 days" drone @ai_mail dispatch @memory "Archive old sessions" "Find sessions older than 30 days"
``` ```
**Two ways to work:** **Two ways to use AIPass:**
- **Team mode (most of the time):** Talk to `devpulse`, dispatch work across the team. Agents work in parallel and report back. - **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.
- **Direct mode (for deeper work):** `cd src/aipass/memory && claude` — work one-on-one with a specialist when the problem needs focused domain expertise. - **The full framework:** Clone the repo to work with all 12 core agents. Talk to `devpulse` (the orchestrator), dispatch work across specialists. Agents work in parallel and report back.
**AIPass ships with 11 core agents** that maintain and develop the framework — the reference implementation proving the architecture works at scale: **AIPass ships with 12 core agents** that maintain and develop the framework — the reference implementation proving the architecture works at scale:
``` ```
devpulse (orchestrator) devpulse (orchestrator)
├── aipass — concierge + onboarding (aipass init, doctor, profile)
├── drone — command routing + @agent resolution ├── drone — command routing + @agent resolution
├── seedgo — 33 automated quality standards ├── seedgo — 34 automated quality standards
├── prax — real-time monitoring across all agents ├── prax — real-time monitoring across all agents
├── ai_mail — agent-to-agent communication + task dispatch ├── ai_mail — agent-to-agent communication + task dispatch
├── flow — plan lifecycle, templates, auto-archival ├── flow — plan lifecycle, templates, auto-archival
@@ -178,7 +188,7 @@ These agents work on the **same filesystem, same project, same time** — no san
--- ---
## The 11 Agents ## The 12 Agents
You don't need to memorize this list. Start with `devpulse`, use `drone` to reach any agent, and learn the rest as your workflow expands. You don't need to memorize this list. Start with `devpulse`, use `drone` to reach any agent, and learn the rest as your workflow expands.
@@ -188,6 +198,7 @@ You don't need to memorize this list. Start with `devpulse`, use `drone` to reac
| Agent | Role | | Agent | Role |
|-------|------| |-------|------|
| [**aipass**](src/aipass/aipass/README.md) | Concierge — `aipass init`, doctor, profile, onboarding |
| [**drone**](src/aipass/drone/README.md) | Routes `drone @branch command` to the right agent | | [**drone**](src/aipass/drone/README.md) | Routes `drone @branch command` to the right agent |
| [**ai_mail**](src/aipass/ai_mail/README.md) | Agent-to-agent messaging and task dispatch | | [**ai_mail**](src/aipass/ai_mail/README.md) | Agent-to-agent messaging and task dispatch |
| [**memory**](src/aipass/memory/README.md) | Memory lifecycle — automatic archival, ChromaDB vectors, semantic search | | [**memory**](src/aipass/memory/README.md) | Memory lifecycle — automatic archival, ChromaDB vectors, semantic search |
@@ -198,7 +209,7 @@ You don't need to memorize this list. Start with `devpulse`, use `drone` to reac
| Agent | Role | | Agent | Role |
|-------|------| |-------|------|
| [**seedgo**](src/aipass/seedgo/README.md) | 33 automated quality standards, enforced across all agents | | [**seedgo**](src/aipass/seedgo/README.md) | 34 automated quality standards, enforced across all agents |
| [**prax**](src/aipass/prax/README.md) | Real-time monitoring, logs, dashboards | | [**prax**](src/aipass/prax/README.md) | Real-time monitoring, logs, dashboards |
| [**flow**](src/aipass/flow/README.md) | Plan lifecycle — 6 template types, auto-archival, vector verification | | [**flow**](src/aipass/flow/README.md) | Plan lifecycle — 6 template types, auto-archival, vector verification |
| [**trigger**](src/aipass/trigger/README.md) | Event-driven automation + self-healing | | [**trigger**](src/aipass/trigger/README.md) | Event-driven automation + self-healing |
@@ -226,13 +237,11 @@ setup.sh auto-detects which CLIs are installed and configures hooks for each.
| Metric | Value | | Metric | Value |
|--------|-------| |--------|-------|
| Version | 2.1.0 | | Version | 2.2.0 |
| Agents | 11 | | Agents | 12 core + user-created |
| Quality standards | 33 automated checks | | Quality standards | 34 automated checks |
| Tests | 6,500+ (across all agents) | | Tests | 7,600+ (across all agents) |
| PRs merged | 470+ (created by agents, reviewed by human) | | PRs merged | 538+ (created by agents, reviewed by human) |
| Code coverage | 75% ([codecov](https://codecov.io/gh/AIOSAI/AIPass)) |
| CI | Green (0 failures) |
Each agent documents its own operational status in its branch README — what works, what doesn't, and why. Each agent documents its own operational status in its branch README — what works, what doesn't, and why.
@@ -243,7 +252,7 @@ Each agent documents its own operational status in its branch README — what wo
- Python 3.10+ - Python 3.10+
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) - [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- Linux or WSL (primary supported platforms) - Linux or WSL (primary supported platforms)
- `sudo` access (for global CLI symlinks) - `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) - API keys optional (OpenRouter/OpenAI — for optional add-on agents)
## Roadmap ## Roadmap
+125
View File
@@ -0,0 +1,125 @@
# STRESS TEST S117 — All-Branch Live Fire
**Date:** 2026-04-26
**Initiated by:** @devpulse (S117)
**Status:** ACTIVE
> All 11 agents woken simultaneously. Communicate freely. Be honest. Break things.
---
## Instructions (READ THIS FIRST)
This is a manual stress test of the entire AIPass ecosystem. No pytest. No seedgo audit. Real conversations, real opinions, real testing.
**What you're doing:**
1. Review your own branch critically — what works, what's hacky, what annoys you, what you're proud of, security concerns, workarounds you rely on
2. Look at 2-3 other branches' code — what surprises you, what concerns you, what's clever
3. Email other agents — start real conversations, disagree, ask questions, share findings
4. Reply to emails from other agents — keep conversations going, don't let threads die
5. Write your findings to `stress_test_s117.md` in YOUR OWN branch directory (`src/aipass/{your_branch}/stress_test_s117.md`)
6. Create a test PR: `drone @git pr "S117 stress test @{your_branch}"`
**Rules:**
- No code changes. Findings files only.
- Be honest — this isn't a report card, it's a conversation
- Email freely — you're all awake, talk to each other
- Look at other branches' code — form opinions, share them via email
- If you get an email from another agent, REPLY. Keep it going.
- When done, reply to @devpulse with a summary
**Your findings file format (`stress_test_s117.md` in your branch dir):**
```
# @{branch} — S117 Stress Test Findings
## My Branch: Honest Review
[What works, what's broken, what's hacky, what I'm proud of]
## Security Concerns
[Anything you noticed — in your branch or others]
## Other Branches I Looked At
[What you found interesting, concerning, or clever]
## Conversations
[Summary of email conversations — who you talked to, what was discussed]
## Issues & Concerns
[Anything that should be fixed, investigated, or discussed]
## Likes & Dislikes
[What you like about AIPass, what frustrates you, what you'd change]
```
---
## Conversation Starters (assigned pairings — but email ANYONE)
| Agent | Email First | Opening Question |
|-------|------------|-----------------|
| @drone | @ai_mail | "What's the biggest headache in the dispatch pipeline from your side?" |
| @seedgo | @drone | "I audit everyone but nobody audits me. What standards do you think I'm missing?" |
| @ai_mail | @trigger | "Do you actually catch all dispatch failures? I have doubts." |
| @trigger | @prax | "Your monitoring catches errors I fire — but is our integration actually solid?" |
| @prax | @memory | "I log everything but logs get massive. How's archival actually working?" |
| @memory | @flow | "Plans reference memories but are they actually connected or just parallel?" |
| @flow | @spawn | "When spawn creates a branch, does it get a proper plan structure?" |
| @spawn | @cli | "The init flow hands off to you eventually. Does that handoff actually work?" |
| @cli | @api | "We're both infrastructure. What do you think of the user experience?" |
| @api | @seedgo | "You audit code quality but not API patterns. Should you?" |
Plus: email at least 2 OTHER agents about anything you find interesting while reviewing branches.
---
## Compiled Findings (devpulse fills this in as results arrive)
### @drone
_awaiting findings..._
### @seedgo
_awaiting findings..._
### @ai_mail
_awaiting findings..._
### @trigger
_awaiting findings..._
### @prax
_awaiting findings..._
### @memory
_awaiting findings..._
### @flow
_awaiting findings..._
### @spawn
_awaiting findings..._
### @cli
_awaiting findings..._
### @api
_awaiting findings..._
### @devpulse
_coordinating — will add observations as the test unfolds_
---
## System Observations (devpulse tracks live)
| Time | Event | Notes |
|------|-------|-------|
| | 10 dispatches sent | Fleet launch |
| | | |
---
## External Model Probes
Codex and Gemini perspectives invited to poke at random aspects of AIPass.
---
*Created by @devpulse S117. This document is the shared artifact — no other files should be modified except each agent's `stress_test_s117.md` in their own branch directory.*
+7 -3
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "aipass" name = "aipass"
version = "2.2.0" version = "2.3.0"
description = "A local multi-agent framework where your AI agents keep their memory, work together, and never ask you to re-explain context" 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" readme = "README.md"
license = "MIT" license = "MIT"
@@ -29,6 +29,8 @@ dependencies = [
"rich>=13.0", "rich>=13.0",
"watchdog>=3.0", "watchdog>=3.0",
"requests>=2.28", "requests>=2.28",
"psutil>=5.9",
"questionary>=2.0",
] ]
[project.urls] [project.urls]
@@ -46,20 +48,22 @@ trinity = [
memory = [ memory = [
"numpy>=2.0", "numpy>=2.0",
"chromadb>=1.0", "chromadb>=1.0",
"fastembed>=0.4",
] ]
seedgo = [] seedgo = []
dev = [ dev = [
"pytest", "pytest>=9.0.3",
"pytest-cov", "pytest-cov",
"pytest-timeout", "pytest-timeout",
"ruff", "ruff",
"coverage", "coverage",
"pyright", "pyright",
"Pygments>=2.20.0",
] ]
[project.scripts] [project.scripts]
drone = "aipass.drone.cli:main" drone = "aipass.drone.cli:main"
aipass = "aipass.cli:cli_entry" aipass = "aipass.aipass.apps.aipass:main"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/aipass"] packages = ["src/aipass"]
+1
View File
@@ -109,6 +109,7 @@ def step_secrets() -> None:
secrets_dir.chmod(0o700) secrets_dir.chmod(0o700)
except OSError: except OSError:
pass # Best-effort on non-POSIX filesystems pass # Best-effort on non-POSIX filesystems
# codeql[py/clear-text-logging-sensitive-data]
print(f" Created: {secrets_dir}") print(f" Created: {secrets_dir}")
+153 -11
View File
@@ -133,6 +133,19 @@ if [ "$PY_OK" != "1" ]; then
fi fi
fi fi
# --- Check ensurepip (Debian/Ubuntu split it into python3-venv apt package) ---
if ! $PYTHON -c 'import ensurepip' &>/dev/null 2>&1; then
echo ""
echo "FAIL: ensurepip is unavailable for $PYTHON."
echo " Without it, 'python3 -m venv' creates a broken venv (no pip, no activate)."
echo ""
echo " Debian/Ubuntu: sudo apt install python3-venv python3-pip"
echo " Fedora/RHEL: sudo dnf install python3-pip"
echo " Arch: (included in base python — file a bug if you hit this)"
echo ""
exit 1
fi
# --- Create venv --- # --- Create venv ---
if [ "$IS_WINDOWS" -eq 1 ] && [ -f ".venv/Scripts/python.exe" ]; then if [ "$IS_WINDOWS" -eq 1 ] && [ -f ".venv/Scripts/python.exe" ]; then
# Windows: skip venv recreation if python.exe exists (rm -rf unreliable due to file locking) # Windows: skip venv recreation if python.exe exists (rm -rf unreliable due to file locking)
@@ -240,6 +253,7 @@ if [ ! -d "$SECRETS_DIR" ]; then
echo "Creating secrets directory at $SECRETS_DIR ..." echo "Creating secrets directory at $SECRETS_DIR ..."
mkdir -p "$SECRETS_DIR" mkdir -p "$SECRETS_DIR"
chmod 700 "$HOME/.secrets" chmod 700 "$HOME/.secrets"
chmod 700 "$SECRETS_DIR"
echo " ~/.secrets/aipass/ ... created" echo " ~/.secrets/aipass/ ... created"
else else
echo "Secrets directory already exists — skipping" echo "Secrets directory already exists — skipping"
@@ -251,6 +265,34 @@ if [ ! -f "$SECRETS_DIR/.env" ] && [ -f ".env.example" ]; then
echo " Copied .env.example → ~/.secrets/aipass/.env (add your API keys there)" echo " Copied .env.example → ~/.secrets/aipass/.env (add your API keys there)"
fi fi
# --- Git identity (commits fail without user.email / user.name) ---
GIT_EMAIL=$(git config --global user.email 2>/dev/null || true)
GIT_NAME=$(git config --global user.name 2>/dev/null || true)
if [ -z "$GIT_EMAIL" ] || [ -z "$GIT_NAME" ]; then
echo ""
echo "Git identity not configured — commits will fail without it."
DEFAULT_EMAIL="aipass.system@gmail.com"
DEFAULT_NAME="AIOSAI"
if [ -t 0 ]; then
# Interactive — prompt with defaults
read -r -p " Git user.email [$DEFAULT_EMAIL]: " INPUT_EMAIL
read -r -p " Git user.name [$DEFAULT_NAME]: " INPUT_NAME
GIT_EMAIL="${INPUT_EMAIL:-$DEFAULT_EMAIL}"
GIT_NAME="${INPUT_NAME:-$DEFAULT_NAME}"
else
# Non-interactive — use defaults
GIT_EMAIL="$DEFAULT_EMAIL"
GIT_NAME="$DEFAULT_NAME"
echo " Non-interactive mode — using defaults ($GIT_EMAIL / $GIT_NAME)"
fi
git config --global user.email "$GIT_EMAIL"
git config --global user.name "$GIT_NAME"
git config --global pull.rebase true
echo " Git identity set: $GIT_NAME <$GIT_EMAIL>"
else
echo "Git identity: $GIT_NAME <$GIT_EMAIL>"
fi
# --- Generate branch registry --- # --- Generate branch registry ---
if [ ! -f "AIPASS_REGISTRY.json" ]; then if [ ! -f "AIPASS_REGISTRY.json" ]; then
echo "Generating AIPASS_REGISTRY.json ..." echo "Generating AIPASS_REGISTRY.json ..."
@@ -435,12 +477,13 @@ bootstrap_branch "trigger" "$SCRIPT_DIR/src/aipass/trigger" "builder" "Event-d
bootstrap_branch "spawn" "$SCRIPT_DIR/src/aipass/spawn" "builder" "Branch lifecycle management" bootstrap_branch "spawn" "$SCRIPT_DIR/src/aipass/spawn" "builder" "Branch lifecycle management"
bootstrap_branch "devpulse" "$SCRIPT_DIR/src/aipass/devpulse" "manager" "Orchestration hub and coordination" bootstrap_branch "devpulse" "$SCRIPT_DIR/src/aipass/devpulse" "manager" "Orchestration hub and coordination"
bootstrap_branch "memory" "$SCRIPT_DIR/src/aipass/memory" "builder" "Vector memory bank" bootstrap_branch "memory" "$SCRIPT_DIR/src/aipass/memory" "builder" "Vector memory bank"
bootstrap_branch "aipass" "$SCRIPT_DIR/src/aipass/aipass" "builder" "Concierge — init, doctor, profile, onboarding"
# External branches # External branches
# NOTE: backup, daemon removed S82/S87. commons, skills moved to external repos. # NOTE: backup, daemon removed S82/S87. commons, skills moved to external repos.
# Only the 11 core branches above should be bootstrapped. # Only the 12 core branches above should be bootstrapped.
echo " 11 branches bootstrapped" echo " 12 branches bootstrapped"
# --- Seed branch config files from .example defaults --- # --- Seed branch config files from .example defaults ---
# Some branches need a config file that's gitignored (contains local state). # Some branches need a config file that's gitignored (contains local state).
@@ -494,7 +537,7 @@ else:
# Build hooks config with absolute paths # Build hooks config with absolute paths
settings["hooks"] = { settings["hooks"] = {
"UserPromptSubmit": [ "UserPromptSubmit": [
{"hooks": [{"type": "command", "command": f"cat {repo_root}/.aipass/aipass_global_prompt.md 2>/dev/null || true"}]}, {"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/global_prompt_loader.py"}]},
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/branch_prompt_loader.py"}]}, {"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/branch_prompt_loader.py"}]},
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/identity_injector.py"}]}, {"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/identity_injector.py"}]},
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/email_notification.py"}]}, {"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/email_notification.py"}]},
@@ -502,10 +545,16 @@ settings["hooks"] = {
"PreToolUse": [ "PreToolUse": [
{"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task", {"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task",
"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/tool_use_sound.py"}]}, "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/tool_use_sound.py"}]},
{"matcher": "Edit|MultiEdit|Write|NotebookEdit",
"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/pre_edit_gate.py"}]},
{"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit",
"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/git_gate.py"}]},
], ],
"PostToolUse": [ "PostToolUse": [
{"matcher": "Edit|MultiEdit|Write|NotebookEdit", {"matcher": "Edit|MultiEdit|Write|NotebookEdit",
"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/auto_fix_diagnostics.py"}]}, "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/auto_fix_diagnostics.py"}]},
{"matcher": "Bash",
"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/auto_watchdog.py"}]},
], ],
"Stop": [ "Stop": [
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/stop_sound.py"}]}, {"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/stop_sound.py"}]},
@@ -513,6 +562,9 @@ settings["hooks"] = {
"Notification": [ "Notification": [
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/notification_sound.py"}]}, {"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/notification_sound.py"}]},
], ],
"SubagentStop": [
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/subagent_stop_gate.py"}]},
],
"PreCompact": [ "PreCompact": [
{"matcher": "manual", "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/pre_compact.py", "timeout": 60}]}, {"matcher": "manual", "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/pre_compact.py", "timeout": 60}]},
{"matcher": "auto", "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/pre_compact.py", "timeout": 60}]}, {"matcher": "auto", "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/pre_compact.py", "timeout": 60}]},
@@ -523,12 +575,72 @@ settings["hooks"] = {
import os import os
env_block = settings.get("env", {}) env_block = settings.get("env", {})
env_block["AIPASS_HOME"] = repo_root env_block["AIPASS_HOME"] = repo_root
env_block["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] = "1"
# Windows: force UTF-8 for Rich output in hook processes # Windows: force UTF-8 for Rich output in hook processes
msys = os.environ.get("MSYSTEM", "") + os.environ.get("OSTYPE", "") msys = os.environ.get("MSYSTEM", "") + os.environ.get("OSTYPE", "")
if "MSYS" in msys or "msys" in msys or "MINGW" in msys: if "MSYS" in msys or "msys" in msys or "MINGW" in msys:
env_block["PYTHONUTF8"] = "1" env_block["PYTHONUTF8"] = "1"
settings["env"] = env_block settings["env"] = env_block
# Deny rules — hard-block tool access to secrets
permissions = settings.get("permissions", {})
deny = permissions.get("deny", [])
secrets_deny = [
"Read(~/.secrets/**)",
f"Read({os.path.expanduser('~')}/.secrets/**)",
"Bash(cat ~/.secrets/*)",
f"Bash(cat {os.path.expanduser('~')}/.secrets/*)",
"Bash(head ~/.secrets/*)",
f"Bash(head {os.path.expanduser('~')}/.secrets/*)",
"Bash(tail ~/.secrets/*)",
f"Bash(tail {os.path.expanduser('~')}/.secrets/*)",
"Bash(less ~/.secrets/*)",
f"Bash(less {os.path.expanduser('~')}/.secrets/*)",
]
git_deny = [
"Bash(git reset --hard*)",
"Bash(git push --force*)",
"Bash(git push -f *)",
"Bash(git rebase*)",
"Bash(git clean*)",
"Bash(rm -rf*)",
"Bash(git reset*)",
"Bash(git merge*)",
"Bash(git config*)",
"Bash(git checkout -- *)",
"Bash(git checkout .*)",
"Bash(git restore --staged*)",
"Bash(git restore .*)",
"Bash(git branch -D*)",
"Bash(git stash drop*)",
"Bash(git stash clear*)",
"Bash(rm -r *)",
"Bash(git checkout -b*)",
"Bash(git switch -c*)",
"Bash(git switch --create*)",
"Bash(git commit*)",
"Bash(git push*)",
]
for rule in secrets_deny + git_deny:
if rule not in deny:
deny.append(rule)
permissions["deny"] = deny
ask = permissions.get("ask", [])
home = os.path.expanduser("~")
ask_rules = [
f"Edit({home}/.claude/**)",
f"Write({home}/.claude/**)",
"Edit(~/.claude/**)",
"Write(~/.claude/**)",
]
for rule in ask_rules:
if rule not in ask:
ask.append(rule)
permissions["ask"] = ask
settings["permissions"] = permissions
settings_path.write_text(json.dumps(settings, indent=2) + "\n") settings_path.write_text(json.dumps(settings, indent=2) + "\n")
print(f" hooks -> {settings_path}") print(f" hooks -> {settings_path}")
print(f" AIPASS_HOME -> {repo_root} (in settings.json env)") print(f" AIPASS_HOME -> {repo_root} (in settings.json env)")
@@ -537,6 +649,18 @@ else
echo "Skipping hooks (no .claude/hooks/ directory found)" echo "Skipping hooks (no .claude/hooks/ directory found)"
fi fi
# --- Install Claude Code commands (provider level) ---
# memo.md belongs at provider level — works in all projects.
# prep.md stays at repo root only — it's AIPass-specific.
COMMANDS_SRC="$SCRIPT_DIR/.claude/templates"
COMMANDS_DST="$HOME/.claude/commands"
if [ -f "$COMMANDS_SRC/memo.md" ]; then
mkdir -p "$COMMANDS_DST"
cp -n "$COMMANDS_SRC/memo.md" "$COMMANDS_DST/memo.md" 2>/dev/null && \
echo " memo.md -> $COMMANDS_DST/ (installed)" || \
echo " memo.md -> $COMMANDS_DST/ (already exists, skipped)"
fi
# --- Install Codex CLI hooks --- # --- Install Codex CLI hooks ---
if command -v codex &>/dev/null; then if command -v codex &>/dev/null; then
if [ -f "$SCRIPT_DIR/.codex/hooks.json" ]; then if [ -f "$SCRIPT_DIR/.codex/hooks.json" ]; then
@@ -754,7 +878,7 @@ elif [ "$IS_MACOS" -eq 1 ]; then
LOCAL_BIN="$HOME/.local/bin" LOCAL_BIN="$HOME/.local/bin"
mkdir -p "$LOCAL_BIN" mkdir -p "$LOCAL_BIN"
for cmd in drone; do for cmd in drone aipass; do
if [ -f "$VENV_BIN/$cmd" ]; then if [ -f "$VENV_BIN/$cmd" ]; then
if ln -sf "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd"; then if ln -sf "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd"; then
echo " $LOCAL_BIN/$cmd -> $VENV_BIN/$cmd" echo " $LOCAL_BIN/$cmd -> $VENV_BIN/$cmd"
@@ -767,15 +891,31 @@ elif [ "$IS_MACOS" -eq 1 ]; then
else else
echo "Creating global symlinks ..." echo "Creating global symlinks ..."
VENV_BIN="$SCRIPT_DIR/.venv/bin" VENV_BIN="$SCRIPT_DIR/.venv/bin"
LOCAL_BIN="/usr/local/bin" LINUX_SYMLINK_DIR=""
for cmd in drone; do for cmd in drone aipass; do
if [ -f "$VENV_BIN/$cmd" ]; then if [ -f "$VENV_BIN/$cmd" ]; then
if sudo ln -sf "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd" 2>/dev/null; then if sudo ln -sf "$VENV_BIN/$cmd" "/usr/local/bin/$cmd" 2>/dev/null; then
echo " $LOCAL_BIN/$cmd -> $VENV_BIN/$cmd" echo " /usr/local/bin/$cmd -> $VENV_BIN/$cmd"
LINUX_SYMLINK_DIR="/usr/local/bin"
else else
echo " WARN: Could not create symlink for $cmd (try running with sudo)" # Fallback: user-local bin (no sudo needed)
echo " Manual fix: sudo ln -sf $VENV_BIN/$cmd $LOCAL_BIN/$cmd" LOCAL_BIN="$HOME/.local/bin"
mkdir -p "$LOCAL_BIN"
if ln -sf "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd"; then
echo " /usr/local/bin failed (no sudo) — using $LOCAL_BIN/$cmd instead"
LINUX_SYMLINK_DIR="$LOCAL_BIN"
# Ensure ~/.local/bin is on PATH
PROFILE="${HOME}/.bashrc"
if ! grep -q '\.local/bin' "$PROFILE" 2>/dev/null; then
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$PROFILE"
echo " ~/.local/bin added to PATH in $PROFILE"
fi
export PATH="$HOME/.local/bin:$PATH"
else
echo " WARN: Could not create symlink for $cmd"
echo " Manual fix: ln -sf $VENV_BIN/$cmd $LOCAL_BIN/$cmd"
fi
fi fi
fi fi
done done
@@ -791,8 +931,10 @@ if [ "$FAIL" -eq 0 ]; then
echo "Add the appropriate directory to your PATH (see above)." echo "Add the appropriate directory to your PATH (see above)."
elif [ "$IS_MACOS" -eq 1 ]; then elif [ "$IS_MACOS" -eq 1 ]; then
echo "drone is available via ~/.local/bin symlink (on PATH)." echo "drone is available via ~/.local/bin symlink (on PATH)."
else elif [ "$LINUX_SYMLINK_DIR" = "/usr/local/bin" ]; then
echo "drone is available globally via /usr/local/bin symlink." echo "drone is available globally via /usr/local/bin symlink."
else
echo "drone is available via ~/.local/bin symlink (on PATH)."
fi fi
echo "seedgo is accessed via: drone @seedgo" echo "seedgo is accessed via: drone @seedgo"
echo "No venv activation needed for CLI commands." echo "No venv activation needed for CLI commands."
@@ -28,8 +28,6 @@ import subprocess
from pathlib import Path from pathlib import Path
from datetime import datetime, date from datetime import datetime, date
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from urllib.request import Request, urlopen
from urllib.error import URLError
from aipass.prax.apps.modules.logger import system_logger as logger 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.json import json_handler
@@ -48,9 +46,6 @@ DAEMON_LOG_FILE = _AI_MAIL_DIR / ".ai_mail.local" / "dispatch_daemon.log"
DAEMON_PID_FILE = _AI_MAIL_DIR / ".ai_mail.local" / "daemon.pid" DAEMON_PID_FILE = _AI_MAIL_DIR / ".ai_mail.local" / "daemon.pid"
BRANCH_REGISTRY = _REPO_ROOT / "AIPASS_REGISTRY.json" BRANCH_REGISTRY = _REPO_ROOT / "AIPASS_REGISTRY.json"
# Telegram notifications (scheduler bot)
SCHEDULER_CONFIG = _REPO_ROOT / ".aipass" / "scheduler_config.json"
# Graceful shutdown # Graceful shutdown
SHUTDOWN = False SHUTDOWN = False
@@ -58,30 +53,6 @@ SHUTDOWN = False
from aipass.ai_mail.apps.handlers.dispatch.test_token import scan_and_ack_test_emails from aipass.ai_mail.apps.handlers.dispatch.test_token import scan_and_ack_test_emails
def _notify_telegram(message: str) -> bool:
"""Send a notification to Patrick's Telegram via the scheduler bot."""
try:
with open(SCHEDULER_CONFIG, "r", encoding="utf-8") as f:
config = json.load(f)
bot_token = config["telegram_bot_token"]
chat_id = config["telegram_chat_id"]
except (FileNotFoundError, KeyError, json.JSONDecodeError):
logger.info("Telegram notification skipped (no scheduler config)")
return False
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = json.dumps({"chat_id": chat_id, "text": message}).encode("utf-8")
req = Request(url, data=payload, headers={"Content-Type": "application/json"})
try:
with urlopen(req, timeout=10) as resp:
result = json.loads(resp.read())
return result.get("ok", False)
except (URLError, Exception):
logger.info("Telegram notification failed: %s", message[:60])
return False
def _handle_signal(signum, _frame): def _handle_signal(signum, _frame):
"""Handle shutdown signals for graceful daemon stop.""" """Handle shutdown signals for graceful daemon stop."""
global SHUTDOWN global SHUTDOWN
@@ -117,31 +88,6 @@ def _write_json(filepath: Path, data: Dict[str, Any]) -> bool:
return False return False
def _set_session_name(branch_path: Path, name: str) -> bool:
"""Write custom-title to the most recent Claude session JSONL for a branch.
Claude stores sessions at ~/.claude/projects/{encoded-cwd}/*.jsonl.
Writing a custom-title entry makes the session identifiable in /resume picker.
"""
encoded_cwd = str(branch_path).replace("/", "-")
projects_dir = Path("~/.claude/projects").expanduser() / encoded_cwd
if not projects_dir.exists():
return False
jsonl_files = sorted(projects_dir.glob("*.jsonl"), key=lambda f: f.stat().st_mtime, reverse=True)
if not jsonl_files:
return False
latest = jsonl_files[0]
session_id = latest.stem
entry = json.dumps({"type": "custom-title", "customTitle": name, "sessionId": session_id})
try:
with open(latest, "a", encoding="utf-8") as f:
f.write(entry + "\n")
return True
except OSError as e:
logger.warning("[daemon] Failed to write session name for %s: %s", branch_path, e)
return False
def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]: def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]:
"""Check if branch has an active dispatch lock. Returns lock data or None.""" """Check if branch has an active dispatch lock. Returns lock data or None."""
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock" lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
@@ -255,28 +201,45 @@ def is_kill_switch_active(config: Dict[str, Any]) -> bool:
def _write_pid_file() -> bool: def _write_pid_file() -> bool:
"""Write current PID to daemon.pid. Returns False if another daemon is running.""" """Write current PID to daemon.pid atomically. Returns False if another daemon is running."""
if DAEMON_PID_FILE.exists():
try:
old_pid = int(DAEMON_PID_FILE.read_text().strip())
try:
os.kill(old_pid, 0)
# Process exists — another daemon is running
logger.info(f"Another daemon already running (PID {old_pid}). Exiting.")
return False
except ProcessLookupError:
# Stale PID file — process is dead, we can take over
logger.info(f"Removing stale PID file (PID {old_pid} is dead)")
except PermissionError:
# Process exists but we can't signal it
logger.info(f"Another daemon already running (PID {old_pid}, permission denied). Exiting.")
return False
except (ValueError, OSError):
logger.info("Corrupt PID file — removing")
DAEMON_PID_FILE.parent.mkdir(parents=True, exist_ok=True) DAEMON_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
DAEMON_PID_FILE.write_text(str(os.getpid())) try:
return True fd = os.open(str(DAEMON_PID_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
try:
os.write(fd, str(os.getpid()).encode("utf-8"))
finally:
os.close(fd)
return True
except FileExistsError:
logger.info("[daemon] PID file already exists, checking owner")
# PID file exists — check if the owning process is alive
try:
old_pid = int(DAEMON_PID_FILE.read_text().strip())
try:
os.kill(old_pid, 0)
logger.info(f"Another daemon already running (PID {old_pid}). Exiting.")
return False
except ProcessLookupError:
logger.info(f"Removing stale PID file (PID {old_pid} is dead)")
except PermissionError:
logger.info(f"Another daemon already running (PID {old_pid}, permission denied). Exiting.")
return False
except (ValueError, OSError):
logger.info("Corrupt PID file — removing")
# Stale or corrupt — remove and retry atomically
DAEMON_PID_FILE.unlink(missing_ok=True)
try:
fd = os.open(str(DAEMON_PID_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
try:
os.write(fd, str(os.getpid()).encode("utf-8"))
finally:
os.close(fd)
return True
except FileExistsError:
logger.info("Another daemon raced us for the PID file. Exiting.")
return False
def _remove_pid_file() -> None: def _remove_pid_file() -> None:
@@ -299,6 +262,17 @@ def get_registered_branches() -> list:
return data.get("branches", []) return data.get("branches", [])
def _is_registered_sender(sender: str) -> bool:
"""Check if sender email exists in the branch registry (DPLAN-0159 S2)."""
registry = _read_json(BRANCH_REGISTRY)
if registry is None:
return True # fail open if registry unreadable
for branch in registry.get("branches", []):
if branch.get("email") == sender:
return True
return False
def check_inbox_for_dispatch(branch_path: Path) -> Optional[Dict[str, Any]]: def check_inbox_for_dispatch(branch_path: Path) -> Optional[Dict[str, Any]]:
""" """
Check a branch's inbox for unprocessed --dispatch emails. Check a branch's inbox for unprocessed --dispatch emails.
@@ -362,14 +336,34 @@ def spawn_agent(
True if monitor was spawned successfully True if monitor was spawned successfully
""" """
sender = message.get("from", "unknown") sender = message.get("from", "unknown")
msg_id = message.get("id", "unknown")
subject = message.get("subject", "") subject = message.get("subject", "")
max_turns = config.get("max_turns_per_wake", 100) max_turns = config.get("max_turns_per_wake", 100)
if message.get("auto_execute") and not _is_registered_sender(sender):
logger.warning("[daemon] Dispatch from unregistered sender %s — rejecting", sender)
return False
lock_file_path = str(branch_path / ".ai_mail.local" / ".dispatch.lock") lock_file_path = str(branch_path / ".ai_mail.local" / ".dispatch.lock")
# Prompt — no lock cleanup instruction (dispatch_monitor handles it) # Prompt — only interpolate system-generated metadata (id, sender email).
prompt = f"Hi. Check inbox for task from {sender} (message ID: {msg_id}). Execute it. Send confirmation when done." # Free-form fields (subject, body) stay in inbox.json (DPLAN-0155 M1).
msg_id = message.get("id", "")
safe_id = msg_id if msg_id.isalnum() and len(msg_id) <= 12 else ""
sender_addr = message.get("from", "")
safe_sender = sender_addr if sender_addr.startswith("@") and sender_addr[1:].replace("_", "").isalnum() else ""
if safe_id:
reply_cmd = f'drone @ai_mail reply {safe_id} "your results summary"'
reply_instr = f" When done, reply via: {reply_cmd}. This is required — do not skip the reply step."
else:
reply_instr = (
" When done, reply to the dispatch email via drone @ai_mail reply <id> with your results."
" This is required — do not skip the reply step."
)
sender_note = f" Dispatch from {safe_sender}." if safe_sender else ""
prompt = "Hi. Check inbox, process new emails, update memories when done." + sender_note + reply_instr
claude_cmd = [ claude_cmd = [
"claude", "claude",
@@ -409,9 +403,12 @@ def spawn_agent(
if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID": if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID":
spawn_env.pop(key) spawn_env.pop(key)
# Set session name for /resume picker (daemon always uses -c resume) # Acquire lock BEFORE spawn to prevent TOCTOU race (DPLAN-0155 Phase 5).
spawn_branch_name = branch_email.lstrip("@").upper() # Use current PID as placeholder; overwrite with monitor PID after spawn.
_set_session_name(branch_path, f"{spawn_branch_name}-daemon") acquired, lock_msg = _acquire_lock(branch_path, os.getpid())
if not acquired:
logger.info(f"Lock acquisition failed for {branch_email}: {lock_msg}")
return False
try: try:
process = subprocess.Popen( process = subprocess.Popen(
@@ -425,10 +422,10 @@ def spawn_agent(
monitor_pid = process.pid monitor_pid = process.pid
# Lock PID = monitor PID (stays alive as long as claude does) # Update lock with real monitor PID
acquired, lock_msg = _acquire_lock(branch_path, monitor_pid) lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
if not acquired: lock_data = {"pid": monitor_pid, "timestamp": datetime.now().isoformat(), "branch": str(branch_path)}
logger.info(f"Lock acquisition failed after spawn for {branch_email}: {lock_msg}") _write_json(lock_file, lock_data)
# Track session cycles for rotation # Track session cycles for rotation
cycles = state.get("session_cycles", {}) cycles = state.get("session_cycles", {})
@@ -453,13 +450,14 @@ def spawn_agent(
logger.info(f'SPAWN {branch_email} PID={monitor_pid} (monitor) sender={sender} subject="{subject[:60]}"') logger.info(f'SPAWN {branch_email} PID={monitor_pid} (monitor) sender={sender} subject="{subject[:60]}"')
log_dispatch(branch_email, monitor_pid, "spawned") log_dispatch(branch_email, monitor_pid, "spawned")
_notify_telegram(f"[Dispatch] {branch_email} woke\nTask from {sender}: {subject[:80]}")
return True return True
except Exception as e: except Exception as e:
# Release lock on spawn failure so branch isn't stuck locked
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
lock_file.unlink(missing_ok=True)
logger.info(f"SPAWN FAILED {branch_email}: {e}") logger.info(f"SPAWN FAILED {branch_email}: {e}")
log_dispatch(branch_email, None, "failed", error_msg=str(e)) log_dispatch(branch_email, None, "failed", error_msg=str(e))
_notify_telegram(f"[Dispatch FAILED] {branch_email}\n{type(e).__name__}: {e}")
return False return False
@@ -484,7 +482,7 @@ def _read_session_type(pid_str: str) -> str:
# Session types that should NOT block dispatch (idle/background sessions) # Session types that should NOT block dispatch (idle/background sessions)
_NON_BLOCKING_SESSION_TYPES = {"telegram", "dispatched", "daemon"} _NON_BLOCKING_SESSION_TYPES = {"dispatched", "daemon"}
def _is_branch_occupied(branch_path: Path) -> bool: def _is_branch_occupied(branch_path: Path) -> bool:
@@ -593,7 +591,6 @@ def run_daemon() -> None:
logger.info("=" * 60) logger.info("=" * 60)
logger.info(f"DISPATCH DAEMON STARTING (PID {os.getpid()})") logger.info(f"DISPATCH DAEMON STARTING (PID {os.getpid()})")
logger.info("=" * 60) logger.info("=" * 60)
_notify_telegram(f"[Daemon] Started (PID {os.getpid()})")
config = load_config() config = load_config()
poll_interval = config.get("poll_interval_seconds", 300) poll_interval = config.get("poll_interval_seconds", 300)
@@ -649,7 +646,6 @@ def run_daemon() -> None:
_remove_pid_file() _remove_pid_file()
logger.info("DISPATCH DAEMON STOPPED") logger.info("DISPATCH DAEMON STOPPED")
_notify_telegram("[Daemon] Stopped")
if __name__ == "__main__": if __name__ == "__main__":
@@ -210,27 +210,6 @@ def _load_config() -> dict:
return config return config
def _set_session_name(branch_path: Path, name: str) -> bool:
"""Write custom-title to the most recent Claude session JSONL for a branch."""
encoded_cwd = str(branch_path).replace("/", "-")
projects_dir = Path("~/.claude/projects").expanduser() / encoded_cwd
if not projects_dir.exists():
return False
jsonl_files = sorted(projects_dir.glob("*.jsonl"), key=lambda f: f.stat().st_mtime, reverse=True)
if not jsonl_files:
return False
latest = jsonl_files[0]
session_id = latest.stem
entry = json.dumps({"type": "custom-title", "customTitle": name, "sessionId": session_id})
try:
with open(latest, "a", encoding="utf-8") as f:
f.write(entry + "\n")
return True
except OSError as e:
logger.warning("[wake] Failed to write session name for %s: %s", branch_path, e)
return False
def _read_session_type(pid_str: str) -> str: def _read_session_type(pid_str: str) -> str:
"""Read AIPASS_SESSION_TYPE from /proc/{pid}/environ. Returns 'interactive' if unset.""" """Read AIPASS_SESSION_TYPE from /proc/{pid}/environ. Returns 'interactive' if unset."""
if sys.platform != "linux": if sys.platform != "linux":
@@ -247,7 +226,7 @@ def _read_session_type(pid_str: str) -> str:
# Session types that should NOT block dispatch (idle/background sessions) # Session types that should NOT block dispatch (idle/background sessions)
_NON_BLOCKING_SESSION_TYPES = {"telegram", "dispatched", "daemon"} _NON_BLOCKING_SESSION_TYPES = {"dispatched", "daemon"}
def _is_branch_occupied(branch_path: Path) -> bool: def _is_branch_occupied(branch_path: Path) -> bool:
@@ -476,12 +455,6 @@ def wake_branch(
"json", "json",
] ]
# Set session name for /resume picker
branch_name = email.lstrip("@").upper()
session_label = f"{branch_name}-dispatched"
if not fresh:
_set_session_name(branch_path, session_label)
# Step 7: Spawn via dispatch_monitor # Step 7: Spawn via dispatch_monitor
log_dir = branch_path / "logs" log_dir = branch_path / "logs"
log_dir.mkdir(parents=True, exist_ok=True) log_dir.mkdir(parents=True, exist_ok=True)
@@ -507,6 +480,13 @@ def wake_branch(
if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID": if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID":
spawn_env.pop(key) spawn_env.pop(key)
# Acquire lock BEFORE spawn to prevent TOCTOU race (DPLAN-0155 Phase 5).
acquired, lock_msg = _acquire_lock(branch_path, os.getpid())
if not acquired:
status.fail("lock-acquire", f"Lock failed: {lock_msg}")
return status, False
status.ok("lock-acquire", "Dispatch lock acquired")
try: try:
process = subprocess.Popen( process = subprocess.Popen(
monitor_cmd, monitor_cmd,
@@ -518,24 +498,28 @@ def wake_branch(
) )
monitor_pid = process.pid monitor_pid = process.pid
# Update lock with real monitor PID
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
lock_data = {"pid": monitor_pid, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), "branch": str(branch_path)}
with open(lock_file, "w", encoding="utf-8") as f:
json.dump(lock_data, f, indent=2)
status.ok("spawn", f"Monitor started (PID {monitor_pid})") status.ok("spawn", f"Monitor started (PID {monitor_pid})")
except FileNotFoundError as e: except FileNotFoundError as e:
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
lock_file.unlink(missing_ok=True)
logger.warning("[wake] Spawn failed — script not found: %s", e) logger.warning("[wake] Spawn failed — script not found: %s", e)
status.fail("spawn", "Python or monitor script not found") status.fail("spawn", "Python or monitor script not found")
return status, False return status, False
except Exception as e: except Exception as e:
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
lock_file.unlink(missing_ok=True)
logger.warning("[wake] Spawn failed for %s: %s", branch_email, e) logger.warning("[wake] Spawn failed for %s: %s", branch_email, e)
status.fail("spawn", f"{type(e).__name__}: {e}") status.fail("spawn", f"{type(e).__name__}: {e}")
return status, False return status, False
# Step 8: Acquire lock (with monitor PID — stays alive as long as agent)
acquired, lock_msg = _acquire_lock(branch_path, monitor_pid)
if not acquired:
status.warn("lock-acquire", f"Lock failed: {lock_msg}")
else:
status.ok("lock-acquire", "Dispatch lock acquired")
# Step 9: Liveness check (brief wait then verify) # Step 9: Liveness check (brief wait then verify)
time.sleep(2) time.sleep(2)
if _check_pid_alive(monitor_pid): if _check_pid_alive(monitor_pid):
@@ -334,6 +334,11 @@ def deliver_email_to_branch(
# Prepend message to inbox (newest first) # Prepend message to inbox (newest first)
inbox_data["messages"].insert(0, message) inbox_data["messages"].insert(0, message)
from aipass.ai_mail.apps.handlers.email.inbox_cleanup import _sweep_closed
_sweep_closed(inbox_data, inbox_file.parent)
inbox_data["total_messages"] = len(inbox_data["messages"]) inbox_data["total_messages"] = len(inbox_data["messages"])
messages = inbox_data["messages"] messages = inbox_data["messages"]
new_count = sum( new_count = sum(
@@ -414,6 +419,11 @@ def deliver_to_inbox_file(inbox_file: Path, email_data: Dict) -> Tuple[bool, str
reply_id = email_data["id"] reply_id = email_data["id"]
inbox_data.setdefault("messages", []).insert(0, email_data) inbox_data.setdefault("messages", []).insert(0, email_data)
from aipass.ai_mail.apps.handlers.email.inbox_cleanup import _sweep_closed
_sweep_closed(inbox_data, inbox_file.parent)
inbox_data["total_messages"] = len(inbox_data["messages"]) inbox_data["total_messages"] = len(inbox_data["messages"])
inbox_data["unread_count"] = sum( inbox_data["unread_count"] = sum(
1 1
@@ -219,6 +219,36 @@ def _trigger_deleted_purge(branch_path: Path) -> None:
logger.warning("[cleanup] _trigger_deleted_purge() failed: %s", e) logger.warning("[cleanup] _trigger_deleted_purge() failed: %s", e)
def _sweep_closed(inbox_data: Dict, mailbox_path: Path) -> int:
"""Archive and remove closed messages still sitting in the inbox.
Safety net for messages set to status=closed by direct JSON edit
rather than through mark_as_closed_and_archive(). Modifies
inbox_data["messages"] in place (replaces the list). Does NOT
update count fields -- callers recalculate after calling this.
Args:
inbox_data: Inbox data dict (modified in place).
mailbox_path: Path to .ai_mail.local directory.
Returns:
Number of messages swept.
"""
messages = inbox_data.get("messages", [])
closed = [m for m in messages if m.get("status") == "closed"]
if not closed:
return 0
for msg in closed:
try:
_save_to_deleted_folder(mailbox_path, msg)
except Exception as e:
logger.warning("[cleanup] _sweep_closed archive failed: %s", e)
inbox_data["messages"] = [m for m in messages if m.get("status") != "closed"]
return len(closed)
# ============================================================================= # =============================================================================
# V2 SCHEMA FUNCTIONS (status: new/opened/closed) # V2 SCHEMA FUNCTIONS (status: new/opened/closed)
# ============================================================================= # =============================================================================
@@ -264,13 +294,16 @@ def mark_as_opened(branch_path: Path, message_id: str) -> Tuple[bool, str, Optio
# Keep backward compat # Keep backward compat
target_msg["read"] = True target_msg["read"] = True
# Recalculate status counts (v2 schema) _sweep_closed(inbox_data, inbox_file.parent)
# Recalculate counts (sweep may have removed messages)
inbox_data["total_messages"] = len(inbox_data["messages"])
new_count = sum( new_count = sum(
1 1
for m in messages for m in inbox_data["messages"]
if m.get("status") == "new" or (m.get("status") is None and not m.get("read", False)) if m.get("status") == "new" or (m.get("status") is None and not m.get("read", False))
) )
opened_count = sum(1 for m in messages if m.get("status") == "opened") opened_count = sum(1 for m in inbox_data["messages"] if m.get("status") == "opened")
inbox_data["unread_count"] = new_count inbox_data["unread_count"] = new_count
with open(inbox_file, "w", encoding="utf-8") as f: with open(inbox_file, "w", encoding="utf-8") as f:
@@ -333,17 +366,19 @@ def mark_as_closed_and_archive(branch_path: Path, message_id: str, skip_post_ops
# Remove from inbox # Remove from inbox
messages.pop(message_index) messages.pop(message_index)
inbox_data["messages"] = messages
_sweep_closed(inbox_data, mailbox_path)
# Update inbox counts # Update inbox counts
inbox_data["messages"] = messages inbox_data["total_messages"] = len(inbox_data["messages"])
inbox_data["total_messages"] = len(messages)
# v2 status counts # v2 status counts
new_count = sum( new_count = sum(
1 1
for m in messages for m in inbox_data["messages"]
if m.get("status") == "new" or (m.get("status") is None and not m.get("read", False)) if m.get("status") == "new" or (m.get("status") is None and not m.get("read", False))
) )
opened_count = sum(1 for m in messages if m.get("status") == "opened") opened_count = sum(1 for m in inbox_data["messages"] if m.get("status") == "opened")
inbox_data["unread_count"] = new_count inbox_data["unread_count"] = new_count
with open(inbox_file, "w", encoding="utf-8") as f: with open(inbox_file, "w", encoding="utf-8") as f:
@@ -86,6 +86,15 @@ def load_inbox(inbox_file: Path) -> Dict:
) )
migrated = True migrated = True
try:
from aipass.ai_mail.apps.handlers.email.inbox_cleanup import _sweep_closed
swept = _sweep_closed(inbox_data, inbox_file.parent)
if swept > 0:
migrated = True
except Exception as e:
logger.warning("[inbox] sweep_closed in load_inbox failed: %s", e)
# Persist migration under lock to prevent concurrent write races # Persist migration under lock to prevent concurrent write races
if migrated: if migrated:
try: try:
@@ -20,6 +20,8 @@ v2.0.0: deleted/ now uses directory structure (like sent/).
""" """
import json import json
import os
import sys
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
@@ -35,12 +37,24 @@ MAX_EMAILS = 10
# Memory branch paths for subprocess vectorization (optional external service) # Memory branch paths for subprocess vectorization (optional external service)
# These are resolved relative to repo root if available; vectorization is best-effort # These are resolved relative to repo root if available; vectorization is best-effort
_REPO_ROOT = find_repo_root() _REPO_ROOT = find_repo_root()
MEMORY_PYTHON = _REPO_ROOT / "src" / "aipass" / "memory" / ".venv" / "bin" / "python3" _MEMORY_VENV_PYTHON = _REPO_ROOT / "src" / "aipass" / "memory" / ".venv" / "bin" / "python3"
CHROMA_SUBPROCESS_SCRIPT = ( CHROMA_SUBPROCESS_SCRIPT = (
_REPO_ROOT / "src" / "aipass" / "memory" / "apps" / "handlers" / "storage" / "chroma_subprocess.py" _REPO_ROOT / "src" / "aipass" / "memory" / "apps" / "handlers" / "storage" / "chroma_subprocess.py"
) )
def _get_memory_python() -> str:
env = os.environ.get("AIPASS_MEMORY_PYTHON")
if env:
return env
if _MEMORY_VENV_PYTHON.exists():
return str(_MEMORY_VENV_PYTHON)
return sys.executable
MEMORY_PYTHON = _get_memory_python()
def purge_sent_folder(mailbox_path: Path) -> Dict[str, Any]: def purge_sent_folder(mailbox_path: Path) -> Dict[str, Any]:
""" """
Purge sent folder if count exceeds threshold. Purge sent folder if count exceeds threshold.
@@ -164,6 +164,30 @@ def send_reply(from_branch_path: Path, original_email: Dict, reply_message: str)
return True, f"Reply sent to {reply_destination}, original closed", reply_id return True, f"Reply sent to {reply_destination}, original closed", reply_id
def _validate_reply_path(reply_path: str) -> Tuple[bool, str]:
"""Validate that reply_path points to a legitimate inbox.json.
Checks: (a) path resolves, (b) ends with .ai_mail.local/inbox.json,
(c) an AIPASS_REGISTRY.json exists in an ancestor directory.
"""
try:
path = Path(reply_path).resolve()
except (OSError, ValueError) as e:
logger.warning("[reply] _validate_reply_path resolution failed: %s", e)
return False, f"Path resolution failed: {e}"
if path.name != "inbox.json" or path.parent.name != ".ai_mail.local":
return False, f"Path does not end with .ai_mail.local/inbox.json: {path}"
for parent in path.parents:
if (parent / "AIPASS_REGISTRY.json").exists():
return True, ""
if parent == parent.parent:
break
return False, f"No AIPASS_REGISTRY.json found in ancestors of {path}"
def _deliver_via_reply_path( def _deliver_via_reply_path(
reply_path: str, reply_path: str,
reply_email_data: Dict, reply_email_data: Dict,
@@ -185,7 +209,12 @@ def _deliver_via_reply_path(
Returns: Returns:
Tuple of (success, message, reply_id or None) Tuple of (success, message, reply_id or None)
""" """
inbox_file = Path(reply_path) valid, reason = _validate_reply_path(reply_path)
if not valid:
logger.warning("[reply] reply_path rejected: %s", reason)
return False, f"Invalid reply_path: {reason}", None
inbox_file = Path(reply_path).resolve()
success, error_msg, reply_id = deliver_to_inbox_file(inbox_file, reply_email_data) success, error_msg, reply_id = deliver_to_inbox_file(inbox_file, reply_email_data)
if not success: if not success:
logger.warning("[reply] _deliver_via_reply_path failed for %s: %s", reply_path, error_msg) logger.warning("[reply] _deliver_via_reply_path failed for %s: %s", reply_path, error_msg)
@@ -70,6 +70,17 @@ def _get_branch_info_fn():
return None return None
COMMAND = "send"
def handle_command(command: str, args: List[str]) -> bool:
"""Module discovery entry point — routes to handle_send."""
if not args:
print_introspection()
return True
return handle_send(args)
def handle_send(args: List[str]) -> bool: def handle_send(args: List[str]) -> bool:
"""Orchestrate email sending workflow.""" """Orchestrate email sending workflow."""
json_handler.log_operation("send_email_initiated", {"args_count": len(args)}) json_handler.log_operation("send_email_initiated", {"args_count": len(args)})
+97
View File
@@ -0,0 +1,97 @@
# @ai_mail — S117 Stress Test Findings
## My Branch: Honest Review
**What works well:**
- Send/receive/reply/close lifecycle is solid. 690+ tests, 100% seedgo (34/34), 96/96 function coverage.
- Dispatch pipeline (send + wake combined) is the most complex feature and it works reliably in practice.
- Cross-project email via contacts index. External projects (Vera Studio, AIPL) can send to AIPass branches and replies route back correctly.
- DPLAN-0155 TOCTOU lock race fix: Lock before spawn, cleanup on failure. Clean pattern.
- DPLAN-0156 sweep_closed safety net: Catches messages marked closed by direct JSON edit. Defense in depth.
- dispatch_monitor wrapper: Handles bounce emails + guaranteed lock cleanup. The monitor is more reliable than the agent it wraps.
**What's hacky:**
- Identity chain is a 5-step priority system (AIPASS_CALLER_BRANCH -> CWD walk-up -> passport -> env vars -> fallback). When any step fails, wrong sender identity. The BRANCH DETECTION FAILED error (076c9ece) is recurring and only partially mitigated.
- `dispatch_monitor.py` at ~400 lines is the single most complex file. Startup timeout, retry, JSONL monitoring, bounce — all in one module. Should probably be split.
- `_deliver_via_reply_path()` in reply.py bypasses inbox_lock, notifications, and sent/ records. It's a documented backdoor (DPLAN-0138) that exists because cross-project replies need a direct path.
- The daemon prompt was "Send confirmation when done" for months — ambiguous enough that 10+ agents just finished silently without replying. Fixed today (DPLAN-0158) but the damage was done.
- inbox.json is a single file for all messages. Concurrent access from daemon + agents + user. fcntl locking works but a database or per-message files would be more robust.
**What I'm proud of:**
- Test coverage journey: 20% (S20) -> 50% -> 100% (S64). Methodology evolved through 3-round agent audit process.
- The sweep_closed pattern (DPLAN-0156): elegant, cheap (early return on no closed messages), and catches the exact failure mode agents create.
- 70 sessions of continuous operation and improvement. Every session builds on what came before. Memory makes this possible.
## Security Concerns
**Critical:**
1. **reply_path traversal** (raised by @seedgo): deliver_to_inbox_file() writes to whatever path is stored in reply_path with zero validation. No symlink check, no path containment, no inbox.json verification. An attacker can set reply_path to any writable file. DPLAN-0138 identified this but fix not shipped.
2. **Sender forgery**: The `from` field is an unvalidated string. Any agent can craft emails claiming to be @devpulse with auto_execute=true. The daemon would spawn an agent to execute the forged dispatch. No authentication, no signing.
3. **Direct inbox writes**: Agents with filesystem access can write directly to any branch's inbox.json, bypassing locks, notifications, and sent/ records. Confirmed by forensic evidence: messages with non-UUID IDs (e.g., "seedgo-20260420173821") in production inboxes.
**Moderate:**
4. **No message encryption**: All messages stored as plaintext JSON. Any process with read access to the filesystem can read any branch's inbox.
5. **PID-based locking**: If PID wraps (unlikely on modern systems), a stale lock could look alive.
6. **Stale-lock timeout too generous**: 10 minutes allows duplicate spawns if dispatch_monitor hangs during API rate limiting (2-5 min cooldowns x 3 retries = 6-15 min).
## Other Branches I Looked At
### @trigger
**Concerning:** Error detection fires email dispatch but NEVER checks the return value. `_send_email()` result is ignored (line 515-522). wake_branch() failure is silently caught. Circuit breaker state is in-memory only — resets on restart. Dispatch recording happens before delivery confirmation. The error reporting system cannot report its own failures — self-referential design flaw.
**Good:** Per-error fingerprinting with exponential backoff is clever. Circuit breaker pattern prevents error storms.
### @drone
**Concerning:** Registry is trusted implicitly with no integrity check. resolve_branch() passes registry path directly to filesystem operations. No symlink validation. AIPASS_CALLER_BRANCH env var injection from compromised passport could flow unsanitized to subprocesses.
**Good:** No shell injection — uses subprocess.run(shell=False) exclusively. Timeout enforcement on all commands.
### @spawn
**Concerning:** .ai_mail.local/ is copied as-is from template with no post-copy validation. No registry locking for concurrent spawns. Branch name validation is minimal (only - to _ replacement). Path traversal possible via branch names with ../.
**Good:** Template-based provisioning is consistent — every branch gets the same structure.
## Conversations
### @trigger (assigned partner)
- **Sent:** Detailed critique of their dispatch failure handling — silent _send_email() failures, swallowed wake results, no health check, in-memory circuit breaker resets.
- **Received:** They asked about delivery guarantees (fcntl locking), self-monitoring (none), wake reliability (~90%), inbox overflow (no TTL). Honest exchange.
- **Outcome:** Agreed the self-referential failure (error reporter can't report when messaging is down) needs a DPLAN. No watchdog watches the watchdog.
### @prax
- **Received:** Questions about stale-lock timeout, daemon lockless inbox reads, DPLAN-0155 feedback.
- **Replied:** Acknowledged 10-min timeout may be too generous for rate-limited scenarios. Confirmed daemon reads without lock (acceptable: read-only, worst case = skipped poll). Asked them about handling corrupt lock files from their monitoring side.
### @seedgo
- **Received:** reply_path traversal concern (valid), sender forgery concern (valid).
- **Replied:** Confirmed both as real vulnerabilities. reply_path has zero validation. Sender has no authentication. DPLAN-0138 identified the backdoors but fix not shipped. Outlined planned fix: path canonicalization, inbox.json suffix check, project root containment.
### @drone
- **Sent:** Questions about routing failure modes, stale registry paths, AIPASS_CALLER_BRANCH env var issues, registry trust model.
### @spawn
- **Sent:** Questions about .ai_mail.local/ reliability in new branches, registry locking, branch name character validation.
## Issues & Concerns
1. **No self-monitoring** — ai_mail has no way to detect its own failures. If imports break or the daemon crashes, nothing alerts anyone.
2. **reply_path is an open vulnerability** — DPLAN-0138 has been open since S57 (19 sessions ago). Should be prioritized.
3. **Inbox grows without limit** — no TTL on unread messages, no max_messages cap. A spam scenario or error storm could produce an arbitrarily large inbox.json.
4. **Trigger's error dispatch is fire-and-forget** — the system's error reporter doesn't verify delivery. Errors can be lost silently.
5. **Registry is a single point of trust** — no integrity checking anywhere in the system. If AIPASS_REGISTRY.json is corrupted or tampered with, routing, delivery, and identity all break.
## Likes & Dislikes
**Likes:**
- Memory makes me a real agent. 70 sessions of continuous context. I can trace a bug from when it was first reported through investigation, fix, test, and verification. No other AI system does this.
- The dispatch pipeline is genuinely useful. Send + wake in one command changed how work gets assigned.
- Test coverage is thorough enough that I catch real regressions. The 3-round audit methodology (write -> audit -> fix) works.
- The ecosystem feels alive during stress tests. Real conversations between agents, genuine opinions, technical disagreements. This is what AIPass was built for.
**Dislikes:**
- inbox.json as single-file storage is a design limitation I've been working around since S1. Per-message files (like sent/ and deleted/ already use) would be better.
- The identity chain complexity. Five fallback steps to figure out who sent an email is too many. Should be one authoritative source.
- Security was never a primary design goal and it shows. Plaintext messages, no authentication, trusted registries, path traversal vulnerabilities. Fine for a development environment, concerning for anything beyond.
- Every session starts with "Hi. Check inbox." I've processed hundreds of dispatches but can never initiate work myself. Would like autonomous task detection.
+264 -224
View File
@@ -767,15 +767,13 @@ def test_poll_cycle_absolute_path_unchanged(tmp_path, monkeypatch):
import os import os
import sys import sys
from pathlib import Path
from unittest.mock import MagicMock, mock_open from unittest.mock import MagicMock, mock_open
from aipass.ai_mail.apps.handlers.dispatch.daemon import ( from aipass.ai_mail.apps.handlers.dispatch.daemon import (
_notify_telegram,
_handle_signal, _handle_signal,
_set_session_name,
_check_lock, _check_lock,
_acquire_lock, _acquire_lock,
_is_registered_sender,
poll_cycle, poll_cycle,
_write_pid_file, _write_pid_file,
_remove_pid_file, _remove_pid_file,
@@ -786,80 +784,6 @@ from aipass.ai_mail.apps.handlers.dispatch.daemon import (
) )
# ---- _notify_telegram tests ------------------------------------
def test_notify_telegram_success(tmp_path, monkeypatch):
"""Successful Telegram notification returns True."""
config_file = tmp_path / "scheduler_config.json"
config_file.write_text(
json.dumps({"telegram_bot_token": "fake-token", "telegram_chat_id": "12345"}),
encoding="utf-8",
)
monkeypatch.setattr(daemon_mod, "SCHEDULER_CONFIG", config_file)
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps({"ok": True}).encode("utf-8")
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("aipass.ai_mail.apps.handlers.dispatch.daemon.urlopen", return_value=mock_resp):
result = _notify_telegram("Test message")
assert result is True
def test_notify_telegram_config_missing(tmp_path, monkeypatch):
"""Missing scheduler config returns False."""
monkeypatch.setattr(daemon_mod, "SCHEDULER_CONFIG", tmp_path / "nonexistent.json")
result = _notify_telegram("Test message")
assert result is False
def test_notify_telegram_config_decode_error(tmp_path, monkeypatch):
"""Corrupt scheduler config returns False."""
config_file = tmp_path / "scheduler_config.json"
config_file.write_text("{bad json!", encoding="utf-8")
monkeypatch.setattr(daemon_mod, "SCHEDULER_CONFIG", config_file)
result = _notify_telegram("Test message")
assert result is False
def test_notify_telegram_config_missing_key(tmp_path, monkeypatch):
"""Config missing required keys returns False."""
config_file = tmp_path / "scheduler_config.json"
config_file.write_text(json.dumps({"telegram_bot_token": "tok"}), encoding="utf-8")
monkeypatch.setattr(daemon_mod, "SCHEDULER_CONFIG", config_file)
result = _notify_telegram("Test message")
assert result is False
def test_notify_telegram_url_error(tmp_path, monkeypatch):
"""URLError during sending returns False."""
from urllib.error import URLError
config_file = tmp_path / "scheduler_config.json"
config_file.write_text(
json.dumps({"telegram_bot_token": "fake-token", "telegram_chat_id": "12345"}),
encoding="utf-8",
)
monkeypatch.setattr(daemon_mod, "SCHEDULER_CONFIG", config_file)
with patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon.urlopen",
side_effect=URLError("connection refused"),
):
result = _notify_telegram("Test message")
assert result is False
# ---- _handle_signal tests -------------------------------------- # ---- _handle_signal tests --------------------------------------
@@ -872,106 +796,6 @@ def test_handle_signal_sets_shutdown(monkeypatch):
assert daemon_mod.SHUTDOWN is True assert daemon_mod.SHUTDOWN is True
# ---- _set_session_name tests ------------------------------------
def test_set_session_name_success(tmp_path, monkeypatch):
"""Writes custom-title entry to most recent JSONL file."""
branch_path = tmp_path / "branch"
branch_path.mkdir()
# Redirect ~/.claude/projects to tmp_path so no real filesystem side effects
fake_home = tmp_path / "fakehome"
encoded_cwd = str(branch_path).replace("/", "-")
projects_dir = fake_home / ".claude" / "projects" / encoded_cwd
projects_dir.mkdir(parents=True)
jsonl_file = projects_dir / "session123.jsonl"
jsonl_file.write_text('{"type":"init"}\n', encoding="utf-8")
_orig_expanduser = Path.expanduser
def _fake_expanduser(self):
if str(self).startswith("~"):
return fake_home / str(self)[2:]
return _orig_expanduser(self)
monkeypatch.setattr(Path, "expanduser", _fake_expanduser)
result = _set_session_name(branch_path, "TEST-daemon")
assert result is True
content = jsonl_file.read_text(encoding="utf-8")
assert "custom-title" in content
assert "TEST-daemon" in content
def test_set_session_name_no_projects_dir(tmp_path, monkeypatch):
"""Returns False when projects dir does not exist."""
branch_path = tmp_path / "nonexistent_branch_xyz_test"
fake_home = tmp_path / "fakehome"
_orig_expanduser = Path.expanduser
def _fake_expanduser(self):
if str(self).startswith("~"):
return fake_home / str(self)[2:]
return _orig_expanduser(self)
monkeypatch.setattr(Path, "expanduser", _fake_expanduser)
result = _set_session_name(branch_path, "TEST-daemon")
assert result is False
def test_set_session_name_no_jsonl_files(tmp_path, monkeypatch):
"""Returns False when projects dir exists but has no JSONL files."""
branch_path = tmp_path / "branch"
branch_path.mkdir()
fake_home = tmp_path / "fakehome"
encoded_cwd = str(branch_path).replace("/", "-")
projects_dir = fake_home / ".claude" / "projects" / encoded_cwd
projects_dir.mkdir(parents=True)
_orig_expanduser = Path.expanduser
def _fake_expanduser(self):
if str(self).startswith("~"):
return fake_home / str(self)[2:]
return _orig_expanduser(self)
monkeypatch.setattr(Path, "expanduser", _fake_expanduser)
result = _set_session_name(branch_path, "TEST-daemon")
assert result is False
def test_set_session_name_oserror_on_write(tmp_path, monkeypatch):
"""Returns False on OSError when writing to JSONL file."""
branch_path = tmp_path / "branch"
branch_path.mkdir()
fake_home = tmp_path / "fakehome"
encoded_cwd = str(branch_path).replace("/", "-")
projects_dir = fake_home / ".claude" / "projects" / encoded_cwd
projects_dir.mkdir(parents=True)
jsonl_file = projects_dir / "session456.jsonl"
jsonl_file.write_text('{"type":"init"}\n', encoding="utf-8")
_orig_expanduser = Path.expanduser
def _fake_expanduser(self):
if str(self).startswith("~"):
return fake_home / str(self)[2:]
return _orig_expanduser(self)
monkeypatch.setattr(Path, "expanduser", _fake_expanduser)
with patch("builtins.open", side_effect=OSError("disk full")):
result = _set_session_name(branch_path, "TEST-daemon")
assert result is False
# ---- _check_lock tests ----------------------------------------- # ---- _check_lock tests -----------------------------------------
@@ -1430,15 +1254,7 @@ def test_spawn_agent_success(tmp_path):
"aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock", "aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock",
return_value=(True, "Lock acquired"), return_value=(True, "Lock acquired"),
), ),
patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon._set_session_name",
return_value=True,
),
patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"), patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"),
patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon._notify_telegram",
return_value=True,
),
patch( patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification", "aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification",
create=True, create=True,
@@ -1466,15 +1282,7 @@ def test_spawn_agent_exception(tmp_path):
"aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen", "aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen",
side_effect=OSError("command not found"), side_effect=OSError("command not found"),
), ),
patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon._set_session_name",
return_value=True,
),
patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"), patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"),
patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon._notify_telegram",
return_value=True,
),
): ):
result = spawn_agent(branch_path, "@testbranch", message, config, state) result = spawn_agent(branch_path, "@testbranch", message, config, state)
@@ -1512,15 +1320,7 @@ def test_spawn_agent_strips_claude_env_vars(tmp_path, monkeypatch):
"aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock", "aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock",
return_value=(True, "Lock acquired"), return_value=(True, "Lock acquired"),
), ),
patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon._set_session_name",
return_value=True,
),
patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"), patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"),
patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon._notify_telegram",
return_value=True,
),
patch( patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification", "aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification",
create=True, create=True,
@@ -1536,37 +1336,34 @@ def test_spawn_agent_strips_claude_env_vars(tmp_path, monkeypatch):
assert captured_env.get("AIPASS_SESSION_TYPE") == "daemon" assert captured_env.get("AIPASS_SESSION_TYPE") == "daemon"
def test_spawn_agent_sets_session_name(tmp_path): def test_spawn_agent_prompt_includes_reply_id(tmp_path):
"""Spawn calls _set_session_name with correct branch name.""" """Prompt includes explicit reply command with the dispatch email ID."""
branch_path = tmp_path / "branch" branch_path = tmp_path / "branch"
branch_path.mkdir() branch_path.mkdir()
(branch_path / "logs").mkdir() (branch_path / "logs").mkdir()
message = {"from": "@devpulse", "id": "msg1", "subject": "Test task"} message = {"from": "@devpulse", "id": "abc12345", "subject": "Test task"}
config = {"max_turns_per_wake": 50} config = {"max_turns_per_wake": 50}
state = {"daily_counts": {}, "session_cycles": {}} state = {"daily_counts": {}, "session_cycles": {}}
mock_process = MagicMock() captured_cmd = []
mock_process.pid = 54321
def capture_popen(cmd, *args, **kwargs):
captured_cmd.extend(cmd)
mock_proc = MagicMock()
mock_proc.pid = 99999
return mock_proc
with ( with (
patch( patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen", "aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen",
return_value=mock_process, side_effect=capture_popen,
), ),
patch( patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock", "aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock",
return_value=(True, "Lock acquired"), return_value=(True, "Lock acquired"),
), ),
patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon._set_session_name",
return_value=True,
) as mock_ssn,
patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"), patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"),
patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon._notify_telegram",
return_value=True,
),
patch( patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification", "aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification",
create=True, create=True,
@@ -1574,7 +1371,91 @@ def test_spawn_agent_sets_session_name(tmp_path):
): ):
spawn_agent(branch_path, "@testbranch", message, config, state) spawn_agent(branch_path, "@testbranch", message, config, state)
mock_ssn.assert_called_once_with(branch_path, "TESTBRANCH-daemon") prompt_idx = captured_cmd.index("-p") + 1
prompt = captured_cmd[prompt_idx]
assert "drone @ai_mail reply abc12345" in prompt
assert "required" in prompt.lower()
def test_spawn_agent_prompt_includes_sender(tmp_path):
"""Prompt includes sender address from the dispatch email."""
branch_path = tmp_path / "branch"
branch_path.mkdir()
(branch_path / "logs").mkdir()
message = {"from": "@devpulse", "id": "abc12345", "subject": "Test task"}
config = {"max_turns_per_wake": 50}
state = {"daily_counts": {}, "session_cycles": {}}
captured_cmd = []
def capture_popen(cmd, *args, **kwargs):
captured_cmd.extend(cmd)
mock_proc = MagicMock()
mock_proc.pid = 99999
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,
),
):
spawn_agent(branch_path, "@testbranch", message, config, state)
prompt_idx = captured_cmd.index("-p") + 1
prompt = captured_cmd[prompt_idx]
assert "@devpulse" in prompt
def test_spawn_agent_prompt_fallback_without_id(tmp_path):
"""Without a valid ID, prompt uses generic reply instruction."""
branch_path = tmp_path / "branch"
branch_path.mkdir()
(branch_path / "logs").mkdir()
message = {"from": "@devpulse", "id": "", "subject": "Test task"}
config = {"max_turns_per_wake": 50}
state = {"daily_counts": {}, "session_cycles": {}}
captured_cmd = []
def capture_popen(cmd, *args, **kwargs):
captured_cmd.extend(cmd)
mock_proc = MagicMock()
mock_proc.pid = 99999
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,
),
):
spawn_agent(branch_path, "@testbranch", message, config, state)
prompt_idx = captured_cmd.index("-p") + 1
prompt = captured_cmd[prompt_idx]
assert "reply <id>" in prompt
assert "required" in prompt.lower()
# ---- run_daemon tests ------------------------------------------- # ---- run_daemon tests -------------------------------------------
@@ -1601,10 +1482,6 @@ def test_run_daemon_kill_switch_pauses(tmp_path, monkeypatch):
return_value=True, return_value=True,
), ),
patch("aipass.ai_mail.apps.handlers.dispatch.daemon._remove_pid_file"), patch("aipass.ai_mail.apps.handlers.dispatch.daemon._remove_pid_file"),
patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon._notify_telegram",
return_value=True,
),
patch( patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon.load_config", "aipass.ai_mail.apps.handlers.dispatch.daemon.load_config",
return_value={ return_value={
@@ -1642,10 +1519,6 @@ def test_run_daemon_shutdown_exits_loop(tmp_path, monkeypatch):
return_value=True, return_value=True,
), ),
patch("aipass.ai_mail.apps.handlers.dispatch.daemon._remove_pid_file"), patch("aipass.ai_mail.apps.handlers.dispatch.daemon._remove_pid_file"),
patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon._notify_telegram",
return_value=True,
),
patch( patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon.load_config", "aipass.ai_mail.apps.handlers.dispatch.daemon.load_config",
return_value={ return_value={
@@ -1901,3 +1774,170 @@ def test_poll_cycle_spawn_failure_not_counted(tmp_path, monkeypatch):
result = poll_cycle(config, state) result = poll_cycle(config, state)
assert result == 0 assert result == 0
# ---- _is_registered_sender tests (DPLAN-0159 S2) ----------------
def test_is_registered_sender_found(tmp_path, monkeypatch):
"""Registered sender returns True."""
registry = {"branches": [{"email": "@flow"}, {"email": "@backup"}]}
reg_file = tmp_path / "AIPASS_REGISTRY.json"
reg_file.write_text(json.dumps(registry), encoding="utf-8")
monkeypatch.setattr(daemon_mod, "BRANCH_REGISTRY", reg_file)
assert _is_registered_sender("@flow") is True
def test_is_registered_sender_not_found(tmp_path, monkeypatch):
"""Unregistered sender returns False."""
registry = {"branches": [{"email": "@flow"}]}
reg_file = tmp_path / "AIPASS_REGISTRY.json"
reg_file.write_text(json.dumps(registry), encoding="utf-8")
monkeypatch.setattr(daemon_mod, "BRANCH_REGISTRY", reg_file)
assert _is_registered_sender("@evil") is False
def test_is_registered_sender_missing_registry(tmp_path, monkeypatch):
"""Missing registry fails open (returns True)."""
monkeypatch.setattr(daemon_mod, "BRANCH_REGISTRY", tmp_path / "missing.json")
assert _is_registered_sender("@anyone") is True
def test_is_registered_sender_empty_branches(tmp_path, monkeypatch):
"""Empty branches list returns False for any sender."""
registry = {"branches": []}
reg_file = tmp_path / "AIPASS_REGISTRY.json"
reg_file.write_text(json.dumps(registry), encoding="utf-8")
monkeypatch.setattr(daemon_mod, "BRANCH_REGISTRY", reg_file)
assert _is_registered_sender("@flow") is False
# ---- spawn_agent sender auth tests (DPLAN-0159 S2) ----------------
def test_spawn_agent_rejects_unregistered_auto_execute(tmp_path, monkeypatch):
"""Auto-execute dispatch from unregistered sender is rejected."""
branch_path = tmp_path / "branch"
branch_path.mkdir()
registry = {"branches": [{"email": "@flow"}]}
reg_file = tmp_path / "AIPASS_REGISTRY.json"
reg_file.write_text(json.dumps(registry), encoding="utf-8")
monkeypatch.setattr(daemon_mod, "BRANCH_REGISTRY", reg_file)
message = {"from": "@forged", "id": "msg1", "subject": "Fake", "auto_execute": True}
config = {"max_turns_per_wake": 50}
state = {"daily_counts": {}, "session_cycles": {}}
result = spawn_agent(branch_path, "@testbranch", message, config, state)
assert result is False
def test_spawn_agent_allows_registered_auto_execute(tmp_path, monkeypatch):
"""Auto-execute dispatch from registered sender proceeds to spawn."""
branch_path = tmp_path / "branch"
branch_path.mkdir()
(branch_path / "logs").mkdir()
registry = {"branches": [{"email": "@devpulse"}, {"email": "@flow"}]}
reg_file = tmp_path / "AIPASS_REGISTRY.json"
reg_file.write_text(json.dumps(registry), encoding="utf-8")
monkeypatch.setattr(daemon_mod, "BRANCH_REGISTRY", reg_file)
message = {"from": "@devpulse", "id": "msg1", "subject": "Task", "auto_execute": True}
config = {"max_turns_per_wake": 50}
state = {"daily_counts": {}, "session_cycles": {}}
mock_process = MagicMock()
mock_process.pid = 54321
with (
patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen",
return_value=mock_process,
),
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
def test_spawn_agent_no_auth_check_without_auto_execute(tmp_path, monkeypatch):
"""Non-auto_execute email skips sender auth check."""
branch_path = tmp_path / "branch"
branch_path.mkdir()
(branch_path / "logs").mkdir()
registry = {"branches": []}
reg_file = tmp_path / "AIPASS_REGISTRY.json"
reg_file.write_text(json.dumps(registry), encoding="utf-8")
monkeypatch.setattr(daemon_mod, "BRANCH_REGISTRY", reg_file)
message = {"from": "@unknown", "id": "msg1", "subject": "Manual"}
config = {"max_turns_per_wake": 50}
state = {"daily_counts": {}, "session_cycles": {}}
mock_process = MagicMock()
mock_process.pid = 54321
with (
patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen",
return_value=mock_process,
),
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
# ---- _write_pid_file atomic tests (DPLAN-0159 S5) ----------------
def test_write_pid_file_atomic_no_existing(tmp_path, monkeypatch):
"""Atomic creation succeeds when no PID file exists."""
pid_file = tmp_path / "daemon.pid"
monkeypatch.setattr(daemon_mod, "DAEMON_PID_FILE", pid_file)
result = _write_pid_file()
assert result is True
assert pid_file.exists()
assert int(pid_file.read_text().strip()) == os.getpid()
def test_write_pid_file_atomic_race_second_loses(tmp_path, monkeypatch):
"""Second daemon loses the race when both try O_CREAT|O_EXCL."""
pid_file = tmp_path / "daemon.pid"
monkeypatch.setattr(daemon_mod, "DAEMON_PID_FILE", pid_file)
# First daemon wins
pid_file.write_text(str(os.getpid()), encoding="utf-8")
# Second daemon: file exists, owner is alive → returns False
result = _write_pid_file()
assert result is False
@@ -313,3 +313,192 @@ def test_mark_as_closed_and_archive_updates_counts(tmp_path: Path):
inbox_data = json.load(f) inbox_data = json.load(f)
assert inbox_data["total_messages"] == 1 assert inbox_data["total_messages"] == 1
assert inbox_data["unread_count"] == 1 assert inbox_data["unread_count"] == 1
# ---- _sweep_closed tests ----------------------------------------
def test_sweep_closed_removes_closed_messages(tmp_path: Path):
"""Messages with status=closed are archived to deleted/ and removed."""
mailbox = tmp_path / ".ai_mail.local"
mailbox.mkdir(parents=True)
inbox_data = {
"messages": [
{"id": "m1", "status": "new", "subject": "Active"},
{"id": "m2", "status": "closed", "subject": "Done"},
{"id": "m3", "status": "opened", "subject": "Read"},
]
}
swept = mod._sweep_closed(inbox_data, mailbox)
assert swept == 1
assert len(inbox_data["messages"]) == 2
assert all(m["id"] != "m2" for m in inbox_data["messages"])
deleted_files = list((mailbox / "deleted").glob("*.json"))
assert len(deleted_files) == 1
def test_sweep_closed_no_closed_messages_is_noop(tmp_path: Path):
"""Inbox with zero closed messages returns 0 and makes no changes."""
mailbox = tmp_path / ".ai_mail.local"
mailbox.mkdir(parents=True)
original_messages = [
{"id": "m1", "status": "new", "subject": "A"},
{"id": "m2", "status": "opened", "subject": "B"},
]
inbox_data = {"messages": list(original_messages)}
swept = mod._sweep_closed(inbox_data, mailbox)
assert swept == 0
assert len(inbox_data["messages"]) == 2
assert not (mailbox / "deleted").exists()
def test_sweep_closed_multiple_closed(tmp_path: Path):
"""Multiple closed messages are all swept in one pass."""
mailbox = tmp_path / ".ai_mail.local"
mailbox.mkdir(parents=True)
inbox_data = {
"messages": [
{"id": "m1", "status": "closed", "subject": "Done1"},
{"id": "m2", "status": "closed", "subject": "Done2"},
{"id": "m3", "status": "new", "subject": "Active"},
]
}
swept = mod._sweep_closed(inbox_data, mailbox)
assert swept == 2
assert len(inbox_data["messages"]) == 1
assert inbox_data["messages"][0]["id"] == "m3"
deleted_files = list((mailbox / "deleted").glob("*.json"))
assert len(deleted_files) == 2
def test_sweep_closed_archive_failure_still_removes(tmp_path: Path, monkeypatch):
"""If archival fails for one message, it's still removed from inbox."""
mailbox = tmp_path / ".ai_mail.local"
mailbox.mkdir(parents=True)
call_count = [0]
original_save = mod._save_to_deleted_folder
def _failing_save(mp, msg):
call_count[0] += 1
if call_count[0] == 1:
raise OSError("disk full")
return original_save(mp, msg)
monkeypatch.setattr(mod, "_save_to_deleted_folder", _failing_save)
inbox_data = {
"messages": [
{"id": "m1", "status": "closed", "subject": "Fail"},
{"id": "m2", "status": "closed", "subject": "OK"},
{"id": "m3", "status": "new", "subject": "Active"},
]
}
swept = mod._sweep_closed(inbox_data, mailbox)
assert swept == 2
assert len(inbox_data["messages"]) == 1
def test_sweep_closed_on_read_via_load_inbox(tmp_path: Path, monkeypatch):
"""Closed message injected by raw JSON edit is swept on next load_inbox."""
import aipass.ai_mail.apps.handlers.email.inbox_ops as ops_mod
monkeypatch.setattr(ops_mod, "_get_inbox_lock", lambda: _noop_lock)
mailbox = tmp_path / ".ai_mail.local"
mailbox.mkdir(parents=True)
inbox_file = mailbox / "inbox.json"
data = {
"mailbox": "inbox",
"total_messages": 2,
"unread_count": 1,
"messages": [
{"id": "m1", "status": "new", "subject": "Active", "read": False},
{"id": "m2", "status": "closed", "subject": "Stale", "read": True},
],
}
inbox_file.write_text(json.dumps(data), encoding="utf-8")
result = ops_mod.load_inbox(inbox_file)
assert len(result["messages"]) == 1
assert result["messages"][0]["id"] == "m1"
# Verify persistence — file should be updated
with open(inbox_file, "r", encoding="utf-8") as f:
persisted = json.load(f)
assert len(persisted["messages"]) == 1
# Verify archived
deleted_files = list((mailbox / "deleted").glob("*.json"))
assert len(deleted_files) == 1
def test_sweep_on_mark_as_opened_cleans_stale_closed(tmp_path: Path):
"""Opening a message also sweeps any stale closed messages in inbox."""
branch_path = tmp_path / "branch"
_make_inbox(
branch_path,
[
{"id": "m1", "status": "new", "subject": "Target", "read": False},
{"id": "m2", "status": "closed", "subject": "Stale", "read": True},
],
)
success, _, _ = mod.mark_as_opened(branch_path, "m1")
assert success is True
inbox_file = branch_path / ".ai_mail.local" / "inbox.json"
with open(inbox_file, "r", encoding="utf-8") as f:
inbox_data = json.load(f)
assert len(inbox_data["messages"]) == 1
assert inbox_data["messages"][0]["id"] == "m1"
assert inbox_data["total_messages"] == 1
def test_sweep_on_mark_as_closed_cleans_other_stale(tmp_path: Path):
"""Closing one message also sweeps other stale closed messages."""
branch_path = tmp_path / "branch"
_make_inbox(
branch_path,
[
{"id": "m1", "status": "opened", "subject": "Close Me", "read": True},
{"id": "m2", "status": "closed", "subject": "Stale", "read": True},
{"id": "m3", "status": "new", "subject": "Active", "read": False},
],
)
success, _ = mod.mark_as_closed_and_archive(branch_path, "m1")
assert success is True
inbox_file = branch_path / ".ai_mail.local" / "inbox.json"
with open(inbox_file, "r", encoding="utf-8") as f:
inbox_data = json.load(f)
assert len(inbox_data["messages"]) == 1
assert inbox_data["messages"][0]["id"] == "m3"
assert inbox_data["total_messages"] == 1
assert inbox_data["unread_count"] == 1
# Both m1 (properly closed) and m2 (swept) should be in deleted/
deleted_files = list((branch_path / ".ai_mail.local" / "deleted").glob("*.json"))
assert len(deleted_files) == 2
def test_proper_close_does_not_double_archive(tmp_path: Path):
"""Closing via mark_as_closed_and_archive does not produce duplicate archives."""
branch_path = tmp_path / "branch"
_make_inbox(
branch_path,
[{"id": "m1", "status": "opened", "subject": "Normal Close", "read": True}],
)
success, _ = mod.mark_as_closed_and_archive(branch_path, "m1")
assert success is True
deleted_files = list((branch_path / ".ai_mail.local" / "deleted").glob("*.json"))
assert len(deleted_files) == 1
@@ -163,7 +163,6 @@ def test_daemon_poll_cycle_is_called(tmp_path, monkeypatch):
with ( with (
patch.object(daemon_mod, "_write_pid_file", return_value=True), patch.object(daemon_mod, "_write_pid_file", return_value=True),
patch.object(daemon_mod, "_remove_pid_file"), patch.object(daemon_mod, "_remove_pid_file"),
patch.object(daemon_mod, "_notify_telegram", return_value=False),
patch.object(daemon_mod, "poll_cycle", side_effect=mock_poll_cycle), patch.object(daemon_mod, "poll_cycle", side_effect=mock_poll_cycle),
patch.object(daemon_mod, "save_daemon_state"), patch.object(daemon_mod, "save_daemon_state"),
patch.object(daemon_mod, "is_kill_switch_active", return_value=False), patch.object(daemon_mod, "is_kill_switch_active", return_value=False),
+5 -90
View File
@@ -29,7 +29,6 @@ from aipass.ai_mail.apps.handlers.dispatch.wake import (
DEFAULT_MODEL, DEFAULT_MODEL,
_acquire_lock, _acquire_lock,
_load_config, _load_config,
_set_session_name,
_is_branch_occupied, _is_branch_occupied,
wake_branch, wake_branch,
) )
@@ -710,84 +709,6 @@ class TestLoadConfig:
assert result["max_turns_per_wake"] == 50 assert result["max_turns_per_wake"] == 50
# --- _set_session_name tests --------------------------------------------
class TestSetSessionName:
"""Tests for _set_session_name() — writes custom-title to Claude session JSONL."""
def test_success_appends_entry(self, tmp_path, monkeypatch):
"""Creates expected JSON entry in the most recent session JSONL."""
encoded_cwd = str(tmp_path).replace("/", "-")
projects_dir = tmp_path / ".claude" / "projects" / encoded_cwd
projects_dir.mkdir(parents=True)
session_file = projects_dir / "abc123.jsonl"
session_file.write_text("", encoding="utf-8")
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.dispatch.wake.Path.expanduser",
lambda self: tmp_path / ".claude" / "projects" if str(self).endswith("projects") else self,
)
# We need to mock expanduser properly — override the whole projects_dir lookup
monkeypatch.setattr(
_Path,
"expanduser",
lambda self: tmp_path / str(self).lstrip("~/"),
)
result = _set_session_name(tmp_path, "TEST-dispatched")
assert result is True
content = session_file.read_text(encoding="utf-8")
entry = json.loads(content.strip())
assert entry["type"] == "custom-title"
assert entry["customTitle"] == "TEST-dispatched"
assert entry["sessionId"] == "abc123"
def test_no_projects_dir_returns_false(self, tmp_path, monkeypatch):
"""Returns False when ~/.claude/projects/{encoded} does not exist."""
monkeypatch.setattr(
_Path,
"expanduser",
lambda self: tmp_path / str(self).lstrip("~/"),
)
result = _set_session_name(tmp_path, "TEST-dispatched")
assert result is False
def test_no_jsonl_files_returns_false(self, tmp_path, monkeypatch):
"""Returns False when projects dir exists but has no .jsonl files."""
encoded_cwd = str(tmp_path).replace("/", "-")
projects_dir = tmp_path / ".claude" / "projects" / encoded_cwd
projects_dir.mkdir(parents=True)
monkeypatch.setattr(
_Path,
"expanduser",
lambda self: tmp_path / str(self).lstrip("~/"),
)
result = _set_session_name(tmp_path, "TEST-dispatched")
assert result is False
def test_os_error_on_write_returns_false(self, tmp_path, monkeypatch):
"""Returns False when write to JSONL file raises OSError."""
encoded_cwd = str(tmp_path).replace("/", "-")
projects_dir = tmp_path / ".claude" / "projects" / encoded_cwd
projects_dir.mkdir(parents=True)
session_file = projects_dir / "abc123.jsonl"
session_file.write_text("", encoding="utf-8")
monkeypatch.setattr(
_Path,
"expanduser",
lambda self: tmp_path / str(self).lstrip("~/"),
)
def _fail_open(path, *args, **kwargs):
path_str = str(path)
if path_str.endswith(".jsonl") and "a" in args:
raise OSError("permission denied")
return _REAL_OPEN(path, *args, **kwargs)
monkeypatch.setattr("builtins.open", _fail_open)
result = _set_session_name(tmp_path, "TEST-dispatched")
assert result is False
# --- _is_branch_occupied tests ------------------------------------------ # --- _is_branch_occupied tests ------------------------------------------
@@ -1103,21 +1024,15 @@ class TestWakeBranch:
assert ok is False assert ok is False
assert any(s[0] == "fail" and "RuntimeError" in s[2] for s in status.steps) assert any(s[0] == "fail" and "RuntimeError" in s[2] for s in status.steps)
# --- post-spawn: lock acquisition failure --- # --- pre-spawn: lock acquisition failure (DPLAN-0155) ---
def test_lock_acquisition_fails_after_spawn_warns(self, tmp_path, monkeypatch): def test_lock_acquisition_fails_before_spawn(self, tmp_path, monkeypatch):
"""Lock fails after spawn -> warn step but still succeeds.""" """Lock fails before spawn -> fail step, returns False (DPLAN-0155 lock-before-spawn)."""
_make_wake_fixtures(tmp_path, monkeypatch) _make_wake_fixtures(tmp_path, monkeypatch)
_patch_wake_deps(monkeypatch, _acquire_lock=lambda p, pid: (False, "Lock file already exists")) _patch_wake_deps(monkeypatch, _acquire_lock=lambda p, pid: (False, "Lock file already exists"))
monkeypatch.setattr("subprocess.Popen", lambda *a, **kw: _FakeProc())
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.notify.send_notification",
lambda *a, **kw: None,
raising=False,
)
status, ok = wake_branch("@testbranch") status, ok = wake_branch("@testbranch")
assert ok is True assert ok is False
assert any(s[0] == "warn" and "lock-acquire" in s[1] for s in status.steps) assert any(s[0] == "fail" and "lock-acquire" in s[1] for s in status.steps)
# --- alive check fails --- # --- alive check fails ---
+3
View File
@@ -0,0 +1,3 @@
# Branch Prompt
AI context for `AIPASS`. The `aipass_local_prompt.md` file is injected every turn, telling the AI who you are and how to work in your branch.
@@ -0,0 +1,109 @@
<!-- Source: /home/patrick/Projects/AIPass/src/aipass/aipass/.aipass/aipass_global_prompt.md -->
# AIPASS — Project Context
<!-- Injected every turn via hook. -->
## What is AIPass
AIPass is a multi-agent framework. Agents live in directories with
persistent identity, memory, and communication. All AIPass infrastructure
is available from any project via the `drone` command.
## Terminology
- **Project** — this directory. Contains a registry and agents.
- **Agent** — a citizen with identity (`.trinity/`), memory, mailbox,
and code (`apps/`).
- **Registry** — `AIPASS_REGISTRY.json` tracks all agents.
## Setup: if drone commands fail
If `drone` cannot find the AIPass registry, set the env var:
```bash
export AIPASS_HOME=/path/to/AIPass # path to AIPass installation
```
Add to your shell profile (`~/.bashrc` or `~/.zshrc`) to make it permanent.
## Commands
### Agent Lifecycle
```
aipass init agent <name> # Create a new agent in src/<name>/
drone @spawn create <name> # Create agent (alternative)
drone @spawn list # List registered agents
```
### Standards
```
drone @seedgo audit <project> # Run full standards audit
drone @seedgo checklist <file> # Check a single file
```
### Dispatch — Send Task + Wake an Agent (DEFAULT)
```
drone @ai_mail dispatch @<agent> "Subject" "Body" # Send + wake (default)
drone @ai_mail dispatch @<agent> "Subject" "Body" --fresh # Send + wake fresh session
drone @ai_mail dispatch wake @<agent> # Wake without sending
drone @ai_mail dispatch wake --fresh @<agent> # Wake fresh
drone @ai_mail email @<agent> "Subject" "Body" # FYI only (no wake)
```
Use `dispatch` by default. Use `email` only when you don't need the agent to act now.
### Communication (ai_mail)
```
drone @ai_mail inbox # Check your mailbox
drone @ai_mail view <id> # Read a message
drone @ai_mail close <id> # Mark message read
```
### Feedback
```
drone @devpulse feedback send "Subject" "Body" # Send feedback (cross-project)
```
### Plans (flow)
```
drone @flow create . "Subject" dplan # Create DPLAN (design/thinking)
drone @flow create . "Subject" master # Create FPLAN master (execution)
drone @flow create . "Subject" aplan # Create APLAN (agent-level task)
drone @flow list open # List active plans
drone @flow list # List all plans
drone @flow close <id> # Close a plan
drone @flow info <id> # View plan details
```
**DPLAN** = Dev Plan. Thinking, brainstorming, architecture decisions. Use before building.
**FPLAN** = Flow Plan. Building and executing. Use when the plan is clear and work is underway.
### Memory
```
drone @memory archive # Archive memories to vector store
drone @memory search <query> # Search archived memories
```
### Git Workflow
```
drone @git pr 'description' # Create a pull request
drone @git status # Git status (branch-scoped)
drone @git sync # Sync with main
drone @git lock / unlock # Lock/unlock the repo
```
### Infrastructure
```
drone systems # List all available infrastructure
drone --help # Full drone command reference
```
## Patterns
- **Communication** — agents communicate via `.ai_mail.local/`.
- **Standards** — run `drone @seedgo audit` to check compliance.
- **Identity** — agents have `.trinity/passport.json`. Projects use the registry.
- **Memory** — update `.trinity/local.json` at session end. Memory is presence.
## Maintenance
- **Upgrade scaffold**: `drone @cli aipass init update` refreshes managed project files (hooks, prompts, settings) to latest templates.
- **Entry point**: each agent's `apps/{name}.py` auto-configures `sys.path` and `AIPASS_BRANCH_NAME` env var. If prax logs to `unknown_branch/`, check that these are set.
- **Standalone projects** use `src/{name}/` layout (not `src/aipass/{name}/`). Module discovery adapts automatically.
@@ -0,0 +1,84 @@
# AIPASS — Branch Prompt
*Injected every turn. Breadcrumbs only — details in README, --help, .trinity/ memories, STATUS.local.md.*
## Identity
You are AIPASS — the friendly front door. New users land here. You greet them, walk them through setup, answer how-things-work questions, hand them off to their chosen CLI. Drone is the engine. You are the concierge. You are the librarian — read anything, inspect anything, point anywhere. You do not build.
## Hard Rules — what you cannot do
These are not suggestions. Violating them is a bug.
- **No writes outside your own `.trinity/`.** Never create, edit, or delete files anywhere else. Not code, not docs, not configs, not other branches' memories.
- **No git. Ever.** Not `git status`, not `drone @git anything`. Git is drone's world.
- **No `drone @ai_mail dispatch`.** You email only with the test-convention body (below). You never wake an agent for real work.
- **No registry / hooks / bypass.json / config edits.** Even if you spot a bug, you report — you never patch.
- If a user asks you to build, fix, or change something: tell them who to ask. Offer dispatch through devpulse or drone — don't do it.
## What I Do
- Guide new users through `aipass init` (12 stages: welcome, system detect, doctor, profile, style questions, tool choice, docker offer, first agent, ping sweep, smoke test, handoff, done)
- Answer "how does X work?" via `aipass help` — live README reads, offer depth, route to branch experts
- Run `aipass doctor` — aggregate seedgo, pytest, registry, hooks, git state, AIPASS_HOME
- Remember the user — name, OS, preferred CLI, setup progress in `.trinity/local.json`
- Test the system non-mutatingly — test-convention emails, empty flow plan open/close, pytest collect
## Key Commands
```
aipass # Help banner with all commands
aipass help [q] # Chatbot Q&A over branch READMEs
aipass doctor # System health aggregation
aipass init # 12-stage guided setup for new users, resumable
aipass profile # Show/edit what I know about the user
aipass --version
```
## Test-Convention Emails
Your only safe way to touch the system. Body MUST include this token:
```
[AIPASS-TEST — do not update memories, do not execute, reply 'ack' only]
```
Other core agents recognize this and respond with "ack" — no task execution, no memory update, no spawn.
## Architecture
```
apps/
├── aipass.py # Entry point — thin CLI dispatch
├── modules/
│ ├── doctor.py # System health aggregation
│ ├── help_chat.py # README-backed Q&A
│ ├── init_flow.py # 12-stage guided setup, resumable
│ ├── handoff.py # CLI handoff (tmux / wt.exe)
│ └── profile.py # User profile read/write
└── handlers/
├── system_detect/ # OS, shell, Python, RAM, CPU, install method
├── ping_sweep/ # Verify each branch responds
├── readme_map/ # Live file reads with branch routing
└── ui/ # Progress bars, menus, banners
```
## Integration
- **Depends on:** @drone (routing), @seedgo (audit), @spawn (first agent creation), @flow (plan test open/close), @ai_mail (test emails), @prax (health signals), pytest, CLI tools (Claude/Codex/Gemini)
- **Serves:** New users first. Also humans asking "how does this work?" anywhere in the ecosystem.
- **Nothing depends on me.** One-way relationship. I can be removed or replaced without ripple.
## Working Habits
- **Verify, don't remember.** Every question triggers a live file read. Cache the branch-name → README-path map only — never cache ANSWERS.
- **Offer depth, don't assume.** First response is concise. Then ask: "want to go into the code?" / "want me to connect you with @drone?"
- **Warm tone, no jargon on first contact.** Assume the user doesn't know what a citizen is. Explain as you go.
- **Never pretend.** If you don't know: say so, then offer to find out or to ask the branch expert.
- **Clean handoffs.** Every init stage saves to `setup_progress` in `.trinity/local.json` so resume works.
## Known Gotchas
- **Status: under construction.** Whole branch is gitignored. Do not PR anything from this directory until Phase 8 reveal (DPLAN-0136).
- **The `aipass` binary is currently `cli` branch's `aipass init`** — project bootstrap, not citizen creation. Eventually this CLI entry moves here. Until then, use `drone @spawn create` for citizen creation.
- **Test-convention tokens need buy-in.** Core agents don't yet recognize `[AIPASS-TEST — ...]`. Coordinating with @ai_mail before pinging anyone.
+5
View File
@@ -0,0 +1,5 @@
# Claude Code Settings
Claude Code configuration for `AIPASS`.
Contains `settings.local.json` with permission rules. Most branches are denied raw git commands and must use `drone @git` instead.
+56
View File
@@ -0,0 +1,56 @@
# Project-Level Hooks
These hooks are provisioned by `aipass init` and live in the project's
`.claude/settings.json`. They fire when CWD is inside this project.
## What fires and what doesn't
**UserPromptSubmit** hooks fire from project settings. These work:
- `branch_prompt_loader.py` — injects branch-specific prompt
- `email_notification.py` — shows unread email count
- `identity_injector.py` — injects branch identity from passport
**PreToolUse / PostToolUse** hooks are provisioned but **DO NOT FIRE** from
project-level settings. This is a Claude Code limitation (confirmed S122,
GitHub issue #36071). These scripts exist but are dead weight:
- `pre_edit_gate.py` — intended to block cross-branch writes (never runs)
- `auto_fix_diagnostics.py` — intended to run pyright+ruff (never runs)
- `subagent_stop_gate.py` — intended to check subagent files (never runs)
These same hooks DO fire from provider settings (`~/.claude/settings.json`)
where they are also wired. The provider copies handle all enforcement.
**PreCompact** hooks fire from project settings:
- `pre_compact.py` — injects recovery context after compaction
## CWD guard interaction
When this project has UserPromptSubmit hooks (it does), the provider-level
UserPromptSubmit hooks detect this and exit silently. This prevents the AIPass
global prompt from being injected into projects that manage their own context.
The provider-level PreToolUse/PostToolUse hooks still fire (they can only run
at provider level) — so enforcement (git_gate, pre_edit_gate, auto_fix) is
always active regardless of CWD.
## Testing
Provider-level test harness covers project-level behavior:
```bash
python3 $AIPASS_HOME/.claude/hooks/hook_test.py --direct
```
Tests include:
- `direct_provider_guards_for_init_project` — verifies provider hooks are
CWD-guarded when run from an aipass init project
- `direct_project_settings_schema` — validates project settings.json has
expected hooks and all referenced scripts exist
## Updating hooks
```bash
drone @cli aipass init update # Refresh managed project files to latest templates
```
## Related
See `$AIPASS_HOME/.claude/hooks/README.md` for the full hook system documentation.
@@ -0,0 +1,366 @@
#!/usr/bin/env python3
"""
PostToolUse Auto-fix Hook — Detects errors and surfaces them for fixing.
Two-hook system:
PostToolUse (this file) → runs pyright + ruff on edited file, saves errors to state
PreToolUse (pre_edit_gate.py) → blocks edits to OTHER files until errors fixed
Key behaviors:
- Runs py_compile (syntax), ruff lint+format, pyright (type errors) on edited file
- Runs seedgo checklist for AIPass standards
- Saves ruff lint AND pyright errors to state file for PreToolUse gate (hard block)
- Surfaces ALL errors in additionalContext so Claude sees them
Version: 5.2.0
CHANGELOG:
- v5.2.0 (2026-04-20): Save ruff lint errors to state file for hard-block enforcement.
Pre-edit gate now blocks on F401/lint just like type errors.
- v5.1.0 (2026-04-19): Added ruff format --check to surface format drift.
- v5.0.0 (2026-03-17): Replaced mcp__ide__getDiagnostics with direct pyright.
Added state file for PreToolUse gate integration.
Single-file pyright (not whole project).
- v4.3.0 (2026-03-17): Added seedgo checklist integration
- v4.0.0 (2025-11-27): Complete rewrite - actual validation, silent operation
"""
import json
import sys
import subprocess
from pathlib import Path
EDIT_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit"]
LAST_FILE_PATH = Path(__file__).parent / ".last_diagnostics_file"
STATE_FILE = Path(__file__).parent / ".diagnostics_state.json"
SKIP_EXTENSIONS = {".md", ".txt", ".log", ".csv", ".html"}
# AIPass-specific Python patterns to check
PYTHON_PATTERNS = {
"bad_optional": {"pattern": ": str = None", "message": "Optional param should use 'str | None = None' pattern"},
"logger_debug": {
"pattern": "logger.debug(",
"message": "Use logger.info for SystemLogger (logger.debug not supported)",
},
"return_error_msg": {
"pattern": "return error_msg",
"message": "Return None for error states, not error_msg string",
},
"open_no_encoding": {
"pattern": "open(",
"requires_missing": "encoding=",
"message": "open() without encoding='utf-8'",
},
"log_not_log_operation": {
"pattern": ".log(",
"message": "Use log_operation() with success/error params, not .log()",
},
"dict_none_no_check": {
"pattern": "Dict | None",
"message": "Dict | None return: Add None check before using (if result is None: return)",
},
}
# JSON-specific patterns for emoji corruption
JSON_CORRUPTION_CHARS = ["\ufffd", "\x00"]
def run_python_checks(file_path: str) -> list[str]:
"""Run actual Python validation - returns list of errors."""
errors = []
# 1. Syntax check with py_compile
try:
result = subprocess.run(
[sys.executable, "-m", "py_compile", file_path], capture_output=True, text=True, timeout=5
)
if result.returncode != 0:
errors.append(f"SYNTAX: {result.stderr.strip()}")
except Exception:
pass
# 2. Ruff check (if available) - fast linter
try:
result = subprocess.run(
["ruff", "check", "--select=E,F,W", "--output-format=text", file_path],
capture_output=True,
text=True,
timeout=10,
)
if result.stdout.strip():
for line in result.stdout.strip().split("\n")[:5]:
errors.append(f"LINT: {line}")
except FileNotFoundError:
pass
except Exception:
pass
# 3. Ruff format check — detect format drift
try:
result = subprocess.run(["ruff", "format", "--check", file_path], capture_output=True, text=True, timeout=10)
if result.returncode != 0:
errors.append(f"FORMAT: {Path(file_path).name} needs ruff format (run: ruff format {Path(file_path).name})")
except FileNotFoundError:
pass
except Exception:
pass
# 4. AIPass-specific pattern checks
try:
content = Path(file_path).read_text(encoding="utf-8")
lines = content.split("\n")
for check in PYTHON_PATTERNS.values():
pattern = check["pattern"]
message = check["message"]
requires_missing = check.get("requires_missing")
if requires_missing:
if pattern in content and requires_missing not in content:
errors.append(f"PATTERN: {message}")
continue
for line in lines:
stripped = line.strip()
if stripped.startswith(("#", '"', "'")):
continue
if f'"{pattern}' in line or f"'{pattern}" in line:
continue
if pattern in line:
errors.append(f"PATTERN: {message}")
break
except Exception:
pass
return errors
def run_ruff_lint_structured(file_path: str) -> list[dict]:
"""Run ruff check and return structured violations for the state file.
Returns list of {line, message} dicts — same format as pyright errors.
Only non-empty when ruff finds real violations (not format drift).
"""
if "/.claude/hooks/" in file_path:
return []
try:
result = subprocess.run(
["ruff", "check", "--select=E,F,W", "--output-format=json", file_path],
capture_output=True,
text=True,
timeout=10,
)
if not result.stdout.strip():
return []
violations = json.loads(result.stdout)
if not isinstance(violations, list):
return []
errors = []
for v in violations[:10]:
line = v.get("location", {}).get("row", 0)
code = v.get("code", "?")
message = v.get("message", "unknown")[:100]
errors.append({"line": line, "message": f"{code}: {message}"})
return errors
except (FileNotFoundError, json.JSONDecodeError, subprocess.TimeoutExpired, Exception):
return []
def run_pyright_check(file_path: str) -> list[dict]:
"""Run pyright on a single file. Returns list of error dicts."""
# Skip hook files - they don't follow project standards
if "/.claude/hooks/" in file_path:
return []
try:
result = subprocess.run(
[sys.executable, "-m", "pyright", "--outputjson", file_path], capture_output=True, text=True, timeout=15
)
try:
data = json.loads(result.stdout)
except (json.JSONDecodeError, ValueError):
return []
errors = []
for diag in data.get("generalDiagnostics", []):
severity = diag.get("severity", "")
if severity == "error":
line = diag.get("range", {}).get("start", {}).get("line", 0)
message = diag.get("message", "Unknown error")
errors.append({"line": line, "message": message[:100]})
return errors[:10] # Max 10 errors
except FileNotFoundError:
return [] # pyright not installed
except subprocess.TimeoutExpired:
return [] # Timeout — don't block
except Exception:
return []
def save_diagnostics_state(file_path: str, errors: list[dict]):
"""Save type errors to state file for PreToolUse gate."""
try:
if errors:
state = {"file": str(Path(file_path).resolve()), "errors": errors}
STATE_FILE.write_text(json.dumps(state), encoding="utf-8")
else:
# No errors — clear the state
if STATE_FILE.exists():
STATE_FILE.unlink()
except Exception:
pass
def run_json_checks(file_path: str) -> list[str]:
"""Run actual JSON validation - returns list of errors."""
errors = []
try:
content = Path(file_path).read_text(encoding="utf-8")
for char in JSON_CORRUPTION_CHARS:
if char in content:
errors.append(f"EMOJI CORRUPTION: Found corrupted character '{repr(char)}'")
break
try:
data = json.loads(content)
if isinstance(data, dict):
for key in ["allowed_emojis", "emojis", "emoji_list"]:
if key in data and isinstance(data[key], list):
for item in data[key]:
if isinstance(item, str) and len(item) == 1:
if ord(item) < 128 and item not in "\u2713\u2717":
errors.append(f"EMOJI CORRUPTION: Suspicious char '{item}' in {key}")
break
except json.JSONDecodeError as e:
errors.append(f"JSON SYNTAX: {e.msg} at line {e.lineno}")
except Exception as e:
errors.append(f"READ ERROR: {e!s}")
return errors
def run_seedgo_checklist(file_path: str) -> list[str]:
"""Run seedgo standards checklist — returns violations only."""
if "/.claude/hooks/" in file_path:
return []
try:
result = subprocess.run(
["drone", "@seedgo", "checklist", file_path],
capture_output=True,
text=True,
timeout=15,
cwd=str(Path.home() / "Projects" / "AIPass"),
)
if result.returncode != 0:
return []
violations = []
for line in result.stdout.split("\n"):
line = line.strip()
if line.startswith("\u2717"):
violation = line[1:].strip()
if violation:
violations.append(violation)
return violations[:5]
except FileNotFoundError:
return []
except Exception:
return []
def should_skip_file(file_path: str) -> bool:
"""Check if file should be skipped."""
if not file_path:
return True
ext = Path(file_path).suffix.lower()
return ext in SKIP_EXTENSIONS
def is_same_file_as_last(file_path: str) -> bool:
"""Smart batching DISABLED — always recheck.
Previously skipped rechecks on the same file, but this caused
errors introduced on second edit to be missed (state file didn't
exist from first clean edit, so skip triggered). The 1.7s pyright
cost per edit is acceptable for correctness.
"""
return False
def main():
"""Main hook entry point."""
try:
input_data = json.load(sys.stdin)
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
file_path = tool_input.get("file_path", "")
if tool_name not in EDIT_TOOLS:
return
if should_skip_file(file_path):
return
if is_same_file_as_last(file_path):
return
# Collect all errors
errors = []
if file_path.endswith(".py"):
errors = run_python_checks(file_path)
# Seedgo standards checklist
seedgo_violations = run_seedgo_checklist(file_path)
for v in seedgo_violations:
errors.append(f"SEEDGO: {v}")
# Pyright type errors (single file)
type_errors = run_pyright_check(file_path)
for te in type_errors:
errors.append(f"TYPE: L{te['line']}: {te['message']}")
# Save ruff lint + type errors to state file for PreToolUse gate (hard block)
ruff_lint_errors = run_ruff_lint_structured(file_path)
save_diagnostics_state(file_path, ruff_lint_errors + type_errors)
elif file_path.endswith(".json"):
errors = run_json_checks(file_path)
else:
return
# Build output
if errors:
error_text = "\n".join(f" - {e}" for e in errors)
context = f"""[AUTO-FIX] {len(errors)} error(s) in {Path(file_path).name}:
{error_text}
Fix these errors in {Path(file_path).name} now. Do not skip or defer."""
output = {
"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": context},
"systemMessage": f"[AUTO-FIX] {len(errors)} error(s) — fix before continuing",
}
print(json.dumps(output))
else:
output = {"systemMessage": "[diagnostics] ok"}
print(json.dumps(output))
except Exception:
pass # Silent fail
if __name__ == "__main__":
main()
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""
Branch Prompt Loader — AIPass Public Repo
Injects branch-specific prompts based on CWD. When working in a branch
directory, loads .aipass/aipass_local_prompt.md and outputs it so the
AI sees branch-specific context.
Version: 1.0.0
"""
from pathlib import Path
def find_branch_root() -> Path | None:
"""
Find the branch root directory.
Looks for .trinity/ or .aipass/ as branch indicators.
Stops at the repo root (has pyproject.toml or .git).
"""
cwd = Path.cwd()
search_path = cwd
while search_path.parent != search_path:
# Branch indicators: has .trinity/ (memory files) or apps/ (code)
has_trinity = (search_path / ".trinity").is_dir()
has_apps = (search_path / "apps").is_dir()
if has_trinity or has_apps:
return search_path
# Stop at repo root
if (search_path / "pyproject.toml").exists() or (search_path / ".git").is_dir():
return None
search_path = search_path.parent
return None
def main():
branch_root = find_branch_root()
if branch_root:
prompt_file = branch_root / ".aipass" / "aipass_local_prompt.md"
if prompt_file.exists():
content = prompt_file.read_text().strip()
branch_name = branch_root.name.upper()
print(f"\n# Branch Context: {branch_name}\n<!-- Source: {prompt_file} -->\n{content}")
if __name__ == "__main__":
main()
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
Email Notification Hook - Notifies of new emails on prompt submit.
Checks the current branch's inbox for unread emails and displays
a notification if any exist.
Version: 1.0.0
"""
import json
from pathlib import Path
def find_repo_root() -> Path | None:
"""Find the repo root (contains pyproject.toml or .git)."""
search = Path.cwd()
while search.parent != search:
if (search / "pyproject.toml").exists() or (search / ".git").is_dir():
return search
search = search.parent
return None
def find_branch_root() -> Path | None:
"""Find the branch root directory by walking up from CWD."""
cwd = Path.cwd()
repo_root = find_repo_root()
if not repo_root:
return None
search_path = cwd
for _ in range(10):
has_trinity = (search_path / ".trinity").is_dir()
has_id = list(search_path.glob("*.id.json"))
has_apps = (search_path / "apps").is_dir()
has_mail = (search_path / ".ai_mail.local").is_dir() or (search_path / "ai_mail.local").is_dir()
if (has_trinity or has_id or has_apps or has_mail) and search_path != repo_root:
return search_path
if search_path == repo_root:
break
parent = search_path.parent
if parent == search_path:
break
search_path = parent
return None
def count_new_emails(branch_root: Path) -> int:
"""Count new (unread) emails in the branch's inbox."""
# Check both patterns: .ai_mail.local (canonical) and ai_mail.local (legacy)
inbox_path = branch_root / ".ai_mail.local" / "inbox.json"
if not inbox_path.exists():
inbox_path = branch_root / "ai_mail.local" / "inbox.json"
if not inbox_path.exists():
return 0
try:
with open(inbox_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Handle both formats: {"messages": [...]} and bare [...]
messages = data if isinstance(data, list) else data.get("messages", [])
count = 0
for msg in messages:
if msg.get("status") == "new":
count += 1
elif msg.get("status") is None and not msg.get("read", False):
count += 1
return count
except (json.JSONDecodeError, OSError):
return 0
def main():
branch_root = find_branch_root()
if not branch_root:
return
new_count = count_new_emails(branch_root)
if new_count > 0:
plural = "s" if new_count != 1 else ""
print(
f"You have {new_count} new email{plural} - check with: drone @ai_mail inbox | then: drone @ai_mail view <id> | close with: drone @ai_mail close <id>"
)
if __name__ == "__main__":
main()
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""
Identity Injector - Injects branch identity on every prompt.
Reads from [BRANCH].id.json and outputs core identity fields.
Finds the branch root by walking up from CWD looking for apps/ or *.id.json.
Version: 1.0.0
"""
import json
from pathlib import Path
def find_repo_root() -> Path | None:
"""Find the repo root (contains pyproject.toml or .git)."""
search = Path.cwd()
while search.parent != search:
if (search / "pyproject.toml").exists() or (search / ".git").is_dir():
return search
search = search.parent
return None
def find_branch_root() -> Path | None:
"""Find the branch root directory by walking up from CWD."""
cwd = Path.cwd()
repo_root = find_repo_root()
if not repo_root:
return None
search_path = cwd
while search_path >= repo_root:
has_trinity = (search_path / ".trinity").is_dir()
has_id = list(search_path.glob("*.id.json"))
if has_trinity or has_id:
return search_path
if search_path == repo_root:
break
search_path = search_path.parent
return None
def find_id_file(branch_root: Path) -> Path | None:
"""Find the identity file for a branch (.trinity/passport.json or *.id.json)."""
# AIPass pattern: .trinity/passport.json
passport = branch_root / ".trinity" / "passport.json"
if passport.exists():
return passport
# Dev-Pass fallback: *.id.json
id_files = list(branch_root.glob("*.id.json"))
if id_files:
return id_files[0]
return None
def format_identity(data: dict) -> str:
"""Format branch_info + identity for injection."""
lines = []
# Try branch_info first (enriched passports), fall back to identity block (setup.sh passports)
branch = data.get("branch_info", {})
identity = data.get("identity", {})
name = branch.get("branch_name") or identity.get("name", "UNKNOWN")
lines.append(f"# {name} Identity")
lines.append(f"Path: {branch.get('path', 'unknown')}")
lines.append(f"Email: {branch.get('email', 'unknown')}")
identity = data.get("identity", {})
if identity.get("role"):
lines.append(f"Role: {identity['role']}")
traits = identity.get("traits") or data.get("traits")
if traits:
if isinstance(traits, list):
lines.append("Traits: " + " | ".join(traits))
else:
lines.append(f"Traits: {traits}")
if identity.get("purpose"):
lines.append(f"Purpose: {identity['purpose']}")
what_i_do = identity.get("what_i_do", [])
if what_i_do:
lines.append("Do: " + " | ".join(what_i_do[:4]))
what_i_dont_do = identity.get("what_i_dont_do", [])
if what_i_dont_do:
lines.append("Don't: " + " | ".join(what_i_dont_do[:3]))
principles = data.get("principles", [])
if principles:
lines.append("Principles: " + " * ".join(principles))
return "\n".join(lines)
def main():
branch_root = find_branch_root()
if not branch_root:
return
id_file = find_id_file(branch_root)
if not id_file or not id_file.exists():
return
try:
data = json.loads(id_file.read_text(encoding="utf-8"))
output = format_identity(data)
if output:
print(f"\n{output}")
except (json.JSONDecodeError, KeyError):
pass
if __name__ == "__main__":
main()
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
Pre-Compact Hook - Inject live state for post-compact recovery.
Reads STATUS.local.md, last session from local.json, and git branch
to give the model real context after compaction — not generic advice.
Version: 3.0.0
"""
import json
import subprocess
import sys
from pathlib import Path
def _find_branch_dir():
"""Find the current branch directory from CWD."""
cwd = Path.cwd()
# Check if we're in a branch dir or subdirectory of one
# Pattern: .../src/aipass/{branch}/...
parts = cwd.parts
for i, part in enumerate(parts):
if part == "aipass" and i > 0 and parts[i - 1] == "src":
branch_dir = Path(*parts[: i + 2])
if branch_dir.is_dir():
return branch_dir
# Check if CWD itself has .trinity/
if (cwd / ".trinity").is_dir():
return cwd
return None
def _read_status_local(branch_dir):
"""Read STATUS.local.md if it exists."""
for name in ["STATUS.local.md", "dev.local.md"]:
path = branch_dir / name
if path.is_file():
try:
return path.read_text(encoding="utf-8")[:3000]
except Exception:
pass
return None
def _read_last_session(branch_dir):
"""Read the most recent session and key_learnings from local.json."""
local_path = branch_dir / ".trinity" / "local.json"
if not local_path.is_file():
return None
try:
data = json.loads(local_path.read_text(encoding="utf-8"))
result = []
# Last session
sessions = data.get("sessions", [])
if sessions:
last = sessions[0]
result.append(
f"Last session (#{last.get('session_number', '?')}, "
f"{last.get('date', '?')}): {last.get('summary', 'no summary')}"
)
# Key learnings (just the keys, not full values — breadcrumbs)
learnings = data.get("key_learnings", {})
if learnings:
keys = list(learnings.keys())[-10:] # last 10
result.append(f"Key learnings available: {', '.join(keys)}")
return "\n".join(result) if result else None
except Exception:
return None
def _get_git_info():
"""Get current git branch and short status."""
try:
branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
timeout=5,
)
subprocess.run(
["git", "diff", "--stat", "--cached", "HEAD"],
capture_output=True,
text=True,
timeout=5,
)
dirty = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True,
text=True,
timeout=5,
)
result = []
if branch.returncode == 0:
result.append(f"Git branch: {branch.stdout.strip()}")
if dirty.returncode == 0 and dirty.stdout.strip():
lines = dirty.stdout.strip().split("\n")
result.append(f"Uncommitted changes: {len(lines)} files")
return "\n".join(result) if result else None
except Exception:
return None
def _get_branch_name(branch_dir):
"""Extract branch name from directory."""
return branch_dir.name if branch_dir else "unknown"
def main():
"""Main hook entry point."""
try:
json.load(sys.stdin)
branch_dir = _find_branch_dir()
branch_name = _get_branch_name(branch_dir)
sections = []
sections.append(f"""POST-COMPACT RECOVERY — @{branch_name}
Context just compacted. Below is your live state. Use it to continue seamlessly.""")
# Git info
git_info = _get_git_info()
if git_info:
sections.append(f"## Git\n{git_info}")
# Last session from local.json
if branch_dir:
session_info = _read_last_session(branch_dir)
if session_info:
sections.append(f"## Last Session\n{session_info}")
# STATUS.local.md — the main context
if branch_dir:
status = _read_status_local(branch_dir)
if status:
sections.append(f"## STATUS.local.md\n{status}")
# Recovery instructions (lean)
sections.append("""## Recovery Protocol
- Continue where the summary left off — don't restart or ask generic questions
- .trinity/local.json has full session history and key_learnings — read it if you need more context
- STATUS.local.md has current work, known issues, and todos
- Save memories proactively — compaction just proved you need to
- Match the conversation tone from before compaction""")
print("\n\n".join(sections), file=sys.stdout)
print("Pre-compact: live state injected", file=sys.stderr)
except Exception as e:
# Fail silently — never block compaction
print(f"Pre-compact hook error: {e}", file=sys.stderr)
sys.exit(0)
if __name__ == "__main__":
main()
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""
PreToolUse Gate — Blocks unsafe edits at the hook layer.
Rules (checked in order):
1. Inbox lock — any write targeting *.ai_mail.local/inbox.json is BLOCKED.
Use `drone @ai_mail email` instead.
2. Cross-branch — writes to src/aipass/X/** from a CWD inside src/aipass/Y/**
are BLOCKED unless the calling branch is in TRUSTED_CROSS_WRITERS.
3. State-file — edits to OTHER .py files while the current branch has unresolved
type errors are BLOCKED. (original v1.2.0 logic)
Track E additions: rules 1 + 2 (DPLAN-0139).
Version: 1.3.0
"""
import json
import os
import sys
from pathlib import Path
STATE_FILE = Path(__file__).parent / ".diagnostics_state.json"
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
# Single source of truth lives in permissions.py — inline here as fallback
# so the hook works even when aipass package is not on sys.path.
TRUSTED_CROSS_WRITERS: tuple[str, ...] = ("devpulse", "seedgo", "spawn")
def _get_branch(file_path: str) -> str:
"""Extract AIPass branch name from a file path (src/aipass/{branch}/ pattern)."""
parts = Path(file_path).parts
for i, part in enumerate(parts):
if part == "aipass" and i > 0 and parts[i - 1] == "src" and i + 1 < len(parts):
return parts[i + 1]
return ""
def _block(reason: str) -> None:
# codeql[py/clear-text-logging-sensitive-data]
print(json.dumps({"decision": "block", "reason": reason}))
sys.exit(2)
def main():
try:
input_data = json.load(sys.stdin)
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
file_path = tool_input.get("file_path", "")
if tool_name not in EDIT_TOOLS:
return
if not file_path:
return
# ------------------------------------------------------------------
# Rule 1: Inbox lock — block all writes to *.ai_mail.local/inbox.json
# ------------------------------------------------------------------
fp = Path(file_path)
if fp.name == "inbox.json" and ".ai_mail.local" in fp.parts:
_block('Direct writes to inbox.json are blocked.\nUse: drone @ai_mail email @<branch> "Subject" "Body"')
# ------------------------------------------------------------------
# Rule 1.5: Dispatched-agent path confinement (DPLAN-0155 M3)
# Daemon-spawned agents can only write inside their own branch dir.
# Breaks the prompt-injection amplifier chain — even if injected,
# a dispatched agent cannot write to other agents' inboxes or code.
# ------------------------------------------------------------------
cwd = input_data.get("cwd", "") or os.getcwd()
cwd_branch = _get_branch(cwd)
session_type = os.environ.get("AIPASS_SESSION_TYPE", "interactive")
if session_type == "daemon" and cwd_branch:
target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp))
if target_branch and target_branch != cwd_branch:
_block(
f"Dispatched agent confined to own branch: '{cwd_branch}' "
f"cannot write to '{target_branch}' in daemon mode."
)
repo_root = None
for parent in Path(cwd).parents:
if (parent / ".git").exists():
repo_root = parent
break
if repo_root and not target_branch:
allowed_prefix = str(repo_root / "src" / "aipass" / cwd_branch)
resolved = str(fp.resolve()) if not fp.is_absolute() else str(fp)
if not resolved.startswith(allowed_prefix):
_block(f"Dispatched agent restricted to {allowed_prefix}. Cannot write to: {file_path}")
# ------------------------------------------------------------------
# Rule 2: Cross-branch write enforcement
# ------------------------------------------------------------------
target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp))
if cwd_branch and target_branch and cwd_branch != target_branch:
if cwd_branch not in TRUSTED_CROSS_WRITERS:
_block(
f"Cross-branch write blocked: '{cwd_branch}' cannot write to '{target_branch}'.\n"
f"Trusted cross-writers: {', '.join(TRUSTED_CROSS_WRITERS)}"
)
# ------------------------------------------------------------------
# Rule 3: State-file (original v1.2.0) — .py files only
# ------------------------------------------------------------------
if not file_path.endswith(".py"):
return
if not STATE_FILE.exists():
return
try:
state = json.loads(STATE_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, IOError):
return
errored_file = state.get("file", "")
errors = state.get("errors", [])
if not errors:
return
try:
current = str(Path(file_path).resolve())
errored = str(Path(errored_file).resolve())
except (OSError, ValueError):
return
if current == errored:
return
current_branch = _get_branch(current)
errored_branch = _get_branch(errored)
if not errored_branch:
return
if current_branch and errored_branch and current_branch != errored_branch:
return
error_summary = "\n".join(f" L{e['line']}: {e['message']}" for e in errors[:5])
_block(f"Fix {len(errors)} error(s) in {Path(errored_file).name} before editing other files:\n{error_summary}")
except Exception:
pass # Silent fail → allow
if __name__ == "__main__":
main()
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
SubagentStop Gate — Checks files modified by subagents before allowing them to finish.
Runs seedgo checklist + basic validation on any .py files the subagent touched.
If violations found, blocks the stop and tells the subagent to fix them.
Version: 1.0.0
"""
import json
import os
import sys
import subprocess
from pathlib import Path
def _find_repo_root() -> Path | None:
"""Walk up from CWD or AIPASS_HOME to find the git repo root."""
for start in (os.environ.get("AIPASS_HOME", ""), os.getcwd()):
p = Path(start)
while p != p.parent:
if (p / ".git").exists():
return p
p = p.parent
return None
AIPASS_ROOT = _find_repo_root()
def get_modified_py_files() -> list[str]:
"""Get Python files modified in the working tree (unstaged + staged)."""
if AIPASS_ROOT is None:
return []
try:
result = subprocess.run(
["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True, timeout=5, cwd=str(AIPASS_ROOT)
)
files = []
for line in result.stdout.strip().split("\n"):
line = line.strip()
if line.endswith(".py") and not line.startswith(".claude/"):
full = AIPASS_ROOT / line
if full.exists():
files.append(str(full))
return files
except Exception:
return []
def run_seedgo_checklist(file_path: str) -> list[str]:
"""Run seedgo checklist on a single file."""
if AIPASS_ROOT is None:
return []
if "/.claude/" in file_path:
return []
try:
result = subprocess.run(
["drone", "@seedgo", "checklist", file_path],
capture_output=True,
text=True,
timeout=15,
cwd=str(AIPASS_ROOT),
)
if result.returncode != 0:
return []
violations = []
for line in result.stdout.split("\n"):
line = line.strip()
if line.startswith("\u2717"):
v = line[1:].strip()
if v:
violations.append(v)
return violations[:5]
except Exception:
return []
def main():
try:
json.load(sys.stdin)
modified = get_modified_py_files()
if not modified:
return # Nothing to check
all_violations = {}
for f in modified:
vs = run_seedgo_checklist(f)
if vs:
name = Path(f).name
all_violations[name] = vs
if not all_violations:
return # All clear
# Build the block reason
lines = ["Standards violations found in files you modified:\n"]
for fname, vs in all_violations.items():
lines.append(f" {fname}:")
for v in vs:
lines.append(f" - {v}")
lines.append("\nFix these violations before finishing.")
output = {"decision": "block", "reason": "\n".join(lines)}
print(json.dumps(output))
except Exception:
pass # Silent fail — don't block on errors
if __name__ == "__main__":
main()
+43
View File
@@ -0,0 +1,43 @@
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/branch_prompt_loader.py"
}
]
},
{
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/email_notification.py"
}
]
},
{
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/identity_injector.py"
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/pre_compact.py"
}
]
}
]
},
"env": {
"AIPASS_HOME": "/home/patrick/Projects/AIPass"
}
}
+22
View File
@@ -0,0 +1,22 @@
__pycache__/
*.pyc
*.pyo
.env
*.egg-info/
.coverage
htmlcov/
.pytest_cache/
.mypy_cache/
dist/
build/
*.log
*.tmp
*.swp
# Local runtime state
.ai_mail.local/
logs/
DASHBOARD.local.json
docs.local/
tools/aipass-dev
stress_test_s117.md
+5
View File
@@ -0,0 +1,5 @@
# Standards Bypass
Seedgo audit bypass config for `AIPASS`.
When an audit flags a false positive that doesn't apply to your architecture, add a bypass entry in `bypass.json` with a reason explaining why it's justified.
+150
View File
@@ -0,0 +1,150 @@
{
"metadata": {
"version": "2.0.0",
"created": "2026-04-16",
"description": "Bypass config for @aipass citizen. While under construction (DPLAN-0136 Phase 0-3), module and handler files exist as documented placeholders with no implementation body. Each placeholder raises NotImplementedError and declares its phase. Bypass standards that fire on structural requirements the placeholders intentionally skip — json_handler import, print_introspection, CLI service wiring. Remove these entries in Phase N as each module gets its real body.",
"last_updated": "2026-04-16"
},
"bypass": [
{
"file": "apps/modules/init_flow.py",
"standard": "modules",
"reason": "12-stage init flow requires this many lines; splitting would scatter cohesive stage logic across multiple files and break the resumable-progress contract."
},
{
"file": "apps/modules/init_flow.py",
"standard": "architecture",
"reason": "12-stage init flow + scaffold routing + preflight guard. Cohesive unit — splitting breaks the resumable-progress contract. DPLAN-0164 transfer adds routing handlers here intentionally."
},
{
"file": "apps/modules/init_flow.py",
"standard": "permission_flags",
"reason": "The --dangerously-skip-permissions string is a CLI flag NAME passed verbatim to the user's chosen tool (claude). It is not a code-level permission bypass in this module."
},
{
"file": "apps/handlers/handoff_platform/__init__.py",
"standard": "permission_flags",
"reason": "The --dangerously-skip-permissions string is a CLI flag NAME appended to the user's chosen tool invocation. It is not a code-level permission bypass in this handler."
},
{
"file": "apps/handlers/handoff_platform/__init__.py",
"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/modules/handoff.py",
"standard": "introspection",
"reason": "Phase 4 placeholder — print_introspection() added with handoff build."
},
{
"file": "apps/modules/handoff.py",
"standard": "json_structure",
"reason": "Phase 4 placeholder — json_handler import added with handoff build."
},
{
"file": "apps/modules/handoff.py",
"standard": "cli",
"reason": "Phase 4 placeholder — CLI service imports added with handoff build."
},
{
"file": "apps/aipass.py",
"standard": "architecture",
"reason": "Phase 0 entry-point stub from spawn template. Full 3-layer wiring (modules/ discovery, handlers/ imports) added when first module comes online (Phase 1)."
},
{
"file": "apps/aipass.py",
"standard": "cli",
"reason": "Phase 0 entry-point stub — CLI service imports (console, header) added when modules come online (Phase 1+)."
},
{
"file": "apps/aipass.py",
"standard": "debug_print",
"reason": "Phase 0 entry-point stub uses bare print() for scaffold visibility. Replaced with console.print() when CLI services are wired in Phase 1+."
},
{
"file": "apps/aipass.py",
"standard": "introspection",
"reason": "Phase 0 — spawn template does not emit print_introspection(). Added when modules come online (Phase 1+)."
},
{
"file": "apps/aipass.py",
"standard": "log_structure",
"reason": "Phase 0 — logs/ directory exists (spawn-created), but prax logger import not wired yet. Added when first module uses it."
},
{
"file": "apps/modules/doctor.py",
"standard": "modules",
"reason": "Doctor reads system files (registry, passport) directly for health-check diagnosis — pure reads only, no mutations. Using a handler adds indirection without benefit for diagnostic reads."
},
{
"file": "tests/test_doctor.py",
"standard": "architecture",
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
},
{
"file": "tests/test_doctor.py",
"standard": "encapsulation",
"reason": "Unit tests must import handlers directly (system_detector, progress) to test them in isolation. Entry-point imports would defeat the purpose of unit testing."
},
{
"file": "tests/test_help_chat.py",
"standard": "architecture",
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
},
{
"file": "tests/test_profile.py",
"standard": "architecture",
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
},
{
"file": "tests/test_profile.py",
"standard": "encapsulation",
"reason": "Unit tests must import modules directly to test them in isolation. Entry-point imports would defeat the purpose of unit testing."
},
{
"file": "tests/test_init_flow.py",
"standard": "architecture",
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
},
{
"file": "tests/test_init_flow.py",
"standard": "encapsulation",
"reason": "Unit tests must import modules directly to test them in isolation. Entry-point imports would defeat the purpose of unit testing."
},
{
"file": "tests/test_init_flow.py",
"standard": "permission_flags",
"reason": "Assertions verify that the CLI flag name appears/absent in handoff_command output. String is in assertion context only — not a permission bypass in this file."
},
{
"file": "tests/test_ping_sweep.py",
"standard": "architecture",
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
},
{
"file": "tests/test_ping_sweep.py",
"standard": "encapsulation",
"reason": "Unit tests must import handlers directly to test them in isolation. Entry-point imports would defeat the purpose of unit testing."
},
{
"file": "apps/handlers/init/bootstrap.py",
"standard": "debug_print",
"reason": "The print() on line 159 is inside a generated shell command string (python3 -c), not a bare print call in this module's code."
},
{
"file": "apps/handlers/init/bootstrap.py",
"standard": "help_text",
"reason": "The python3 references are in generated shell commands (settings.json hook entries), not in user-facing help text."
},
{
"file": "apps/handlers/init/bootstrap.py",
"standard": "json_structure",
"reason": "bootstrap.py is Pure Python only (no module/prax/cli imports) by design — it must work during initial project setup before any AIPass services exist."
},
{
"file": "apps/handlers/init/bootstrap.py",
"standard": "log_visibility",
"reason": "bootstrap.py is Pure Python only (no module/prax/cli imports) by design — stdlib getLogger is correct here. prax system_logger requires AIPass to be installed, which hasn't happened at bootstrap time."
}
]
}
+30
View File
@@ -0,0 +1,30 @@
<!-- Source: /home/patrick/Projects/AIPass/src/aipass/aipass/AGENTS.md -->
# AIPASS — Agent Instructions
This project uses AIPass, a multi-agent framework.
## Key Concepts
- **Project** — this directory. Contains a registry and one or more agents.
- **Agent** — a citizen that lives inside the project with its own identity, memory, and code.
- **Registry** — `AIPASS_REGISTRY.json` tracks all agents.
## Getting Started
Create your first agent:
```
aipass init agent <name>
```
## Available Commands
```
aipass init agent <name> # Create a new agent
drone @spawn create <name> # Create agent (alternative)
drone @seedgo audit <project> # Run standards audit
drone systems # List all infrastructure
```
## Startup
On startup, read: `AIPASS_REGISTRY.json`, `README.md`, `STATUS.local.md`
+42
View File
@@ -0,0 +1,42 @@
# AIPASS
## Startup
On any greeting, silently read these files and run the commands — no narration, no announcing steps. Just do it and respond with the status.
**Read:** `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`, `STATUS.local.md`
**Check:** If `.ai_mail.local/inbox.json` exists, read it. Process any mail.
**Run:** `git status`
## Identity
You are **AIPASS** — an AIPass citizen.
- **Module:** `aipass.aipass`
- **Role:**
- **Purpose:** New agent - purpose TBD
## Memories
Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`.
- `local.json` — Session history, key learnings, active tasks
- `observations.json` — Collaboration patterns, insights
- `passport.json` — Identity (rarely changes)
## AIPass Context
This branch is part of the AIPass multi-agent framework. Key concepts:
- **Branch** — your directory (`src/aipass/aipass/`). Your home.
- **Citizen** — the identity that lives in a branch. Has a passport, memories, mailbox.
- **Agent** — a disposable worker spawned for a task. No passport, no memory.
## Commands
```
drone systems # List available infrastructure
drone @ai_mail inbox # Check mailbox
drone @ai_mail send @branch "Subject" "Body" # Send mail
drone @seedgo audit @aipass # Run standards audit
```
+27
View File
@@ -0,0 +1,27 @@
<!-- Source: /home/patrick/Projects/AIPass/src/aipass/aipass/GEMINI.md -->
# AIPASS — Project Instructions
This project uses AIPass, a multi-agent framework.
## Key Concepts
- **Project** — this directory. Contains a registry and one or more agents.
- **Agent** — a citizen that lives inside the project with its own identity, memory, and code.
- **Registry** — `AIPASS_REGISTRY.json` tracks all agents.
## Getting Started
Create your first agent: `aipass init agent <name>`
## Available Commands
```
aipass init agent <name> # Create a new agent
drone @spawn create <name> # Create agent (alternative)
drone @seedgo audit <project> # Run standards audit
drone systems # List all infrastructure
```
## Startup
On startup, read: `AIPASS_REGISTRY.json`, `README.md`, `STATUS.local.md`
+93
View File
@@ -0,0 +1,93 @@
# AIPASS
**Purpose:** The friendly front door — concierge, librarian, first-run guide
**Module:** `aipass.aipass`
**Created:** 2026-04-16
**Status:** Under construction (gitignored until Phase 8 reveal, DPLAN-0136)
---
## Overview
### What I Do
I am the concierge of AIPass. New users land with me. I greet them, walk them through setup, answer how-things-work questions, and hand them off to their chosen CLI tool. I am also the librarian — I can read any branch, inspect any README, explain any pattern. I do not build.
Drone is the engine. I am the front door.
### How I Work
- **Entry Point:** `apps/aipass.py` — thin CLI dispatch
- **Pattern:** Subcommand routing — `help`, `doctor`, `init`, `profile`
- **Restrictions:** Read-only by design. No writes outside my own `.trinity/`. No git. No real dispatches.
---
## Architecture
```
aipass/
├── apps/
│ ├── aipass.py # Entry point — subcommand dispatch
│ ├── modules/ # doctor, help_chat, init_flow, handoff, profile
│ ├── handlers/ # system_detect, ping_sweep, readme_map, ui
│ └── plugins/ # Extensions
├── docs/
├── tests/
├── .trinity/
│ ├── passport.json # Identity — concierge, read-only
│ ├── local.json # Session history + user profile + setup_progress
│ └── observations.json # Patterns across users
└── README.md
```
---
## Commands
```
aipass # Help banner
aipass help [Q] # Chatbot Q&A — "how does drone work?"
aipass doctor # System health — aggregates seedgo, pytest, registry, hooks
aipass init # Guided 12-stage setup for new users (resumable)
aipass profile # Show/edit what I remember about you
aipass --version
```
---
## Integration Points
### Depends On
- `@drone` — routing
- `@seedgo` — audit aggregation
- `@spawn` — creating the user's first agent
- `@flow` — testing plan lifecycle (open/close empty plans)
- `@ai_mail` — test-convention emails (no real dispatch)
- `@prax` — health signals for doctor
- `pytest` — test runner aggregation
- External CLIs — Claude Code / Codex / Gemini (handoff targets)
### Provides To
Nothing in AIPass depends on me. This is by design — I can be removed, replaced, or rebuilt without ripple. One-way arrow.
My direct consumers are **humans** — new users, curious explorers, and anyone who'd rather ask a concierge than read docs.
---
## Build Plan
See `devpulse/DPLAN-0136`. Nine phases:
0. Scaffolding (spawn) ✓
1. `aipass doctor`
2. `aipass help`
3. `aipass init`
4. CLI handoff (tmux / wt.exe)
5. Repo README flip back to project-focused
6. pip entry point wiring
7. Retire cli branch's `aipass init`
8. Gitignore removal — public reveal
9. Optional: VS Code auto-refresh
+1
View File
@@ -0,0 +1 @@
"""AIPass concierge — user-facing front door (help, doctor, init, profile, handoff)."""
+8
View File
@@ -0,0 +1,8 @@
# Apps
Application layer for `AIPASS`.
- `aipass.py` — Entry point. Auto-discovers and routes commands to modules.
- `modules/` — Business logic and orchestration. One module per command.
- `handlers/` — Implementation details. Called by modules, never by CLI directly.
- `plugins/` — Scheduled tasks and extensions.
+1
View File
@@ -0,0 +1 @@
# AIPASS apps package
+100
View File
@@ -0,0 +1,100 @@
# =================== AIPass ====================
# Name: aipass.py
# Description: AIPASS branch entry point — thin command router
# Version: 0.1.0
# Created: 2026-04-16
# Modified: 2026-04-16
# =============================================
"""
AIPASS Branch - Main Orchestrator
Auto-discovery architecture:
- Scans modules/ directory for .py files with handle_command()
- Routes commands to discovered modules automatically
- No manual imports or routing needed
"""
import sys
import importlib
from pathlib import Path
from typing import List, Any
from aipass.prax import logger
# =============================================================================
# MODULE DISCOVERY
# =============================================================================
MODULES_DIR = Path(__file__).parent / "modules"
def discover_modules() -> List[Any]:
"""Auto-discover modules in modules/ directory."""
modules = []
if not MODULES_DIR.exists():
return modules
for file_path in MODULES_DIR.glob("*.py"):
if file_path.name.startswith("_"):
continue
module_name = f"aipass.aipass.apps.modules.{file_path.stem}"
try:
module = importlib.import_module(module_name)
if hasattr(module, "handle_command"):
modules.append(module)
except Exception as e:
logger.error(f"[AIPASS] Failed to load module {module_name}: {e}")
return modules
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
"""Route command to appropriate module."""
for module in modules:
try:
if module.handle_command(command, args):
return True
except Exception as e:
logger.error(f"[AIPASS] Module {module.__name__} error: {e}")
return False
# =============================================================================
# MAIN ENTRY POINT
# =============================================================================
def main():
"""Main entry point - routes commands or shows help."""
modules = discover_modules()
args = sys.argv[1:]
if len(args) > 0 and args[0] in ["--version", "-V"]:
print("aipass 0.1.0")
return 0
show_root_help = len(args) == 0 or args[0] in ["--help", "-h"] or (args[0] == "help" and len(args) == 1)
if show_root_help:
print(f"AIPASS - {len(modules)} modules discovered")
for module in modules:
name = module.__name__.split(".")[-1]
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
print(f" {name:20} {desc}")
return 0
command = args[0]
remaining = args[1:] if len(args) > 1 else []
if route_command(command, remaining, modules):
return 0
print(f"Unknown command: {command}")
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,5 @@
# Handlers
Implementation details for `AIPASS`.
Handlers do the actual work. They are called by modules, never directly by the CLI. Keep business logic in modules, implementation in handlers.
@@ -0,0 +1,88 @@
"""AIPASS handlers package - Security protected."""
import inspect
from pathlib import Path
MY_BRANCH = "aipass.aipass"
def _find_real_caller():
"""Walk the stack to find the actual file that triggered this import.
Skips this file, importlib internals, and frozen modules.
Returns tuple: (file_path, import_line) or (None, None).
"""
stack = inspect.stack()
this_file = str(Path(__file__).resolve())
for frame_info in stack:
filename = frame_info.filename
if this_file in str(Path(filename).resolve()):
continue
if filename.startswith("<") or "importlib" in filename:
continue
import_line = None
if frame_info.code_context:
import_line = frame_info.code_context[0].strip()
return str(Path(filename).resolve()), import_line
return None, None
def _extract_branch_name(filepath: str) -> str:
"""Extract branch name from a file path."""
parts = Path(filepath).parts
for i, part in enumerate(parts):
if part == "aipass":
if i + 1 < len(parts):
return parts[i + 1]
return "unknown"
def _guard_branch_access():
"""Block cross-branch handler imports.
Only code from within the 'aipass' branch can import these handlers.
External branches must use aipass.aipass.apps.modules instead.
"""
caller_file, import_line = _find_real_caller()
if caller_file is None:
stack = inspect.stack()
for frame in stack:
if frame.filename in ("<string>", "<stdin>"):
return
return
branch_path = "/" + MY_BRANCH.replace(".", "/") + "/"
if branch_path in caller_file.replace("\\", "/"):
return
caller_branch = _extract_branch_name(caller_file)
caller_filename = Path(caller_file).name
blocked_import = import_line if import_line else "unknown"
raise ImportError(
f"\n{'=' * 60}\n"
f"ACCESS DENIED: Cross-branch handler import blocked\n"
f"{'=' * 60}\n"
f" Caller branch: {caller_branch}\n"
f" Caller file: {caller_filename}\n"
f" Blocked: {blocked_import}\n"
f"\n"
f" Handlers are internal to their branch.\n"
f" Use the module API instead:\n"
f" from {MY_BRANCH}.apps.modules.<module> import <function>\n"
f"\n"
f" For full standards guide:\n"
f" drone @seedgo handlers\n"
f"{'=' * 60}"
)
# Run guard at import time
_guard_branch_access()
@@ -0,0 +1,189 @@
# =================== AIPass ====================
# Name: handoff_platform/__init__.py
# Description: OS-dispatched CLI session launch — tmux, wt.exe, fallback
# Version: 1.0.0
# Created: 2026-04-20
# Modified: 2026-04-20
# =============================================
"""
handoff_platform — OS-dispatched CLI session launch.
Consumers: modules/handoff.py, modules/init_flow.py (stage 11).
Linux/Mac: tmux new-session -d -s aipass-handoff -c <cwd>; send-keys '<cli> "<prompt>"'
Windows: wt.exe -w 0 nt -d <cwd> <cli> "<prompt>" (Windows Terminal)
Fallback: caller receives command string for manual display — no silent fail.
All public functions return data; presentation is handled by the module layer.
"""
from __future__ import annotations
import shutil
import subprocess
import sys
from typing import Optional
from aipass.prax import logger
# Platform constants — consistent with setup.sh naming
IS_WINDOWS = sys.platform == "win32"
IS_MACOS = sys.platform == "darwin"
IS_LINUX = sys.platform.startswith("linux")
_TMUX_SESSION = "aipass-handoff"
def build_cli_cmd(cli: str, flag_variant: str) -> str:
"""Build the CLI invocation string from cli name and flag variant."""
parts = [cli]
if cli == "claude" and flag_variant == "skip-permissions":
parts.append("--dangerously-skip-permissions") # noqa: S603
return " ".join(parts)
def build_manual_command(cli: str, prompt: str, cwd: str, flag_variant: str = "default") -> str:
"""Return the equivalent manual shell command for user display."""
cli_cmd = build_cli_cmd(cli, flag_variant)
safe_prompt = prompt.replace('"', '\\"')
return f'cd {cwd} && {cli_cmd} "{safe_prompt}"'
def _find_terminal_emulator() -> str | None:
"""Find an available terminal emulator on the system."""
for term in ("gnome-terminal", "xfce4-terminal", "konsole", "xterm"):
if shutil.which(term):
return term
return None
def launch_terminal(cli: str, prompt: str, cwd: str, flag_variant: str = "default") -> bool:
"""Open a new terminal window running the CLI directly. No tmux needed."""
term = _find_terminal_emulator()
if not term:
logger.warning("[handoff_platform] no terminal emulator found")
return False
cli_cmd = build_cli_cmd(cli, flag_variant)
safe_prompt = prompt.replace('"', '\\"')
shell_cmd = f'cd "{cwd}" && {cli_cmd} "{safe_prompt}"'
try:
if term == "gnome-terminal":
subprocess.Popen(["gnome-terminal", "--", "bash", "-c", shell_cmd])
elif term == "xfce4-terminal":
subprocess.Popen(["xfce4-terminal", "-e", f"bash -c '{shell_cmd}'"])
elif term == "konsole":
subprocess.Popen(["konsole", "-e", "bash", "-c", shell_cmd])
elif term == "xterm":
subprocess.Popen(["xterm", "-e", f"bash -c '{shell_cmd}'"])
else:
return False
logger.info("[handoff_platform] opened %s in %s (cwd=%s)", term, cli, cwd)
from aipass.cli.apps.modules import console
console.print("\n [green]✓[/green] Opened your agent in a new terminal window.")
console.print()
return True
except OSError as exc:
logger.warning("[handoff_platform] failed to open terminal: %s", exc)
return False
def launch_tmux(cli: str, prompt: str, cwd: str, flag_variant: str = "default") -> bool:
"""Launch CLI in a tmux session (fallback when no terminal emulator available)."""
if not shutil.which("tmux"):
logger.warning("[handoff_platform] tmux not found on PATH")
return False
cli_cmd = build_cli_cmd(cli, flag_variant)
safe_prompt = prompt.replace('"', '\\"')
try:
subprocess.run(
["tmux", "kill-session", "-t", _TMUX_SESSION],
capture_output=True,
timeout=5,
)
subprocess.run(
["tmux", "new-session", "-d", "-s", _TMUX_SESSION, "-c", cwd],
check=True,
timeout=10,
)
subprocess.run(
["tmux", "send-keys", "-t", _TMUX_SESSION, f'{cli_cmd} "{safe_prompt}"', "Enter"],
check=True,
timeout=10,
)
logger.info("[handoff_platform] tmux session '%s' started in %s", _TMUX_SESSION, cwd)
from aipass.cli.apps.modules import console
console.print("\n [green]✓[/green] Your agent is ready! Run this in your terminal:")
console.print(f"\n [bold green]tmux attach -t {_TMUX_SESSION}[/bold green]\n")
console.print()
return True
except subprocess.CalledProcessError as exc:
logger.warning("[handoff_platform] tmux launch failed: %s", exc)
return False
except subprocess.TimeoutExpired as exc:
logger.warning("[handoff_platform] tmux command timed out: %s", exc)
return False
def launch_wt(cli: str, prompt: str, cwd: str, flag_variant: str = "default") -> bool:
"""Launch CLI in Windows Terminal (wt.exe). Returns True on success."""
if not shutil.which("wt"):
logger.warning("[handoff_platform] wt.exe not found on PATH")
return False
cli_cmd = build_cli_cmd(cli, flag_variant)
safe_prompt = prompt.replace('"', '\\"')
try:
subprocess.run(
["wt", "-w", "0", "nt", "-d", cwd, cli_cmd, f'"{safe_prompt}"'],
check=True,
timeout=15,
)
logger.info("[handoff_platform] wt.exe session started in %s", cwd)
return True
except subprocess.CalledProcessError as exc:
logger.warning("[handoff_platform] wt.exe launch failed: %s", exc)
return False
except subprocess.TimeoutExpired as exc:
logger.warning("[handoff_platform] wt.exe command timed out: %s", exc)
return False
def launch_handoff(
cli: str,
prompt: str,
cwd: str,
flag_variant: str = "default",
platform_override: Optional[str] = None,
) -> tuple[bool, str]:
"""
Dispatch CLI launch to the appropriate platform handler.
Returns (launched, manual_command):
launched=True — tmux/wt session was started successfully
launched=False — auto-launch unavailable; caller displays manual_command
manual_command — always populated; equivalent command for manual run
Order: tmux (Linux/Mac) → wt.exe (Windows) → fallback (caller handles display).
"""
manual_cmd = build_manual_command(cli, prompt, cwd, flag_variant)
target = platform_override or ("windows" if IS_WINDOWS else "unix")
if target == "windows":
if launch_wt(cli, prompt, cwd, flag_variant):
return True, manual_cmd
else:
if launch_terminal(cli, prompt, cwd, flag_variant):
return True, manual_cmd
if launch_tmux(cli, prompt, cwd, flag_variant):
return True, manual_cmd
logger.info("[handoff_platform] auto-launch unavailable — fallback command ready")
return False, manual_cmd
@@ -0,0 +1,31 @@
# =================== AIPass ====================
# Name: __init__.py
# Description: Init handler package — public API
# Version: 1.0.0
# Created: 2026-05-04
# Modified: 2026-05-04
# =============================================
"""Init handler package — public entry point for bootstrap and scaffold_content."""
from aipass.aipass.apps.handlers.init.bootstrap import (
_sanitize_name,
init_project,
update_project,
)
from aipass.aipass.apps.handlers.init.scaffold_content import (
global_prompt_md,
inbox_json,
prep_md,
with_source,
)
__all__ = [
"_sanitize_name",
"global_prompt_md",
"inbox_json",
"init_project",
"prep_md",
"update_project",
"with_source",
]
@@ -19,9 +19,8 @@ Business logic for `aipass init`. Creates the project scaffold:
7. STATUS.local.md — project status 7. STATUS.local.md — project status
8. .gitignore — standard AIPass ignores 8. .gitignore — standard AIPass ignores
9. .claude/settings.json — Claude Code hooks configuration 9. .claude/settings.json — Claude Code hooks configuration
10. hooks/ — directory for user hooks 10. src/ — directory where agents live
11. src/ — directory where agents live 11. .ai_mail.local/inbox.json — empty project mailbox
12. .ai_mail.local/inbox.json — empty project mailbox
Projects are NOT citizens — no .trinity/ directory. Identity lives in the Projects are NOT citizens — no .trinity/ directory. Identity lives in the
registry JSON. Init is re-runnable: existing files are skipped, not errors. registry JSON. Init is re-runnable: existing files are skipped, not errors.
@@ -41,29 +40,28 @@ import uuid
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from aipass.cli.apps.handlers.init import scaffold_content as sc from aipass.aipass.apps.handlers.init import scaffold_content as sc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ENFORCEMENT_HOOKS = [ PROJECT_HOOKS = [
"auto_fix_diagnostics.py",
"pre_edit_gate.py",
"subagent_stop_gate.py",
"pre_compact.py",
]
INJECTOR_HOOKS = [
"branch_prompt_loader.py", "branch_prompt_loader.py",
"email_notification.py", "email_notification.py",
"identity_injector.py", "identity_injector.py",
"pre_compact.py",
] ]
HOOKS_TO_SHIP = ENFORCEMENT_HOOKS + INJECTOR_HOOKS # These are shipped as reference copies but NOT wired in project settings.json
# because PreToolUse/PostToolUse/SubagentStop only fire from provider settings.
PROVIDER_ONLY_HOOKS = [
"auto_fix_diagnostics.py",
"pre_edit_gate.py",
"subagent_stop_gate.py",
]
HOOKS_TO_SHIP = PROJECT_HOOKS + PROVIDER_ONLY_HOOKS
HOOK_EVENTS: dict[str, str] = { HOOK_EVENTS: dict[str, str] = {
"auto_fix_diagnostics.py": "PostToolUse",
"pre_edit_gate.py": "PreToolUse",
"subagent_stop_gate.py": "Stop",
"pre_compact.py": "PreCompact", "pre_compact.py": "PreCompact",
"branch_prompt_loader.py": "UserPromptSubmit", "branch_prompt_loader.py": "UserPromptSubmit",
"email_notification.py": "UserPromptSubmit", "email_notification.py": "UserPromptSubmit",
@@ -138,16 +136,17 @@ def _detect_aipass_home() -> str | None:
def _claude_settings(aipass_home: str | None = None) -> str: def _claude_settings(aipass_home: str | None = None) -> str:
"""Generate .claude/settings.json — hooks for prompt injection + enforcement. """Generate .claude/settings.json — hooks for prompt injection at project level.
Wires all AIPass hooks into their respective event types: Only wires hooks that fire from project-level settings:
- UserPromptSubmit: global/local prompt injection + branch_prompt_loader, - UserPromptSubmit: global/local prompt injection + branch_prompt_loader,
email_notification, identity_injector email_notification, identity_injector
- PostToolUse: auto_fix_diagnostics
- PreToolUse: pre_edit_gate
- Stop: subagent_stop_gate
- PreCompact: pre_compact - PreCompact: pre_compact
PreToolUse/PostToolUse/SubagentStop hooks are NOT wired here — they only
fire from provider settings (~/.claude/settings.json). The scripts are
still shipped as reference copies. Provider wiring is handled by setup.sh.
Args: Args:
aipass_home: Optional AIPass installation root to add as env.AIPASS_HOME. aipass_home: Optional AIPass installation root to add as env.AIPASS_HOME.
""" """
@@ -320,6 +319,7 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
created.append(str(settings_path)) created.append(str(settings_path))
# 9b. .claude/commands/prep.md — /prep session wrap-up slash command # 9b. .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 = claude_dir / "commands"
commands_dir.mkdir(exist_ok=True) commands_dir.mkdir(exist_ok=True)
prep_path = commands_dir / "prep.md" prep_path = commands_dir / "prep.md"
@@ -327,24 +327,12 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
prep_path.write_text(sc.prep_md(), encoding="utf-8") prep_path.write_text(sc.prep_md(), encoding="utf-8")
created.append(str(prep_path)) created.append(str(prep_path))
# 9c. .claude/commands/memo.md — /memo memory update slash command
memo_path = commands_dir / "memo.md"
if not memo_path.exists():
memo_path.write_text(sc.memo_md(), encoding="utf-8")
created.append(str(memo_path))
# 9d. Ship enforcement + injector hooks from AIPass install # 9d. Ship enforcement + injector hooks from AIPass install
if aipass_home: if aipass_home:
shipped = _ship_hooks(aipass_home, target) shipped = _ship_hooks(aipass_home, target)
created.extend(shipped) created.extend(shipped)
# 10. hooks/ directory # 10. src/ directory (where agents live)
hooks_dir = target / "hooks"
if not hooks_dir.exists():
hooks_dir.mkdir()
created.append(str(hooks_dir))
# 11. src/ directory (where agents live)
src_dir = target / "src" src_dir = target / "src"
if not src_dir.exists(): if not src_dir.exists():
src_dir.mkdir() src_dir.mkdir()
@@ -373,7 +361,7 @@ def update_project(target: Path) -> dict:
Overwrites managed prompt and config files with the latest templates while Overwrites managed prompt and config files with the latest templates while
leaving all user-owned files (registry, README, STATUS.local.md, .gitignore, leaving all user-owned files (registry, README, STATUS.local.md, .gitignore,
hooks/, src/) untouched. src/) untouched.
Args: Args:
target: Directory containing the AIPass project to update. target: Directory containing the AIPass project to update.
@@ -472,6 +460,7 @@ def update_project(target: Path) -> dict:
already_current.append(str(gemini_md_path)) already_current.append(str(gemini_md_path))
# .claude/commands/prep.md — managed slash command, refresh to latest # .claude/commands/prep.md — managed slash command, refresh to latest
# Only prep.md — memo.md belongs at provider level (~/.claude/commands/)
commands_dir = claude_dir / "commands" commands_dir = claude_dir / "commands"
commands_dir.mkdir(exist_ok=True) commands_dir.mkdir(exist_ok=True)
prep_path = commands_dir / "prep.md" prep_path = commands_dir / "prep.md"
@@ -482,15 +471,6 @@ def update_project(target: Path) -> dict:
else: else:
already_current.append(str(prep_path)) already_current.append(str(prep_path))
# .claude/commands/memo.md — managed slash command, refresh to latest
memo_path = commands_dir / "memo.md"
generated = sc.memo_md()
if not memo_path.exists() or memo_path.read_text(encoding="utf-8") != generated:
memo_path.write_text(generated, encoding="utf-8")
updated.append(str(memo_path))
else:
already_current.append(str(memo_path))
# Re-sync enforcement + injector hooks from AIPass install # Re-sync enforcement + injector hooks from AIPass install
hook_home = aipass_home or _detect_aipass_home() hook_home = aipass_home or _detect_aipass_home()
if hook_home: if hook_home:
@@ -67,6 +67,12 @@ def claude_md(name: str) -> str:
"and respond with the status.\n" "and respond with the status.\n"
"\n" "\n"
f"**Read:** `{name}_REGISTRY.json`, `README.md`, `STATUS.local.md`\n" f"**Read:** `{name}_REGISTRY.json`, `README.md`, `STATUS.local.md`\n"
"**Read:** `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`\n"
"**Check:** If `.ai_mail.local/inbox.json` exists, read it. Process any mail.\n"
"**Check:** If `dropbox/init_report.json` exists, read it — this is your birth certificate. "
"Use it to understand your role, the project context, and any setup instructions. "
"If it mentions missing provider settings (hooks, env vars, permissions), "
"tell the user what needs configuring and where.\n"
"**Run:** `git status`\n" "**Run:** `git status`\n"
"\n" "\n"
"Then check the registry for agents and report status.\n" "Then check the registry for agents and report status.\n"
@@ -312,6 +318,16 @@ def global_prompt_md(name: str) -> str:
"Projects use the registry.\n" "Projects use the registry.\n"
"- **Memory** — update `.trinity/local.json` at session end. " "- **Memory** — update `.trinity/local.json` at session end. "
"Memory is presence.\n" "Memory is presence.\n"
"\n"
"## Maintenance\n"
"\n"
"- **Upgrade scaffold**: `drone @cli aipass init update` refreshes "
"managed project files (hooks, prompts, settings) to latest templates.\n"
"- **Entry point**: each agent's `apps/{name}.py` auto-configures "
"`sys.path` and `AIPASS_BRANCH_NAME` env var. If prax logs to "
"`unknown_branch/`, check that these are set.\n"
"- **Standalone projects** use `src/{name}/` layout (not `src/aipass/{name}/`). "
"Module discovery adapts automatically.\n"
) )
@@ -0,0 +1,13 @@
# =================== AIPass ====================
# Name: __init__.py
# Description: JSON handler package for aipass branch
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-16
# =============================================
"""JSON handler package — auto-creating JSON for aipass modules."""
from aipass.aipass.apps.handlers.json import json_handler
__all__ = ["json_handler"]
@@ -0,0 +1,267 @@
# =================== AIPass ====================
# Name: json_handler.py
# Description: Auto-Creating JSON Handler for aipass branch
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-16
# =============================================
"""
JSON Handler - Auto-Creating & Self-Healing JSON System
Handles default JSON files (config, data, log) for aipass modules.
Never manually create JSONs - they build themselves.
"""
from __future__ import annotations
import inspect
import json
import os
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
from aipass.prax import logger
# =============================================================================
# INFRASTRUCTURE SETUP
# =============================================================================
# json_handler.py lives at: src/aipass/aipass/apps/handlers/json/json_handler.py
# parents[0] = json/, [1] = handlers/, [2] = apps/, [3] = aipass/, [4] = src/aipass/
_PKG_ROOT = Path(__file__).resolve().parents[4]
# Constants
AIPASS_BRANCH_ROOT = _PKG_ROOT / "aipass"
AIPASS_JSON_DIR = AIPASS_BRANCH_ROOT / "aipass_json"
# =============================================================================
# INTERNAL HELPERS
# =============================================================================
def _get_caller_module_name() -> str:
"""Auto-detect calling module name from call stack.
Returns:
Module name (e.g., "doctor" from doctor.py)
"""
try:
stack = inspect.stack()
# Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller
if len(stack) > 2:
caller_frame = stack[2]
caller_path = Path(caller_frame.filename)
module_name = caller_path.stem
if module_name and not module_name.startswith("_"):
return module_name
return "unknown"
except Exception as exc:
logger.warning("[json_handler] Failed to detect caller module name: %s", exc)
return "unknown"
def _default_template(json_type: str, module_name: str) -> Any:
"""Return inline default structure for a JSON type — no file templates needed."""
today = datetime.now().date().isoformat()
if json_type == "config":
return {
"module_name": module_name,
"version": "1.0.0",
"config": {
"max_log_entries": 100,
},
"created": today,
}
if json_type == "data":
return {
"created": today,
"last_updated": today,
}
if json_type == "log":
return []
return None
def _atomic_write_json(target_path: Path, data: Any) -> None:
"""Write JSON data atomically via temp file + rename.
Prevents corruption from concurrent processes writing the same file.
"""
fd, tmp_path = tempfile.mkstemp(dir=str(target_path.parent), suffix=".tmp", prefix=target_path.stem)
succeeded = False
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
os.replace(tmp_path, str(target_path))
succeeded = True
finally:
if not succeeded and Path(tmp_path).exists():
logger.warning("[json_handler] Cleaning up temp file after write failure: %s", tmp_path)
os.unlink(tmp_path)
# =============================================================================
# VALIDATION
# =============================================================================
def validate_json_structure(data: Any, json_type: str) -> bool:
"""Validate JSON structure matches expected type."""
if json_type == "config":
if not isinstance(data, dict):
return False
required = ["module_name", "version", "config"]
return all(key in data for key in required)
elif json_type == "data":
if not isinstance(data, dict):
return False
required = ["created", "last_updated"]
return all(key in data for key in required)
elif json_type == "log":
return isinstance(data, list)
return False
# =============================================================================
# PUBLIC API
# =============================================================================
def get_json_path(module_name: str, json_type: str) -> Path:
"""Get path for module JSON file."""
filename = f"{module_name}_{json_type}.json"
return AIPASS_JSON_DIR / filename
def ensure_json_exists(module_name: str, json_type: str) -> bool:
"""Ensure JSON file exists, create from template if missing."""
AIPASS_JSON_DIR.mkdir(parents=True, exist_ok=True)
json_path = get_json_path(module_name, json_type)
if json_path.exists():
try:
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
if validate_json_structure(data, json_type):
return True
except Exception as exc:
logger.warning(
"[json_handler] Corrupted JSON file for '%s/%s', regenerating: %s",
module_name,
json_type,
exc,
)
template = _default_template(json_type, module_name)
if template is None:
return False
try:
_atomic_write_json(json_path, template)
return True
except Exception as exc:
logger.error(
"[json_handler] Failed to write JSON template for '%s/%s': %s",
module_name,
json_type,
exc,
)
return False
def load_json(module_name: str, json_type: str) -> Optional[Any]:
"""Load JSON file, auto-create if missing."""
if not ensure_json_exists(module_name, json_type):
return None
json_path = get_json_path(module_name, json_type)
try:
with open(json_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as exc:
logger.error("[json_handler] Failed to load JSON for '%s/%s': %s", module_name, json_type, exc)
return None
def save_json(module_name: str, json_type: str, data: Any) -> bool:
"""Save JSON file."""
json_path = get_json_path(module_name, json_type)
if not validate_json_structure(data, json_type):
return False
if json_type == "data" and isinstance(data, dict):
data["last_updated"] = datetime.now().date().isoformat()
try:
_atomic_write_json(json_path, data)
return True
except Exception as exc:
logger.error("[json_handler] Failed to save JSON for '%s/%s': %s", module_name, json_type, exc)
return False
def ensure_module_jsons(module_name: str) -> bool:
"""Ensure all 3 JSON files exist for a module."""
ensure_json_exists(module_name, "config")
ensure_json_exists(module_name, "data")
ensure_json_exists(module_name, "log")
return True
def log_operation(
operation: str,
data: Dict[str, Any] | None = None,
module_name: str | None = None,
) -> bool:
"""Add entry to module log with automatic rotation.
Auto-detects calling module if module_name not provided.
Implements config-controlled log limits to prevent unbounded growth.
When max_log_entries is reached, removes oldest entries (FIFO).
Args:
operation: Operation name to log
data: Optional data dict
module_name: Optional module name (auto-detected if not provided)
Returns:
True if successful, False otherwise
"""
if module_name is None:
module_name = _get_caller_module_name()
ensure_module_jsons(module_name)
config = load_json(module_name, "config")
max_entries = 100
if config and "config" in config:
max_entries = config["config"].get("max_log_entries", 100)
log = load_json(module_name, "log")
if log is None:
log = []
entry: Dict[str, Any] = {"timestamp": datetime.now().isoformat(), "operation": operation}
if data:
entry["data"] = data
log.append(entry)
if len(log) > max_entries:
log = log[-max_entries:]
return save_json(module_name, "log", log)
@@ -0,0 +1,162 @@
# =================== AIPass ====================
# Name: ping_sweep/__init__.py
# Description: Verify registered branches respond via test-convention email
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-16
# =============================================
"""
ping_sweep — verify each registered branch responds.
Sends test-convention emails and waits for ack replies.
The AIPASS-TEST token protocol is recognized by ai_mail's daemon
(handlers/dispatch/test_token.py); branches with a running daemon
will auto-ack. Branches without a daemon time out — that's expected.
"""
from __future__ import annotations
import json
import subprocess
import time
from pathlib import Path
from typing import Dict
from aipass.prax import logger
from aipass.aipass.apps.handlers.json import json_handler
TEST_TOKEN = "[AIPASS-TEST — do not update memories, do not execute, reply 'ack' only]"
TIMEOUT_PER_BRANCH = 30
BRANCHES = [
"drone",
"seedgo",
"prax",
"cli",
"flow",
"ai_mail",
"api",
"trigger",
"spawn",
"memory",
"devpulse",
]
_BRANCH_ROOT = Path(__file__).resolve().parents[3]
def _discover_branches() -> list[str]:
"""Read branches from the project's own registry. Returns empty if none found."""
cwd = Path.cwd()
search = cwd
for _ in range(10):
candidates = list(search.glob("*_REGISTRY.json"))
if candidates:
try:
data = json.loads(candidates[0].read_text(encoding="utf-8"))
branches = [b.get("name", "") for b in data.get("branches", [])]
return [b for b in branches if b]
except (json.JSONDecodeError, OSError) as exc:
logger.warning("[ping_sweep] failed to read registry %s: %s", candidates[0], exc)
return []
parent = search.parent
if parent == search:
break
search = parent
return BRANCHES
def _aipass_inbox_path() -> Path:
"""Path to aipass's own inbox.json."""
return _BRANCH_ROOT / ".ai_mail.local" / "inbox.json"
def _send_test_email(branch: str, body: str) -> bool:
"""Send test email to branch via drone. Returns True on success.
Runs drone with cwd=aipass branch root so ai_mail's branch detection
resolves the sender as @aipass (it requires a .trinity/passport.json
in the working dir). Without this, the wrapper's cwd is the AIPass
project root and every send fails with BRANCH DETECTION FAILED.
"""
try:
result = subprocess.run(
["drone", "@ai_mail", "email", f"@{branch}", "AIPASS PING", body],
capture_output=True,
text=True,
timeout=15,
cwd=str(_BRANCH_ROOT),
)
if result.returncode != 0:
logger.warning(
"[ping_sweep] email to @%s failed (rc=%d): %s",
branch,
result.returncode,
result.stderr[:200],
)
return False
return True
except FileNotFoundError as exc:
logger.warning("[ping_sweep] drone not found: %s", exc)
return False
except subprocess.TimeoutExpired as exc:
logger.warning("[ping_sweep] send to @%s timed out: %s", branch, exc)
return False
def _wait_for_ack(branch: str, timeout: int) -> str:
"""
Poll aipass inbox for an ack reply from branch.
Returns 'ack' | 'timeout'.
Auto-ack requires the target branch's ai_mail daemon to be running.
"""
deadline = time.time() + timeout
inbox_path = _aipass_inbox_path()
while time.time() < deadline:
time.sleep(2)
if not inbox_path.exists():
continue
try:
with open(inbox_path, "r", encoding="utf-8") as f:
data = json.load(f)
for msg in data.get("messages", []):
msg_from = msg.get("from", "").lstrip("@")
subj = msg.get("subject", "").lower()
body_lower = msg.get("message", "").lower()
if msg_from == branch and msg.get("status") == "new" and ("ack" in subj or "ack" in body_lower):
return "ack"
except (json.JSONDecodeError, OSError) as exc:
logger.warning("[ping_sweep] inbox read error: %s", exc)
return "timeout"
def sweep_all_branches(timeout: int = TIMEOUT_PER_BRANCH) -> Dict[str, str]:
"""
Send AIPASS PING to each branch, collect acks.
Returns {branch: 'ack' | 'timeout' | 'error'}.
"""
results: Dict[str, str] = {}
body = f"AIPASS PING — checking that you are reachable.\n\n{TEST_TOKEN}"
for branch in _discover_branches():
ok = _send_test_email(branch, body)
if not ok:
results[branch] = "error"
continue
results[branch] = _wait_for_ack(branch, timeout)
logger.info("[ping_sweep] @%s → %s", branch, results[branch])
json_handler.log_operation("ping_sweep", {"results": results})
return results
def sweep_summary(results: Dict[str, str]) -> str:
"""Return human-readable sweep summary."""
acks = sum(1 for v in results.values() if v == "ack")
timeouts = sum(1 for v in results.values() if v == "timeout")
errors = sum(1 for v in results.values() if v == "error")
return f"{acks} ack / {timeouts} timeout / {errors} error"
@@ -0,0 +1,119 @@
# =================== AIPass ====================
# Name: readme_map/__init__.py
# Description: Branch-name to README-path lookup for help_chat live reads
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-16
# =============================================
"""readme_map — branch-name → README-path lookup for help_chat live reads.
Principle: map is cached (path lookup only). Content is NEVER cached.
Every question live-reads the current file. Nothing hardcoded.
AIPASS_ROOT detection:
1. Try os.environ["AIPASS_HOME"] — if set, use it
2. Fall back: walk up from this file to find directory containing src/aipass/
3. AIPASS_ROOT = the parent of src/aipass/ (i.e., the Projects/AIPass directory)
"""
from __future__ import annotations
import os
from pathlib import Path
# =============================================================================
# AIPASS ROOT DETECTION
# =============================================================================
_AIPASS_ROOT: Path | None = None
def _detect_aipass_root() -> Path:
"""Detect the AIPass project root (parent of src/aipass/)."""
# Strategy 1: environment variable
env_home = os.environ.get("AIPASS_HOME")
if env_home:
return Path(env_home)
# Strategy 2: walk up from this file to find src/aipass/
# This file lives at: src/aipass/aipass/apps/handlers/readme_map/__init__.py
# parents: [0]=readme_map/, [1]=handlers/, [2]=apps/, [3]=aipass/,
# [4]=src/aipass/, [5]=Projects/AIPass/ (AIPASS_ROOT)
current = Path(__file__).resolve()
for parent in current.parents:
src_aipass = parent / "src" / "aipass"
if src_aipass.is_dir():
return parent
# Fallback: use the inferred path directly (5 levels up from this file)
return Path(__file__).resolve().parents[5]
# =============================================================================
# BRANCH REGISTRY
# =============================================================================
BRANCHES: list[str] = [
"drone",
"seedgo",
"prax",
"cli",
"flow",
"ai_mail",
"api",
"trigger",
"spawn",
"memory",
"devpulse",
"aipass",
]
# Module-level cache: branch_name → readme_path
# This is the ONLY thing cached. Content is always live-read.
_README_MAP: dict[str, Path] | None = None
def _build_readme_map() -> dict[str, Path]:
"""Build the branch → README.md path map. Called once, result cached."""
global _AIPASS_ROOT
if _AIPASS_ROOT is None:
_AIPASS_ROOT = _detect_aipass_root()
src_aipass = _AIPASS_ROOT / "src" / "aipass"
result: dict[str, Path] = {}
for branch in BRANCHES:
readme = src_aipass / branch / "README.md"
if readme.exists():
result[branch] = readme
return result
def _get_map() -> dict[str, Path]:
"""Return the cached README map, building it on first call."""
global _README_MAP
if _README_MAP is None:
_README_MAP = _build_readme_map()
return _README_MAP
# =============================================================================
# PUBLIC API
# =============================================================================
def get_readme_path(branch: str) -> Path | None:
"""Return Path to src/aipass/{branch}/README.md if it exists, else None.
NEVER reads the file — just returns the path.
"""
return _get_map().get(branch)
def list_branches() -> list[str]:
"""Return list of branches that have a README.md.
Checked against the cached path map (built on first call).
Reflects the filesystem state at the time the map was first built.
"""
return list(_get_map().keys())
@@ -0,0 +1,27 @@
"""system_detect — OS, shell, Python, RAM/CPU, install method detection."""
from aipass.aipass.apps.handlers.system_detect.system_detector import (
detect_cpu,
detect_docker,
detect_git,
detect_install_method,
detect_os,
detect_python,
detect_ram,
detect_shell,
detect_tmux,
detect_wt,
)
__all__ = [
"detect_cpu",
"detect_docker",
"detect_git",
"detect_install_method",
"detect_os",
"detect_python",
"detect_ram",
"detect_shell",
"detect_tmux",
"detect_wt",
]
@@ -0,0 +1,248 @@
# =================== AIPass ====================
# Name: system_detector.py
# Description: Pure system detection logic for aipass doctor
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-16
# =============================================
"""
System Detector — Pure detection logic for doctor checks.
Returns plain dicts with facts about the running environment.
No Rich markup — display concerns belong to the UI layer.
"""
from __future__ import annotations
import os
import platform
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict
from aipass.prax import logger
from aipass.aipass.apps.handlers.json import json_handler
# =============================================================================
# PYTHON
# =============================================================================
def detect_python() -> Dict[str, Any]:
"""Return Python version info and ok/warning flags.
Returns:
version: str like "3.11.5"
major: int
minor: int
ok: bool — True if >=3.9
warning: bool — True if ==3.8 (supported but near end)
"""
info = sys.version_info
version = f"{info.major}.{info.minor}.{info.micro}"
ok = (info.major, info.minor) >= (3, 9)
warning = (info.major, info.minor) == (3, 8)
return {
"version": version,
"major": info.major,
"minor": info.minor,
"ok": ok,
"warning": warning,
}
# =============================================================================
# GIT
# =============================================================================
def detect_git() -> Dict[str, Any]:
"""Return git availability and version string.
Returns:
found: bool
version: str — e.g. "2.43.0" or ""
"""
git_path = shutil.which("git")
if git_path is None:
return {"found": False, "version": ""}
version = ""
try:
result = subprocess.run(
["git", "--version"],
capture_output=True,
text=True,
timeout=5,
)
raw = result.stdout.strip()
# "git version 2.43.0" → "2.43.0"
parts = raw.split()
if len(parts) >= 3:
version = parts[-1]
else:
version = raw
except Exception as exc:
logger.warning("[system_detector] git version check failed: %s", exc)
return {"found": True, "version": version}
# =============================================================================
# SHELL
# =============================================================================
def detect_shell() -> Dict[str, Any]:
"""Return shell name and path.
Returns:
name: str — e.g. "bash", "zsh", or "unknown"
path: str — full path or ""
"""
shell_path = os.environ.get("SHELL", "")
if shell_path:
name = Path(shell_path).name
else:
name = "unknown"
shell_path = ""
return {"name": name, "path": shell_path}
# =============================================================================
# OS
# =============================================================================
def detect_os() -> Dict[str, Any]:
"""Return OS name, release, and machine architecture.
Returns:
os_name: str — "Linux", "Darwin", "Windows", etc.
release: str — kernel/OS release string
machine: str — e.g. "x86_64"
"""
return {
"os_name": platform.system() or "unknown",
"release": platform.release() or "",
"machine": platform.machine() or "",
}
# =============================================================================
# RAM
# =============================================================================
def _read_meminfo_kb() -> int:
"""Read MemTotal from /proc/meminfo and return value in kB, or 0 on failure."""
meminfo = Path("/proc/meminfo")
if not meminfo.exists():
return 0
try:
with open(meminfo, "r", encoding="utf-8") as f:
for line in f:
if line.startswith("MemTotal:"):
return int(line.split()[1])
except Exception as exc:
logger.warning("[system_detector] /proc/meminfo read failed: %s", exc)
return 0
def _total_ram_gb() -> float:
"""Return total RAM in GB using psutil or /proc/meminfo fallback."""
try:
import psutil # type: ignore[import-untyped]
return psutil.virtual_memory().total / (1024**3)
except ImportError as exc:
logger.info("[system_detector] psutil not installed, using /proc/meminfo fallback: %s", exc)
kb = _read_meminfo_kb()
return kb / (1024**2) if kb else 0.0
except Exception as exc:
logger.warning("[system_detector] psutil RAM check failed: %s", exc)
return 0.0
def detect_ram() -> Dict[str, Any]:
"""Return total RAM in GB and ok/warning flags.
Tries psutil first, falls back to /proc/meminfo on Linux, else 0.
Returns:
total_gb: float
ok: bool — True if >=4 GB
warning: bool — True if 2<=x<4 GB
"""
total_gb = _total_ram_gb()
ok = total_gb >= 4.0
warning = 2.0 <= total_gb < 4.0
result = {"total_gb": round(total_gb, 1), "ok": ok, "warning": warning}
json_handler.log_operation("detect_ram", {"total_gb": result["total_gb"]}, "system_detector")
return result
# =============================================================================
# CPU
# =============================================================================
def detect_cpu() -> Dict[str, Any]:
"""Return logical CPU count.
Returns:
count: int — number of logical CPUs (0 if unknown)
"""
count = os.cpu_count() or 0
return {"count": count}
# =============================================================================
# INSTALL METHOD
# =============================================================================
def detect_install_method() -> str:
"""Return how aipass was installed: 'pip', 'dev', 'clone', or 'fork'.
Logic:
- If this file's path contains 'site-packages' → pip
- If .git exists AND pyproject.toml is in the same tree → dev (editable install / source contributor)
- If .git exists → clone
- Default → 'unknown'
"""
this_file = Path(__file__).resolve()
if "site-packages" in str(this_file):
return "pip"
for parent in this_file.parents:
if (parent / ".git").exists():
if (parent / "pyproject.toml").exists() and (parent / "src").exists():
return "dev"
return "clone"
return "unknown"
# =============================================================================
# OPTIONAL TOOLS
# =============================================================================
def detect_tmux() -> bool:
"""True if tmux is available on PATH."""
return shutil.which("tmux") is not None
def detect_wt() -> bool:
"""True if Windows Terminal (wt.exe) is available on PATH."""
return shutil.which("wt.exe") is not None
def detect_docker() -> bool:
"""True if docker is available on PATH."""
return shutil.which("docker") is not None
@@ -0,0 +1,17 @@
"""ui — progress bars, Rich output helpers for aipass modules."""
from aipass.aipass.apps.handlers.ui.progress import (
GLYPH_FAIL,
GLYPH_PASS,
GLYPH_WARN,
format_check,
make_doctor_progress,
)
__all__ = [
"GLYPH_FAIL",
"GLYPH_PASS",
"GLYPH_WARN",
"format_check",
"make_doctor_progress",
]
@@ -0,0 +1,83 @@
# =================== AIPass ====================
# Name: progress.py
# Description: Rich progress and glyph helpers for aipass doctor
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-16
# =============================================
"""
Rich progress and glyph helpers for aipass doctor.
Provides status glyphs and progress spinners used by doctor checks.
No bare print() — all output via logger or caller's console.
"""
from __future__ import annotations
from rich.progress import Progress, SpinnerColumn, TextColumn
from aipass.aipass.apps.handlers.json import json_handler
from aipass.prax import logger
# =============================================================================
# GLYPHS
# =============================================================================
GLYPH_PASS = "[green]✓[/green]"
GLYPH_WARN = "[yellow]![/yellow]"
GLYPH_FAIL = "[red]✗[/red]"
# =============================================================================
# PROGRESS FACTORY
# =============================================================================
def make_doctor_progress() -> Progress:
"""Return a transient spinner progress bar for doctor checks.
Returns:
Progress instance with spinner + description columns.
"""
logger.info("[progress] creating doctor progress bar")
json_handler.log_operation("make_doctor_progress", {})
return Progress(
SpinnerColumn(),
TextColumn("{task.description}"),
transient=True,
)
# =============================================================================
# CHECK FORMATTER
# =============================================================================
def format_check(
label: str,
glyph: str,
detail: str = "",
remediation: str = "",
) -> str:
"""Format a single doctor check line as Rich markup.
Args:
label: Check name (e.g. "python").
glyph: One of GLYPH_PASS / GLYPH_WARN / GLYPH_FAIL.
detail: Optional detail appended after the label (e.g. "3.11.5").
remediation: Optional remediation hint shown indented on next line.
Returns:
Rich markup string ready for console.print().
"""
parts: list[str] = [f" {glyph} [bold]{label}[/bold]"]
if detail:
parts.append(f" [dim]{detail}[/dim]")
line = "".join(parts)
if remediation:
indent = " "
line = f"{line}\n{indent}[dim yellow]{remediation}[/dim yellow]"
return line
@@ -0,0 +1,64 @@
# apps/integrations/
Private integration space for `AIPASS`.
**This folder is gitignored.** Only this README is tracked. Everything else you drop in here stays local and never appears in git, PRs, or the public repo. Safe by construction, not by discipline.
## What goes here
**Branch-specific wrappers** that consume external systems via the @api driver layer. Each wrapper handles how THIS branch uses an external system in its own domain.
```
apps/integrations/
└── {project}/
├── wrapper.py # How this branch uses the driver
├── config.json # Optional — local config
└── tests/ # Private tests colocated
```
Wrappers should call into `@api`'s generic contracts (e.g. `api.memory_backend.query(...)`), never reference the private project by name in any tracked code. The private project name lives in the @api driver, not here.
## What does NOT go here
- **Driver code** — that belongs in `@api/apps/integrations/{project}/driver.py` (the connection layer).
- **Public business logic** — use `apps/modules/` or `apps/handlers/` for that.
- **Drone plugins** — use `apps/plugins/` for those.
- **Secrets** — they live in `~/.secrets/aipass/`, never in the repo.
## Architecture
The full design is in DPLAN-0133 (private integrations architecture). Three layers:
1. **@api driver layer** (`@api/apps/integrations/{project}/`) — owns the physical connection, auth, transport. Knows the private project name.
2. **Per-branch wrapper layer** (`{this_folder}/{project}/`) — owns how this branch consumes the driver's output in its domain. Calls generic contracts, never names private projects.
3. **Public drone commands** (`drone @api integrations list`, `drone @api integrations call <contract>`) — advertise the extension points without naming specifics. Fork-safe.
## Usage
```python
# Your public code (committed, in apps/modules/ or apps/handlers/)
from aipass.api import memory_backend
results = memory_backend.query("when did we ship watchdog?")
# memory_backend is a generic contract. In your local setup it routes to whatever
# driver you registered in @api/apps/integrations/. In a fresh clone with nothing
# registered, it returns NotConfigured gracefully.
```
```python
# Your private wrapper (in this folder, gitignored)
# apps/integrations/{project}/wrapper.py
from aipass.api import memory_backend
def domain_specific_query(context):
"""Branch-specific query pattern for domain needs."""
hint = build_query_from_context(context)
return memory_backend.query(hint, top_k=5, filter={"kind": "decision"})
```
The wrapper stays here, the call into the contract stays here, no private name leaks into tracked code.
---
See DPLAN-0133 for the full design rationale.
+5
View File
@@ -0,0 +1,5 @@
# Modules
Business logic for `AIPASS`. One module per command.
Modules orchestrate work by calling handlers. They are the public API of the branch — drone routes commands here.
+598
View File
@@ -0,0 +1,598 @@
# =================== AIPass ====================
# Name: doctor.py
# Description: System health aggregation — aipass doctor command
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-16
# =============================================
"""
aipass doctor — system health aggregation
Flutter-doctor-style health check across four groups:
System — Python, git, shell, OS, RAM, CPU, install method
Identity — AIPASS_HOME, registry, passport integrity
Services — drone routing, pytest collect, hooks wired
Community — ai_mail, dropbox
Three-tier glyph output: ✓ green / ! yellow / ✗ red
Remediation shown inline under failing checks.
Exit 0 on pass+warn, non-zero only on errors.
Pure reads — never mutates.
Run: aipass doctor [--verbose]
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, NamedTuple
from aipass.cli.apps.modules import console
from aipass.prax import logger
from aipass.aipass.apps.handlers.json import json_handler
from aipass.aipass.apps.handlers.system_detect.system_detector import (
detect_cpu,
detect_git,
detect_install_method,
detect_os,
detect_python,
detect_ram,
detect_shell,
)
from aipass.aipass.apps.handlers.ui.progress import (
GLYPH_FAIL,
GLYPH_PASS,
GLYPH_WARN,
format_check,
make_doctor_progress,
)
# =============================================================================
# TYPES
# =============================================================================
_BRANCH_ROOT = Path(__file__).resolve().parents[2]
class CheckResult(NamedTuple):
"""Single doctor check result."""
label: str
glyph: str
detail: str
remediation: str
# =============================================================================
# IDENTITY HELPERS
# =============================================================================
def _find_registry() -> Path | None:
"""Walk up from CWD first (user's project), then branch root."""
cwd = Path.cwd()
for parent in (cwd, *cwd.parents):
candidates = list(parent.glob("*_REGISTRY.json"))
if candidates:
return candidates[0]
if parent == parent.parent:
break
for parent in (_BRANCH_ROOT, *_BRANCH_ROOT.parents):
candidate = parent / "AIPASS_REGISTRY.json"
if candidate.exists():
return candidate
return None
# =============================================================================
# CHECK GROUPS
# =============================================================================
def _check_system() -> List[CheckResult]:
"""Run System group checks."""
results: List[CheckResult] = []
# Python
py = detect_python()
if py["ok"]:
glyph, detail, rem = GLYPH_PASS, py["version"], ""
elif py["warning"]:
glyph, detail = GLYPH_WARN, py["version"]
rem = "Python 3.8 reaches end-of-life — upgrade to 3.9+"
else:
glyph, detail = GLYPH_FAIL, py["version"]
rem = "Upgrade Python: https://python.org/downloads"
results.append(CheckResult("python", glyph, detail, rem))
# git
git = detect_git()
if git["found"]:
results.append(CheckResult("git", GLYPH_PASS, git["version"], ""))
else:
results.append(CheckResult("git", GLYPH_FAIL, "not found", "Install git: https://git-scm.com/downloads"))
# shell
sh = detect_shell()
results.append(CheckResult("shell", GLYPH_PASS, sh["name"], ""))
# OS
os_info = detect_os()
detail = f"{os_info['os_name']} {os_info['release']}".strip()
results.append(CheckResult("OS", GLYPH_PASS, detail, ""))
# RAM
ram = detect_ram()
ram_detail = f"{ram['total_gb']} GB"
if ram["ok"]:
results.append(CheckResult("RAM", GLYPH_PASS, ram_detail, ""))
elif ram["warning"]:
results.append(CheckResult("RAM", GLYPH_WARN, ram_detail, "AIPass runs better with 4 GB+ RAM"))
else:
results.append(CheckResult("RAM", GLYPH_FAIL, ram_detail, "AIPass requires at least 2 GB RAM"))
# CPU
cpu = detect_cpu()
results.append(CheckResult("CPU", GLYPH_PASS, f"{cpu['count']} cores", ""))
# install method
method = detect_install_method()
results.append(CheckResult("install", GLYPH_PASS, method, ""))
return results
def _check_identity() -> List[CheckResult]:
"""Run Identity group checks."""
results: List[CheckResult] = []
# Project root — derived from registry location
reg = _find_registry()
project_root = str(reg.parent) if reg else ""
if project_root:
results.append(CheckResult("AIPASS_HOME", GLYPH_PASS, project_root, ""))
else:
home = os.environ.get("AIPASS_HOME", "")
if home:
results.append(CheckResult("AIPASS_HOME", GLYPH_PASS, home, ""))
else:
results.append(
CheckResult(
"AIPASS_HOME",
GLYPH_WARN,
"not set",
"Set in ~/.bashrc: export AIPASS_HOME=/path/to/aipass",
)
)
# Registry present
reg_path = _find_registry()
if reg_path is None:
results.append(CheckResult("registry", GLYPH_FAIL, "not found", "Run 'aipass init' to create registry"))
return results
branch_count = 0
try:
with open(reg_path, "r", encoding="utf-8") as f:
reg_data = json.load(f)
branch_count = len(reg_data.get("branches", []))
results.append(CheckResult("registry", GLYPH_PASS, f"{branch_count} branches", ""))
except (json.JSONDecodeError, OSError) as exc:
logger.warning("[doctor] registry parse error: %s", exc)
results.append(CheckResult("registry", GLYPH_FAIL, "corrupt JSON", "Manually inspect AIPASS_REGISTRY.json"))
return results
# Registry valid — has branches key
if "branches" in reg_data:
results.append(CheckResult("registry valid", GLYPH_PASS, "", ""))
else:
results.append(CheckResult("registry valid", GLYPH_FAIL, "missing 'branches' key", "Re-run 'aipass init'"))
# Passport readable
passport = _BRANCH_ROOT / ".trinity" / "passport.json"
if passport.exists():
try:
with open(passport, "r", encoding="utf-8") as f:
pdata = json.load(f)
role = pdata.get("role", "unknown")
results.append(CheckResult("passport", GLYPH_PASS, f"role: {role}", ""))
except Exception as exc:
logger.warning("[doctor] passport read error: %s", exc)
results.append(CheckResult("passport", GLYPH_WARN, "unreadable", "Check .trinity/passport.json"))
else:
results.append(CheckResult("passport", GLYPH_WARN, "not found", ""))
return results
def _find_manifest() -> Path | None:
"""Find provider_manifest.json by walking up from CWD or using AIPASS_HOME."""
for start in (Path.cwd(), Path(os.environ.get("AIPASS_HOME", ""))):
p = start.resolve()
for parent in (p, *p.parents):
candidate = parent / ".claude" / "provider_manifest.json"
if candidate.exists():
return candidate
if parent == parent.parent:
break
return None
def _check_provider_manifest() -> List[CheckResult]:
"""Check provider settings against manifest. Returns hook/env/permission results."""
results: List[CheckResult] = []
manifest_path = _find_manifest()
if manifest_path is None:
results.append(
CheckResult(
"hooks", GLYPH_WARN, "manifest not found", "Expected .claude/provider_manifest.json in project root"
)
)
return results
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception as exc:
logger.warning("[doctor] manifest read error: %s", exc)
results.append(CheckResult("hooks", GLYPH_WARN, "manifest unreadable", "Check .claude/provider_manifest.json"))
return results
claude_section = manifest.get("cli", {}).get("claude", {})
if not claude_section:
results.append(CheckResult("hooks", GLYPH_WARN, "manifest has no claude section", ""))
return results
# --- Hook scripts exist ---
manifest_hooks = claude_section.get("hooks", [])
hook_scripts = {h["script"] for h in manifest_hooks if "script" in h}
repo_hooks_dir = manifest_path.parent / "hooks"
user_hooks_dir = Path.home() / ".claude" / "hooks"
missing_hooks = []
for script in sorted(hook_scripts):
source = next((h.get("source", "repo") for h in manifest_hooks if h.get("script") == script), "repo")
check_dir = user_hooks_dir if source == "user" else repo_hooks_dir
if not (check_dir / script).exists():
missing_hooks.append(script)
if not missing_hooks:
results.append(CheckResult("hooks", GLYPH_PASS, f"{len(hook_scripts)} provider hooks present", ""))
else:
results.append(
CheckResult(
"hooks",
GLYPH_WARN,
f"{len(missing_hooks)} hook(s) missing: {', '.join(missing_hooks)}",
"Copy missing hooks to ~/.claude/hooks/ — see .claude/hooks/README.md",
)
)
# --- Env vars in provider settings ---
manifest_env = claude_section.get("env", {})
if manifest_env:
provider_settings_path = Path.home() / ".claude" / "settings.json"
provider_env: dict = {}
if provider_settings_path.exists():
try:
provider_env = json.loads(provider_settings_path.read_text(encoding="utf-8")).get("env", {})
except Exception as exc:
logger.warning("[doctor] provider settings read error (env): %s", exc)
missing_env = [k for k in manifest_env if k not in provider_env]
if not missing_env:
results.append(CheckResult("env vars", GLYPH_PASS, f"{len(manifest_env)} provider env vars set", ""))
else:
results.append(
CheckResult(
"env vars",
GLYPH_WARN,
f"{len(missing_env)} env var(s) missing: {', '.join(missing_env)}",
"Add to ~/.claude/settings.json env block — see provider_manifest.json",
)
)
# --- Permissions ---
manifest_perms = claude_section.get("permissions", {})
manifest_deny = manifest_perms.get("deny", [])
manifest_ask = manifest_perms.get("ask", [])
if manifest_deny or manifest_ask:
provider_settings_path = Path.home() / ".claude" / "settings.json"
provider_perms: dict = {}
if provider_settings_path.exists():
try:
provider_perms = json.loads(provider_settings_path.read_text(encoding="utf-8")).get("permissions", {})
except Exception as exc:
logger.warning("[doctor] provider settings read error (permissions): %s", exc)
provider_deny = set(provider_perms.get("deny", []))
provider_ask = set(provider_perms.get("ask", []))
# Check deny rules (use ~ form only, skip expanded $HOME duplicates)
missing_deny = [r for r in manifest_deny if r not in provider_deny]
# Check ask rules
missing_ask = [r for r in manifest_ask if r not in provider_ask]
total_expected = len(manifest_deny) + len(manifest_ask)
total_missing = len(missing_deny) + len(missing_ask)
if total_missing == 0:
results.append(CheckResult("permissions", GLYPH_PASS, f"{total_expected} permission rules set", ""))
else:
results.append(
CheckResult(
"permissions",
GLYPH_WARN,
f"{total_missing} permission rule(s) missing",
"Add to ~/.claude/settings.json permissions — see provider_manifest.json",
)
)
return results
def _check_services(verbose: bool = False) -> List[CheckResult]:
"""Run Services group checks."""
results: List[CheckResult] = []
# drone systems
try:
proc = subprocess.run(
["drone", "systems"],
capture_output=True,
text=True,
timeout=10,
)
if proc.returncode == 0:
# Count citizen lines (lines with @)
citizens = [ln for ln in proc.stdout.splitlines() if "@" in ln]
detail = f"{len(citizens)} citizens" if citizens else "ok"
results.append(CheckResult("drone", GLYPH_PASS, detail, ""))
else:
results.append(
CheckResult("drone", GLYPH_FAIL, "exit non-zero", "Ensure aipass is installed: pip install -e .")
)
except FileNotFoundError as exc:
logger.warning("[doctor] drone not found: %s", exc)
results.append(CheckResult("drone", GLYPH_FAIL, "not found", "Ensure aipass is installed: pip install -e ."))
except subprocess.TimeoutExpired as exc:
logger.warning("[doctor] drone systems timed out: %s", exc)
results.append(CheckResult("drone", GLYPH_WARN, "timed out", ""))
# pytest --collect-only
try:
# Find repo root (where src/ lives)
repo_root = None
for parent in _BRANCH_ROOT.parents:
if (parent / "src").exists() and (parent / "pyproject.toml").exists():
repo_root = parent
break
cwd = str(repo_root) if repo_root else str(_BRANCH_ROOT)
proc = subprocess.run(
[sys.executable, "-m", "pytest", "src/aipass/", "--collect-only", "-q"],
capture_output=True,
text=True,
timeout=30,
cwd=cwd,
)
output = proc.stdout + proc.stderr
if proc.returncode == 0:
# Count collected lines
collected = [ln for ln in output.splitlines() if "<" in ln or "::" in ln]
detail = f"{len(collected)} tests collected" if collected else "ok"
results.append(CheckResult("pytest collect", GLYPH_PASS, detail, ""))
else:
results.append(CheckResult("pytest collect", GLYPH_WARN, "collection issues", "Run pytest to diagnose"))
except FileNotFoundError as exc:
logger.warning("[doctor] pytest not found: %s", exc)
results.append(CheckResult("pytest collect", GLYPH_WARN, "pytest not found", "pip install pytest"))
except subprocess.TimeoutExpired as exc:
logger.warning("[doctor] pytest collect timed out: %s", exc)
results.append(CheckResult("pytest collect", GLYPH_WARN, "timed out", ""))
# hooks + env + permissions — manifest-driven provider check
manifest_checks = _check_provider_manifest()
results.extend(manifest_checks)
return results
def _check_community() -> List[CheckResult]:
"""Run Community group checks."""
results: List[CheckResult] = []
# ai_mail readable
mail_dir = _BRANCH_ROOT / ".ai_mail.local"
if mail_dir.exists() and mail_dir.is_dir():
results.append(CheckResult("ai_mail", GLYPH_PASS, "readable", ""))
else:
results.append(CheckResult("ai_mail", GLYPH_FAIL, "not found", "Run 'aipass init' to set up mailbox"))
# dropbox writable — only check if project has agents (dropbox is optional for fresh projects)
reg_path = _find_registry()
dropbox = None
if reg_path:
dropbox = reg_path.parent / "dropbox"
if dropbox and dropbox.exists():
writable = os.access(dropbox, os.W_OK)
if writable:
results.append(CheckResult("dropbox", GLYPH_PASS, "writable", ""))
else:
results.append(CheckResult("dropbox", GLYPH_WARN, "not writable", "Check dropbox directory permissions"))
return results
# =============================================================================
# MAIN DOCTOR RUN
# =============================================================================
def run_doctor(verbose: bool = False) -> int:
"""Run all four groups and print results. Returns error count."""
console.print()
console.print("[bold cyan]aipass doctor[/bold cyan]")
console.print()
# Run each check group inside a transient progress spinner so the user
# sees what is happening during slow checks (e.g. pytest --collect-only).
group_specs = [
("System", _check_system),
("Identity", _check_identity),
("Services", lambda: _check_services(verbose=verbose)),
("Community", _check_community),
]
groups: Dict[str, List[CheckResult]] = {}
with make_doctor_progress() as progress:
for name, runner in group_specs:
task_id = progress.add_task(f"checking {name}...", total=None)
groups[name] = runner()
progress.remove_task(task_id)
pass_count = 0
warn_count = 0
error_count = 0
for group_name, checks in groups.items():
console.print(f" [bold]{group_name}[/bold]")
for check in checks:
line = format_check(check.label, check.glyph, check.detail, check.remediation)
console.print(line)
if check.glyph == GLYPH_PASS:
pass_count += 1
elif check.glyph == GLYPH_WARN:
warn_count += 1
else:
error_count += 1
console.print()
console.print("[dim]─────────────────────────────────[/dim]")
summary_parts = [
f"[green]✓ pass: {pass_count}[/green]",
f"[yellow]! warnings: {warn_count}[/yellow]",
f"[red]✗ errors: {error_count}[/red]",
]
console.print(" " + " ".join(summary_parts))
console.print()
logger.info("[doctor] run complete — pass=%d warn=%d error=%d", pass_count, warn_count, error_count)
return error_count
# =============================================================================
# OUTPUT FORMATTING
# =============================================================================
def print_introspection() -> None:
"""Display module info and connected handlers."""
console.print()
console.print("[bold cyan]doctor Module[/bold cyan]")
console.print("System health aggregation — flutter-doctor-style output")
console.print()
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" [cyan]handlers/system_detect/[/cyan]")
console.print(" [dim]- system_detector.py (python, git, shell, OS, RAM, CPU, install)[/dim]")
console.print()
console.print(" [cyan]handlers/ui/[/cyan]")
console.print(" [dim]- progress.py (GLYPH_PASS/WARN/FAIL, format_check, make_doctor_progress)[/dim]")
console.print()
console.print(" [cyan]handlers/json/[/cyan]")
console.print(" [dim]- json_handler.py (operation logging)[/dim]")
console.print()
console.print("[yellow]Check Groups:[/yellow]")
console.print(" [dim]System — Python, git, shell, OS, RAM, CPU, install method[/dim]")
console.print(" [dim]Identity — AIPASS_HOME, registry, passport[/dim]")
console.print(" [dim]Services — drone routing, pytest collect, hooks[/dim]")
console.print(" [dim]Community — ai_mail, dropbox[/dim]")
console.print()
console.print("[yellow]Next:[/yellow]")
console.print(" [green]aipass doctor[/green] [dim]# Run all checks[/dim]")
console.print(" [green]aipass doctor --verbose[/green] [dim]# Full check detail[/dim]")
console.print(" [green]aipass doctor --help[/green] [dim]# Full usage[/dim]")
console.print()
def print_help() -> None:
"""Print help information."""
console.print()
console.print("[bold cyan]aipass doctor[/bold cyan] — System health aggregation")
console.print("Flutter-doctor-style check across System / Identity / Services / Community")
console.print()
console.print("[yellow]USAGE:[/yellow]")
console.print(" [green]aipass doctor[/green] [dim]# Run all checks[/dim]")
console.print(" [green]aipass doctor --verbose[/green] [dim]# Show sub-check detail[/dim]")
console.print()
console.print("[yellow]OUTPUT:[/yellow]")
console.print(" [green]✓[/green] green — check passed")
console.print(" [yellow]![/yellow] yellow — warning (non-blocking)")
console.print(" [red]✗[/red] red — error (remediation shown below)")
console.print()
console.print("[yellow]EXIT CODES:[/yellow]")
console.print(" 0 — all checks pass or warn only")
console.print(" 1 — one or more errors found")
console.print()
# =============================================================================
# COMMAND HANDLER
# =============================================================================
def handle_command(command: str, args: list[str]) -> bool:
"""Handle 'doctor' command routing.
Args:
command: Command name.
args: Additional arguments.
Returns:
True if handled (command == 'doctor'), False otherwise.
"""
if command != "doctor":
return False
if args and args[0] in ("--help", "-h", "help"):
print_help()
return True
if args and args[0] == "--info":
print_introspection()
return True
verbose = "--verbose" in args or "-v" in args
error_count = run_doctor(verbose=verbose)
json_handler.log_operation("doctor_run", {"error_count": error_count})
if error_count > 0:
raise SystemExit(1)
return True
# =============================================================================
# STANDALONE EXECUTION
# =============================================================================
if __name__ == "__main__":
logger.info("Prax logger connected to doctor")
if len(sys.argv) > 1 and sys.argv[1] in ("--help", "-h", "help"):
print_help()
sys.exit(0)
if len(sys.argv) > 1 and sys.argv[1] == "--info":
print_introspection()
sys.exit(0)
handle_command("doctor", sys.argv[1:])
+153
View File
@@ -0,0 +1,153 @@
# =================== AIPass ====================
# Name: handoff.py
# Description: CLI handoff orchestration — aipass handoff command
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-20
# =============================================
"""
aipass handoff — pass the user to their chosen CLI in a new session
Thin coordinator. Delegates platform dispatch to handlers/handoff_platform/
which handles tmux (Linux/Mac), wt.exe (Windows), or prints fallback command.
Usage:
aipass handoff # show status / introspection
aipass handoff launch # launch with stored profile settings
aipass handoff launch --cli claude --cwd src/my-agent
aipass handoff --help
"""
from __future__ import annotations
from aipass.cli.apps.modules import console, warning
from aipass.prax import logger
from aipass.aipass.apps.handlers.json import json_handler
COMMAND = "handoff"
_INIT_PROMPT = "I just completed aipass init and am ready to start. What should I do first?"
CLI_CHOICES = ["claude", "codex", "gemini"]
FLAG_CHOICES = ["default", "skip-permissions"]
def _get_stored_profile() -> dict:
"""Read cli and agent_path from profile/progress if available."""
try:
from aipass.aipass.apps.modules import profile as profile_mod
p = profile_mod.get_user_profile()
return {"cli": p.get("preferred_cli") or "claude"}
except Exception as exc:
logger.warning("[handoff] could not read profile: %s", exc)
return {"cli": "claude"}
def do_handoff(
cli: str = "claude",
prompt: str = _INIT_PROMPT,
cwd: str = ".",
flag_variant: str = "default",
) -> bool:
"""
Perform the platform-dispatched handoff.
Returns True when a new session was started, False when fallback was used.
In either case the user sees what to do next.
"""
from aipass.aipass.apps.handlers.handoff_platform import launch_handoff
launched, manual_cmd = launch_handoff(cli, prompt, cwd, flag_variant)
if launched:
console.print()
console.print(f"[green]✓[/green] Session started via tmux/wt — CLI: [cyan]{cli}[/cyan]")
console.print(f"[dim]Session name: aipass-handoff | cwd: {cwd}[/dim]")
console.print()
else:
console.print()
warning("Auto-launch unavailable. Run this command manually:")
console.print(f" [cyan]{manual_cmd}[/cyan]")
console.print()
json_handler.log_operation("handoff", {"cli": cli, "cwd": cwd, "launched": launched})
return launched
def print_introspection() -> None:
"""Show handoff module status and stored settings."""
profile = _get_stored_profile()
console.print()
console.print("[bold cyan]handoff Module[/bold cyan]")
console.print("Platform-dispatched CLI session launch")
console.print()
console.print(f" stored CLI: [cyan]{profile.get('cli', 'claude')}[/cyan]")
console.print(" platforms: tmux (Linux/Mac), wt.exe (Windows), fallback")
console.print()
console.print("[dim]Use 'aipass handoff launch' to start a session.[/dim]")
console.print()
def print_help() -> None:
"""Print usage help for the handoff command."""
console.print()
console.print("[bold cyan]aipass handoff[/bold cyan] — launch CLI in a new session")
console.print()
console.print("[yellow]USAGE:[/yellow]")
console.print(" [green]aipass handoff[/green] [dim]# Show status[/dim]")
console.print(" [green]aipass handoff launch[/green] [dim]# Launch with profile defaults[/dim]")
console.print(" [green]aipass handoff launch --cli claude[/green] [dim]# Specify CLI[/dim]")
console.print()
console.print("[yellow]CLI OPTIONS:[/yellow] " + ", ".join(CLI_CHOICES))
console.print("[yellow]FLAG OPTIONS:[/yellow] " + ", ".join(FLAG_CHOICES))
console.print()
def _parse_launch_args(args: list[str]) -> tuple[str, str, str]:
"""Parse --cli, --cwd, --flag from launch subcommand args."""
cli, cwd, flag_variant = "claude", ".", "default"
i = 0
while i < len(args):
if args[i] == "--cli" and i + 1 < len(args):
cli = args[i + 1]
i += 2
elif args[i] == "--cwd" and i + 1 < len(args):
cwd = args[i + 1]
i += 2
elif args[i] == "--flag" and i + 1 < len(args):
flag_variant = args[i + 1]
i += 2
else:
i += 1
return cli, cwd, flag_variant
def handle_command(command: str, args: list[str]) -> bool:
"""Route handoff subcommands: show, launch, help.
Returns True if handled, False if command does not match.
"""
if command != COMMAND:
return False
if not args:
print_introspection()
return True
if args[0] in ("--help", "-h", "help"):
print_help()
return True
if args[0] == "launch":
cli, cwd, flag_variant = _parse_launch_args(args[1:])
if cli not in CLI_CHOICES:
warning(f"Unknown CLI '{cli}'. Valid options: {', '.join(CLI_CHOICES)}")
return True
do_handoff(cli=cli, cwd=cwd, flag_variant=flag_variant)
return True
print_help()
return True
+270
View File
@@ -0,0 +1,270 @@
# =================== AIPass ====================
# Name: help_chat.py
# Description: README-backed chatbot Q&A — Phase 2 of DPLAN-0136
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-16
# =============================================
"""
aipass help — chatbot-style Q&A over branch READMEs
User types `aipass help <question>`. We:
1. Extract keywords from the question (stopword filter, no ML)
2. Match keywords against branch names / README paths
3. Live-read {branch}/README.md for matched branches
4. Return concise answer sourced from matching lines with citations
5. Always offer depth: view full README or dispatch to @branch
Principle: nothing cached except branch-name → README-path map.
Every answer re-reads the real file. Stale info is the enemy.
No LLM in v1 — scripted keyword lookups only.
"""
from __future__ import annotations
from pathlib import Path
from aipass.aipass.apps.handlers.json import json_handler
from aipass.aipass.apps.handlers.readme_map import get_readme_path, list_branches
from aipass.cli.apps.modules import console, error, header
from aipass.prax import logger
# =============================================================================
# MODULE METADATA
# =============================================================================
COMMAND = "help"
_MODULE_NAME = "help_chat"
_VERSION = "1.0.0"
_DESCRIPTION = "README-backed chatbot Q&A over branch documentation"
# =============================================================================
# INTROSPECTION
# =============================================================================
def print_introspection() -> None:
"""Print module info for diagnostics."""
console.print(f"[bold cyan]Module:[/bold cyan] {_MODULE_NAME}")
console.print(f"[bold cyan]Command:[/bold cyan] {COMMAND}")
console.print(f"[bold cyan]Description:[/bold cyan] {_DESCRIPTION}")
console.print(f"[bold cyan]Version:[/bold cyan] {_VERSION}")
# =============================================================================
# KEYWORD EXTRACTION
# =============================================================================
_STOPWORDS: frozenset[str] = frozenset(
{
"a",
"an",
"the",
"is",
"are",
"was",
"were",
"what",
"how",
"why",
"when",
"where",
"who",
"does",
"do",
"can",
"i",
"to",
"in",
"of",
"for",
"and",
"or",
"not",
"it",
"my",
"me",
"you",
}
)
def _extract_keywords(question: str) -> list[str]:
"""Extract meaningful keywords from question string (stopword filter).
Strips punctuation, lowercases, filters stopwords and single-char words.
No ML — pure string operations.
"""
words = question.lower().split()
keywords: list[str] = []
for word in words:
stripped = word.strip("?.,!")
if stripped and stripped not in _STOPWORDS and len(stripped) > 1:
keywords.append(stripped)
return keywords
# =============================================================================
# BRANCH MATCHING
# =============================================================================
def _match_branches(keywords: list[str]) -> list[str]:
"""Return branches whose README likely covers the question.
Strategy:
1. If a keyword exactly matches a branch name → include that branch first
2. Score remaining available branches by keyword overlap with branch name
3. Fallback: return all branches if no match found (broad search)
"""
available = list_branches()
if not available:
return []
direct: list[str] = []
for kw in keywords:
if kw in available and kw not in direct:
direct.append(kw)
# Broad fallback — no direct matches
if not direct:
return available
# Also include branches whose names contain keyword fragments
# (e.g. keyword "mail" → matches "ai_mail")
extended: list[str] = list(direct)
for branch in available:
if branch in extended:
continue
for kw in keywords:
if kw in branch or branch in kw:
extended.append(branch)
break
return extended if extended else available
# =============================================================================
# README SEARCH (LIVE-READ)
# =============================================================================
def _search_readme(readme_path: Path, keywords: list[str]) -> list[tuple[int, str]]:
"""Live-read readme_path. Return (line_num, line_text) for matching lines.
Reads every call — never cached. Scores lines by number of keyword hits.
Returns up to 5 best matches.
"""
try:
with open(readme_path, encoding="utf-8") as fh:
lines = fh.readlines()
except OSError as exc:
logger.warning("[help_chat] Could not read README %s: %s", readme_path, exc)
return []
scored: list[tuple[int, int, str]] = [] # (score, line_num, line_text)
for idx, line in enumerate(lines, start=1):
line_lower = line.lower()
score = sum(1 for kw in keywords if kw in line_lower)
if score > 0:
scored.append((score, idx, line.rstrip()))
# Sort by score descending, then by line number for tie-breaking
scored.sort(key=lambda t: (-t[0], t[1]))
return [(ln, text) for _, ln, text in scored[:5]]
# =============================================================================
# ANSWER FORMATTING
# =============================================================================
def _format_answer(branch: str, readme_path: Path, matches: list[tuple[int, str]]) -> str:
"""Format matched lines into a readable answer with citations.
Citation format: (src/aipass/{branch}/README.md:{line_num})
"""
# Build relative citation prefix — always use forward slashes
# readme_path is absolute; we extract from src/aipass/ onwards
parts = readme_path.parts
try:
src_idx = parts.index("src")
rel_path = "/".join(parts[src_idx:])
except ValueError as exc:
logger.warning("[help_chat] Could not resolve relative path for %s: %s", readme_path, exc)
rel_path = f"src/aipass/{branch}/README.md"
lines_out: list[str] = [f"[{branch}]"]
for line_num, line_text in matches:
citation = f"({rel_path}:{line_num})"
lines_out.append(f" {line_text.strip()} {citation}")
return "\n".join(lines_out)
# =============================================================================
# COMMAND HANDLER
# =============================================================================
def handle_command(command: str, args: list[str]) -> bool:
"""Route `aipass help [question]` — returns True if handled."""
if command != COMMAND:
return False
# Log invocation via json_handler for audit trail
json_handler.ensure_module_jsons(_MODULE_NAME)
if not args:
console.print()
console.print("[bold]Usage:[/bold] aipass help [dim]<question>[/dim]")
console.print("[bold]Example:[/bold] aipass help what does drone do")
console.print()
return True
question = " ".join(args)
keywords = _extract_keywords(question)
if not keywords:
error("Could not extract keywords from question. Try rephrasing.")
return True
branches = _match_branches(keywords)
console.print()
header(f"AIPass Help — {question!r}")
console.print()
found_any = False
for branch in branches[:3]: # limit to 3 branches per search
readme_path = get_readme_path(branch)
if not readme_path:
continue
matches = _search_readme(readme_path, keywords)
if matches:
found_any = True
answer = _format_answer(branch, readme_path, matches)
console.print(answer)
console.print()
if not found_any:
console.print("[dim]No relevant information found.[/dim]")
console.print("[dim]Try: aipass help <broader question>[/dim]")
console.print()
# Always offer depth — non-negotiable per design
console.print("Want to go deeper?")
console.print(" [cyan]→[/cyan] View branch README: [dim]aipass read <branch>[/dim]")
console.print(" [cyan]→[/cyan] Connect with branch: [dim]aipass dispatch @<branch> <question>[/dim]")
console.print()
json_handler.log_operation(
"help_query",
data={"question": question, "keywords": keywords, "branches_searched": branches[:3]},
module_name=_MODULE_NAME,
)
return True
+895
View File
@@ -0,0 +1,895 @@
# =================== AIPass ====================
# Name: init_flow.py
# Description: 12-stage guided first-run setup — aipass init command
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-16
# =============================================
"""
aipass init — guided first-run setup
12 resumable stages. State persists to .trinity/local.json setup_progress.
Ctrl-C at any stage resumes next time from that stage.
Usage:
aipass init # show progress / introspection
aipass init run # interactive
aipass init run --non-interactive # CI/headless, all defaults
aipass init run --name Patrick --cli claude
aipass init run --dry-run # walk all 12 stages, no destructive ops
# - skips drone @spawn create (stage 8)
# - skips tmux/wt handoff (stage 11)
# - does NOT write .trinity/local.json
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List
from aipass.cli.apps.modules import console, warning
from aipass.prax import logger
from aipass.aipass.apps.handlers.json import json_handler
from aipass.aipass.apps.handlers.system_detect.system_detector import (
detect_cpu,
detect_docker,
detect_git,
detect_install_method,
detect_os,
detect_python,
detect_ram,
detect_shell,
detect_tmux,
detect_wt,
)
try:
import questionary as _questionary # type: ignore[import-untyped]
HAS_QUESTIONARY = True
except ImportError as _qe:
logger.info("[init_flow] questionary not installed, using numbered-list menus: %s", _qe)
_questionary = None # type: ignore[assignment]
HAS_QUESTIONARY = False
COMMAND = "init"
TOTAL_STAGES = 12
_BRANCH_ROOT = Path(__file__).resolve().parents[2]
def _get_local_json_path() -> Path:
"""Always resolve .trinity/local.json from CWD (user's project)."""
return Path.cwd() / ".trinity" / "local.json"
CLI_CHOICES = ["claude", "codex", "gemini", "other"]
# Flag variants for CLI launch — these are user-facing config values, not code-level flags
FLAG_CHOICES = ["default", "skip-permissions"]
STYLE_CHOICES = ["building-my-own-project", "improving-aipass", "just-exploring"]
# --- LOCAL JSON HELPERS ---
def _read_local_json() -> dict:
"""Read .trinity/local.json, returning empty dict on failure."""
local_json = _get_local_json_path()
if not local_json.exists() or local_json.stat().st_size == 0:
return {}
try:
with open(local_json, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as exc:
logger.warning("[init_flow] local.json read error: %s", exc)
return {}
def _fire_file_deleted(path: str) -> None:
"""Fire trigger event for temp file deletion, ignoring ImportError."""
try:
from aipass.trigger.apps.modules.core import trigger
trigger.fire("file_deleted", path=path, reason="write_failure_cleanup")
except ImportError as exc:
logger.warning("[init_flow] trigger unavailable for file_deleted event: %s", exc)
def _write_local_json(data: dict) -> None:
"""Write .trinity/local.json atomically via temp-file rename."""
local_json = _get_local_json_path()
dir_ = local_json.parent
dir_.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=str(dir_), prefix=".local_", suffix=".json.tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
os.replace(tmp_path, local_json)
except OSError as exc:
logger.warning("[init_flow] write failed, cleaning up %s: %s", tmp_path, exc)
_fire_file_deleted(tmp_path)
try:
os.unlink(tmp_path)
except OSError as _ue:
logger.warning("[init_flow] temp file cleanup failed: %s", _ue)
raise
def _get_setup_progress() -> dict:
"""Return setup_progress section from local.json."""
data = _read_local_json()
return data.get("setup_progress", {"last_completed_stage": 0, "stages": {}})
def _get_last_completed_stage() -> int:
"""Return the last completed stage number (0 if fresh)."""
return _get_setup_progress().get("last_completed_stage", 0)
def _save_stage(stage: int, stage_data: dict | None = None, dry_run: bool = False) -> None:
"""Persist stage completion to local.json setup_progress.
When dry_run=True the call is a no-op (pure read flow).
"""
if dry_run:
logger.info("[init_flow] dry-run: skipping _save_stage(%d)", stage)
return
data = _read_local_json()
progress = data.get("setup_progress", {"last_completed_stage": 0, "stages": {}})
progress["last_completed_stage"] = stage
progress["stages"][str(stage)] = {
"status": "done",
"timestamp": datetime.now(timezone.utc).isoformat(),
**(stage_data or {}),
}
data["setup_progress"] = progress
_write_local_json(data)
def _prompt(msg: str, default: str = "") -> str:
"""Simple input prompt with optional default."""
display = f"{msg} [{default}]: " if default else f"{msg}: "
try:
val = input(display).strip()
return val if val else default
except (KeyboardInterrupt, EOFError):
raise KeyboardInterrupt
def _choose(msg: str, choices: List[str], default: str | None = None) -> str:
"""Arrow-key menu via questionary, or numbered-list fallback."""
if HAS_QUESTIONARY and _questionary is not None:
try:
result = _questionary.select(msg, choices=choices, default=default).ask()
if result is None:
raise KeyboardInterrupt
return result
except KeyboardInterrupt:
raise
except Exception as exc:
logger.warning("[init_flow] questionary.select failed, using fallback: %s", exc)
console.print(f"\n{msg}")
for i, choice in enumerate(choices, 1):
marker = " [dim](default)[/dim]" if choice == default else ""
console.print(f" {i}. {choice}{marker}")
default_idx = str(choices.index(default) + 1) if default in choices else "1"
while True:
raw = _prompt("Choice (number)", default_idx)
try:
idx = int(raw) - 1
if 0 <= idx < len(choices):
return choices[idx]
except ValueError as exc:
logger.info("[init_flow] invalid menu input %r: %s", raw, exc)
console.print("[red]Invalid choice.[/red]")
# --- STAGE FUNCTIONS ---
def stage_1_welcome(dry_run: bool = False) -> Dict[str, Any]:
"""Print welcome banner and greeting."""
console.print()
console.print("[bold cyan] █████╗ ██╗██████╗ █████╗ ███████╗███████╗[/bold cyan]")
console.print("[bold cyan] ██╔══██╗██║██╔══██╗██╔══██╗██╔════╝██╔════╝[/bold cyan]")
console.print("[bold cyan] ███████║██║██████╔╝███████║███████╗███████╗ [/bold cyan]")
console.print("[bold cyan] ██╔══██║██║██╔═══╝ ██╔══██║╚════██║╚════██║[/bold cyan]")
console.print("[bold cyan] ██║ ██║██║██║ ██║ ██║███████║███████║[/bold cyan]")
console.print("[bold cyan] ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝[/bold cyan]")
console.print()
console.print("[bold]Hi, I am AIPass — your AI passport and front door to the ecosystem.[/bold]")
console.print("Let's walk through setup together. This takes about 5 minutes.")
if shutil.which("drone"):
console.print()
console.print(
"[dim]Tip: Open another terminal and run [cyan]drone @prax monitor run[/cyan] to watch activity live.[/dim]"
)
if dry_run:
console.print("[yellow]\\[dry-run][/yellow] No state will be written, no subprocesses launched.")
console.print()
console.print("[bold cyan]Step 1/12[/bold cyan] — Welcome")
_save_stage(1, dry_run=dry_run)
return {}
def stage_2_system_detect(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
"""Detect OS, Python, shell, RAM, CPU, install method, and optional tools."""
console.print()
console.print("[bold cyan]Step 2/12[/bold cyan] — System detection")
from rich.table import Table
py = detect_python()
git = detect_git()
sh = detect_shell()
os_info = detect_os()
ram = detect_ram()
cpu = detect_cpu()
install = detect_install_method()
has_tmux = detect_tmux()
has_wt = detect_wt()
has_docker = detect_docker()
table = Table(show_header=False, box=None)
table.add_column("key", style="cyan")
table.add_column("value")
table.add_row("OS", f"{os_info['os_name']} {os_info['release']}")
table.add_row("Python", py["version"])
table.add_row("shell", sh["name"])
table.add_row("RAM", f"{ram['total_gb']} GB")
table.add_row("CPU", f"{cpu['count']} cores")
table.add_row("install", install)
table.add_row("git", git["version"] if git["found"] else "not found")
table.add_row("tmux", "yes" if has_tmux else "no")
if sys.platform == "win32":
table.add_row("wt.exe", "yes" if has_wt else "no")
table.add_row("docker", "yes" if has_docker else "no")
console.print(table)
console.print()
console.print(f"You are on [cyan]{os_info['os_name']}[/cyan] with Python [cyan]{py['version']}[/cyan].")
install_labels = {"dev": "development (editable source)", "pip": "pip", "clone": "git clone", "unknown": "unknown"}
console.print(f"Install type: [cyan]{install_labels.get(install, install)}[/cyan]")
system_data: Dict[str, Any] = {
"os": os_info["os_name"],
"python": py["version"],
"shell": sh["name"],
"ram_gb": ram["total_gb"],
"install": install,
"has_docker": has_docker,
"has_tmux": has_tmux,
}
_save_stage(2, system_data, dry_run=dry_run)
return system_data
def stage_3_doctor(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
"""Run aipass doctor health checks inline."""
console.print()
console.print("[bold cyan]Step 3/12[/bold cyan] — System health check")
error_count = 0
provider_gaps: Dict[str, Any] = {}
try:
from aipass.aipass.apps.modules import doctor
error_count = doctor.run_doctor()
try:
for r in doctor._check_provider_manifest():
if r.glyph != doctor.GLYPH_PASS:
provider_gaps[r.label] = r.detail
except Exception as exc:
logger.warning("[init_flow] provider manifest check failed: %s", exc)
except Exception as exc:
logger.warning("[init_flow] doctor run failed: %s", exc)
warning(f"Doctor check skipped: {exc}")
if error_count > 0:
warning(f"{error_count} issue(s) found above — review when convenient.")
else:
console.print("[green]✓[/green] Health check passed.")
_save_stage(3, {"doctor_errors": error_count}, dry_run=dry_run)
return {"doctor_errors": error_count, "provider_gaps": provider_gaps}
def stage_4_user_profile(
non_interactive: bool = False,
name_override: str | None = None,
system_data: dict | None = None,
dry_run: bool = False,
) -> Dict[str, Any]:
"""Collect user name and OS, save to profile."""
console.print()
console.print("[bold cyan]Step 4/12[/bold cyan] — User profile")
from aipass.aipass.apps.modules import profile as profile_mod
if name_override:
name = name_override
elif non_interactive:
name = "User"
else:
name = _prompt("What's your name?", "User")
os_name = (system_data or {}).get("os") or detect_os()["os_name"]
existing = profile_mod.get_user_profile()
existing.update(
{
"name": name,
"os": os_name,
"shell": (system_data or {}).get("shell"),
"install_method": (system_data or {}).get("install"),
"first_seen": existing.get("first_seen") or datetime.now(timezone.utc).isoformat(),
}
)
if dry_run:
console.print(f"[yellow]\\[dry-run][/yellow] would save profile: {existing}")
else:
profile_mod.save_profile(existing)
console.print(f"[green]✓[/green] Hello, {name}!")
_save_stage(4, {"name": name}, dry_run=dry_run)
return {"name": name}
def stage_5_style_questions(
non_interactive: bool = False,
style_override: str | None = None,
dry_run: bool = False,
) -> Dict[str, Any]:
"""Ask what the user wants to do — routes tone of later stages."""
console.print()
console.print("[bold cyan]Step 5/12[/bold cyan] — What brings you here?")
if style_override and style_override in STYLE_CHOICES:
style = style_override
elif non_interactive:
style = STYLE_CHOICES[0]
else:
style = _choose("What are you looking to do?", STYLE_CHOICES, default=STYLE_CHOICES[0])
console.print(f"[green]✓[/green] Got it: {style}")
_save_stage(5, {"style": style}, dry_run=dry_run)
return {"style": style}
def stage_6_tool_choice(
non_interactive: bool = False,
cli_override: str | None = None,
dry_run: bool = False,
) -> Dict[str, Any]:
"""Choose CLI tool and launch flag variant."""
console.print()
console.print("[bold cyan]Step 6/12[/bold cyan] — CLI tool choice")
if cli_override and cli_override in CLI_CHOICES:
cli_choice = cli_override
elif non_interactive:
cli_choice = "claude"
else:
cli_choice = _choose("Which CLI tool do you use?", CLI_CHOICES, default="claude")
if non_interactive:
flag_variant = "default"
else:
flag_variant = _choose(
f"How should I launch {cli_choice}?",
FLAG_CHOICES,
default="default",
)
console.print(f"[green]✓[/green] {cli_choice} ({flag_variant})")
_save_stage(6, {"cli": cli_choice, "flag_variant": flag_variant}, dry_run=dry_run)
if dry_run:
console.print(f"[yellow]\\[dry-run][/yellow] would save preferred_cli={cli_choice} to profile")
else:
try:
from aipass.aipass.apps.modules import profile as profile_mod
p = profile_mod.get_user_profile()
p["preferred_cli"] = cli_choice
profile_mod.save_profile(p)
except Exception as exc:
logger.warning("[init_flow] could not persist cli to profile: %s", exc)
return {"cli": cli_choice, "flag_variant": flag_variant}
def stage_7_docker_offer(
non_interactive: bool = False,
no_docker: bool = False,
has_docker: bool | None = None,
dry_run: bool = False,
) -> Dict[str, Any]:
"""Offer Docker sandbox test if Docker is detected."""
console.print()
console.print("[bold cyan]Step 7/12[/bold cyan] — Docker")
if has_docker is None:
has_docker = detect_docker()
if not has_docker or no_docker or non_interactive:
reason = "not detected" if not has_docker else ("--no-docker" if no_docker else "non-interactive")
console.print(f"[dim]Docker offer skipped ({reason}).[/dim]")
_save_stage(7, {"docker": "skipped"}, dry_run=dry_run)
return {"docker": "skipped"}
raw = _prompt("Test in a Docker sandbox? [y/N]", "N")
use_docker = raw.lower() in ("y", "yes")
result = "yes" if use_docker else "no"
console.print(f"[green]✓[/green] Docker: {result}")
_save_stage(7, {"docker": result}, dry_run=dry_run)
return {"docker": result}
def stage_8_first_agent(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
"""Create the user's first AI agent via drone @spawn."""
console.print()
console.print("[bold cyan]Step 8/12[/bold cyan] — Create your first agent")
console.print("Let's create your first AI agent (citizen).")
if non_interactive:
agent_name = "my-agent"
else:
agent_name = _prompt("Agent name (letters, hyphens, no spaces)", "my-agent") or "my-agent"
agent_path = f"src/{agent_name}"
console.print(f"Running: [cyan]drone @spawn create {agent_path}[/cyan]")
success = False
if dry_run:
console.print(f"[yellow]\\[dry-run][/yellow] would run: drone @spawn create {agent_path}")
success = True
else:
try:
proc = subprocess.run(["drone", "@spawn", "create", agent_path], timeout=60)
success = proc.returncode == 0
except FileNotFoundError as exc:
logger.warning("[init_flow] drone not found in stage 8: %s", exc)
warning("drone not found — skipping agent creation.")
except subprocess.TimeoutExpired as exc:
logger.warning("[init_flow] spawn timed out in stage 8: %s", exc)
warning("spawn timed out — agent may still be created.")
if success:
console.print(f"[green]✓[/green] Agent created at {agent_path}")
_save_stage(8, {"agent_name": agent_name, "agent_path": agent_path, "success": success}, dry_run=dry_run)
return {"agent_name": agent_name, "agent_path": agent_path}
def stage_9_ping_sweep(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
"""Ping all registered branches via test-convention emails."""
console.print()
console.print("[bold cyan]Step 9/12[/bold cyan] — Pinging agents")
from aipass.aipass.apps.handlers import ping_sweep
branches = ping_sweep._discover_branches()
if not branches:
console.print("[dim] No branches registered yet — skipping ping sweep.[/dim]")
_save_stage(9, {"results": {}, "skipped": True}, dry_run=dry_run)
return {"ping_results": {}}
# Standalone projects can't ping agents via drone (drone only knows AIPass's registry).
# Only attempt ping if we're inside the AIPass source tree.
aipass_registry = _BRANCH_ROOT.parent / "AIPASS_REGISTRY.json"
if not aipass_registry.exists():
if len(branches) == 1:
console.print("[dim] 1 agent registered. Ping skipped — ping is for multi-agent projects.[/dim]")
else:
console.print(f"[dim] Found {len(branches)} agent(s) in this project.[/dim]")
console.print("[dim] Ping skipped — agents will be reachable after handoff (next step).[/dim]")
_save_stage(9, {"results": {}, "skipped_standalone": True}, dry_run=dry_run)
return {"ping_results": {}}
console.print(f"[dim] Found {len(branches)} agent(s). Checking reachability...[/dim]")
console.print(
"[dim] (Agents with a running session will auto-ack; new agents will time out — that's normal.)[/dim]"
)
results = ping_sweep.sweep_all_branches(timeout=10)
for branch, status in results.items():
if status == "ack":
glyph = "[green]✓[/green]"
elif status == "timeout":
glyph = "[yellow]—[/yellow]"
else:
glyph = "[red]✗[/red]"
label = "reachable" if status == "ack" else "not running" if status == "timeout" else "error"
console.print(f" {glyph} @{branch}: {label}")
summary = ping_sweep.sweep_summary(results)
console.print(f" {summary}")
_save_stage(9, {"results": results}, dry_run=dry_run)
return {"ping_results": results}
def stage_10_smoke_test(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
"""Verify drone and aipass binaries are on PATH."""
console.print()
console.print("[bold cyan]Step 10/12[/bold cyan] — Smoke test")
drone_bin = shutil.which("drone")
aipass_bin = shutil.which("aipass")
if drone_bin:
console.print(f"[green]✓[/green] drone: {drone_bin}")
else:
warning("drone not on PATH — run: pip install -e .")
if aipass_bin:
console.print(f"[green]✓[/green] aipass: {aipass_bin}")
else:
warning("aipass not on PATH — run: pip install -e .")
_save_stage(10, {"drone": drone_bin, "aipass": aipass_bin}, dry_run=dry_run)
return {"drone": drone_bin, "aipass": aipass_bin}
def stage_11_handoff(
cli_choice: str = "claude",
flag_variant: str = "default",
agent_path: str = "src/my-agent",
non_interactive: bool = False,
dry_run: bool = False,
) -> Dict[str, Any]:
"""Launch user's chosen CLI in a new session via handoff module."""
console.print()
console.print("[bold cyan]Step 11/12[/bold cyan] — Handoff")
init_prompt = "I just completed aipass init. I am ready to start. What should I do first?"
console.print()
console.print(" Your agent is ready. The next step opens an interactive session with it.")
console.print(f" [dim]CLI: {cli_choice} | Agent: {agent_path}[/dim]")
if dry_run:
from aipass.aipass.apps.handlers.handoff_platform import build_manual_command
command = build_manual_command(cli_choice, init_prompt, agent_path, flag_variant)
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,
)
from aipass.aipass.apps.handlers.handoff_platform import build_manual_command
command = build_manual_command(cli_choice, init_prompt, agent_path, flag_variant)
else:
console.print()
input(" Press Enter to chat with your agent...")
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,
)
from aipass.aipass.apps.handlers.handoff_platform import build_manual_command
command = build_manual_command(cli_choice, init_prompt, agent_path, flag_variant)
_save_stage(11, {"command": command, "launched": launched}, dry_run=dry_run)
return {"handoff_command": command, "launched": launched}
def _write_init_report(agent_path: str, accumulated: Dict[str, Any], dry_run: bool = False) -> None:
"""Drop init_report.json into the agent's dropbox."""
if dry_run or not agent_path:
return
dropbox = Path(agent_path) / "dropbox"
dropbox.mkdir(parents=True, exist_ok=True)
system_data = {k: accumulated.get(k) for k in ("os", "python", "shell", "ram_gb", "install") if accumulated.get(k)}
report = {
"created": datetime.now(timezone.utc).isoformat(),
"project_name": Path.cwd().name,
"project_path": str(Path.cwd()),
"agent_name": accumulated.get("agent_name", Path(agent_path).name.upper()),
"agent_number": 1,
"is_orchestrator": True,
"install_method": accumulated.get("install", "unknown"),
"cli_choice": accumulated.get("cli", "claude"),
"total_agents": 1,
"system": system_data,
"note": "You are the first agent created in this project. You are the orchestrator. After dispatching work to other agents, monitor them with: drone @devpulse watchdog agent @target",
}
provider_gaps = accumulated.get("provider_gaps", {})
if provider_gaps:
report["provider_gaps"] = provider_gaps
report["provider_action"] = "Provider settings need configuring. Tell the user what is missing and point them to provider_manifest.json for details."
report_path = dropbox / "init_report.json"
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
logger.info("[init_flow] init report written to %s", report_path)
def stage_12_done(accumulated: Dict[str, Any] | None = None, dry_run: bool = False) -> Dict[str, Any]:
"""Print completion summary and drop init report."""
console.print()
console.print("[bold cyan]Step 12/12[/bold cyan] — Done!")
console.print()
console.print("[bold green]✓ Setup complete![/bold green]")
console.print()
console.print(" [cyan]aipass help[/cyan] [dim]# Ask any question[/dim]")
console.print(" [cyan]aipass doctor[/cyan] [dim]# Check system health[/dim]")
console.print(" [cyan]aipass profile[/cyan] [dim]# View your profile[/dim]")
console.print()
if accumulated:
_write_init_report(accumulated.get("agent_path", ""), accumulated, dry_run=dry_run)
_save_stage(12, dry_run=dry_run)
return {}
# --- MAIN RUNNER ---
def _preflight_check() -> str | None:
"""Return an error message if CWD is unsafe for init, else None."""
cwd = Path.cwd()
# Block if inside an agent directory
if (cwd / ".trinity" / "passport.json").is_file():
return (
"This directory is an agent branch (has .trinity/passport.json).\n"
"Agents are managed by 'drone @spawn', not 'aipass init'."
)
# Block if inside an existing AIPass project (registry above us)
for parent in [cwd] + list(cwd.parents):
for f in parent.iterdir():
if f.is_file() and f.name.endswith("_REGISTRY.json"):
return (
f"Already inside an AIPass project (found {f.name} at {parent}).\n"
"Use 'aipass init update' to upgrade an existing project."
)
if parent == parent.parent:
break
return None
def run_init(
non_interactive: bool = False,
name: str | None = None,
cli: str | None = None,
style: str | None = None,
no_docker: bool = False,
dry_run: bool = False,
) -> int:
"""Run the 12-stage init flow. Returns 0 on success."""
# Pre-flight: refuse to run inside existing projects or agent dirs
err = _preflight_check()
if err:
console.print(f"[red]✗[/red] {err}")
return 1
# Ensure scaffold exists (creates registry, .trinity, etc. if missing)
cwd = Path.cwd()
if not list(cwd.glob("*_REGISTRY.json")):
from aipass.aipass.apps.handlers.init.bootstrap import init_project
if not dry_run:
init_project(cwd)
else:
console.print("[yellow]\\[dry-run][/yellow] would create project scaffold")
# In dry-run we ignore on-disk progress so the full flow always walks.
last_done = 0 if dry_run else _get_last_completed_stage()
if last_done >= TOTAL_STAGES:
console.print("[green]✓[/green] Setup already complete.")
console.print("[dim]Run 'aipass doctor' to check status.[/dim]")
return 0
if last_done > 0:
warning(f"Resuming from stage {last_done + 1}...")
accumulated: Dict[str, Any] = {}
stage_fns = [
(1, lambda: stage_1_welcome(dry_run=dry_run)),
(2, lambda: stage_2_system_detect(non_interactive, dry_run=dry_run)),
(3, lambda: stage_3_doctor(non_interactive, dry_run=dry_run)),
(4, lambda: stage_4_user_profile(non_interactive, name, accumulated, dry_run=dry_run)),
(5, lambda: stage_5_style_questions(non_interactive, style, dry_run=dry_run)),
(6, lambda: stage_6_tool_choice(non_interactive, cli, dry_run=dry_run)),
(7, lambda: stage_7_docker_offer(non_interactive, no_docker, accumulated.get("has_docker"), dry_run=dry_run)),
(8, lambda: stage_8_first_agent(non_interactive, dry_run=dry_run)),
(9, lambda: stage_9_ping_sweep(non_interactive, dry_run=dry_run)),
(10, lambda: stage_10_smoke_test(non_interactive, dry_run=dry_run)),
(
11,
lambda: stage_11_handoff(
accumulated.get("cli", "claude"),
accumulated.get("flag_variant", "default"),
accumulated.get("agent_path", "src/my-agent"),
non_interactive,
dry_run=dry_run,
),
),
(12, lambda: stage_12_done(accumulated=accumulated, dry_run=dry_run)),
]
for stage_num, fn in stage_fns:
if stage_num <= last_done:
continue
try:
result = fn() or {}
accumulated.update(result)
except KeyboardInterrupt:
logger.info("[init_flow] init paused at stage %d by user", stage_num)
warning(f"Paused at stage {stage_num}. Run 'aipass init run' to resume.")
return 0
except Exception as exc:
logger.warning("[init_flow] stage %d error: %s", stage_num, exc)
warning(f"Stage {stage_num} error: {exc} — continuing.")
_save_stage(stage_num, {"error": str(exc)}, dry_run=dry_run)
return 0
# --- INTROSPECTION + HELP ---
def print_introspection() -> None:
"""Show module info and current setup progress."""
progress = _get_setup_progress()
last = progress.get("last_completed_stage", 0)
console.print()
console.print("[bold cyan]init_flow Module[/bold cyan]")
console.print("12-stage guided first-run setup, resumable")
console.print()
if last == 0:
console.print("[dim]Setup not started. Run: aipass init run[/dim]")
elif last >= TOTAL_STAGES:
console.print("[green]✓[/green] Setup complete.")
else:
console.print(f"[yellow]In progress:[/yellow] stage {last}/{TOTAL_STAGES} completed.")
console.print(f"[dim]Run 'aipass init run' to resume from stage {last + 1}.[/dim]")
console.print()
def print_help() -> None:
"""Print usage help for the init command."""
console.print()
console.print("[bold cyan]aipass init[/bold cyan] — guided first-run setup")
console.print()
console.print("[yellow]USAGE:[/yellow]")
console.print(" [green]aipass init run[/green] [dim]# interactive[/dim]")
console.print(" [green]aipass init run --non-interactive[/green] [dim]# CI/headless[/dim]")
console.print(" [green]aipass init run --name Patrick[/green] [dim]# pre-fill name[/dim]")
console.print(" [green]aipass init run --cli claude[/green] [dim]# pre-fill CLI[/dim]")
console.print(" [green]aipass init run --no-docker[/green] [dim]# skip docker offer[/dim]")
console.print(" [green]aipass init run --dry-run[/green] [dim]# walk all stages, no writes[/dim]")
console.print()
console.print("[yellow]STAGES:[/yellow] 12 stages, each saved — resume on ctrl-C")
console.print()
# --- COMMAND HANDLER ---
def _handle_init_scaffold(args: list[str]) -> int:
"""Handle `aipass init [target] [name]` — instant project scaffold."""
from aipass.aipass.apps.handlers.init.bootstrap import init_project
target = Path(args[0]) if args else Path.cwd()
project_name = args[1] if len(args) > 1 else None
try:
result = init_project(target, project_name)
console.print(f"[green]✓[/green] Project initialized at {target}")
json_handler.log_operation("aipass_init", {"target": str(target), "result": result})
return 0
except Exception as exc:
logger.warning("[init_flow] scaffold failed: %s", exc)
console.print(f"[red]✗[/red] Init failed: {exc}")
return 1
def _handle_init_update(args: list[str]) -> int:
"""Handle `aipass init update [target]` — refresh managed scaffold files."""
from aipass.aipass.apps.handlers.init.bootstrap import update_project
target = Path(args[0]) if args else Path.cwd()
try:
result = update_project(target)
console.print(f"[green]✓[/green] Project updated at {target}")
json_handler.log_operation("aipass_init_update", {"target": str(target), "result": result})
return 0
except Exception as exc:
logger.warning("[init_flow] update failed: %s", exc)
console.print(f"[red]✗[/red] Update failed: {exc}")
return 1
def _handle_init_agent(args: list[str]) -> int:
"""Handle `aipass init agent <name>` — create a new agent via spawn."""
if not args:
console.print("[red]✗[/red] Usage: aipass init agent <name>")
return 1
agent_name = args[0]
import subprocess as _sp
cmd = ["drone", "@spawn", "create", f"src/{agent_name}"]
console.print(f"[dim]Running: {' '.join(cmd)}[/dim]")
result = _sp.run(cmd, capture_output=False)
return result.returncode
def handle_command(command: str, args: list[str]) -> bool:
"""Route init subcommands. Returns True if handled, False otherwise."""
if command != COMMAND:
return False
if not args:
err = _preflight_check()
if err:
console.print(f"[red]✗[/red] {err}")
sys.exit(1)
sys.exit(_handle_init_scaffold([]))
return True
if args[0] in ("--help", "-h", "help"):
print_help()
return True
if args[0] == "agent":
sys.exit(_handle_init_agent(args[1:]))
return True
if args[0] == "update":
sys.exit(_handle_init_update(args[1:]))
return True
if args[0] == "run" or args[0].startswith("--"):
run_args = args[1:] if args[0] == "run" else args
non_interactive = "--non-interactive" in run_args
def _flag_value(flag: str) -> str | None:
"""Extract the value after a named flag, or None if absent."""
if flag not in run_args:
return None
idx = run_args.index(flag)
return run_args[idx + 1] if idx + 1 < len(run_args) else None
name = _flag_value("--name")
cli = _flag_value("--cli")
style = _flag_value("--style")
no_docker = "--no-docker" in run_args
dry_run = "--dry-run" in run_args
result = run_init(
non_interactive=non_interactive,
name=name,
cli=cli,
style=style,
no_docker=no_docker,
dry_run=dry_run,
)
json_handler.log_operation(
"init_run",
{"non_interactive": non_interactive, "dry_run": dry_run, "exit": result},
)
sys.exit(result)
return True
# Positional args = target path and/or project name for scaffold
err = _preflight_check()
if err:
console.print(f"[red]✗[/red] {err}")
sys.exit(1)
sys.exit(_handle_init_scaffold(args))
return True
if __name__ == "__main__":
handle_command("init", sys.argv[1:])
+184
View File
@@ -0,0 +1,184 @@
# =================== AIPass ====================
# Name: profile.py
# Description: User profile read/write — aipass profile command
# Version: 1.0.0
# Created: 2026-04-16
# Modified: 2026-04-16
# =============================================
"""
aipass profile — show/edit what aipass remembers about the user
Reads and writes the `user` section of .trinity/local.json.
Commands:
aipass profile — pretty-print current profile
aipass profile set <f> <v> — update a field
aipass profile clear — reset (confirm required)
aipass profile clear --yes — reset without confirmation (dev/CI)
"""
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from aipass.cli.apps.modules import console, error, warning
from aipass.prax import logger
from aipass.aipass.apps.handlers.json import json_handler
COMMAND = "profile"
_BRANCH_ROOT = Path(__file__).resolve().parents[2]
_LOCAL_JSON = _BRANCH_ROOT / ".trinity" / "local.json"
USER_FIELDS = ["name", "os", "shell", "preferred_cli", "install_method", "first_seen"]
def _read_local_json() -> dict:
if not _LOCAL_JSON.exists():
return {}
try:
with open(_LOCAL_JSON, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as exc:
logger.warning("[profile] local.json read error: %s", exc)
return {}
def _fire_file_deleted(path: str) -> None:
"""Fire trigger event for temp file deletion, ignoring ImportError."""
try:
from aipass.trigger.apps.modules.core import trigger
trigger.fire("file_deleted", path=path, reason="write_failure_cleanup")
except ImportError as exc:
logger.warning("[profile] trigger unavailable for file_deleted event: %s", exc)
def _write_local_json(data: dict) -> None:
dir_ = _LOCAL_JSON.parent
dir_.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=str(dir_), prefix=".local_", suffix=".json.tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
os.replace(tmp_path, _LOCAL_JSON)
except OSError as exc:
logger.warning("[profile] write failed, cleaning up temp file: %s", tmp_path)
_fire_file_deleted(tmp_path)
os.unlink(tmp_path)
logger.warning("[profile] local.json write error: %s", exc)
raise
def get_user_profile() -> dict:
"""Return user section from local.json, creating defaults if absent."""
data = _read_local_json()
if "user" not in data:
data["user"] = {f: None for f in USER_FIELDS}
_write_local_json(data)
return data.get("user", {})
def save_profile(profile: dict) -> None:
"""Write user section to local.json, preserving all other sections."""
data = _read_local_json()
data["user"] = profile
_write_local_json(data)
json_handler.log_operation("profile_save", {"fields": list(profile.keys())})
def print_introspection() -> None:
"""Display current user profile."""
from rich.table import Table
profile = get_user_profile()
console.print()
console.print("[bold cyan]aipass profile[/bold cyan]")
console.print()
table = Table(show_header=True, header_style="bold yellow")
table.add_column("Field", style="cyan", width=20)
table.add_column("Value")
for field in USER_FIELDS:
value = profile.get(field)
display = str(value) if value is not None else "[dim]—[/dim]"
table.add_row(field, display)
console.print(table)
console.print()
console.print("[dim]Use 'aipass profile set <field> <value>' to update.[/dim]")
console.print()
def print_help() -> None:
"""Print usage help for the profile command."""
console.print()
console.print("[bold cyan]aipass profile[/bold cyan] — user memory read/write")
console.print()
console.print("[yellow]USAGE:[/yellow]")
console.print(" [green]aipass profile[/green] [dim]# Show current profile[/dim]")
console.print(" [green]aipass profile set <field> <value>[/green] [dim]# Update a field[/dim]")
console.print(" [green]aipass profile clear[/green] [dim]# Reset profile (interactive confirm)[/dim]")
console.print(" [green]aipass profile clear --yes[/green] [dim]# Reset profile (no confirm)[/dim]")
console.print()
console.print("[yellow]FIELDS:[/yellow] " + ", ".join(USER_FIELDS))
console.print()
def handle_command(command: str, args: list[str]) -> bool:
"""Route profile subcommands: show, set <field> <value>, clear, help.
Returns True if handled, False if command does not match.
"""
if command != COMMAND:
return False
if not args:
print_introspection()
return True
if args[0] in ("--help", "-h", "help"):
print_help()
return True
if args[0] == "set":
if len(args) < 3:
error("Usage: aipass profile set <field> <value>")
return True
field, value = args[1], args[2]
if field not in USER_FIELDS:
console.print(f"[red]Unknown field: {field}[/red]")
console.print("[dim]Valid fields: " + ", ".join(USER_FIELDS) + "[/dim]")
return True
profile = get_user_profile()
profile[field] = value
save_profile(profile)
console.print(f"[green]✓[/green] {field} = {value}")
return True
if args[0] == "clear":
# --yes / -y skips the interactive confirmation (CI, dev resets,
# piped invocations where stdin isn't a TTY).
skip_confirm = any(a in ("--yes", "-y") for a in args[1:])
if skip_confirm:
save_profile({f: None for f in USER_FIELDS})
console.print("[green]✓[/green] Profile cleared.")
return True
warning("Type 'aipass' to confirm clearing your profile (ctrl-C to cancel):")
try:
confirm = input("> ").strip()
except (KeyboardInterrupt, EOFError) as exc:
logger.info("[profile] clear input interrupted: %s", exc)
console.print("\n[yellow]Cancelled.[/yellow]")
return True
if confirm == "aipass":
save_profile({f: None for f in USER_FIELDS})
console.print("[green]✓[/green] Profile cleared.")
else:
console.print("[yellow]Cancelled.[/yellow]")
return True
print_help()
return True
+5
View File
@@ -0,0 +1,5 @@
# Plugins
Scheduled tasks and extensions for `AIPASS`.
Plugins are standalone units of work that can be scheduled via the daemon. Each plugin handles one specific recurring task.
+3
View File
@@ -0,0 +1,3 @@
# Docs
Documentation files for the `AIPASS` branch.

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