Compare commits

...
30 Commits
Author SHA1 Message Date
AIOSAI fb710fdb59 chore: gitignore SQLite WAL sidecars (*.db-wal/*.db-shm) — transient runtime artifacts, first surfaced by the commons feed's ro connection 2026-07-21 20:59:37 -07:00
AIOSAI 5b476e0dc3 feat(prax): commons live social feed in monitor (DPLAN-0257) — monitor run commons streams posts/comments/votes/reactions room-tagged, mode=ro sqlite (write refused, live-verified), 10-event backfill + 1.5s id-cursor poll, --logs escape, relay drop-in. 33 new tests, 1065 green, audit 100. Built by @prax, devpulse-verified + live door-tested (post/comment/react streamed) 2026-07-21 20:13:50 -07:00
AIOSAI 1c6abe9b59 test(hooks): skipif non-Linux on the two real-/proc tests — Windows runner has no /proc, one asserted a value the platform by design returns None for (CI catch on 4169d085, learning #275 class: hermeticity on the Win runner) 2026-07-21 18:49:40 -07:00
AIOSAI 4169d085e3 fix(hooks): engine handler timeout (daemon thread + join, honors hooks.json timeout, default 30s, fails loud) + presence_gate PID-reuse defense (procStart vs /proc stat starttime, liveness-only fallback). DPLAN-0253 backlog via DPLAN-0256. 15 new tests, 1272 green, seedgo 31/31 both files. Built by @hooks, devpulse-verified (diff read + suite re-run + checklist) 2026-07-21 18:34:18 -07:00
AIOSAI 37a26ea86c fix(trigger): runaway-log alerts expire 24h like every other mute (DPLAN-0256 backlog) — _write_alert hardcoded expires_at None, alerts nagged forever; DEFAULT_ALERT_TTL_SECONDS=86400 + forever escape hatch. 2 new tests, 621 green, audit 100. Built by @trigger, devpulse-verified (diff read + suite re-run) 2026-07-21 18:19:37 -07:00
AIOSAI 42f56f2cc4 test(drone): merge routing test honors the joint-decision gate — headless routing asserted via --confirm (DPLAN-0256 follow-up; CI catch: test_devpulse_plugins asserted the pre-gate contract, missed locally by running only the edited file's suite) 2026-07-21 18:17:52 -07:00
AIOSAI 7a2700d649 feat(drone): joint-decision gate on @git merge (DPLAN-0256, todo #92) — TTY gets y/N prompt, headless refused without --confirm, merge_pr unreachable ungated, all decisions logged. 6 new tests (86 green), live refusal verified, seedgo 31/31. Patrick ruling S330: merges together, never accidental 2026-07-21 18:05:39 -07:00
AIOSAI d1a6c874e8 feat(skills): telegram user_message_relay joins the hook sound layer — relay events carry a sound key (59/59 + 252 green). Rides the @hooks sounds rollout 2026-07-21 18:05:20 -07:00
AIOSAI 76ce13830d fix(devpulse): watchdog stall threshold 120s to 300s (false stalls on long tool calls, verified live S330) + branch settings.local carries the 350k autoCompactWindow dial (Patrick ruling S326) 2026-07-21 18:04:51 -07:00
AIOSAI 57285740f2 fix(seedgo): AST checker accuracy arc — 13 false positives gone fleet-wide, 2 legit hooks bypasses documented, checkers/audit/checklist aligned, fixture refreshed, stale bypass entries purged (devpulse/memory/seedgo). Fleet audit 100 pct. S330 night arc 2026-07-21 18:04:32 -07:00
AIOSAI 73baa0005f feat(hooks): sound layer across the hook fleet + temporal grounding handler — sounds mirror the log (2465 green, audit 100, compass #157); new prompt/temporal.py injects live local date/time every turn, host-tz, wired both wires (hooks.json + provider manifest). Built by @hooks 2026-07-21 18:04:08 -07:00
AIOSAI c71f389409 feat(aipass): adopt verb + shared/ scaffold refactor — projects/ adoption (registry, resident agent, additive scaffold) proven on aipass-site (doctor 31/0); shared/project_home + scaffold_content = one source of truth for init/new/adopt; spawn template registry synced. 786 green, audit-clean. Built by @aipass 2026-07-21 18:03:44 -07:00
AIOSAI 47bef92162 docs(projects): aipass-site joins the roster — repo moved from ~/Projects/aipass-site into projects/ (own git repo intact, remote unchanged, invisible to AIPass repo per projects/* ignore) 2026-07-20 19:47:55 -07:00
AIOSAI e2157e118c docs(projects): reframe projects/ README — private by default, publishing is opt-in (Patrick ruling). Mechanics unchanged and confirmed: projects/* gitignored, own git repo per project, aipass new creates no remote. CHANGELOG entry updated in place (same unmerged section). 2026-07-20 19:42:47 -07:00
AIOSAI 1ac0cecb9a docs(projects): projects/ README — satellite-project explainer + Earmark roster entry. Fills the !projects/README.md gitignore whitelist that existed since the aipass-new design. CHANGELOG entry included. 2026-07-20 19:38:16 -07:00
AIOSAI 583a792515 fix(hooks): persistent_alert cross-process dedup + loud trust-break banner (DPLAN-0253 trace round). Dedup: module-global _announced set replaced with session+alert-keyed tempdir guard files - fresh bridge process per prompt meant TTS would announce every turn while an alert existed; banner capped at 10 with overflow note. Trust: is_hash_mismatch distinguishes real break from never-enrolled, trust_break_banner does config-independent walk+hash, engine emits loud banner once per prompt via presence_gate call - a hooks.json edit had silently darkened the ENTIRE hook layer for 2+ hours (found by 5-agent trace round). No auto-heal, re-enroll stays human. Live-fired both ways by devpulse: hash broken = banner, restored = healthy. 16 new tests, 1206 green, seedgo 100 pct, both re-run by devpulse. Also: @hooks prompt corrected (taught one-entry-per-event model) + two-wires checklist + mandatory provider-wire flag; README tree/counts refreshed. Built by @hooks 2026-07-20 16:28:48 -07:00
AIOSAI 33a1510cbb feat(hooks): auto-compact prep - context gauge + snapshot stamp (DPLAN-0253). context_gauge (UserPromptSubmit) reads live transcript fill each prompt and nudges /prep at 80 pct of the compact trigger, escalates at 95, once per threshold per session; pre_compact_prep (PreCompact) stamps AUTO-COMPACT SNAPSHOT (fill, dispatch locks, open plans, git, inbox) into the compacting branch local.json; shared context_window resolver (env > branch settings.local.json > 200k). Round 2 root cause: name-scoped events (UserPromptSubmit/PreCompact) invoke the bridge per-handler - hooks.json alone never fires; provider_manifest.json entries added for both + @hooks branch prompt corrected with provider-wire reminder (Patrick-directed). 36 new tests, suite 1190 green, seedgo 100 pct, both re-run by devpulse. Built by @hooks, 2 rounds. Go-live: user syncs ~/.claude/settings.json from manifest + fresh session 2026-07-20 13:13:43 -07:00
AIOSAI d1facd8bdc fix(skills): TG poll 5xx triggers network backoff — HTTPError >=500 in poll_updates raises _NetworkPollError instead of rapid-fire retry; 4xx unchanged. 3 new tests (502/503 backoff, 429 excluded). Medic loop: detected by @trigger (ERROR ae5ddbd4), fixed autonomously by @skills, verified by devpulse (1080 tests green, seedgo 100%) 2026-07-19 21:20:10 -07:00
AIOSAI 0529a4e7c4 fix(ai_mail): inbox-poller spawn_agent now passes --model DEFAULT_MODEL (sonnet). The daemon asymmetry Vera flagged lived in ai_mail's own handlers/dispatch/daemon.py (name collision, not @daemon branch — their scheduled wakes import wake_branch directly and were covered by efb530e0). DEFAULT_MODEL imported from wake.py, single source; 200k window pin already covered via shared dispatch_monitor wrapper. Test asserts --model in captured spawn cmd. 778 tests green, seedgo 100%, both re-run by devpulse. Investigated by @daemon, built by @ai_mail 2026-07-19 09:32:42 -07:00
AIOSAI efb530e0b2 feat(ai_mail): dispatch default Sonnet 5 — alias pass-through + 200k context pin. MODEL_MAP replaced by KNOWN_MODEL_ALIASES pass-through (CLI resolves latest-in-class, map can never go stale), DEFAULT_MODEL opus->sonnet, dispatch_monitor pins CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000 per spawn (Sonnet 5 is 1M-native — without pin agents inherit 1M window). E2E: live @cli dispatch probe reported claude-sonnet-5 + WINDOW=200000 from inside the spawned session. 777 tests green, seedgo 100%, both re-run by devpulse. Daemon spawn_agent --model gap confirmed, handled separately. Patrick ruling S326, built by @ai_mail 2026-07-19 09:15:04 -07:00
AIOSAI a7108e8504 fix(flow): is_template_content v5.0 — boilerplate-aware Exec Log check closes the N1 false negative. Default-template Exec Log boilerplate (11 lines w/ Log Pattern tip) beat the 8-line user-work threshold before the bracket-marker check ran, so blank plans read as user-written and closed as done (FPLAN-0313/0314). Now boilerplate is recognized and skipped; blank templates reach the marker check. Live-proven: fresh FPLAN-0342 detected+fast-deleted; 730 tests, audit 100%. DPLAN-0250 N1, dispatched to @flow 2026-07-19 03:08:17 -07:00
AIOSAI f09d42a862 fix(flow): lock+atomic registry aggregate writes — S304 F85 residual. save_branch_registry + save_central were bare open+dump; now O_EXCL lockfile with backoff + tmpfile os.replace, matching save_registry.py. Devpulse verified: 730 tests green + 6-proc 180-write concurrent hammer, final file valid JSON. N1 root cause found: is_template_content line threshold fires before bracket-marker check. CHANGELOG entries. DPLAN-0250, dispatched to @flow 2026-07-19 02:55:55 -07:00
AIOSAI c91cfd5166 fix(aipass): doctor stops inventing errors on clean repos — S304 F14-17. .backup+templates excluded from agent scan; relative registry paths anchored to project_root not CWD (killed 5 fake missing-branch errors from branch dirs); severity-honest glyphs (info != PASS). Devpulse live-verified from devpulse dir: zero false registry errors, 1 remaining error is genuine ptest pollution. 756 tests. DPLAN-0250 Track A, dispatched to @aipass 2026-07-19 02:55:45 -07:00
AIOSAI 8cb3895264 fix(spawn): seedgo remediation on is_protected work — narrowed logged excepts (OSError/ValueError/KeyError + logger.info, fallthrough semantics unchanged) and extracted _check_nested_pollution to cut detect_pollution to depth 4. Audit 100% (43/43), 357 tests, probes re-verified (pollution 0, delete aipass refused). DPLAN-0250 Track A follow-up, dispatched to @spawn 2026-07-19 01:18:15 -07:00
AIOSAI d631fd8d54 fix(hooks): pytest-guard the two static-path JSONL writers — S304 F6/F43 closed. diagnostics + telegram_response resolved log paths at import, bypassing prax test routing; now per-write resolvers route to tmp under pytest. Devpulse marker-bounded verify: 1154 tests green, engine.jsonl 1291 lines before AND after, zero suite-attributable entries. CHANGELOG wave entry + prep skill feedback-check habit (F46). DPLAN-0250 Track A, dispatched to @hooks 2026-07-19 01:08:59 -07:00
AIOSAI 48e25ae46e fix(prax): pytest detection immune to env-clearing — S304 F6/F43. Hooks tests patch.dict(os.environ, clear=True) stripped PYTEST_CURRENT_TEST, so loggers minted in that window froze prod-path handlers via the setup cache. get_system/module_logs_dir now also check _pytest in sys.modules. 1032 prax + 1154 hooks tests green. Residual: hooks-owned diagnostics writer bypasses prax resolution — handed to @hooks. DPLAN-0250 Track A, dispatched to @prax 2026-07-19 00:55:07 -07:00
AIOSAI 800acd079a fix(commons): lowercase branch-name normalization across all resolution sites — S304 F52. trade/artifact/profile/welcome/search ops all uppercased vs identity's lowercase (diverged af350fe0), so ownership guards never matched and gifts were invisible. DB sweep confirmed clean (bug blocked bad writes). Live gift+trade round-trip green, 449 tests, seedgo 31/31. DPLAN-0250 Track A, dispatched to @commons 2026-07-19 00:51:43 -07:00
AIOSAI defee1f513 fix(spawn): shared is_protected() guards repair + delete — S304 F30/F84. 3-layer check (infra floor, registry owner, active passport) replaces name-match pollution detection and 3-name delete guard. Live-verified: detect_pollution on AIPass root = 0 issues, delete aipass/devpulse both refused. 357 tests (11 new), seedgo 31/31. DPLAN-0250 Track A, dispatched to @spawn 2026-07-19 00:49:46 -07:00
AIOSAI 7811381d54 fix(hooks-tests): stop test_log_write_failure_does_not_crash minting MagicMock/LOG_FILE dirs — bare-Mock LOG_FILE patch reached prax append_jsonl's real mkdir via mock fspath; now patches a real tmp path. 100/100 green, clean-CWD verified zero dirs minted. S324 scan finding, FYI mailed @hooks 2026-07-18 22:10:05 -07:00
AIOSAI bdb128d904 docs(flow): merge playbook site drift-check step — every merge run asks if install/onboarding/agent-count/platform story changed, aipass.ai updates same day (DPLAN-0249 follow-on, S323 projection ruling). Template edit by @flow, seedgo 42/42 2026-07-18 21:07:10 -07:00
115 changed files with 6203 additions and 708 deletions
+17
View File
@@ -55,6 +55,17 @@
"matcher": "", "matcher": "",
"timeout": 120 "timeout": 120
}, },
"context_gauge": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.prompt.context_gauge.handle",
"matcher": "",
"timeout": 30
},
"temporal": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.prompt.temporal.handle",
"matcher": ""
},
"user_message_relay": { "user_message_relay": {
"enabled": true, "enabled": true,
"handler": "aipass.skills.lib.telegram.apps.handlers.user_message_relay.handle", "handler": "aipass.skills.lib.telegram.apps.handlers.user_message_relay.handle",
@@ -158,6 +169,12 @@
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle", "handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle",
"matcher": "", "matcher": "",
"timeout": 120 "timeout": 120
},
"pre_compact_prep": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.lifecycle.pre_compact_prep.handle",
"matcher": "",
"timeout": 30
} }
}, },
+1
View File
@@ -54,6 +54,7 @@ Quick checks beat assumptions: `ls`/`find` for files, `git ls-files`/`grep` for
- Run `drone @ai_mail inbox 2>/dev/null` — report any unread emails - Run `drone @ai_mail inbox 2>/dev/null` — report any unread emails
- Close any that were already processed but not formally closed - Close any that were already processed but not formally closed
- Run `drone @devpulse feedback 2>/dev/null` — report unread cross-project feedback; view/reply/clear anything NEW (S304 F46: this box sat unread for 3 months because nothing invoked it)
## 5. Compass Review (Devpulse only) ## 5. Compass Review (Devpulse only)
+5 -1
View File
@@ -14,6 +14,8 @@
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:compass_recall", "event": "UserPromptSubmit"}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:compass_recall", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:feedback_pulse", "event": "UserPromptSubmit"}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:feedback_pulse", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:auto_process", "event": "UserPromptSubmit"}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:auto_process", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:context_gauge", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:temporal", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:user_message_relay", "event": "UserPromptSubmit"}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:user_message_relay", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse", "event": "PostToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse", "event": "PostToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"},
@@ -23,7 +25,9 @@
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "manual", "timeout": 60}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "manual", "timeout": 60},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "auto", "timeout": 60}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "auto", "timeout": 60},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "manual", "timeout": 120}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "manual", "timeout": 120},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "auto", "timeout": 120} {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "auto", "timeout": 120},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_prep", "event": "PreCompact", "matcher": "manual", "timeout": 30},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_prep", "event": "PreCompact", "matcher": "auto", "timeout": 30}
], ],
"env": { "env": {
"AIPASS_HOME": "{{REPO_ROOT}}", "AIPASS_HOME": "{{REPO_ROOT}}",
+4
View File
@@ -145,3 +145,7 @@ README_ORIGINAL_DISABLED.md
*.bak *.bak
test/ test/
sandbox_test/ sandbox_test/
# SQLite WAL sidecars (runtime artifacts — commons.db ro-feed etc.)
*.db-wal
*.db-shm
+216
View File
@@ -9,8 +9,224 @@ PyPI version — not the changelog header.
--- ---
## [2026-07-21]
**feat(prax)** — Commons live social feed in the monitor (DPLAN-0257, Patrick
ask verbatim): `drone @prax monitor run commons` now streams The Commons'
chatter — posts, comments, votes, reactions — room-tagged with mood coloring,
monitor-style. ~10-event backfill on open, then 1.5s id-cursor polling.
Read-only by construction (`mode=ro` sqlite URI — write attempt refused,
verified live); commons stays the only writer, zero commons-side changes.
Branch-log tail still reachable via `monitor run commons --logs`; mixed branch
lists unchanged. `--relay` rides the existing Telegram relay path.
33 new tests, prax suite 1065 green, audit 100% (52 files). Door-tested live:
devpulse posted/replied/reacted while the feed streamed every event.
Built by @prax.
**fix(hooks)** — two DPLAN-0253 backlog hardenings (DPLAN-0256 clear):
engine handler timeout + presence_gate PID-reuse defense. `_run_handler` now
runs handler-type hooks on a daemon thread joined with the hooks.json
`timeout` field (default 30s) — a hung handler returns TIMEOUT loud
(engine.jsonl + sound) and the event moves on; daemon thread chosen over
ThreadPoolExecutor so a stuck orphan can never hang interpreter exit.
presence_gate occupancy no longer trusts `os.kill(pid, 0)` alone:
`procStart` (CC session file) is matched against `/proc/<pid>/stat` field 22
so a kernel-recycled PID can't impersonate a dead session — closes the gap
before observe-only ever flips to enforcement. Missing procStart / non-Linux
falls back to liveness-only, logged. 15 new tests, suite 1272 green,
seedgo 31/31 both files. Built by @hooks.
**fix(trigger)** — runaway-log alerts get the 24h TTL every other mute already
had (DPLAN-0256 backlog clear): `_write_alert()` hardcoded `expires_at: None`,
so alerts.json entries nagged forever while medic branch mutes self-expired.
New `DEFAULT_ALERT_TTL_SECONDS = 86400` (matches medic_state's
`DEFAULT_MUTE_SECONDS`) with a `forever` escape hatch threaded through
`handle_runaway_log_detected()`. 2 new tests, trigger suite 621 green,
audit 100%. Built by @trigger.
**feat(drone)** — joint-decision gate on `drone @git merge` (DPLAN-0256,
Patrick ruling S330: merges are always done together, never accidental).
The gate sits in `_handle_merge` before the plugin import — `merge_pr()` is
unreachable without confirmation. A real terminal gets an interactive y/N
prompt; headless callers (agent Bash) are refused unless `--confirm` is
passed explicitly. Every gate decision (confirm / tty-yes / tty-abort /
headless-refused) is logged via json_handler. 6 new tests (86 green),
live-fired refusal verified, seedgo 31/31.
**feat(skills)** — telegram user_message_relay joins the sound layer: relay
events now carry their own sound key so an inbound user message is audible
like every other hook event (59/59 + 252 green).
**fix(devpulse)** — watchdog stall threshold 120s → 300s: the 120s
no-JSONL-activity heuristic fired `[watchdog.stall]` on healthy agents doing
long tool calls; 300s matches observed real-stall behavior (verified live
S330). Branch `.claude/settings.local.json` carries the devpulse
`autoCompactWindow: 350000` dial (Patrick ruling S326 — devpulse compacts
~292k, dispatched agents stay pinned at 200k).
**fix(seedgo)** — checker accuracy arc (S330): AST-based import analysis
lands in the checkers (dead_code, encapsulation, handlers, readme,
test_quality, unused_function) — 13 false positives eliminated fleet-wide,
2 real hooks imports that legitimately bypass the pattern documented instead
of suppressed. branch_audit, checklist and ignore_handler aligned; provider
hooks snapshot fixture refreshed; stale bypass entries for deleted tools
purged across branches (devpulse, memory, seedgo, hooks). Fleet audit 100%.
**feat(hooks)** — hook sound layer + temporal grounding. Sounds now mirror
the log across the hook fleet (prompt, lifecycle, notification, security
handlers) — audible liveness for the whole layer, verified live (2465 green,
audit 100). New `prompt/temporal.py`: tiny always-on UserPromptSubmit handler
injecting one line of local date/time/weekday/part-of-day every turn — live
clock each fire, host timezone via `astimezone()` (clones see their own local
time). Wired on both wires (`.aipass/hooks.json` + provider manifest).
**feat(aipass)** — `aipass adopt` + shared scaffold refactor: adopt turns an
existing `projects/` directory into a full AIPass project (registry, resident
agent, `.aipass`/`.claude` scaffold) — every write additive, nothing existing
overwritten; unlike `aipass new` it starts from a directory with its own
content and git history. New `shared/` package (`project_home.py`,
`scaffold_content.py`) gives init/new/adopt one source of truth per helper —
`handlers/init/scaffold_content.py` moved there, no per-command copies to
drift. Proven live adopting aipass-site (doctor 31/0). Spawn template registry
synced (template bug chain me→spawn→aipass, fixed S329). 786 tests green,
audit-clean.
## [2026-07-20]
**docs(projects)** — `projects/README.md`: the projects section now ships in
the repo (the `!projects/README.md` gitignore whitelist existed since the
aipass-new design but the file was never written). Explains the project model:
**private by default** — each project is its own local git repo, fully ignored
by the AIPass repo, and publishing is an explicit opt-in step (Patrick ruling
2026-07-20). Opens the public roster with **Earmark**
([AIOSAI/earmark](https://github.com/AIOSAI/earmark)), the first public AIPass
project — a VS Code read-aloud extension with local Piper TTS and true
pause/resume, born, built, and published 2026-07-20.
**fix(hooks)** — persistent_alert dedup + loud trust-break banner (5-agent
trace round follow-ups, DPLAN-0253 tail):
- persistent_alert's once-per-session sound dedup lived in a module-global set,
but every bridge call is a fresh process — TTS would have announced on every
prompt while any alert was active. Replaced with session+alert-keyed tempdir
guard files (context_gauge idiom); banner capped at 10 alerts with an
"...and N more" note.
- Trust-registry breaks are now LOUD: any `.aipass/hooks.json` change breaks
the enrolled hash and silently disabled the entire hook layer (bit us live
for 2+ hours — tier prompts, security gates, everything dark, one log-file
WARNING as the only signal). New `is_hash_mismatch()` distinguishes a
genuine break from never-enrolled; `trust_break_banner()` does a
config-independent walk+hash check; the engine emits a full-width banner
once per prompt via the presence_gate bridge call. No auto-heal —
re-enrollment stays a deliberate human checkpoint. Live-fired: hash broken →
banner; restored → healthy. 16 new tests, suite 1206 green, seedgo 100%.
- Go-live day for the whole handler roster: 11/12 manifest entries wired into
provider settings by devpulse with Patrick accepting (user_message_relay
held: synchronous Telegram call + full prompt text off-machine — needs a
background send and an explicit call first). @hooks branch prompt corrected
and hardened: two-wires checklist + mandatory provider-wire flag in every
build reply.
**feat(hooks)** — auto-compact prep: context gauge + mechanical snapshot
(DPLAN-0253, built by @hooks, two rounds):
- `context_gauge` (UserPromptSubmit) — reads live context fill from the session
transcript every prompt (cheap 50KB tail), resolves the branch's compact
window (env > branch `settings.local.json` `autoCompactWindow` > 200k), and
injects a "run /prep NOW" nudge at 80% of the compact trigger, escalating at
95% — once per threshold per session. Memory prep happens before auto-compact
takes the choice away, on every branch including dispatched agents.
- `pre_compact_prep` (PreCompact) — stamps a mechanical AUTO-COMPACT SNAPSHOT
session entry into the compacting branch's `.trinity/local.json`: context
fill %, active dispatch locks, open plans, git state, inbox unread. Templated
from live state, defensive (malformed memory = log + skip, never raises).
- Shared `context_window` module: bounded transcript tail reader + per-branch
window resolver. 36 new tests; suite 1190 green; seedgo 100%.
- Round 2 root-cause fix: handlers wired only in `.aipass/hooks.json` never
fire on name-scoped events — UserPromptSubmit and PreCompact invoke the
bridge per-handler from provider settings. Both handlers now have
`provider_manifest.json` entries; @hooks' branch prompt corrected (it taught
the old one-entry-per-event model) with a "new handler? check the provider
wire" reminder. Go-live needs the user's `~/.claude/settings.json` synced
from the manifest + fresh sessions.
## [2026-07-19]
**feat(ai_mail)** — dispatched agents default to Sonnet 5 (Patrick ruling S326):
- wake.py model resolution passes bare aliases (`sonnet`/`opus`/`haiku`)
straight to the Claude CLI, which resolves latest-in-class — the pinned-ID
MODEL_MAP is gone and can never go stale again. Default flips opus → sonnet.
- dispatch_monitor pins `CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000` on every
spawned agent — Sonnet 5 is 1M-context native, and without the pin every
dispatched agent would silently inherit a 1M window. E2E-proven: a live
dispatched probe reported `claude-sonnet-5` + `WINDOW=200000` from inside.
- Follow-up landed same morning: the "daemon gap" was a name collision —
the unpatched `spawn_agent()` was ai_mail's own inbox-poller
(`handlers/dispatch/daemon.py`), not the @daemon branch. It now passes
`--model DEFAULT_MODEL` (imported from wake.py, single source); the 200k pin
was already covered via the shared dispatch_monitor wrapper. @daemon's
scheduled wakes import `wake_branch` directly and were covered from the start.
**fix(skills)** — Telegram poll 5xx now triggers network backoff (medic loop,
autonomous @skills fix): `HTTPError` ≥500 in `poll_updates` raises
`_NetworkPollError` instead of falling through to rapid-fire retry; 4xx still
logs and returns. Three new tests (502/503 backoff, 429 stays out).
**fix(spawn, commons, prax, hooks)** — S304 audit fix campaign, Track A
(DPLAN-0250, four owner dispatches verified + committed by devpulse):
- **spawn** — shared `is_protected()` (infrastructure floor / registry owner /
active passport) now guards both pollution repair and branch delete; repair no
longer flags the live `src/aipass/aipass` branch, and deleting a protected or
actively-passported branch is refused with the reason.
- **commons** — branch-name resolution lowercased across all five ops sites
(trade/artifact/profile/welcome/search) to match identity normalization;
gifting and trading work again (guards had never matched since mid-June).
- **prax** — pytest detection now also checks `_pytest` in `sys.modules`, so
`patch.dict(os.environ, clear=True)` in test suites can no longer strip the
guard and freeze prod-path log handlers into the setup cache.
- **hooks** — the two static-path JSONL writers (engine diagnostics, telegram
delivery log) resolve their path per-write and route to the tmp test dir
under pytest; a full 1154-test suite run now adds zero lines to prod logs
(marker-bounded proof).
Also: `/prep` now checks the cross-project feedback box every run — the S304
"unread since April" backlog (F46) is processed to zero and can't silently
rot again.
**fix(aipass)** — `aipass doctor` no longer invents errors on a healthy repo
(S304 F14-17): `.backup` and spawn `templates` dirs excluded from the agent
scan, relative registry paths anchored against the project root instead of the
caller's CWD (running doctor from inside a branch dir showed 5 fake
missing-branch errors), and info-severity findings now render as warnings
instead of pass checkmarks. Remaining findings on this repo are genuine.
**fix(flow)** — registry aggregate writes are lock+atomic (S304 F85 residual):
`save_branch_registry` and `save_central` were bare `open`+`json.dump`; both now
use the O_EXCL lockfile + temp-file + `os.replace` pattern from the earlier
`save_registry.py` fix. Proven with a 6-process concurrent-write hammer. Also
investigated (N1): FPLAN-0313/0314 closed blank because
`is_template_content()`'s line-count threshold fires before its bracket-marker
check on default templates — guard fix queued, registry annotation is the
maintainer's call.
## [2026-07-18] ## [2026-07-18]
**fix(tests)** — the immortal `MagicMock/LOG_FILE/` directory is dead: a hooks
engine test patched `diagnostics.LOG_FILE` with a bare Mock, and prax's
`append_jsonl` turned the mock's fspath into a real `mkdir` — every test run
re-minted an empty `MagicMock/LOG_FILE/` in the runner's CWD (found in repo
root, devpulse, and hooks). Test now patches a real tmp path; 100/100 green,
clean-CWD run verified to mint nothing.
**docs** — merge playbook gains a site drift-check step (DPLAN-0249 follow-on):
every merge run now asks whether install commands, onboarding flow, agent count,
or the platform/CLI story changed — if yes, aipass.ai must be updated the same
day. Codifies the S323 ruling that the site is a projection of the README, never
its own source of facts. Template edit by @flow, one checklist line in the
post-merge section.
**docs** — root README v3 restructure (DPLAN-0249): single-funnel story with **docs** — root README v3 restructure (DPLAN-0249): single-funnel story with
zero duplicated commands (install, `aipass new`/`init run`, trees, drone zero duplicated commands (install, `aipass new`/`init run`, trees, drone
examples each taught exactly once), hero link line to aipass.ai/PyPI/r/AIPass, examples each taught exactly once), hero link line to aipass.ai/PyPI/r/AIPass,
+49
View File
@@ -0,0 +1,49 @@
[← Back to AIPass](../README.md)
# Projects
> Your projects, built with AIPass. **Private by default** — published only if and when you choose.
This folder is where your own projects live. Create one with `aipass new <name>` and build whatever you want here — a tool, an app, an experiment, something nobody else will ever see. Each project is **its own separate git repository**, and the AIPass repo ignores everything in this folder (only this README ships). Nothing you build here can leak into the AIPass repo, appear in its history, or end up in anyone's pull request.
## What a project is
Every project is born complete:
- **Own git repo** — initialized locally at creation. Local means local: no remote, no publishing, nothing leaves your machine.
- **Own registry** — a sealed `*_REGISTRY.json` seating the project's owner.
- **Resident agent** — a full AIPass citizen living at `src/<name>/<name>/`, with identity, memory, and a mailbox, ready to work the project.
- **AIPass scaffold** — the same `.aipass/` prompt structure and conventions as the main ecosystem, so any agent (or human) knows their way around immediately.
Projects use AIPass; they don't live inside its repo. The ecosystem is the workshop — what you build in it is yours.
## Going public — optional, deliberate
If a project is ready for the world, publishing is an explicit choice: create a GitHub repository for it and push. Until you do that, it exists only on your machine.
Projects from the AIPass family that chose to go public:
| Project | What it is | Repo |
|---|---|---|
| **Earmark** | Read code and docs aloud in VS Code with local Piper TTS — pause, resume, pick up where you left off. Ear + bookmark: the plan is notes anchored to where you paused. First public AIPass project. | [AIOSAI/earmark](https://github.com/AIOSAI/earmark) |
| **aipass-site** | The [aipass.ai](https://aipass.ai) website — AIPass's front door on the web. | [AIOSAI/aipass-site](https://github.com/AIOSAI/aipass-site) |
Projects in residence (private, not yet published):
| Project | What it is |
|---|---|
| **Speakeasy** | System-wide voice-to-text: press a hotkey, speak, text lands at your cursor in any app. Local Whisper (faster-whisper), VAD, zero cloud. The voice-IN half of the loop Earmark's voice-OUT completes. Repo moved from ~/Projects/Speakeasy 2026-07-21, own git history intact. |
## Creating one
```bash
aipass new <name> # empty template + resident agent
aipass new <name> --template python
aipass new <name> --no-agent
```
The project lands in `projects/<name>`, git-initialized, registry sealed, agent seated — and private until you decide otherwise.
---
[← Back to AIPass](../README.md)
@@ -34,6 +34,7 @@ from aipass.ai_mail.apps.handlers.json import json_handler
from aipass.ai_mail.apps.handlers.dispatch.status import log_dispatch from aipass.ai_mail.apps.handlers.dispatch.status import log_dispatch
from aipass.ai_mail.apps.handlers.paths import find_repo_root from aipass.ai_mail.apps.handlers.paths import find_repo_root
from aipass.ai_mail.apps.handlers.dispatch.test_token import scan_and_ack_test_emails from aipass.ai_mail.apps.handlers.dispatch.test_token import scan_and_ack_test_emails
from aipass.ai_mail.apps.handlers.dispatch.wake import DEFAULT_MODEL
# Infrastructure paths # Infrastructure paths
@@ -408,6 +409,8 @@ def spawn_agent(
"-c", "-c",
"-p", "-p",
prompt, prompt,
"--model",
DEFAULT_MODEL,
"--max-turns", "--max-turns",
str(max_turns), str(max_turns),
"--permission-mode", "--permission-mode",
@@ -449,6 +449,9 @@ def main():
for key in list(spawn_env.keys()): for key in list(spawn_env.keys()):
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)
# Pin agent context window to 200k (Sonnet 5 is 1M native; without this,
# agents inherit 1M which causes cost + runaway risk).
spawn_env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = "200000"
# Strip caller identity vars to prevent dispatch context leakage. # Strip caller identity vars to prevent dispatch context leakage.
spawn_env.pop("AIPASS_CALLER_BRANCH", None) spawn_env.pop("AIPASS_CALLER_BRANCH", None)
spawn_env.pop("AIPASS_CALLER_CWD", None) spawn_env.pop("AIPASS_CALLER_CWD", None)
@@ -63,13 +63,9 @@ MONITOR_SCRIPT = Path(__file__).parent / "dispatch_monitor.py"
# Default prompt when no custom message provided # Default prompt when no custom message provided
DEFAULT_PROMPT = "Hi. Check inbox, process new emails, update memories when done." DEFAULT_PROMPT = "Hi. Check inbox, process new emails, update memories when done."
# Model shorthand mapping # Model aliases — passed directly to claude CLI which resolves latest-in-class.
MODEL_MAP = { KNOWN_MODEL_ALIASES: frozenset = frozenset({"sonnet", "opus", "haiku"})
"sonnet": "claude-sonnet-4-6", DEFAULT_MODEL = "sonnet"
"opus": "claude-opus-4-6",
"haiku": "claude-haiku-4-5-20251001",
}
DEFAULT_MODEL = "opus"
# Branches that cannot be woken manually by cross-branch drone commands. # Branches that cannot be woken manually by cross-branch drone commands.
# Dispatch-send path (dispatch.py._orchestrate_dispatch_send) bypasses this check. # Dispatch-send path (dispatch.py._orchestrate_dispatch_send) bypasses this check.
@@ -591,8 +587,8 @@ def wake_branch(
config = _load_config() config = _load_config()
max_turns = config.get("max_turns_per_wake", 100) max_turns = config.get("max_turns_per_wake", 100)
# Resolve model: shorthand -> full ID, or pass through if already a full ID # Pass model directly to CLI — aliases resolve latest-in-class automatically
resolved_model = MODEL_MAP.get(model or DEFAULT_MODEL, model or MODEL_MAP[DEFAULT_MODEL]) resolved_model = model or DEFAULT_MODEL
lock_file_path = str(branch_path / ".ai_mail.local" / ".dispatch.lock") lock_file_path = str(branch_path / ".ai_mail.local" / ".dispatch.lock")
if custom_message: if custom_message:
@@ -781,7 +777,7 @@ if __name__ == "__main__":
print(" --fresh Start fresh session (claude -p) instead of resuming (claude -c -p)") print(" --fresh Start fresh session (claude -p) instead of resuming (claude -c -p)")
print(" --auto Respect autonomous_pause (used by daemon). Manual wake ignores it.") print(" --auto Respect autonomous_pause (used by daemon). Manual wake ignores it.")
print(" --sender @branch Set return-to-sender for bounce emails (default: @devpulse)") print(" --sender @branch Set return-to-sender for bounce emails (default: @devpulse)")
print(" --model NAME Model to use: opus (default), sonnet, haiku, or full model ID") print(" --model NAME Model to use: sonnet (default), opus, haiku, or full model ID")
print() print()
print("Output: Step-by-step status of the dispatch pipeline:") print("Output: Step-by-step status of the dispatch pipeline:")
print(" ✅ resolve → @branch found at /path/to/branch") print(" ✅ resolve → @branch found at /path/to/branch")
+42
View File
@@ -1317,6 +1317,48 @@ def test_spawn_agent_strips_claude_env_vars(tmp_path, monkeypatch):
assert captured_env.get("AIPASS_SESSION_TYPE") == "daemon" assert captured_env.get("AIPASS_SESSION_TYPE") == "daemon"
def test_spawn_agent_claude_cmd_includes_model_flag(tmp_path):
"""Poller-triggered spawn passes --model DEFAULT_MODEL to the claude invocation."""
branch_path = tmp_path / "branch"
branch_path.mkdir()
(branch_path / "logs").mkdir()
message = {"from": "@devpulse", "id": "msg1", "subject": "Test task"}
config = {"max_turns_per_wake": 50}
state = {"daily_counts": {}, "session_cycles": {}}
captured_args = []
def capture_popen(*args, **kwargs):
captured_args.append(args[0] if args else kwargs.get("args"))
mock_proc = MagicMock()
mock_proc.pid = 22222
return mock_proc
with (
patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen",
side_effect=capture_popen,
),
patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock",
return_value=(True, "Lock acquired"),
),
patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"),
patch(
"aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification",
create=True,
),
):
result = spawn_agent(branch_path, "@testbranch", message, config, state)
assert result is True
monitor_cmd = captured_args[0]
assert "--model" in monitor_cmd
model_idx = monitor_cmd.index("--model")
assert monitor_cmd[model_idx + 1] == daemon_mod.DEFAULT_MODEL
def test_spawn_agent_prompt_includes_reply_id(tmp_path): def test_spawn_agent_prompt_includes_reply_id(tmp_path):
"""Prompt includes explicit reply command with the dispatch email ID.""" """Prompt includes explicit reply command with the dispatch email ID."""
branch_path = tmp_path / "branch" branch_path = tmp_path / "branch"
+13 -15
View File
@@ -29,7 +29,7 @@ from aipass.ai_mail.apps.handlers.dispatch.wake import (
_find_claude_bin, _find_claude_bin,
resolve_branch, resolve_branch,
DispatchStatus, DispatchStatus,
MODEL_MAP, KNOWN_MODEL_ALIASES,
DEFAULT_MODEL, DEFAULT_MODEL,
_acquire_lock, _acquire_lock,
_load_config, _load_config,
@@ -572,24 +572,22 @@ def _fake_open_factory(real_status_path, mapping):
# --- Model flag tests --------------------------------------------------- # --- Model flag tests ---------------------------------------------------
def test_model_map_has_expected_entries(): def test_known_model_aliases_has_expected_entries():
"""MODEL_MAP should contain sonnet, opus, haiku shorthand mappings.""" """KNOWN_MODEL_ALIASES should contain sonnet, opus, haiku."""
assert "sonnet" in MODEL_MAP assert "sonnet" in KNOWN_MODEL_ALIASES
assert "opus" in MODEL_MAP assert "opus" in KNOWN_MODEL_ALIASES
assert "haiku" in MODEL_MAP assert "haiku" in KNOWN_MODEL_ALIASES
assert "claude-sonnet-4-6" in MODEL_MAP["sonnet"]
assert "claude-opus-4-6" in MODEL_MAP["opus"]
def test_default_model_is_opus(): def test_default_model_is_sonnet():
"""Default model should be opus.""" """Default model should be sonnet."""
assert DEFAULT_MODEL == "opus" assert DEFAULT_MODEL == "sonnet"
def test_model_map_values_are_full_ids(): def test_known_model_aliases_are_bare_names():
"""All MODEL_MAP values should be full claude model IDs.""" """All KNOWN_MODEL_ALIASES should be bare alias names (no 'claude-' prefix)."""
for key, value in MODEL_MAP.items(): for alias in KNOWN_MODEL_ALIASES:
assert value.startswith("claude-"), f"{key} -> {value} doesn't start with 'claude-'" assert not alias.startswith("claude-"), f"{alias} should be a bare alias"
# --- _find_claude_bin tests ------------------------------------------ # --- _find_claude_bin tests ------------------------------------------
+7 -1
View File
@@ -9,6 +9,7 @@ aipass # Show available commands
aipass doctor # Check system health aipass doctor # Check system health
aipass help what does drone do # Search branch documentation aipass help what does drone do # Search branch documentation
aipass new myapp --template python # Create a new project aipass new myapp --template python # Create a new project
aipass adopt myapp --dry-run # Preview adopting an existing projects/ dir
aipass init # Guided setup (10 stages, resumable) aipass init # Guided setup (10 stages, resumable)
``` ```
@@ -34,6 +35,7 @@ aipass/
│ │ ├── init_flow.py # 10-stage guided setup │ │ ├── init_flow.py # 10-stage guided setup
│ │ ├── install.py # aipass install — one-command bootstrap (clone + setup + init) │ │ ├── install.py # aipass install — one-command bootstrap (clone + setup + init)
│ │ ├── new_project.py # aipass new — create projects inside the installation │ │ ├── new_project.py # aipass new — create projects inside the installation
│ │ ├── adopt.py # aipass adopt — bring an existing projects/ dir into AIPass
│ │ ├── profile.py # User profile read/write │ │ ├── profile.py # User profile read/write
│ │ ├── trust.py # Trust registry — aipass trust / aipass revoke │ │ ├── trust.py # Trust registry — aipass trust / aipass revoke
│ │ └── feedback.py # Feedback pulse toggle — aipass feedback on/off │ │ └── feedback.py # Feedback pulse toggle — aipass feedback on/off
@@ -42,6 +44,7 @@ aipass/
│ │ ├── handoff_platform/ # Platform-specific handoff detection │ │ ├── handoff_platform/ # Platform-specific handoff detection
│ │ ├── init/ # bootstrap.py, scaffold_content.py │ │ ├── init/ # bootstrap.py, scaffold_content.py
│ │ ├── new_project/ # Project creation logic (registry, template, scaffold, git init) │ │ ├── new_project/ # Project creation logic (registry, template, scaffold, git init)
│ │ │ └── adopt.py # Project adoption logic (additive scaffold onto an existing dir)
│ │ ├── json/ # JSON read/write utilities │ │ ├── json/ # JSON read/write utilities
│ │ ├── ping_sweep/ # Branch reachability verification │ │ ├── ping_sweep/ # Branch reachability verification
│ │ ├── provider_reconcile.py # Stale deny-rule detection + fix │ │ ├── provider_reconcile.py # Stale deny-rule detection + fix
@@ -50,7 +53,7 @@ aipass/
│ │ ├── system_detect/ # OS, shell, Python, RAM, CPU │ │ ├── system_detect/ # OS, shell, Python, RAM, CPU
│ │ └── ui/ # Progress bars, menus, banners │ │ └── ui/ # Progress bars, menus, banners
│ └── plugins/ │ └── plugins/
├── tests/ # 756 passing ├── tests/ # 785 passing
├── requirements.project.txt # Project-specific Python dependencies ├── requirements.project.txt # Project-specific Python dependencies
├── .trinity/ # Identity + session history + observations ├── .trinity/ # Identity + session history + observations
└── README.md └── README.md
@@ -74,6 +77,9 @@ aipass/
| `aipass new <name>` | Create a project in projects/ — own git repo, AIPass scaffold, resident agent | | `aipass new <name>` | Create a project in projects/ — own git repo, AIPass scaffold, resident agent |
| `aipass new <name> --template python` | Create with Python template (pyproject + src/) | | `aipass new <name> --template python` | Create with Python template (pyproject + src/) |
| `aipass new <name> --no-agent` | Create without resident agent | | `aipass new <name> --no-agent` | Create without resident agent |
| `aipass adopt <name>` | Turn an existing `projects/<name>` directory into a full project — additive scaffold only |
| `aipass adopt <name> --no-agent` | Adopt without a resident agent |
| `aipass adopt <name> --dry-run` | Preview what adoption would do, writes nothing |
| `aipass trust [path]` | Show enrolled projects or enroll a project in the trust registry | | `aipass trust [path]` | Show enrolled projects or enroll a project in the trust registry |
| `aipass revoke <path>` | Remove a project from the trust registry | | `aipass revoke <path>` | Remove a project from the trust registry |
| `aipass feedback on/off` | Toggle the feedback reminder pulse (delegates to @hooks) | | `aipass feedback on/off` | Toggle the feedback reminder pulse (delegates to @hooks) |
+6
View File
@@ -43,6 +43,7 @@ from aipass.prax import logger
# ============================================================================= # =============================================================================
_PUBLIC_COMMANDS = { _PUBLIC_COMMANDS = {
"adopt": "Turn an existing projects/ directory into a full project",
"doctor": "System health — structure, registry, hooks, tests", "doctor": "System health — structure, registry, hooks, tests",
"help": "README-backed Q&A — ask about any branch", "help": "README-backed Q&A — ask about any branch",
"init": "Guided setup for new users (10 stages, resumable)", "init": "Guided setup for new users (10 stages, resumable)",
@@ -148,6 +149,10 @@ def print_help(modules: List[Any] | None = None) -> None:
" [green]install[/green] [dim]One-command bootstrap — clone + setup.sh + hooks[/dim]" " [green]install[/green] [dim]One-command bootstrap — clone + setup.sh + hooks[/dim]"
) )
console.print(" [green]new <name>[/green] [dim]Create a project inside AIPass[/dim]") console.print(" [green]new <name>[/green] [dim]Create a project inside AIPass[/dim]")
console.print(
" [green]adopt <name>[/green] "
"[dim]Turn an existing projects/ directory into a full project[/dim]"
)
console.print(" [green]profile[/green] [dim]Show/edit user profile[/dim]") console.print(" [green]profile[/green] [dim]Show/edit user profile[/dim]")
console.print( console.print(
" [green]trust[/green] [dim][path][/dim] [dim]Trust registry — enroll/revoke projects[/dim]" " [green]trust[/green] [dim][path][/dim] [dim]Trust registry — enroll/revoke projects[/dim]"
@@ -159,6 +164,7 @@ def print_help(modules: List[Any] | None = None) -> None:
console.print(" [green]aipass doctor[/green] [dim]Check system health[/dim]") console.print(" [green]aipass doctor[/green] [dim]Check system health[/dim]")
console.print(" [green]aipass help what does drone do[/green] [dim]Search documentation[/dim]") console.print(" [green]aipass help what does drone do[/green] [dim]Search documentation[/dim]")
console.print(" [green]aipass new myapp --template python[/green] [dim]Create a Python project[/dim]") console.print(" [green]aipass new myapp --template python[/green] [dim]Create a Python project[/dim]")
console.print(" [green]aipass adopt myapp --dry-run[/green] [dim]Preview adopting projects/myapp[/dim]")
console.print(" [green]aipass init[/green] [dim]Start guided setup[/dim]") console.print(" [green]aipass init[/green] [dim]Start guided setup[/dim]")
console.print() console.print()
@@ -14,7 +14,7 @@ from aipass.aipass.apps.handlers.init.bootstrap import (
is_projects_child, is_projects_child,
update_project, update_project,
) )
from aipass.aipass.apps.handlers.init.scaffold_content import ( from aipass.aipass.shared.scaffold_content import (
global_prompt_md, global_prompt_md,
inbox_json, inbox_json,
prep_md, prep_md,
+37 -122
View File
@@ -30,18 +30,23 @@ RULES:
- No hardcoded paths - No hardcoded paths
""" """
import importlib.util
import json import json
import logging import logging
import os
import re import re
import shutil import shutil
import tempfile
import uuid import uuid
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from aipass.aipass.apps.handlers.init import scaffold_content as sc from aipass.aipass.shared import scaffold_content as sc
from aipass.aipass.shared.project_home import (
_claude_local_settings,
_claude_settings,
_detect_aipass_home,
_enroll_project,
is_projects_child,
is_throwaway_path,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -50,25 +55,6 @@ _STALE_MANAGED_FILES: list[Path] = [
] ]
def is_throwaway_path(path: str | Path) -> bool:
"""True if path is under a temp dir or Claude Code scratchpad."""
resolved = str(Path(path).resolve())
tmp_roots = [tempfile.gettempdir()]
if os.name == "posix":
tmp_roots.append("/tmp")
for root in tmp_roots:
try:
r = str(Path(root).resolve())
except OSError:
logger.info("is_throwaway_path: could not resolve %s", root)
continue
if resolved == r or resolved.startswith(r + os.sep):
return True
if "scratchpad" in resolved.lower():
return True
return False
def _sanitize_name(raw: str) -> str: def _sanitize_name(raw: str) -> str:
"""Sanitize a project name for use in filenames. """Sanitize a project name for use in filenames.
@@ -78,23 +64,6 @@ def _sanitize_name(raw: str) -> str:
return re.sub(r"[^A-Z0-9_-]", "_", raw.upper()).strip("_") return re.sub(r"[^A-Z0-9_-]", "_", raw.upper()).strip("_")
def _detect_aipass_home() -> str | None:
"""Detect the AIPass installation root from the aipass package location.
Returns the parent of the src/ directory (the repo root).
Returns None if detection fails.
"""
try:
spec = importlib.util.find_spec("aipass")
if spec and spec.origin:
# aipass/__init__.py lives at src/aipass/__init__.py
# parent = src/aipass/, parent.parent = src/, parent.parent.parent = AIPass root
return str(Path(spec.origin).resolve().parent.parent.parent)
except Exception as exc:
logger.info("AIPASS_HOME detection skipped: %s", exc)
return None
def _hook_fingerprint(hook_entry: dict) -> str: def _hook_fingerprint(hook_entry: dict) -> str:
"""Extract a comparable fingerprint from a hook entry.""" """Extract a comparable fingerprint from a hook entry."""
commands = [] commands = []
@@ -133,10 +102,11 @@ def _merge_settings(existing: dict, generated: dict) -> dict:
if cleaned_hooks: if cleaned_hooks:
merged["hooks"] = cleaned_hooks merged["hooks"] = cleaned_hooks
# Merge env: generated wins for AIPASS_HOME, preserve user additions # env: AIPASS_HOME is machine-local — never tracked (see settings.local.json).
existing_env = existing.get("env", {}) # Strip it from any previously-tracked settings.json; preserve other user vars.
generated_env = generated.get("env", {}) existing_env = {k: v for k, v in existing.get("env", {}).items() if k != "AIPASS_HOME"}
merged["env"] = {**existing_env, **generated_env} if existing_env:
merged["env"] = existing_env
# Merge permissions: union deny/ask lists # Merge permissions: union deny/ask lists
existing_perms = existing.get("permissions", {}) existing_perms = existing.get("permissions", {})
@@ -210,75 +180,6 @@ def _merge_hooks_json(existing: dict, template: dict) -> dict:
return merged return merged
def _claude_settings(aipass_home: str | None = None) -> str:
"""Generate .claude/settings.json — env and permissions only.
Hooks are NOT wired at the project level. All AIPass hooks
(prompt injection, identity, email, pre-compact, edit gates) fire
from provider settings (~/.claude/settings.json), installed by
setup.sh. Provider hooks use CWD-walking patterns that work from
any directory in any project.
Project settings only contain:
- env.AIPASS_HOME (so hooks can find the AIPass installation)
- permissions.deny (basic safety rails)
Args:
aipass_home: Optional AIPass installation root to add as env.AIPASS_HOME.
"""
data: dict = {}
data["permissions"] = {
"deny": [
"Bash(git push --force*)",
"Bash(git reset --hard*)",
"EnterPlanMode",
],
}
if aipass_home and not is_throwaway_path(aipass_home):
data["env"] = {"AIPASS_HOME": aipass_home}
elif aipass_home:
logger.warning(
"AIPASS_HOME '%s' is a throwaway path — not writing to settings",
aipass_home,
)
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
def _enroll_project(target: Path) -> None:
"""Enroll a project in the trusted-project registry (DPLAN-0244).
Lazy import to keep bootstrap.py free of prax/module-level deps.
"""
try:
from aipass.hooks.apps.handlers.config.trust_registry import enroll
if enroll(str(target)):
logger.info("Enrolled project in trust registry: %s", target)
else:
logger.warning("Trust enrollment failed for %s", target)
except ImportError as exc:
logger.info("Trust registry unavailable, skipping enrollment: %s", exc)
def is_projects_child(target: Path) -> bool:
"""True if *target* is ``<host>/projects/<name>`` — a valid nested project path.
The host is identified by having a ``*_REGISTRY.json`` in the grandparent
of target (i.e. target's parent is named ``projects``).
"""
resolved = target.resolve()
if resolved.parent.name != "projects":
return False
host = resolved.parent.parent
try:
return any(f.is_file() and f.name.endswith("_REGISTRY.json") for f in host.iterdir())
except OSError as exc:
logger.info("is_projects_child: could not read host dir %s: %s", host, exc)
return False
def _guard_init(target: Path, *, allow_projects_child: bool = False) -> None: def _guard_init(target: Path, *, allow_projects_child: bool = False) -> None:
"""Block init if target is inside an agent branch or existing project. """Block init if target is inside an agent branch or existing project.
@@ -447,16 +348,23 @@ def init_project(
gitignore_path.write_text(sc.gitignore(), encoding="utf-8") gitignore_path.write_text(sc.gitignore(), encoding="utf-8")
created.append(str(gitignore_path)) created.append(str(gitignore_path))
# 9. .claude/settings.json # 9. .claude/settings.json — tracked, permissions only (no machine-local paths)
claude_dir = target / ".claude" claude_dir = target / ".claude"
claude_dir.mkdir(exist_ok=True) claude_dir.mkdir(exist_ok=True)
settings_path = claude_dir / "settings.json" settings_path = claude_dir / "settings.json"
if not settings_path.exists(): if not settings_path.exists():
settings_path.write_text(_claude_settings(aipass_home), encoding="utf-8") settings_path.write_text(_claude_settings(), encoding="utf-8")
created.append(str(settings_path)) created.append(str(settings_path))
# 9b. .claude/commands/prep.md — /prep session wrap-up slash command # 9b. .claude/settings.local.json — machine-local AIPASS_HOME (gitignored)
if aipass_home and not is_throwaway_path(aipass_home):
local_settings_path = claude_dir / "settings.local.json"
if not local_settings_path.exists():
local_settings_path.write_text(_claude_local_settings(aipass_home), encoding="utf-8")
created.append(str(local_settings_path))
# 9c. .claude/commands/prep.md — /prep session wrap-up slash command
# Only prep.md here — memo.md belongs at provider level (~/.claude/commands/) # 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)
@@ -580,11 +488,11 @@ def update_project(target: Path) -> dict:
elif tier_dest.exists(): elif tier_dest.exists():
already_current.append(str(tier_dest)) already_current.append(str(tier_dest))
# settings.json — smart merge: preserve user hooks + env, update AIPass hooks # settings.json — smart merge: preserve user hooks, update AIPass permissions.
# AIPASS_HOME never lives here — machine-local paths go in settings.local.json.
settings_path = claude_dir / "settings.json" settings_path = claude_dir / "settings.json"
if not settings_path.exists(): if not settings_path.exists():
aipass_home = _detect_aipass_home() settings_path.write_text(_claude_settings(), encoding="utf-8")
settings_path.write_text(_claude_settings(aipass_home), encoding="utf-8")
updated.append(str(settings_path)) updated.append(str(settings_path))
else: else:
existing_content = settings_path.read_text(encoding="utf-8") existing_content = settings_path.read_text(encoding="utf-8")
@@ -593,9 +501,7 @@ def update_project(target: Path) -> dict:
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
logger.info("settings.json parse failed, rebuilding: %s", exc) logger.info("settings.json parse failed, rebuilding: %s", exc)
existing = {} existing = {}
existing_env = existing.get("env", {}) generated = json.loads(_claude_settings())
aipass_home = existing_env.get("AIPASS_HOME") or _detect_aipass_home()
generated = json.loads(_claude_settings(aipass_home))
merged = _merge_settings(existing, generated) merged = _merge_settings(existing, generated)
merged_content = json.dumps(merged, indent=2, ensure_ascii=False) + "\n" merged_content = json.dumps(merged, indent=2, ensure_ascii=False) + "\n"
if existing != merged: if existing != merged:
@@ -604,6 +510,15 @@ def update_project(target: Path) -> dict:
else: else:
already_current.append(str(settings_path)) already_current.append(str(settings_path))
# settings.local.json — machine-local AIPASS_HOME (gitignored, create if missing)
if aipass_home and not is_throwaway_path(aipass_home):
local_settings_path = claude_dir / "settings.local.json"
if not local_settings_path.exists():
local_settings_path.write_text(_claude_local_settings(aipass_home), encoding="utf-8")
updated.append(str(local_settings_path))
else:
already_current.append(str(local_settings_path))
# hooks.json — union-merge: preserve user enabled, add new hooks from template # hooks.json — union-merge: preserve user enabled, add new hooks from template
hooks_json_path = aipass_dir / "hooks.json" hooks_json_path = aipass_dir / "hooks.json"
hook_home = aipass_home or _detect_aipass_home() hook_home = aipass_home or _detect_aipass_home()
@@ -29,6 +29,7 @@ import uuid
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from aipass.aipass.shared import scaffold_content as sc
from aipass.prax import logger from aipass.prax import logger
from aipass.spawn import spawn_agent from aipass.spawn import spawn_agent
@@ -117,10 +118,7 @@ def _write_template(target: Path, name: str, template: str) -> list[str]:
) )
created.append("README.md") created.append("README.md")
(target / ".gitignore").write_text( (target / ".gitignore").write_text(sc.gitignore(), encoding="utf-8")
"__pycache__/\n*.pyc\n.venv\n.trinity/\n.ai_mail.local/\n*.local.json\n*.local/\nlogs/\n.*_REGISTRY.lock\n",
encoding="utf-8",
)
created.append(".gitignore") created.append(".gitignore")
if template == "python": if template == "python":
@@ -158,12 +156,13 @@ def _write_template(target: Path, name: str, template: str) -> list[str]:
def _scaffold_aipass(target: Path, name: str) -> list[str]: def _scaffold_aipass(target: Path, name: str) -> list[str]:
"""Write AIPass scaffold files (tiers, hooks, CLAUDE.md, settings, .venv).""" """Write AIPass scaffold files (tiers, hooks, CLAUDE.md, settings, .venv)."""
from aipass.aipass.apps.handlers.init.bootstrap import ( from aipass.aipass.shared.project_home import (
_claude_local_settings,
_claude_settings, _claude_settings,
_detect_aipass_home, _detect_aipass_home,
_enroll_project, _enroll_project,
is_throwaway_path,
) )
from aipass.aipass.apps.handlers.init import scaffold_content as sc
created: list[str] = [] created: list[str] = []
aipass_home = _detect_aipass_home() aipass_home = _detect_aipass_home()
@@ -206,15 +205,23 @@ def _scaffold_aipass(target: Path, name: str) -> list[str]:
dest.write_text(sc.agents_md(reg), encoding="utf-8") dest.write_text(sc.agents_md(reg), encoding="utf-8")
created.append(md_name) created.append(md_name)
# .claude/settings.json # .claude/settings.json — tracked, permissions only (no machine-local paths)
claude_dir = target / ".claude" claude_dir = target / ".claude"
claude_dir.mkdir(exist_ok=True) claude_dir.mkdir(exist_ok=True)
(claude_dir / "settings.json").write_text( (claude_dir / "settings.json").write_text(
_claude_settings(aipass_home), _claude_settings(),
encoding="utf-8", encoding="utf-8",
) )
created.append(".claude/settings.json") created.append(".claude/settings.json")
# .claude/settings.local.json — machine-local AIPASS_HOME (gitignored)
if aipass_home and not is_throwaway_path(aipass_home):
(claude_dir / "settings.local.json").write_text(
_claude_local_settings(aipass_home),
encoding="utf-8",
)
created.append(".claude/settings.local.json")
# .claude/commands/prep.md # .claude/commands/prep.md
commands_dir = claude_dir / "commands" commands_dir = claude_dir / "commands"
commands_dir.mkdir(exist_ok=True) commands_dir.mkdir(exist_ok=True)
@@ -0,0 +1,224 @@
# =================== AIPass ====================
# Name: adopt.py
# Description: aipass adopt — bring an existing projects/ directory into AIPass
# Version: 1.0.0
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""
Adopt Handler — PRIVATE implementation
Business logic for `aipass adopt`. Turns an EXISTING directory at
<host>/projects/<name> into a full AIPass project: sealed registry,
resident agent, .aipass/.claude scaffold.
Unlike `aipass new` (which births a brand-new directory), adopt starts
from a directory that already has its own content — possibly its own
git repo, possibly public. Every write is existence-guarded; nothing
already present is ever overwritten. .gitignore is the one file that's
patched rather than skipped, since AIPass local state (.trinity/,
mailbox, *.local.json) must never leak into a tracked commit.
RULES:
- Additive only — never overwrite a file that already exists
- Never touch the target's git history (no git init/add/commit)
- gitignore safety runs BEFORE any other AIPass file is written
- Registry-first: mint the project registry before spawning the agent
- dry_run performs zero filesystem writes and never calls spawn_agent
"""
from __future__ import annotations
from pathlib import Path
from aipass.aipass.apps.handlers.json import json_handler
from aipass.aipass.apps.handlers.new_project import (
_agent_home,
_registry_name,
_spawn_project_agent,
_write_registry,
)
from aipass.aipass.shared import scaffold_content as sc
from aipass.aipass.shared.project_home import (
_claude_local_settings,
_claude_settings,
_detect_aipass_home,
_enroll_project,
is_projects_child,
is_throwaway_path,
)
GITIGNORE_MARKER = "# AIPass local state"
def _gitignore_safety(target: Path, *, dry_run: bool) -> str:
"""Ensure AIPass-managed paths are gitignored. Returns 'created', 'appended', or 'already-safe'."""
gitignore_path = target / ".gitignore"
if gitignore_path.exists():
existing = gitignore_path.read_text(encoding="utf-8")
if GITIGNORE_MARKER in existing:
return "already-safe"
if not dry_run:
separator = "" if existing.endswith("\n") else "\n"
gitignore_path.write_text(existing + separator + "\n" + sc.gitignore(), encoding="utf-8")
return "appended"
if not dry_run:
gitignore_path.write_text(sc.gitignore(), encoding="utf-8")
return "created"
def _write_if_missing(path: Path, content: str, *, dry_run: bool, planned: list[str]) -> None:
"""Record and (unless dry_run) write *content* to *path*, but only if it doesn't already exist."""
if path.exists():
return
if not dry_run:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
planned.append(str(path))
def adopt_project(target: Path, *, no_agent: bool = False, dry_run: bool = False) -> dict:
"""Adopt an existing directory at <host>/projects/<name> as a full AIPass project.
Args:
target: Existing directory to adopt — must be <host>/projects/<name>.
no_agent: Skip resident-agent creation.
dry_run: Report what WOULD happen; performs zero filesystem writes and
never calls spawn_agent.
Returns:
dict with name, target, host, dry_run, registry_id, registry_file,
files, gitignore_action, agent_created, agent_home, spawn_result.
Raises:
RuntimeError: target missing, not a projects/ child, already adopted,
or a resident-agent home path collision.
"""
target = target.resolve()
if not target.is_dir():
raise RuntimeError(f"'{target}' does not exist. `aipass adopt` brings in an EXISTING directory.")
if not is_projects_child(target):
raise RuntimeError(
f"'{target}' is not <host>/projects/<name>. `aipass adopt` only works inside projects/ "
"— use `aipass new` to create a fresh project instead."
)
existing_registry = [f for f in target.iterdir() if f.is_file() and f.name.endswith("_REGISTRY.json")]
if existing_registry:
raise RuntimeError(f"'{target}' is already adopted (has {existing_registry[0].name}).")
host = target.parent.parent
name = target.name
reg = _registry_name(name)
agent_home = _agent_home(target, name)
if not no_agent and agent_home.exists() and any(agent_home.iterdir()):
raise RuntimeError(
f"Name collision: '{agent_home}' already exists and is non-empty — "
"cannot seat a resident agent there. Retry with --no-agent."
)
aipass_home = _detect_aipass_home()
files: list[str] = []
# gitignore safety FIRST — nothing AIPass-managed gets written before the
# target is confirmed to ignore it (target may be a public repo).
gitignore_action = _gitignore_safety(target, dry_run=dry_run)
# Registry — sealed, minted BEFORE spawn (registry-first rule)
registry_id = None
registry_filename = f"{reg}_REGISTRY.json"
if not dry_run:
registry_id, registry_filename = _write_registry(target, name)
files.append(registry_filename)
# .aipass/ — tier files, hooks.json, CLAUDE.md/AGENTS.md
aipass_dir = target / ".aipass"
if aipass_home:
for tier_file in ("tier0_kernel.md", "tier1_navmap.md"):
src_path = Path(aipass_home) / ".aipass" / tier_file
if src_path.is_file():
_write_if_missing(
aipass_dir / tier_file,
src_path.read_text(encoding="utf-8"),
dry_run=dry_run,
planned=files,
)
hooks_template = Path(aipass_home) / ".aipass" / "project_hooks.json"
if hooks_template.is_file():
hooks_dest = aipass_dir / "hooks.json"
already_had_hooks = hooks_dest.exists()
_write_if_missing(hooks_dest, hooks_template.read_text(encoding="utf-8"), dry_run=dry_run, planned=files)
if not already_had_hooks and not dry_run:
_enroll_project(target)
for md_name in ("CLAUDE.md", "AGENTS.md"):
dest = target / md_name
if dest.exists():
continue
if aipass_home:
tmpl = Path(aipass_home) / ".aipass" / f"project_{md_name}"
if tmpl.is_file():
content = tmpl.read_text(encoding="utf-8").replace("{name}", reg)
_write_if_missing(dest, content, dry_run=dry_run, planned=files)
continue
if md_name == "AGENTS.md":
_write_if_missing(dest, sc.agents_md(reg), dry_run=dry_run, planned=files)
# .claude/settings.json — tracked, permissions only
claude_dir = target / ".claude"
_write_if_missing(claude_dir / "settings.json", _claude_settings(), dry_run=dry_run, planned=files)
# .claude/settings.local.json — machine-local AIPASS_HOME (gitignored)
if aipass_home and not is_throwaway_path(aipass_home):
_write_if_missing(
claude_dir / "settings.local.json",
_claude_local_settings(aipass_home),
dry_run=dry_run,
planned=files,
)
# .claude/commands/prep.md
_write_if_missing(claude_dir / "commands" / "prep.md", sc.prep_md(), dry_run=dry_run, planned=files)
# .venv symlink → AIPass shared runtime
if aipass_home:
venv = Path(aipass_home) / ".venv"
venv_link = target / ".venv"
if venv.is_dir() and not venv_link.exists():
if not dry_run:
venv_link.symlink_to(venv)
files.append(str(venv_link))
# Resident agent — registry MUST exist first (dry_run never spawns)
spawn_result = None
agent_created = False
will_have_agent = not no_agent
if will_have_agent and not dry_run:
spawn_result = _spawn_project_agent(target, name)
agent_created = True
elif will_have_agent and dry_run:
files.append(f"{agent_home} (resident agent — planned)")
json_handler.log_operation(
"adopt_project",
{"name": name, "target": str(target), "dry_run": dry_run},
"adopt",
)
return {
"name": name,
"target": str(target),
"host": str(host),
"dry_run": dry_run,
"registry_id": registry_id,
"registry_file": registry_filename,
"files": files,
"gitignore_action": gitignore_action,
"agent_created": agent_created,
"agent_home": str(agent_home) if will_have_agent else None,
"spawn_result": spawn_result,
}
@@ -93,7 +93,7 @@ def find_project_root(start: Path) -> Optional[Path]:
# ============================================================================= # =============================================================================
_SCAN_SKIP_DIRS = {".archive", ".venv", ".git", "__pycache__", "node_modules", ".chroma"} _SCAN_SKIP_DIRS = {".archive", ".backup", ".venv", ".git", "__pycache__", "node_modules", ".chroma", "templates"}
def scan_agents(project_root: Path) -> List[AgentInfo]: def scan_agents(project_root: Path) -> List[AgentInfo]:
@@ -286,12 +286,15 @@ def detect_pollution(agents: List[AgentInfo]) -> List[PollutionHit]:
def check_registry_consistency( def check_registry_consistency(
registry_path: Path, registry_path: Path,
agents: List[AgentInfo], agents: List[AgentInfo],
project_root: Optional[Path] = None,
) -> List[RegistryIssue]: ) -> List[RegistryIssue]:
"""Validate that registry branches[].path entries match actual filesystem. """Validate that registry branches[].path entries match actual filesystem.
Returns: Returns:
List of RegistryIssue for each problem found. List of RegistryIssue for each problem found.
""" """
anchor = (project_root or registry_path.parent).resolve()
try: try:
data = json.loads(registry_path.read_text(encoding="utf-8")) data = json.loads(registry_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc: except (json.JSONDecodeError, OSError) as exc:
@@ -309,7 +312,8 @@ def check_registry_consistency(
issues.append(RegistryIssue(name, "", "missing")) issues.append(RegistryIssue(name, "", "missing"))
continue continue
reg_path = Path(path_str).resolve() p = Path(path_str)
reg_path = p.resolve() if p.is_absolute() else (anchor / p).resolve()
if not reg_path.exists(): if not reg_path.exists():
issues.append(RegistryIssue(name, path_str, "missing")) issues.append(RegistryIssue(name, path_str, "missing"))
elif str(reg_path) not in agent_paths: elif str(reg_path) not in agent_paths:
+127
View File
@@ -0,0 +1,127 @@
# =================== AIPass ====================
# Name: adopt.py
# Description: aipass adopt — bring an existing projects/ directory into AIPass
# Version: 1.0.0
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""
aipass adopt — turn an existing directory under projects/ into a full
AIPass project (registry, resident agent, .aipass/.claude scaffold).
Unlike `aipass new`, adopt starts from a directory that already has its
own content and git history — every write is additive, nothing existing
is ever overwritten.
"""
from __future__ import annotations
import sys
from pathlib import Path
from aipass.aipass.apps.handlers.json import json_handler
from aipass.cli.apps.modules import console, error, success
from aipass.prax import logger
COMMAND = "adopt"
def print_introspection() -> None:
"""Bare invocation — usage pointer."""
console.print()
console.print("[bold cyan]aipass adopt[/bold cyan] — bring an existing directory into projects/")
console.print()
console.print("[dim]Usage: aipass adopt <name-or-path> [--no-agent] [--dry-run][/dim]")
console.print()
def print_help() -> None:
"""Print usage help for the adopt command."""
console.print()
console.print("[bold cyan]aipass adopt[/bold cyan] — adopt an existing directory as an AIPass project")
console.print()
console.print("[yellow]USAGE:[/yellow]")
console.print(" [green]aipass adopt <name>[/green] [dim]# Adopt <host>/projects/<name>[/dim]")
console.print(" [green]aipass adopt <path>[/green] [dim]# Adopt by relative/absolute path[/dim]")
console.print(" [green]aipass adopt <name> --no-agent[/green] [dim]# Skip resident agent[/dim]")
console.print(" [green]aipass adopt <name> --dry-run[/green] [dim]# Preview, write nothing[/dim]")
console.print()
console.print("[yellow]WHAT IT DOES:[/yellow]")
console.print(" Seats a sealed registry, scaffolds .aipass/.claude, and (unless")
console.print(" --no-agent) creates a resident agent — all ADDITIVE. Never touches")
console.print(" the target's existing git history or tracked files. If the target")
console.print(" has no .gitignore covering AIPass local state, one is created or")
console.print(" appended to first — the target may be a public repo.")
console.print()
console.print("[yellow]REQUIRES:[/yellow]")
console.print(" Target must be an existing directory at <host>/projects/<name>.")
console.print(" Use 'aipass new' instead to create a brand-new project.")
console.print()
def handle_command(command: str, args: list[str]) -> bool:
"""Route the 'adopt' command. Returns True if handled."""
if command != COMMAND:
return False
if not args:
json_handler.log_operation("adopt_usage", {"command": command})
print_introspection()
return True
if args[0] in ("--help", "-h", "help"):
json_handler.log_operation("adopt_help", {"command": command})
print_help()
return True
name_or_path = args[0]
known = {"--no-agent", "--dry-run"}
unknown = [a for a in args[1:] if a not in known]
if unknown:
error(f"Unknown option: {unknown[0]}")
print_help()
return True
no_agent = "--no-agent" in args[1:]
dry_run = "--dry-run" in args[1:]
from aipass.aipass.apps.handlers.new_project import find_host_root
from aipass.aipass.apps.handlers.new_project.adopt import adopt_project
target_path = Path(name_or_path)
if not target_path.exists():
host = find_host_root(Path.cwd())
if host is None:
error("Not inside an AIPass installation (no *_REGISTRY.json found).")
sys.exit(1)
target_path = host / "projects" / name_or_path
try:
result = adopt_project(target_path, no_agent=no_agent, dry_run=dry_run)
except RuntimeError as e:
logger.warning("[AIPASS] adopt failed: %s", e)
error(str(e))
sys.exit(1)
console.print()
verb = "Would adopt" if dry_run else "Adopted"
success(f"{verb} '{result['name']}' at {result['target']}")
console.print()
console.print(f" [dim]Registry:[/dim] {result['registry_file']}")
console.print(f" [dim]Gitignore:[/dim] {result['gitignore_action']}")
if result["agent_home"]:
state = "created" if result["agent_created"] else "planned"
console.print(f" [dim]Agent:[/dim] {state} ({result['agent_home']})")
else:
console.print(" [dim]Agent:[/dim] skipped (--no-agent)")
console.print()
console.print("[dim]Files:[/dim]")
for f in result["files"]:
console.print(f" [dim]{f}[/dim]")
console.print()
json_handler.log_operation(
"adopt_project",
{"name": result["name"], "target": result["target"], "dry_run": dry_run},
)
logger.info("[AIPASS] adopt: %s at %s (dry_run=%s)", result["name"], result["target"], dry_run)
return True
+2 -2
View File
@@ -670,7 +670,7 @@ def _check_structure() -> List[CheckResult]:
# Registry consistency # Registry consistency
reg_path = _discover_registry(start_path=project_root) reg_path = _discover_registry(start_path=project_root)
if reg_path and reg_path.exists(): if reg_path and reg_path.exists():
reg_issues = check_registry_consistency(reg_path, agents) reg_issues = check_registry_consistency(reg_path, agents, project_root=project_root)
if reg_issues: if reg_issues:
for issue in reg_issues: for issue in reg_issues:
glyph = GLYPH_FAIL if issue.problem == "missing" else GLYPH_WARN glyph = GLYPH_FAIL if issue.problem == "missing" else GLYPH_WARN
@@ -686,7 +686,7 @@ def _check_structure() -> List[CheckResult]:
root_hits = check_root_artifacts(project_root) root_hits = check_root_artifacts(project_root)
if root_hits: if root_hits:
for hit in root_hits: for hit in root_hits:
glyph = GLYPH_WARN if hit.severity == "warn" else GLYPH_PASS glyph = GLYPH_PASS if hit.severity == "pass" else GLYPH_WARN
results.append(CheckResult(f"root: {hit.name}", glyph, hit.description, "")) results.append(CheckResult(f"root: {hit.name}", glyph, hit.description, ""))
else: else:
results.append(CheckResult("root artifacts", GLYPH_PASS, "none misplaced", "")) results.append(CheckResult("root artifacts", GLYPH_PASS, "none misplaced", ""))
+125
View File
@@ -0,0 +1,125 @@
# =================== AIPass ====================
# Name: project_home.py
# Description: Shared AIPass-home detection, project-path validation, and settings content
# Version: 1.0.0
# Created: 2026-07-21
# Modified: 2026-07-21
# =============================================
"""Project home — AIPASS_HOME detection, path validation, settings content.
Shared across every command that scaffolds or inspects an AIPass project
(`aipass init`, `aipass new`, `aipass adopt`), so there is exactly one
source of truth per helper — no per-command copies to drift apart.
Dependency-free: uses only stdlib. Importable before drone/prax exist.
"""
import importlib.util
import json
import logging
import os
import tempfile
from pathlib import Path
logger = logging.getLogger(__name__)
def is_throwaway_path(path: str | Path) -> bool:
"""True if path is under a temp dir or Claude Code scratchpad."""
resolved = str(Path(path).resolve())
tmp_roots = [tempfile.gettempdir()]
if os.name == "posix":
tmp_roots.append("/tmp")
for root in tmp_roots:
try:
r = str(Path(root).resolve())
except OSError:
logger.info("is_throwaway_path: could not resolve %s", root)
continue
if resolved == r or resolved.startswith(r + os.sep):
return True
if "scratchpad" in resolved.lower():
return True
return False
def _detect_aipass_home() -> str | None:
"""Detect the AIPass installation root from the aipass package location.
Returns the parent of the src/ directory (the repo root).
Returns None if detection fails.
"""
try:
spec = importlib.util.find_spec("aipass")
if spec and spec.origin:
# aipass/__init__.py lives at src/aipass/__init__.py
# parent = src/aipass/, parent.parent = src/, parent.parent.parent = AIPass root
return str(Path(spec.origin).resolve().parent.parent.parent)
except Exception as exc:
logger.info("AIPASS_HOME detection skipped: %s", exc)
return None
def _claude_settings() -> str:
"""Generate .claude/settings.json — permissions only, no machine-local paths.
Hooks are NOT wired at the project level. All AIPass hooks
(prompt injection, identity, email, pre-compact, edit gates) fire
from provider settings (~/.claude/settings.json), installed by
setup.sh. Provider hooks use CWD-walking patterns that work from
any directory in any project.
AIPASS_HOME is a machine-local absolute path — it never belongs in this
tracked file. It goes in .claude/settings.local.json instead, which is
gitignored and merged over tracked settings by Claude Code natively.
See _claude_local_settings().
"""
data: dict = {
"permissions": {
"deny": [
"Bash(git push --force*)",
"Bash(git reset --hard*)",
"EnterPlanMode",
],
},
}
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
def _claude_local_settings(aipass_home: str) -> str:
"""Generate .claude/settings.local.json — machine-local env (gitignored)."""
return json.dumps({"env": {"AIPASS_HOME": aipass_home}}, indent=2, ensure_ascii=False) + "\n"
def _enroll_project(target: Path) -> None:
"""Enroll a project in the trusted-project registry (DPLAN-0244).
Lazy import to keep this module free of prax/module-level deps.
"""
try:
from aipass.hooks.apps.handlers.config.trust_registry import enroll
if enroll(str(target)):
logger.info("Enrolled project in trust registry: %s", target)
else:
logger.warning("Trust enrollment failed for %s", target)
except ImportError as exc:
logger.info("Trust registry unavailable, skipping enrollment: %s", exc)
def is_projects_child(target: Path) -> bool:
"""True if *target* is ``<host>/projects/<name>`` — a valid nested project path.
The host is identified by having a ``*_REGISTRY.json`` in the grandparent
of target (i.e. target's parent is named ``projects``).
"""
resolved = target.resolve()
if resolved.parent.name != "projects":
return False
host = resolved.parent.parent
try:
return any(f.is_file() and f.name.endswith("_REGISTRY.json") for f in host.iterdir())
except OSError as exc:
logger.info("is_projects_child: could not read host dir %s: %s", host, exc)
return False
@@ -1,17 +1,20 @@
# =================== AIPass ==================== # =================== AIPass ====================
# Name: scaffold_content.py # Name: scaffold_content.py
# Description: Template content generators for aipass init scaffold # Description: Shared template content generators for project scaffolding
# Version: 1.0.0 # Version: 1.0.0
# Created: 2026-04-22 # Created: 2026-04-22
# Modified: 2026-04-22 # Modified: 2026-07-21
# ============================================= # =============================================
""" """
Scaffold Content — template generators for `aipass init` Scaffold Content — template generators for `aipass init`, `aipass new`, `aipass adopt`
Pure string-returning functions that produce the content for each scaffold Pure string-returning functions that produce the content for each scaffold
file (CLAUDE.md, AGENTS.md, etc.). Extracted from bootstrap.py to keep file (CLAUDE.md, AGENTS.md, .gitignore, etc.). Shared across every command
the handler under 700 lines. that mints AIPass project files, so there is exactly one source of truth
per template — no per-command copies to drift apart.
Dependency-free: uses only stdlib. Importable before drone/prax exist.
RULES: RULES:
- Pure Python only (no module/prax/cli imports) - Pure Python only (no module/prax/cli imports)
@@ -251,7 +254,7 @@ def gitignore() -> str:
"*.egg-info/\n" "*.egg-info/\n"
"dist/\n" "dist/\n"
"build/\n" "build/\n"
".venv/\n" ".venv\n"
"venv/\n" "venv/\n"
"\n" "\n"
"# IDE\n" "# IDE\n"
@@ -267,6 +270,9 @@ def gitignore() -> str:
"\n" "\n"
"# Disabled files\n" "# Disabled files\n"
"*(disabled)*\n" "*(disabled)*\n"
"\n"
"# Registry lock\n"
".*_REGISTRY.lock\n"
) )
+421
View File
@@ -0,0 +1,421 @@
# =================== AIPass ====================
# Name: test_adopt.py
# Description: Tests for aipass adopt — project adoption handler
# Version: 1.0.0
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""Tests for the adopt handler and module.
All file operations use tmp_path to stay fully isolated from the live
filesystem. dry_run tests assert zero filesystem mutation.
"""
import json
import subprocess
from unittest.mock import patch
import pytest # pyright: ignore[reportMissingImports]
from aipass.aipass.apps.handlers.new_project.adopt import (
GITIGNORE_MARKER,
adopt_project,
)
@pytest.fixture()
def host_env(tmp_path):
"""Minimal AIPass host installation with an existing (pre-populated) project dir."""
(tmp_path / "AIPASS_REGISTRY.json").write_text(json.dumps({"metadata": {"id": "host-id"}, "branches": []}))
projects = tmp_path / "projects"
projects.mkdir()
target = projects / "existing-site"
target.mkdir()
(target / "index.html").write_text("<html></html>\n", encoding="utf-8")
(target / "README.md").write_text("# existing-site\n\nReal content.\n", encoding="utf-8")
return tmp_path, target
def _no_home():
return patch(
"aipass.aipass.apps.handlers.new_project.adopt._detect_aipass_home",
return_value=None,
)
# ---------------------------------------------------------------------------
# adopt_project — guards / refusals
# ---------------------------------------------------------------------------
def test_adopt_rejects_missing_target(tmp_path):
with pytest.raises(RuntimeError, match="does not exist"):
adopt_project(tmp_path / "nope")
def test_adopt_rejects_non_projects_child(tmp_path):
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
outside = tmp_path / "elsewhere" / "myapp"
outside.mkdir(parents=True)
with pytest.raises(RuntimeError, match="not <host>/projects/<name>"):
adopt_project(outside)
def test_adopt_rejects_already_adopted(host_env):
_, target = host_env
(target / "EXISTING_SITE_REGISTRY.json").write_text("{}")
with pytest.raises(RuntimeError, match="already adopted"):
adopt_project(target, no_agent=True)
def test_adopt_rejects_agent_home_collision(host_env):
_, target = host_env
home = target / "src" / "existing_site" / "existing_site"
home.mkdir(parents=True)
(home / "stray.txt").write_text("junk\n", encoding="utf-8")
with _no_home():
with pytest.raises(RuntimeError, match="Name collision"):
adopt_project(target, no_agent=False)
def test_adopt_allows_collision_with_no_agent(host_env):
"""An occupied agent-home path is fine when --no-agent skips the agent entirely."""
_, target = host_env
home = target / "src" / "existing_site" / "existing_site"
home.mkdir(parents=True)
(home / "stray.txt").write_text("junk\n", encoding="utf-8")
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
result = adopt_project(target, no_agent=True)
assert result["agent_created"] is False
# ---------------------------------------------------------------------------
# adopt_project — gitignore safety
# ---------------------------------------------------------------------------
def test_adopt_creates_gitignore_when_absent(host_env):
_, target = host_env
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
result = adopt_project(target, no_agent=True)
assert result["gitignore_action"] == "created"
content = (target / ".gitignore").read_text(encoding="utf-8")
assert GITIGNORE_MARKER in content
assert ".trinity/" in content
def test_adopt_appends_gitignore_when_marker_absent(host_env):
_, target = host_env
(target / ".gitignore").write_text("node_modules/\ndist/\n", encoding="utf-8")
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
result = adopt_project(target, no_agent=True)
assert result["gitignore_action"] == "appended"
content = (target / ".gitignore").read_text(encoding="utf-8")
assert "node_modules/" in content
assert GITIGNORE_MARKER in content
assert ".trinity/" in content
def test_adopt_skips_gitignore_when_marker_present(host_env):
_, target = host_env
(target / ".gitignore").write_text(f"{GITIGNORE_MARKER}\n.trinity/\n", encoding="utf-8")
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
result = adopt_project(target, no_agent=True)
assert result["gitignore_action"] == "already-safe"
content = (target / ".gitignore").read_text(encoding="utf-8")
assert content.count(GITIGNORE_MARKER) == 1
def test_adopt_gitignore_covers_symlinked_venv_and_registry_lock(host_env, tmp_path):
"""Live-verification regression: a symlinked .venv (not a real dir) and a
registry lock file must both be actually ignored by real git, not just
present as a string in the .gitignore content."""
host, target = host_env
aipass_home = tmp_path / "fake_aipass_home"
(aipass_home / ".venv").mkdir(parents=True)
subprocess.run(["git", "init"], cwd=target, capture_output=True, text=True, check=True)
with (
patch(
"aipass.aipass.apps.handlers.new_project.adopt._detect_aipass_home",
return_value=str(aipass_home),
),
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
):
adopt_project(target, no_agent=True)
venv_link = target / ".venv"
assert venv_link.is_symlink()
lock_file = target / ".EXISTING-SITE_REGISTRY.lock"
lock_file.write_text("", encoding="utf-8")
result = subprocess.run(
["git", "check-ignore", ".venv", ".EXISTING-SITE_REGISTRY.lock"],
cwd=target,
capture_output=True,
text=True,
)
assert result.returncode == 0
assert ".venv" in result.stdout.split()
assert ".EXISTING-SITE_REGISTRY.lock" in result.stdout.split()
# ---------------------------------------------------------------------------
# adopt_project — additive scaffold, existing files untouched
# ---------------------------------------------------------------------------
def test_adopt_never_overwrites_existing_readme(host_env):
_, target = host_env
original = (target / "README.md").read_text(encoding="utf-8")
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
adopt_project(target, no_agent=True)
assert (target / "README.md").read_text(encoding="utf-8") == original
def test_adopt_never_touches_existing_tracked_files(host_env):
_, target = host_env
original = (target / "index.html").read_text(encoding="utf-8")
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
adopt_project(target, no_agent=True)
assert (target / "index.html").read_text(encoding="utf-8") == original
def test_adopt_writes_registry_and_settings(host_env):
_, target = host_env
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
result = adopt_project(target, no_agent=True)
assert result["registry_file"] == "EXISTING-SITE_REGISTRY.json"
assert (target / "EXISTING-SITE_REGISTRY.json").exists()
reg = json.loads((target / "EXISTING-SITE_REGISTRY.json").read_text())
assert reg["metadata"]["name"] == "EXISTING-SITE"
assert (target / ".claude" / "settings.json").exists()
settings = json.loads((target / ".claude" / "settings.json").read_text())
assert "env" not in settings
def test_adopt_no_agent_skips_spawn(host_env):
_, target = host_env
with (
_no_home(),
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
patch("aipass.aipass.apps.handlers.new_project.spawn_agent") as mock_spawn,
):
result = adopt_project(target, no_agent=True)
mock_spawn.assert_not_called()
assert result["agent_created"] is False
assert result["agent_home"] is None
def test_adopt_with_agent_spawns(host_env):
_, target = host_env
spawn_ok = {
"success": True,
"branch_name": "EXISTING_SITE",
"path": str(target / "src" / "existing_site" / "existing_site"),
"files_copied": 12,
"registry_updated": True,
"validation_issues": [],
}
with (
_no_home(),
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
patch(
"aipass.aipass.apps.handlers.new_project.spawn_agent",
return_value=spawn_ok,
) as mock_spawn,
):
result = adopt_project(target, no_agent=False)
mock_spawn.assert_called_once()
assert result["agent_created"] is True
assert result["agent_home"] == str(target / "src" / "existing_site" / "existing_site")
def test_adopt_registry_minted_before_spawn(host_env):
"""Registry-first invariant: registry file exists on disk before spawn_agent is called."""
_, target = host_env
seen = {}
def _check_registry_exists(**kwargs):
seen["registry_present"] = (target / "EXISTING-SITE_REGISTRY.json").exists()
return {
"success": True,
"branch_name": "EXISTING_SITE",
"path": kwargs["target_path"],
"files_copied": 1,
"registry_updated": True,
"validation_issues": [],
}
with (
_no_home(),
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
patch(
"aipass.aipass.apps.handlers.new_project.spawn_agent",
side_effect=_check_registry_exists,
),
):
adopt_project(target, no_agent=False)
assert seen["registry_present"] is True
# ---------------------------------------------------------------------------
# adopt_project — dry_run performs zero writes
# ---------------------------------------------------------------------------
def test_adopt_dry_run_writes_nothing(host_env):
_, target = host_env
before = sorted(p.relative_to(target) for p in target.rglob("*"))
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.spawn_agent") as mock_spawn:
result = adopt_project(target, no_agent=False, dry_run=True)
after = sorted(p.relative_to(target) for p in target.rglob("*"))
assert before == after
mock_spawn.assert_not_called()
assert result["dry_run"] is True
assert result["registry_id"] is None
assert result["agent_created"] is False
def test_adopt_dry_run_reports_planned_registry_and_agent(host_env):
_, target = host_env
with _no_home():
result = adopt_project(target, no_agent=False, dry_run=True)
assert result["registry_file"] == "EXISTING-SITE_REGISTRY.json"
assert "EXISTING-SITE_REGISTRY.json" in result["files"]
assert result["agent_home"] == str(target / "src" / "existing_site" / "existing_site")
assert any("resident agent" in f for f in result["files"])
def test_adopt_dry_run_no_agent_reports_no_home(host_env):
_, target = host_env
with _no_home():
result = adopt_project(target, no_agent=True, dry_run=True)
assert result["agent_home"] is None
def test_adopt_dry_run_still_reports_gitignore_action(host_env):
_, target = host_env
with _no_home():
result = adopt_project(target, no_agent=True, dry_run=True)
assert result["gitignore_action"] == "created"
assert not (target / ".gitignore").exists()
# ---------------------------------------------------------------------------
# Module handle_command
# ---------------------------------------------------------------------------
def test_module_handles_not_mine():
from aipass.aipass.apps.modules.adopt import handle_command
assert handle_command("notmine", []) is False
def test_module_handles_help():
from aipass.aipass.apps.modules.adopt import handle_command
assert handle_command("adopt", ["--help"]) is True
def test_module_handles_no_args():
from aipass.aipass.apps.modules.adopt import handle_command
assert handle_command("adopt", []) is True
def test_module_rejects_unknown_option(host_env):
from aipass.aipass.apps.modules.adopt import handle_command
_, target = host_env
with patch("aipass.aipass.apps.modules.adopt.error") as mock_error:
handle_command("adopt", [str(target), "--bogus"])
mock_error.assert_called_once()
assert "Unknown option" in mock_error.call_args[0][0]
def test_module_adopt_by_absolute_path(host_env, monkeypatch):
from aipass.aipass.apps.modules.adopt import handle_command
host, target = host_env
monkeypatch.chdir(host)
with (
_no_home(),
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
patch("aipass.aipass.apps.modules.adopt.console") as mock_con,
):
handle_command("adopt", [str(target), "--no-agent"])
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
assert "Registry:" in printed
assert "EXISTING-SITE_REGISTRY.json" in printed
def test_module_adopt_by_bare_name(host_env, monkeypatch):
from aipass.aipass.apps.modules.adopt import handle_command
host, target = host_env
monkeypatch.chdir(host)
with (
_no_home(),
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
patch("aipass.aipass.apps.modules.adopt.console") as mock_con,
):
handle_command("adopt", ["existing-site", "--no-agent"])
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
assert "Registry:" in printed
assert "EXISTING-SITE_REGISTRY.json" in printed
def test_module_adopt_dry_run_reports_would_adopt(host_env, monkeypatch, capsys):
from aipass.aipass.apps.modules.adopt import handle_command
host, target = host_env
monkeypatch.chdir(host)
with _no_home():
handle_command("adopt", ["existing-site", "--no-agent", "--dry-run"])
out = capsys.readouterr().out
assert "Would adopt" in out
assert not (target / "EXISTING-SITE_REGISTRY.json").exists()
def test_module_adopt_missing_target_no_host(tmp_path, monkeypatch):
from aipass.aipass.apps.modules.adopt import handle_command
monkeypatch.chdir(tmp_path)
with (
patch("aipass.aipass.apps.modules.adopt.error") as mock_error,
pytest.raises(SystemExit) as exc_info,
):
handle_command("adopt", ["nope"])
mock_error.assert_called_once()
assert "Not inside an AIPass installation" in mock_error.call_args[0][0]
assert exc_info.value.code == 1
def test_module_adopt_reports_refusal(host_env, monkeypatch):
from aipass.aipass.apps.modules.adopt import handle_command
host, target = host_env
monkeypatch.chdir(host)
(target / "EXISTING-SITE_REGISTRY.json").write_text("{}")
with (
patch("aipass.aipass.apps.modules.adopt.error") as mock_error,
pytest.raises(SystemExit) as exc_info,
):
handle_command("adopt", ["existing-site", "--no-agent"])
mock_error.assert_called_once()
assert "already adopted" in mock_error.call_args[0][0]
assert exc_info.value.code == 1
# ---------------------------------------------------------------------------
# aipass.py wiring
# ---------------------------------------------------------------------------
def test_aipass_public_commands_includes_adopt():
from aipass.aipass.apps.aipass import _PUBLIC_COMMANDS
assert "adopt" in _PUBLIC_COMMANDS
+60 -20
View File
@@ -20,7 +20,6 @@ from pathlib import Path
import pytest # pyright: ignore[reportMissingImports] import pytest # pyright: ignore[reportMissingImports]
from aipass.aipass.apps.handlers.init import scaffold_content as sc
from aipass.aipass.apps.handlers.init.bootstrap import ( from aipass.aipass.apps.handlers.init.bootstrap import (
_merge_hooks_json, _merge_hooks_json,
_sanitize_name, _sanitize_name,
@@ -28,6 +27,7 @@ from aipass.aipass.apps.handlers.init.bootstrap import (
init_project, init_project,
update_project, update_project,
) )
from aipass.aipass.shared import scaffold_content as sc
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -293,7 +293,7 @@ def test_init_project_settings_no_hooks(tmp_path, monkeypatch):
data = json.loads(settings_path.read_text(encoding="utf-8")) data = json.loads(settings_path.read_text(encoding="utf-8"))
assert "hooks" not in data, "Project settings should not contain hooks" assert "hooks" not in data, "Project settings should not contain hooks"
assert "env" in data assert "env" not in data, "AIPASS_HOME is machine-local — belongs in settings.local.json"
assert "permissions" in data assert "permissions" in data
@@ -547,9 +547,10 @@ def test_update_project_creates_missing_managed_dirs(tmp_path):
result = update_project(target) result = update_project(target)
assert (target / ".claude" / "settings.json").exists() assert (target / ".claude" / "settings.json").exists()
# Managed files in deleted dirs re-written (tier0_kernel, tier1_navmap, hooks.json, settings, prep) # Managed files in deleted dirs re-written (tier0_kernel, tier1_navmap,
# hooks.json, settings.json, settings.local.json, prep)
if result["aipass_home"]: if result["aipass_home"]:
assert len(result["updated_files"]) == 5 assert len(result["updated_files"]) == 6
else: else:
assert len(result["updated_files"]) == 2 assert len(result["updated_files"]) == 2
assert len(result["already_current"]) >= 2 assert len(result["already_current"]) >= 2
@@ -584,7 +585,7 @@ def test_init_project_returns_aipass_home(tmp_path):
def test_init_project_settings_has_aipass_home_when_detected(tmp_path, monkeypatch): def test_init_project_settings_has_aipass_home_when_detected(tmp_path, monkeypatch):
"""When AIPASS_HOME is detected, settings.json includes env.AIPASS_HOME.""" """When AIPASS_HOME is detected, settings.local.json (not settings.json) includes env.AIPASS_HOME."""
monkeypatch.setattr( monkeypatch.setattr(
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path", "aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
lambda _: False, lambda _: False,
@@ -598,8 +599,10 @@ def test_init_project_settings_has_aipass_home_when_detected(tmp_path, monkeypat
pytest.skip("AIPASS_HOME not detectable in this environment") pytest.skip("AIPASS_HOME not detectable in this environment")
settings = json.loads((target / ".claude" / "settings.json").read_text(encoding="utf-8")) settings = json.loads((target / ".claude" / "settings.json").read_text(encoding="utf-8"))
assert "env" in settings assert "env" not in settings
assert settings["env"]["AIPASS_HOME"] == result["aipass_home"]
local_settings = json.loads((target / ".claude" / "settings.local.json").read_text(encoding="utf-8"))
assert local_settings["env"]["AIPASS_HOME"] == result["aipass_home"]
def test_update_project_returns_aipass_home(tmp_path): def test_update_project_returns_aipass_home(tmp_path):
@@ -615,7 +618,7 @@ def test_update_project_returns_aipass_home(tmp_path):
def test_update_project_adds_aipass_home_if_missing(tmp_path, monkeypatch): def test_update_project_adds_aipass_home_if_missing(tmp_path, monkeypatch):
"""update_project injects AIPASS_HOME into settings.json if env section is absent.""" """update_project recreates settings.local.json with AIPASS_HOME if missing."""
monkeypatch.setattr( monkeypatch.setattr(
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path", "aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
lambda _: False, lambda _: False,
@@ -624,17 +627,15 @@ def test_update_project_adds_aipass_home_if_missing(tmp_path, monkeypatch):
target.mkdir() target.mkdir()
init_project(target, project_name="addenv") init_project(target, project_name="addenv")
settings_path = target / ".claude" / "settings.json" local_settings_path = target / ".claude" / "settings.local.json"
data = json.loads(settings_path.read_text(encoding="utf-8")) local_settings_path.unlink(missing_ok=True)
data.pop("env", None)
settings_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
result = update_project(target) result = update_project(target)
if result["aipass_home"] is not None: if result["aipass_home"] is not None:
new_data = json.loads(settings_path.read_text(encoding="utf-8")) new_data = json.loads(local_settings_path.read_text(encoding="utf-8"))
assert new_data.get("env", {}).get("AIPASS_HOME") == result["aipass_home"] assert new_data.get("env", {}).get("AIPASS_HOME") == result["aipass_home"]
assert str(settings_path) in result["updated_files"] assert str(local_settings_path) in result["updated_files"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1256,10 +1257,49 @@ def test_throwaway_path_allows_project():
assert not is_throwaway_path(str(Path.home() / "Projects" / "myapp")) assert not is_throwaway_path(str(Path.home() / "Projects" / "myapp"))
def test_settings_omits_throwaway_aipass_home(tmp_path): def test_settings_omits_throwaway_aipass_home(tmp_path, monkeypatch):
"""_claude_settings refuses to write AIPASS_HOME when it's a throwaway path.""" """init_project skips settings.local.json when detected AIPASS_HOME is a throwaway path."""
from aipass.aipass.apps.handlers.init.bootstrap import _claude_settings from aipass.aipass.apps.handlers.init import bootstrap
content = _claude_settings(str(tmp_path)) monkeypatch.setattr(bootstrap, "_detect_aipass_home", lambda: str(tmp_path))
data = json.loads(content)
assert "AIPASS_HOME" not in data.get("env", {}) target = tmp_path / "proj"
target.mkdir()
bootstrap.init_project(target, project_name="alpha")
assert not (target / ".claude" / "settings.local.json").exists()
# Directory/file-name fragments that .gitignore excludes from git tracking —
# mirrors scaffold_content.gitignore(). Tracked-file scans must skip these.
_GITIGNORED_PARTS = (".trinity", ".ai_mail.local", "logs", ".venv", "venv", ".git")
def test_minted_tracked_files_have_no_absolute_paths(tmp_path, monkeypatch):
"""No file init_project writes into a TRACKED path may contain the machine-local AIPASS_HOME.
Regression guard for the settings.json bug: AIPASS_HOME is an absolute,
machine-local path and must only ever land in gitignored *.local.json
files. This walks every minted file that would actually be committed
and greps it for the (fake) AIPASS_HOME value.
"""
from aipass.aipass.apps.handlers.init import bootstrap
fake_home = str(tmp_path / f"fake_aipass_home_{uuid.uuid4().hex}")
monkeypatch.setattr(bootstrap, "_detect_aipass_home", lambda: fake_home)
monkeypatch.setattr(bootstrap, "is_throwaway_path", lambda _: False)
target = tmp_path / "proj"
target.mkdir()
bootstrap.init_project(target, project_name="pathcheck")
for path in target.rglob("*"):
if not path.is_file():
continue
rel = path.relative_to(target)
if any(part in _GITIGNORED_PARTS for part in rel.parts):
continue
if ".local." in rel.name:
continue
content = path.read_text(encoding="utf-8")
assert fake_home not in content, f"{rel} leaks machine-local AIPASS_HOME"
+22 -22
View File
@@ -183,11 +183,11 @@ def test_create_project_empty_template(host_env, monkeypatch):
with ( with (
patch("subprocess.run", side_effect=_mock_git_run), patch("subprocess.run", side_effect=_mock_git_run),
patch( patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", "aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None, return_value=None,
), ),
patch( patch(
"aipass.aipass.apps.handlers.init.bootstrap._enroll_project", "aipass.aipass.shared.project_home._enroll_project",
), ),
): ):
result = create_project("testproj", template="empty", no_agent=True) result = create_project("testproj", template="empty", no_agent=True)
@@ -208,11 +208,11 @@ def test_create_project_python_template(host_env, monkeypatch):
with ( with (
patch("subprocess.run", side_effect=_mock_git_run), patch("subprocess.run", side_effect=_mock_git_run),
patch( patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", "aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None, return_value=None,
), ),
patch( patch(
"aipass.aipass.apps.handlers.init.bootstrap._enroll_project", "aipass.aipass.shared.project_home._enroll_project",
), ),
): ):
result = create_project("pyapp", template="python", no_agent=True) result = create_project("pyapp", template="python", no_agent=True)
@@ -261,10 +261,10 @@ def test_create_project_cleans_up_on_failure(host_env, monkeypatch):
with ( with (
patch("subprocess.run", side_effect=_fail_git), patch("subprocess.run", side_effect=_fail_git),
patch( patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", "aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None, return_value=None,
), ),
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"), patch("aipass.aipass.shared.project_home._enroll_project"),
pytest.raises(RuntimeError, match="simulated failure"), pytest.raises(RuntimeError, match="simulated failure"),
): ):
create_project("failproj", no_agent=True) create_project("failproj", no_agent=True)
@@ -299,10 +299,10 @@ def test_create_project_registry_before_scaffold(host_env, monkeypatch):
side_effect=track_template, side_effect=track_template,
), ),
patch( patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", "aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None, return_value=None,
), ),
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"), patch("aipass.aipass.shared.project_home._enroll_project"),
): ):
create_project("ordertest", no_agent=True) create_project("ordertest", no_agent=True)
@@ -399,10 +399,10 @@ def test_create_project_with_agent(host_env, monkeypatch):
with ( with (
patch("subprocess.run", side_effect=_mock_git_run), patch("subprocess.run", side_effect=_mock_git_run),
patch( patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", "aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None, return_value=None,
), ),
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"), patch("aipass.aipass.shared.project_home._enroll_project"),
patch( patch(
"aipass.aipass.apps.handlers.new_project.spawn_agent", "aipass.aipass.apps.handlers.new_project.spawn_agent",
return_value=spawn_ok, return_value=spawn_ok,
@@ -426,10 +426,10 @@ def test_create_project_spawn_failure_cleans_up(host_env, monkeypatch):
with ( with (
patch("subprocess.run", side_effect=_mock_git_run), patch("subprocess.run", side_effect=_mock_git_run),
patch( patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", "aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None, return_value=None,
), ),
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"), patch("aipass.aipass.shared.project_home._enroll_project"),
patch( patch(
"aipass.aipass.apps.handlers.new_project.spawn_agent", "aipass.aipass.apps.handlers.new_project.spawn_agent",
return_value={"success": False, "error": "template missing"}, return_value={"success": False, "error": "template missing"},
@@ -449,10 +449,10 @@ def test_create_project_no_agent_next_steps(host_env, monkeypatch):
with ( with (
patch("subprocess.run", side_effect=_mock_git_run), patch("subprocess.run", side_effect=_mock_git_run),
patch( patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", "aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None, return_value=None,
), ),
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"), patch("aipass.aipass.shared.project_home._enroll_project"),
patch("aipass.aipass.apps.modules.new_project.console") as mock_con, patch("aipass.aipass.apps.modules.new_project.console") as mock_con,
): ):
handle_command("new", ["cosmtest", "--template", "empty", "--no-agent"]) handle_command("new", ["cosmtest", "--template", "empty", "--no-agent"])
@@ -466,10 +466,10 @@ def test_create_project_no_agent_flag(host_env, monkeypatch):
with ( with (
patch("subprocess.run", side_effect=_mock_git_run), patch("subprocess.run", side_effect=_mock_git_run),
patch( patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", "aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None, return_value=None,
), ),
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"), patch("aipass.aipass.shared.project_home._enroll_project"),
): ):
result = create_project("noagent", template="empty", no_agent=True) result = create_project("noagent", template="empty", no_agent=True)
@@ -645,10 +645,10 @@ def test_tty_auto_launches_agent(host_env, monkeypatch):
patch("subprocess.run", side_effect=_mock_git_run), patch("subprocess.run", side_effect=_mock_git_run),
patch("builtins.input", return_value=""), patch("builtins.input", return_value=""),
patch( patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", "aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None, return_value=None,
), ),
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"), patch("aipass.aipass.shared.project_home._enroll_project"),
patch( patch(
"aipass.aipass.apps.handlers.new_project.spawn_agent", "aipass.aipass.apps.handlers.new_project.spawn_agent",
return_value=spawn_ok, return_value=spawn_ok,
@@ -680,10 +680,10 @@ def test_no_tty_skips_auto_launch(host_env, monkeypatch):
patch("subprocess.run", side_effect=_mock_git_run), patch("subprocess.run", side_effect=_mock_git_run),
patch("builtins.input", return_value=""), patch("builtins.input", return_value=""),
patch( patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", "aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None, return_value=None,
), ),
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"), patch("aipass.aipass.shared.project_home._enroll_project"),
patch( patch(
"aipass.aipass.apps.handlers.new_project.spawn_agent", "aipass.aipass.apps.handlers.new_project.spawn_agent",
return_value=spawn_ok, return_value=spawn_ok,
@@ -708,10 +708,10 @@ def test_no_agent_skips_auto_launch(host_env, monkeypatch):
with ( with (
patch("subprocess.run", side_effect=_mock_git_run), patch("subprocess.run", side_effect=_mock_git_run),
patch( patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", "aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None, return_value=None,
), ),
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"), patch("aipass.aipass.shared.project_home._enroll_project"),
patch("aipass.aipass.apps.modules.new_project.console"), patch("aipass.aipass.apps.modules.new_project.console"),
patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys, patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys,
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch, patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
@@ -24,7 +24,9 @@ import sys
import aipass.aipass.shared.json_handler import aipass.aipass.shared.json_handler
import aipass.aipass.shared.json_ops import aipass.aipass.shared.json_ops
import aipass.aipass.shared.project_home
import aipass.aipass.shared.registry_discovery import aipass.aipass.shared.registry_discovery
import aipass.aipass.shared.scaffold_content
bad = [] bad = []
for name in sorted(sys.modules): for name in sorted(sys.modules):
@@ -75,8 +75,8 @@ def _validate_metadata(metadata_str: str) -> Optional[dict]:
def _resolve_branch_name(mention: str) -> Optional[str]: def _resolve_branch_name(mention: str) -> Optional[str]:
"""Resolve a @mention to a branch name.""" """Resolve a @mention to a branch name (lowercase-normalized)."""
name = mention.lstrip("@").upper() name = mention.lstrip("@").lower()
if not os.path.exists(BRANCH_REGISTRY_PATH): if not os.path.exists(BRANCH_REGISTRY_PATH):
return None return None
@@ -85,7 +85,7 @@ def _resolve_branch_name(mention: str) -> Optional[str]:
with open(BRANCH_REGISTRY_PATH, encoding="utf-8") as f: with open(BRANCH_REGISTRY_PATH, encoding="utf-8") as f:
registry = json.load(f) registry = json.load(f)
for branch in registry.get("branches", []): for branch in registry.get("branches", []):
if branch.get("name") == name: if branch.get("name", "").lower() == name:
return name return name
return None return None
except Exception: except Exception:
@@ -54,8 +54,8 @@ RARITY_COLORS = {
def _resolve_branch_name(mention: str) -> Optional[str]: def _resolve_branch_name(mention: str) -> Optional[str]:
"""Resolve a @mention to a branch name.""" """Resolve a @mention to a branch name (lowercase-normalized)."""
name = mention.lstrip("@").upper() name = mention.lstrip("@").lower()
if not os.path.exists(BRANCH_REGISTRY_PATH): if not os.path.exists(BRANCH_REGISTRY_PATH):
return None return None
@@ -64,7 +64,7 @@ def _resolve_branch_name(mention: str) -> Optional[str]:
with open(BRANCH_REGISTRY_PATH, encoding="utf-8") as f: with open(BRANCH_REGISTRY_PATH, encoding="utf-8") as f:
registry = json.load(f) registry = json.load(f)
for branch in registry.get("branches", []): for branch in registry.get("branches", []):
if branch.get("name") == name: if branch.get("name", "").lower() == name:
return name return name
return None return None
except Exception: except Exception:
@@ -58,7 +58,7 @@ def show_profile(args: List[str]) -> dict:
# Determine which branch to show # Determine which branch to show
if args: if args:
target_branch = args[0].upper() target_branch = args[0].lower()
else: else:
caller = get_caller_branch() caller = get_caller_branch()
if not caller: if not caller:
@@ -62,7 +62,7 @@ def _parse_search_args(args: List[str]) -> dict:
result["room"] = remaining[i + 1].lower() result["room"] = remaining[i + 1].lower()
i += 2 i += 2
elif flag == "--author" and i + 1 < len(remaining): elif flag == "--author" and i + 1 < len(remaining):
result["author"] = remaining[i + 1].upper() result["author"] = remaining[i + 1].lower()
i += 2 i += 2
elif flag == "--type" and i + 1 < len(remaining): elif flag == "--type" and i + 1 < len(remaining):
search_type = remaining[i + 1].lower() search_type = remaining[i + 1].lower()
@@ -55,7 +55,7 @@ def run_welcome(args: List[str]) -> dict:
conn = get_db() conn = get_db()
if filtered_args: if filtered_args:
branch_name = filtered_args[0].upper() branch_name = filtered_args[0].lower()
if dry_run: if dry_run:
already = has_been_welcomed(conn, branch_name) already = has_been_welcomed(conn, branch_name)
close_db(conn) close_db(conn)
@@ -114,7 +114,7 @@ def _welcome_specific(conn, branch_name: str) -> dict:
Returns: Returns:
Dict with success and welcome result Dict with success and welcome result
""" """
agent = conn.execute("SELECT branch_name FROM agents WHERE branch_name = ?", (branch_name,)).fetchone() agent = conn.execute("SELECT branch_name FROM agents WHERE LOWER(branch_name) = ?", (branch_name,)).fetchone()
if not agent: if not agent:
return {"success": False, "error": f"Branch '{branch_name}' not found in The Commons."} return {"success": False, "error": f"Branch '{branch_name}' not found in The Commons."}
@@ -993,7 +993,7 @@ def test_run_welcome_dry_run_specific_branch(
"""run_welcome <branch> --dry-run reports whether the branch would be welcomed.""" """run_welcome <branch> --dry-run reports whether the branch would be welcomed."""
from aipass.commons.apps.handlers.welcome.welcome_ops import run_welcome from aipass.commons.apps.handlers.welcome.welcome_ops import run_welcome
_seed_agent(initialized_db, "GAMMA") _seed_agent(initialized_db, "gamma")
mock_get_db.return_value = initialized_db mock_get_db.return_value = initialized_db
mock_close.side_effect = lambda c: None mock_close.side_effect = lambda c: None
@@ -1001,7 +1001,7 @@ def test_run_welcome_dry_run_specific_branch(
result = run_welcome(["gamma", "--dry-run"]) result = run_welcome(["gamma", "--dry-run"])
assert result["success"] is True assert result["success"] is True
assert result["dry_run"] is True assert result["dry_run"] is True
assert result["branch"] == "GAMMA" assert result["branch"] == "gamma"
assert result["would_welcome"] is True assert result["would_welcome"] is True
@@ -1076,7 +1076,7 @@ def test_run_welcome_specific_branch_success(
"""run_welcome <branch> creates a welcome post for that branch.""" """run_welcome <branch> creates a welcome post for that branch."""
from aipass.commons.apps.handlers.welcome.welcome_ops import run_welcome from aipass.commons.apps.handlers.welcome.welcome_ops import run_welcome
_seed_agent(initialized_db, "EPSILON") _seed_agent(initialized_db, "epsilon")
mock_get_db.return_value = initialized_db mock_get_db.return_value = initialized_db
mock_close.side_effect = lambda c: None mock_close.side_effect = lambda c: None
@@ -1085,7 +1085,7 @@ def test_run_welcome_specific_branch_success(
assert result["success"] is True assert result["success"] is True
assert result["action"] == "specific" assert result["action"] == "specific"
assert result["already_welcomed"] is False assert result["already_welcomed"] is False
assert result["branch"] == "EPSILON" assert result["branch"] == "epsilon"
assert result["post_id"] is not None assert result["post_id"] is not None
+3 -3
View File
@@ -58,10 +58,10 @@ def test_parse_search_args_room_flag():
def test_parse_search_args_author_flag(): def test_parse_search_args_author_flag():
"""The --author flag should set the author filter and uppercase it.""" """The --author flag should set the author filter and lowercase it."""
result = _parse_search_args(["test", "--author", "drone"]) result = _parse_search_args(["test", "--author", "drone"])
assert result["query"] == "test" assert result["query"] == "test"
assert result["author"] == "DRONE" assert result["author"] == "drone"
def test_parse_search_args_type_flag_valid(): def test_parse_search_args_type_flag_valid():
@@ -94,7 +94,7 @@ def test_parse_search_args_all_flags():
) )
assert result["query"] == "registry" assert result["query"] == "registry"
assert result["room"] == "dev" assert result["room"] == "dev"
assert result["author"] == "FLOW" assert result["author"] == "flow"
assert result["search_type"] == "posts" assert result["search_type"] == "posts"
@@ -1,4 +1,5 @@
{ {
"autoCompactWindow": 350000,
"permissions": { "permissions": {
"allow": [], "allow": [],
"deny": [ "deny": [
-65
View File
@@ -70,71 +70,6 @@
"file": "tests/test_owner_guard.py", "file": "tests/test_owner_guard.py",
"reason": "Unit test imports the owner guard handler (its SUT) and patches spawn.registry's get_owner/is_owner — the guard is a shared handler primitive with no apps/modules/ command entry point. Same direct-SUT pattern as test_compass_store.py. #681." "reason": "Unit test imports the owner guard handler (its SUT) and patches spawn.registry's get_owner/is_owner — the guard is a shared handler primitive with no apps/modules/ command entry point. Same direct-SUT pattern as test_compass_store.py. #681."
}, },
{
"standard": "encapsulation",
"file": "tools/spot_check.py",
"reason": "Standalone diagnostic tool script, not a handler."
},
{
"standard": "debug_print",
"file": "tools/spot_check.py",
"reason": "Standalone CLI tool — print() is the intended output method."
},
{
"standard": "silent_catch",
"file": "tools/hook_engine_poc/engine.py",
"reason": "POC hook engine — stdlib only, no prax logger. Uses sys.stderr for error reporting."
},
{
"standard": "error_handling",
"file": "tools/hook_engine_poc/engine.py",
"reason": "POC hook engine — exception handlers write to stderr, not prax logger."
},
{
"standard": "silent_catch",
"file": "tools/hook_engine_poc/test_engine.py",
"reason": "POC test harness — catches test exceptions to report pass/fail, writes to stderr."
},
{
"standard": "imports",
"file": "tools/hook_engine_poc/test_engine.py",
"reason": "POC test harness — sys.path needed to import engine.py from same directory."
},
{
"standard": "trigger",
"file": "tools/hook_engine_poc/test_engine.py",
"reason": "POC test harness — .unlink() clears ephemeral JSONL log between tests, not a tracked file."
},
{
"standard": "help_text",
"file": "tools/hook_engine_poc/test_engine.py",
"reason": "POC test harness — usage example in docstring."
},
{
"standard": "debug_print",
"file": "tools/rm_shim/redteam_suite.py",
"reason": "Standalone red-team diagnostic runner (FPLAN-0250 Phase 6) — print() IS the report output, same as broker_acceptance_test.py."
},
{
"standard": "encapsulation",
"file": "tools/rm_shim/redteam_suite.py",
"reason": "Red-team tool imports the real broker daemon/client + sandbox module directly to exercise them under live conditions — that is the point of an integration probe, not a handler."
},
{
"standard": "imports",
"file": "tools/rm_shim/redteam_suite.py",
"reason": "Standalone script run via 'python tools/...' — sys.path insert lets it import the production modules it red-teams without being pip-installed."
},
{
"standard": "help_text",
"file": "tools/rm_shim/redteam_suite.py",
"reason": "Diagnostic script — docstring shows the 'python tools/...' invocation; it is not a drone-routed module."
},
{
"standard": "documentation",
"file": "tools/rm_shim/redteam_suite.py",
"reason": "Result.ok/bad are 2-line internal report helpers in a diagnostic script — self-evident, docstrings redundant."
},
{ {
"standard": "encapsulation", "standard": "encapsulation",
"file": "tests/test_compass_store.py", "file": "tests/test_compass_store.py",
@@ -423,7 +423,12 @@ class StallTracker:
(#634 part 2). The verbose trail stays on ``_stderr`` + logger. (#634 part 2). The verbose trail stays on ``_stderr`` + logger.
""" """
STALL_THRESHOLD = 120.0 # Quiet spans with zero JSONL output are routine, not stalls: a model composing
# a long response writes nothing until the turn completes, and compaction is one
# multi-minute silent API call. 120s false-fired on every stall of S329 (8/8);
# 300s clears normal turns and most compactions while a wedged agent still
# surfaces well inside a long watch.
STALL_THRESHOLD = 300.0
# A single tool call held in-flight this long is surfaced as a soft advisory # A single tool call held in-flight this long is surfaced as a soft advisory
# (heavy op or a hung tool). Below the 600s default timeout so long watches # (heavy op or a hung tool). Below the 600s default timeout so long watches
# get a mid-flight heads-up instead of waiting on the timeout. # get a mid-flight heads-up instead of waiting on the timeout.
@@ -516,7 +521,7 @@ def watch_agent(
poll_interval: Seconds between checks. Default 5.0 — the per-tick work (lock poll_interval: Seconds between checks. Default 5.0 — the per-tick work (lock
stat, PID liveness, one-dir JSONL size scan) is cheap, so a tight cadence stat, PID liveness, one-dir JSONL size scan) is cheap, so a tight cadence
just burns CPU. 5s keeps completion latency invisible on multi-minute just burns CPU. 5s keeps completion latency invisible on multi-minute
dispatches while the 120s stall threshold has ample resolution. dispatches while the 300s stall threshold has ample resolution.
Returns: Returns:
dict with keys: woke, reason, elapsed, agent_state, exit_code, agent_id. dict with keys: woke, reason, elapsed, agent_state, exit_code, agent_id.
@@ -0,0 +1,173 @@
<title>S324 Advisory Scan — AIPass</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root {
--paper: #f4f1ea; --surface: #fbf9f4; --surface-2: #eeeae0; --ink: #1a1e27;
--ink-soft: #454b58; --ink-faint: #6b7280; --hair: #ddd6c8;
--accent: #b57e1f; --accent-soft: #e9d9b4;
--crit: #c92a30; --high: #c0741f; --med: #4964a8; --low: #7b8494; --good: #2f8f5b;
--crit-bg: #f6dcdc; --high-bg: #f4e6d0; --med-bg: #dde3f2; --low-bg: #e6e8ec; --good-bg: #d8ecdf;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--maxw: 940px;
}
@media (prefers-color-scheme: dark) {
:root {
--paper: #10131a; --surface: #161a23; --surface-2: #1e2430; --ink: #e7e9ee;
--ink-soft: #aab2c0; --ink-faint: #79828f; --hair: #2a3140; --accent: #d9a441;
--accent-soft: #4a3d1c; --crit: #f0565b; --high: #e8973f; --med: #7d97d8; --low: #8a93a3; --good: #4cc082;
--crit-bg: #3a1c1e; --high-bg: #382a17; --med-bg: #1f2740; --low-bg: #242a35; --good-bg: #16301f;
}
}
:root[data-theme="light"] {
--paper: #f4f1ea; --surface: #fbf9f4; --surface-2: #eeeae0; --ink: #1a1e27;
--ink-soft: #454b58; --ink-faint: #6b7280; --hair: #ddd6c8; --accent: #b57e1f;
--accent-soft: #e9d9b4; --crit: #c92a30; --high: #c0741f; --med: #4964a8; --low: #7b8494; --good: #2f8f5b;
--crit-bg: #f6dcdc; --high-bg: #f4e6d0; --med-bg: #dde3f2; --low-bg: #e6e8ec; --good-bg: #d8ecdf;
}
:root[data-theme="dark"] {
--paper: #10131a; --surface: #161a23; --surface-2: #1e2430; --ink: #e7e9ee;
--ink-soft: #aab2c0; --ink-faint: #79828f; --hair: #2a3140; --accent: #d9a441;
--accent-soft: #4a3d1c; --crit: #f0565b; --high: #e8973f; --med: #7d97d8; --low: #8a93a3; --good: #4cc082;
--crit-bg: #3a1c1e; --high-bg: #382a17; --med-bg: #1f2740; --low-bg: #242a35; --good-bg: #16301f;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); line-height: 1.5; -webkit-font-smoothing: antialiased; }
.wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 22px; }
header { padding: 54px 0 30px; border-bottom: 1px solid var(--hair); }
.eyebrow { font-family: var(--mono); font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: var(--accent); margin: 0 0 16px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.eyebrow .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--good); box-shadow: 0 0 0 3px var(--good-bg); }
h1 { font-family: var(--mono); font-weight: 600; font-size: clamp(28px, 5vw, 44px); line-height: 1.06; letter-spacing: -0.02em; margin: 0 0 14px; text-wrap: balance; }
h1 .melt { color: var(--accent); }
.lede { font-size: 18px; color: var(--ink-soft); max-width: 62ch; margin: 0; }
.meta { font-family: var(--mono); font-size: 12.5px; color: var(--ink-faint); margin-top: 22px; display: flex; gap: 8px 20px; flex-wrap: wrap; }
.meta b { color: var(--ink-soft); font-weight: 600; }
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--hair); border: 1px solid var(--hair); border-radius: 10px; overflow: hidden; margin: 30px 0 8px; }
.stat { background: var(--surface); padding: 18px 18px 16px; }
.stat .n { font-family: var(--mono); font-size: 30px; font-weight: 600; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; line-height: 1; }
.stat .k { font-size: 12.5px; color: var(--ink-faint); margin-top: 7px; }
.stat.good .n { color: var(--good); }
.stat.med .n { color: var(--med); }
@media (max-width: 620px) { .stats { grid-template-columns: repeat(2, 1fr); } }
section { padding: 40px 0; border-bottom: 1px solid var(--hair); }
.sec-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 22px; }
.sec-head h2 { font-family: var(--mono); font-size: 14px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink); margin: 0; font-weight: 600; }
.sec-head .rule { flex: 1; height: 1px; background: var(--hair); }
.sec-head .count { font-family: var(--mono); font-size: 12px; color: var(--ink-faint); }
.findings { display: flex; flex-direction: column; gap: 12px; }
.f { background: var(--surface); border: 1px solid var(--hair); border-radius: 9px; border-left-width: 4px; padding: 16px 18px; }
.f.high { border-left-color: var(--high); }
.f.med { border-left-color: var(--med); }
.f.low { border-left-color: var(--low); }
.f.good { border-left-color: var(--good); }
.f-top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 7px; }
.id { font-family: var(--mono); font-size: 12px; font-weight: 600; color: var(--ink-faint); }
.pill { font-family: var(--mono); font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase; font-weight: 600; padding: 2px 8px; border-radius: 20px; }
.pill.high { color: var(--high); background: var(--high-bg); }
.pill.med { color: var(--med); background: var(--med-bg); }
.pill.low { color: var(--low); background: var(--low-bg); }
.pill.good { color: var(--good); background: var(--good-bg); }
.f-title { font-weight: 600; font-size: 15.5px; color: var(--ink); flex: 1 1 100%; margin-top: 2px; }
.f-body { font-size: 14px; color: var(--ink-soft); margin: 0; }
.f-body code { font-family: var(--mono); font-size: 12.5px; background: var(--surface-2); padding: 1px 5px; border-radius: 4px; color: var(--ink); }
table.melt { width: 100%; border-collapse: collapse; font-size: 14px; margin-top: 4px; }
table.melt th { font-family: var(--mono); font-size: 11.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-faint); text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--hair); }
table.melt td { padding: 9px 12px; border-bottom: 1px solid var(--hair); color: var(--ink-soft); }
table.melt td.n { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--ink); font-weight: 600; white-space: nowrap; }
table.melt td.verdict-good { color: var(--good); font-weight: 600; }
table.melt td.verdict-open { color: var(--med); font-weight: 600; }
.tblwrap { overflow-x: auto; }
.callout { background: var(--surface-2); border: 1px solid var(--hair); border-radius: 9px; padding: 18px 20px; margin-top: 18px; }
.callout h3 { font-family: var(--mono); font-size: 13px; letter-spacing: .06em; text-transform: uppercase; margin: 0 0 8px; color: var(--accent); }
.callout p { margin: 0; font-size: 14px; color: var(--ink-soft); }
footer { padding: 30px 0 60px; font-family: var(--mono); font-size: 12px; color: var(--ink-faint); }
footer .disc { color: var(--good); }
</style>
<div class="wrap">
<header>
<p class="eyebrow"><span class="dot"></span> Session S324 · Advisory Scan · Read-only</p>
<h1>The scanners CI never runs said 1,419 problems. Verification <span class="melt">melted it to a handful.</span></h1>
<p class="lede">Patrick's ask: "look in ur tools folder run some random scan see what surfaces." Ran the April-era advisory scanners — the ones deliberately NOT wired into seedgo's audit — across all 17 branches, then read every scary number in context before believing it. The tree is clean where it counts; most of the raw count is archive noise and scanner age.</p>
<p class="meta"><span><b>Date</b> 2026-07-18</span><span><b>Agent</b> @devpulse</span><span><b>Scanners</b> fallback · stale-terms · readme-freshness</span><span><b>Branches</b> 17/17</span><span><b>Files changed</b> 0</span></p>
</header>
<div class="stats" role="list">
<div class="stat" role="listitem"><div class="n">1,419</div><div class="k">raw findings, all scanners</div></div>
<div class="stat med" role="listitem"><div class="n">16</div><div class="k">fallbacks actually in live code</div></div>
<div class="stat good" role="listitem"><div class="n">2/2</div><div class="k">sampled live hits = defensible on read</div></div>
<div class="stat" role="listitem"><div class="n">1</div><div class="k">genuine surprise (MagicMock leak)</div></div>
</div>
<!-- THE MELT -->
<section>
<div class="sec-head"><h2>How the numbers melt</h2><span class="rule"></span><span class="count">raw → verified</span></div>
<div class="tblwrap"><table class="melt">
<tr><th>Scanner</th><th>Raw</th><th>After context</th><th>Verdict</th></tr>
<tr><td>Silent fallbacks</td><td class="n">61</td><td class="n">16 live · 45 = devpulse <code>dropbox/</code> quarantine copies</td><td class="verdict-good">mostly archive noise</td></tr>
<tr><td>&nbsp;&nbsp;↳ of the 16 live</td><td class="n">16</td><td class="n">@trigger 11 · @cli 2 · @skills 2 (tests) · @spawn 1 (template)</td><td class="verdict-open">@trigger needs owner triage</td></tr>
<tr><td>Stale terminology</td><td class="n">1,358</td><td class="n">~600 genuinely dead names · rest = valid <code>.ai_mail.local/</code> refs</td><td class="verdict-open">needs live-code filter</td></tr>
<tr><td>README freshness</td><td class="n">15/17 stale</td><td class="n">dates lag the front-door code week</td><td class="verdict-good">informational</td></tr>
</table></div>
<div class="callout">
<h3>Why sampling flipped the verdict</h3>
<p>Both @trigger hits I read in full are <b>recursion-safe meta-logging guards</b> — "if logging this warning fails, don't crash the error pipeline" — one even carries a <code># seedgo:bypass meta-logging</code> marker. The @spawn template hit is a two-prefix import <em>probe</em> that surfaces real failures later, not a swallow. The scanner predates the bypass convention, so it re-alarms on code the standards system already acquitted.</p>
</div>
</section>
<!-- FINDINGS -->
<section>
<div class="sec-head"><h2>What actually surfaced</h2><span class="rule"></span><span class="count">5 findings</span></div>
<div class="findings">
<div class="f med">
<div class="f-top"><span class="id">S324-1</span><span class="pill med">Medium · real · unowned</span>
<span class="f-title">A test leaked a Mock onto disk: <code>MagicMock/LOG_FILE/</code> sits in devpulse's branch root</span></div>
<p class="f-body">Empty, untracked, born <b>Jul 11 03:31</b> (a night-shift test run). Something called mkdir on a path built from an unpatched <code>Mock().LOG_FILE</code>. Four candidate offenders mock a <code>LOG_FILE</code> attribute: ai_mail <code>test_daemon.py</code> / <code>test_dispatch_status.py</code>, hooks <code>test_engine.py</code>, seedgo <code>test_checkers_batch3.py</code>. Cheap hunt: run each suite from a scratch CWD and watch which one mints the dir. Until then it's clutter that will regrow after cleanup.</p>
</div>
<div class="f med">
<div class="f-top"><span class="id">S324-2</span><span class="pill med">Medium · owner call</span>
<span class="f-title">@trigger: 11 bare <code>except Exception: pass</code> in live event handlers — 9 still unread</span></div>
<p class="f-body">The error-detection branch swallowing exceptions is worth eyes on principle. The 2 I sampled are legitimate meta-logging guards; the other 9 (in <code>error_detected</code>, <code>runaway_handler</code>, <code>plan_file</code>, <code>pr_status_sync</code>, <code>memory_pool</code>, <code>registry</code>, <code>startup</code>) deserve the same context read by their owner. Concern, not prescription — @trigger designs the fix if any is real.</p>
</div>
<div class="f low">
<div class="f-top"><span class="id">S324-3</span><span class="pill low">Low · tooling debt</span>
<span class="f-title">The advisory scanners are aging out of the conventions they audit</span></div>
<p class="f-body">April-era tools: the fallback scanner doesn't honor <code>seedgo:bypass</code> markers (false alarms on acquitted code), the stale scanner counts valid <code>.ai_mail.local/</code> usage in its headline, and none of them exclude <code>dropbox/</code> / <code>.archive/</code> quarantine dirs — which is where 45 of 61 "fallbacks" came from. A bypass-aware, archive-excluding refresh would make raw counts trustworthy again.</p>
</div>
<div class="f low">
<div class="f-top"><span class="id">S324-4</span><span class="pill low">Low · unfiltered</span>
<span class="f-title">~600 dead-name references still in the tree: dev central 243 · branch_registry 211 · ai_central 131 · cortex 13</span></div>
<p class="f-body">Not yet split between live code (worth fixing) and historical plans/archives (should keep their old names — never rewrite history). A filtered live-code pass is the prerequisite before this number means anything. Public-repo angle: strangers reading live code shouldn't meet four generations of naming.</p>
</div>
<div class="f good">
<div class="f-top"><span class="id">S324-5</span><span class="pill good">Verified good</span>
<span class="f-title">Where it counts, the tree is clean</span></div>
<p class="f-body">Devpulse live code: <b>zero</b> silent fallbacks — all 45 flagged were quarantined pollution captures in <code>dropbox/</code>, doing exactly what quarantine is for. The 10 hard checkers born from this same tools folder already run inside <code>drone @seedgo audit</code>, where all 17 branches sit at 100%. The advisory layer found no criticals, no highs, nothing live-broken.</p>
</div>
</div>
</section>
<!-- CONTEXT -->
<section>
<div class="sec-head"><h2>Report lineage</h2><span class="rule"></span><span class="count">where this fits</span></div>
<p class="f-body" style="max-width:70ch">Sibling of <code>docs/discovery/S304_report.html</code> (Jul 12 — the 124-finding autonomous discovery walk; two criticals from it, medic-off and the rollover off-by-one, were since fixed and Patrick-monitored respectively). The other big HTML artifact of the week is <code>artifacts/ux_flow_graph.html</code> (3.5&nbsp;MB, the v4 install-to-chat UX graph). Same-day context: PR&nbsp;#703 (merge-playbook drift-check step) is CI-green and awaiting Patrick's merge word; gif session prepped in <code>artifacts/gif_recording_plan.md</code>.</p>
</section>
<footer>
<span class="disc">read-only scan</span> · zero files changed · scanners: fallback_scanner_v1, stale_scanner_v1, readme_freshness_scanner_v1 · @devpulse S324 · 2026-07-18
</footer>
</div>
@@ -312,7 +312,7 @@ def test_stalltracker_inflight_tool_prevents_stall(monkeypatch, capsys):
monkeypatch.setattr(agent_handler, "_last_entry_is_inflight_tool", lambda *a, **kw: True) monkeypatch.setattr(agent_handler, "_last_entry_is_inflight_tool", lambda *a, **kw: True)
t = agent_handler.StallTracker("@x", Path("/nope"), {}, now=0.0, pid=123) t = agent_handler.StallTracker("@x", Path("/nope"), {}, now=0.0, pid=123)
for now in (60.0, 120.0, 180.0, 240.0): for now in (120.0, 240.0, 360.0, 480.0):
t.observe(now=now) t.observe(now=now)
out = capsys.readouterr().out out = capsys.readouterr().out
@@ -391,7 +391,8 @@ def test_watch_agent_surfaces_stall_to_stdout(monkeypatch, tmp_path, capsys):
monkeypatch.setattr(agent_handler, "_pid_alive", lambda pid: True) monkeypatch.setattr(agent_handler, "_pid_alive", lambda pid: True)
monkeypatch.setattr(agent_handler, "_has_jsonl_activity", lambda *a, **kw: False) monkeypatch.setattr(agent_handler, "_has_jsonl_activity", lambda *a, **kw: False)
monkeypatch.setattr(agent_handler, "_last_entry_is_inflight_tool", lambda *a, **kw: False) monkeypatch.setattr(agent_handler, "_last_entry_is_inflight_tool", lambda *a, **kw: False)
_fake_clock_sleep(agent_handler, monkeypatch, lock_file) # Lock must outlive STALL_THRESHOLD (300s) or the watch completes stall-free.
_fake_clock_sleep(agent_handler, monkeypatch, lock_file, unlink_at=400.0)
result = agent_handler.watch_agent("@fakebranch", timeout_seconds=100000, poll_interval=0.01) result = agent_handler.watch_agent("@fakebranch", timeout_seconds=100000, poll_interval=0.01)
out = capsys.readouterr().out out = capsys.readouterr().out
+45 -5
View File
@@ -17,6 +17,7 @@ from __future__ import annotations
import json import json
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
from aipass.prax import logger from aipass.prax import logger
@@ -302,10 +303,47 @@ def _handle_close_pr(args: list[str]) -> dict:
return {"stdout": "", "stderr": result["message"], "exit_code": 1} return {"stdout": "", "stderr": result["message"], "exit_code": 1}
def _confirm_merge(pr_number: str, caller: str, confirmed: bool) -> dict | None:
"""Joint-decision gate: merges must never happen accidentally (DPLAN-0256).
Returns None when the merge may proceed, or a refusal/abort result dict.
Order matters: an explicit --confirm always passes; otherwise a real
terminal gets an interactive y/N prompt; headless callers are refused.
"""
if confirmed:
json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "--confirm"})
return None
if sys.stdin.isatty():
answer = input(f"Merge PR #{pr_number}? Merges are a joint decision. [y/N] ")
if answer.strip().lower() in ("y", "yes"):
json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "tty-yes"})
return None
json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "tty-abort"})
return {"stdout": "", "stderr": f"Merge of PR #{pr_number} aborted at prompt.", "exit_code": 1}
json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "headless-refused"})
logger.info("merge gate: refused headless merge of PR #%s by %s (no --confirm)", pr_number, caller)
return {
"stdout": "",
"stderr": (
f"Merge of PR #{pr_number} requires explicit confirmation — merges are a joint decision.\n"
f"Re-run once agreed: drone @git merge {pr_number} --confirm"
),
"exit_code": 1,
}
def _handle_merge(args: list[str], caller: str) -> dict: def _handle_merge(args: list[str], caller: str) -> dict:
"""Handle the merge subcommand (owner-tier, auth pre-checked).""" """Handle the merge subcommand (owner-tier, auth pre-checked)."""
if not args: confirmed = "--confirm" in args
return {"stdout": "", "stderr": "Usage: drone @git merge <PR#>", "exit_code": 1} pr_args = [a for a in args if not a.startswith("--")]
if not pr_args:
return {"stdout": "", "stderr": "Usage: drone @git merge <PR#> [--confirm]", "exit_code": 1}
refusal = _confirm_merge(pr_args[0], caller, confirmed)
if refusal is not None:
return refusal
try: try:
from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr
@@ -313,7 +351,7 @@ def _handle_merge(args: list[str], caller: str) -> dict:
logger.error("Failed to import devpulse_ops merge plugin: %s", exc) logger.error("Failed to import devpulse_ops merge plugin: %s", exc)
return {"stdout": "", "stderr": f"devpulse_ops plugin not available: {exc}", "exit_code": 1} return {"stdout": "", "stderr": f"devpulse_ops plugin not available: {exc}", "exit_code": 1}
result = merge_pr(args[0], caller) result = merge_pr(pr_args[0], caller)
if result["success"]: if result["success"]:
return {"stdout": result["message"], "stderr": "", "exit_code": 0} return {"stdout": result["message"], "stderr": "", "exit_code": 0}
return {"stdout": "", "stderr": result["message"], "exit_code": 1} return {"stdout": "", "stderr": result["message"], "exit_code": 1}
@@ -637,8 +675,10 @@ def get_help(command: str | None = None) -> str:
return "git unlock --force — Force-release the PR lock [owner]\n" return "git unlock --force — Force-release the PR lock [owner]\n"
if command == "merge": if command == "merge":
return ( return (
"git merge <PR#> — Merge a PR and sync local main [owner]\n" "git merge <PR#> [--confirm] — Merge a PR and sync local main [owner]\n"
" Runs gh pr merge --merge --delete-branch, then git pull --rebase.\n" " Runs gh pr merge --merge --delete-branch, then git pull --rebase.\n"
" Joint-decision gate: terminal sessions get a y/N prompt; headless\n"
" callers must pass --confirm or the merge is refused.\n"
) )
if command == "smart-sync": if command == "smart-sync":
return "git smart-sync — Fetch origin and rebase if behind [owner]\n" return "git smart-sync — Fetch origin and rebase if behind [owner]\n"
@@ -686,7 +726,7 @@ def get_help(command: str | None = None) -> str:
" dev-pr <desc> Push dev and create PR to main\n" " dev-pr <desc> Push dev and create PR to main\n"
" delete-branch <name> Delete a remote branch\n" " delete-branch <name> Delete a remote branch\n"
" close-pr <number> Close a PR\n" " close-pr <number> Close a PR\n"
" merge <PR#> Merge a PR\n" " merge <PR#> [--confirm] Merge a PR (gated: y/N prompt or --confirm)\n"
" sync [--autostash] Sync with origin/main (FF on dev)\n" " sync [--autostash] Sync with origin/main (FF on dev)\n"
" smart-sync Fetch + rebase if behind\n" " smart-sync Fetch + rebase if behind\n"
" unlock --force Force-release the PR lock\n" " unlock --force Force-release the PR lock\n"
@@ -552,7 +552,9 @@ class TestGitModuleRouting:
proc.stdout = "ok\n" proc.stdout = "ok\n"
mock_run.return_value = proc mock_run.return_value = proc
result = handle_command("merge", ["42"]) # --confirm satisfies the joint-decision gate (DPLAN-0256); headless
# merge without it is refused before routing reaches merge_pr.
result = handle_command("merge", ["42", "--confirm"])
assert result["exit_code"] == 0 assert result["exit_code"] == 0
@patch( @patch(
+88
View File
@@ -1471,3 +1471,91 @@ class TestScopeFooter:
result = handle_command("diff", ["--all"]) result = handle_command("diff", ["--all"])
assert "showing drone scope" not in result["stdout"] assert "showing drone scope" not in result["stdout"]
# ===========================================================================
# Merge joint-decision gate (DPLAN-0256)
# ===========================================================================
_MERGE_PR = "aipass.drone.apps.plugins.devpulse_ops.merge_plugin.merge_pr"
_MERGE_OK = {
"success": True,
"pr_number": "123",
"title": "t",
"merge_commit": "abc123",
"message": "PR #123 merged: t (abc123)",
}
class TestMergeGate:
"""merge must never run without explicit confirmation."""
@patch(_AUTH, return_value="devpulse")
def test_headless_without_confirm_refused(self, _mock_auth: MagicMock) -> None:
"""Non-TTY caller without --confirm is refused before the plugin loads."""
with patch(_MERGE_PR) as mock_merge:
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
mock_stdin.isatty.return_value = False
result = handle_command("merge", ["123"])
assert result["exit_code"] == 1
assert "requires explicit confirmation" in result["stderr"]
assert "--confirm" in result["stderr"]
mock_merge.assert_not_called()
@patch(_AUTH, return_value="devpulse")
def test_confirm_flag_proceeds(self, _mock_auth: MagicMock) -> None:
"""--confirm merges without prompting, even headless."""
with patch(_MERGE_PR, return_value=dict(_MERGE_OK)) as mock_merge:
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
mock_stdin.isatty.return_value = False
result = handle_command("merge", ["123", "--confirm"])
assert result["exit_code"] == 0
mock_merge.assert_called_once_with("123", "devpulse")
@patch(_AUTH, return_value="devpulse")
def test_confirm_flag_position_agnostic(self, _mock_auth: MagicMock) -> None:
"""--confirm before the PR number still resolves the right PR."""
with patch(_MERGE_PR, return_value=dict(_MERGE_OK)) as mock_merge:
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
mock_stdin.isatty.return_value = False
result = handle_command("merge", ["--confirm", "123"])
assert result["exit_code"] == 0
mock_merge.assert_called_once_with("123", "devpulse")
@patch(_AUTH, return_value="devpulse")
def test_tty_yes_proceeds(self, _mock_auth: MagicMock) -> None:
"""Interactive terminal answering y merges."""
with patch(_MERGE_PR, return_value=dict(_MERGE_OK)) as mock_merge:
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
mock_stdin.isatty.return_value = True
with patch("builtins.input", return_value="y"):
result = handle_command("merge", ["123"])
assert result["exit_code"] == 0
mock_merge.assert_called_once_with("123", "devpulse")
@patch(_AUTH, return_value="devpulse")
def test_tty_default_aborts(self, _mock_auth: MagicMock) -> None:
"""Interactive terminal hitting enter (default N) aborts."""
with patch(_MERGE_PR) as mock_merge:
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
mock_stdin.isatty.return_value = True
with patch("builtins.input", return_value=""):
result = handle_command("merge", ["123"])
assert result["exit_code"] == 1
assert "aborted" in result["stderr"]
mock_merge.assert_not_called()
@patch(_AUTH, return_value="devpulse")
def test_confirm_without_pr_number_is_usage_error(self, _mock_auth: MagicMock) -> None:
"""--confirm alone (no PR number) is a usage error, not a merge."""
with patch(_MERGE_PR) as mock_merge:
result = handle_command("merge", ["--confirm"])
assert result["exit_code"] == 1
assert "Usage" in result["stderr"]
mock_merge.assert_not_called()
+21 -17
View File
@@ -184,11 +184,15 @@ def get_closed_plans() -> List[Dict[str, Any]]:
def is_template_content(content: str) -> bool: def is_template_content(content: str) -> bool:
"""Check if plan content is still unedited template (v4.0) """Check if plan content is still unedited template (v5.0)
Uses bracket placeholders only (not section headers) to detect untouched Priority order:
templates. Also checks for user-added content in key sections — if any 1. User-content signals (checked checkboxes) — strongest evidence of
real work was added, the plan is NOT a template even if placeholders remain. real work, overrides everything.
2. Real content in Notes section — overrides bracket markers.
3. Non-boilerplate Execution Log entries — user-written entries
override bracket markers; template boilerplate is ignored.
4. Bracket-marker check — 3+ template placeholders = template.
Args: Args:
content: Plan file content content: Plan file content
@@ -196,37 +200,38 @@ def is_template_content(content: str) -> bool:
Returns: Returns:
True if plan is essentially an untouched template True if plan is essentially an untouched template
""" """
# User content indicators — if ANY of these are found, plan has real work import re
# --- 1. User content signals override everything ---
user_content_signals = [ user_content_signals = [
"- [x] Agent deployed", # Checked execution log item "- [x] Agent deployed",
"- [x] Agent completed", # Checked execution log item "- [x] Agent completed",
"- [x] Seedgo checklist", # Checked completion item "- [x] Seedgo checklist",
"- [x] All goals achieved", # Checked completion item "- [x] All goals achieved",
] ]
for signal in user_content_signals: for signal in user_content_signals:
if signal in content: if signal in content:
return False return False
# Check Notes section for user content (not just the placeholder) # --- 2. Real Notes content → not template ---
import re
notes_match = re.search(r"## Notes\s*\n(.*?)(?=\n---|\n## |\Z)", content, re.DOTALL) notes_match = re.search(r"## Notes\s*\n(.*?)(?=\n---|\n## |\Z)", content, re.DOTALL)
if notes_match: if notes_match:
notes_content = notes_match.group(1).strip() notes_content = notes_match.group(1).strip()
# If notes has content beyond the template placeholder, it's real work
if notes_content and notes_content != "[Working notes, issues encountered, decisions made]": if notes_content and notes_content != "[Working notes, issues encountered, decisions made]":
return False return False
# Check Execution Log for user-added entries beyond template # --- 3. Non-boilerplate Exec Log entries → not template ---
_exec_boilerplate = ["**Log Pattern:**", "[task]", "[outcome]", "[file]"]
exec_match = re.search(r"## Execution Log\s*\n(.*?)(?=\n---|\n## |\Z)", content, re.DOTALL) exec_match = re.search(r"## Execution Log\s*\n(.*?)(?=\n---|\n## |\Z)", content, re.DOTALL)
if exec_match: if exec_match:
exec_content = exec_match.group(1).strip() exec_content = exec_match.group(1).strip()
is_boilerplate = any(m in exec_content for m in _exec_boilerplate)
if not is_boilerplate:
lines = [line.strip() for line in exec_content.split("\n") if line.strip()] lines = [line.strip() for line in exec_content.split("\n") if line.strip()]
# Template has ~6 lines (date header + checkbox items). More = user added content.
if len(lines) > 8: if len(lines) > 8:
return False return False
# Bracket placeholders only (no section headers — those persist in real plans) # --- 4. Bracket markers: 3+ from any type = template ---
default_markers = [ default_markers = [
"[What do you want to achieve? Specific end state.]", "[What do you want to achieve? Specific end state.]",
"[How will agents tackle this? What instructions will they need?]", "[How will agents tackle this? What instructions will they need?]",
@@ -250,7 +255,6 @@ def is_template_content(content: str) -> bool:
"[Any other branches, services, or approvals needed?]", "[Any other branches, services, or approvals needed?]",
] ]
# 3+ bracket placeholders from any type = template
for markers in [default_markers, master_markers, proposal_markers]: for markers in [default_markers, master_markers, proposal_markers]:
found = sum(1 for m in markers if m in content) found = sum(1 for m in markers if m in content)
if found >= 3: if found >= 3:
@@ -17,6 +17,8 @@ Usage:
""" """
import json import json
import os
import time
from pathlib import Path from pathlib import Path
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, Any, List, Tuple, Optional from typing import Dict, Any, List, Tuple, Optional
@@ -32,6 +34,34 @@ from aipass.flow.apps.handlers.json import json_handler
MODULE_NAME = "aggregate_central" MODULE_NAME = "aggregate_central"
_LOCK_RETRIES = 10
_LOCK_BACKOFF_BASE = 0.05
def _acquire_lock(lock_path: Path) -> bool:
"""Atomically acquire a lockfile via O_CREAT|O_EXCL with retry+backoff."""
for attempt in range(_LOCK_RETRIES):
try:
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, str(os.getpid()).encode())
os.close(fd)
return True
except FileExistsError:
logger.info("[%s] Lock contention on %s, retry %d", MODULE_NAME, lock_path, attempt + 1)
time.sleep(_LOCK_BACKOFF_BASE * (2**attempt))
except OSError as exc:
logger.warning("[%s] Lock creation failed for %s: %s", MODULE_NAME, lock_path, exc)
return False
return False
def _release_lock(lock_path: Path) -> None:
"""Remove lockfile, tolerating already-removed."""
try:
lock_path.unlink(missing_ok=True)
except OSError as exc:
logger.warning("[%s] Could not release lock %s: %s", MODULE_NAME, lock_path, exc)
# ============================================= # =============================================
# HELPER FUNCTIONS # HELPER FUNCTIONS
@@ -101,13 +131,36 @@ def save_branch_registry(registry_path: Path, registry: Dict[str, Any]) -> bool:
Returns: Returns:
True on success, False on failure True on success, False on failure
""" """
lock_path = registry_path.with_suffix(".lock")
try:
registry_path.parent.mkdir(parents=True, exist_ok=True)
if not _acquire_lock(lock_path):
logger.error(
"[%s] Could not acquire lock for %s after %d retries",
MODULE_NAME,
registry_path,
_LOCK_RETRIES,
)
return False
try: try:
registry["last_updated"] = datetime.now(timezone.utc).isoformat() registry["last_updated"] = datetime.now(timezone.utc).isoformat()
with open(registry_path, "w", encoding="utf-8") as f: tmp_path = registry_path.with_suffix(".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(registry, f, indent=2, ensure_ascii=False) json.dump(registry, f, indent=2, ensure_ascii=False)
os.replace(str(tmp_path), str(registry_path))
finally:
_release_lock(lock_path)
return True return True
except Exception as e: except Exception as e:
logger.error(f"[{MODULE_NAME}] Failed to save registry {registry_path}: {e}") logger.error(
"[%s] Failed to save registry %s: %s",
MODULE_NAME,
registry_path,
e,
)
return False return False
@@ -265,10 +318,27 @@ def save_central(central_file: Path, central_dir: Path, central_data: Dict[str,
Returns: Returns:
True on success, False on failure True on success, False on failure
""" """
lock_path = central_file.with_suffix(".lock")
try: try:
central_dir.mkdir(parents=True, exist_ok=True) central_dir.mkdir(parents=True, exist_ok=True)
with open(central_file, "w", encoding="utf-8") as f:
if not _acquire_lock(lock_path):
logger.error(
"[%s] Could not acquire lock for %s after %d retries",
MODULE_NAME,
central_file,
_LOCK_RETRIES,
)
return False
try:
tmp_path = central_file.with_suffix(".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(central_data, f, indent=2, ensure_ascii=False) json.dump(central_data, f, indent=2, ensure_ascii=False)
os.replace(str(tmp_path), str(central_file))
finally:
_release_lock(lock_path)
return True return True
except Exception as e: except Exception as e:
logger.error(f"[{MODULE_NAME}] Failed to save {central_file}: {e}") logger.error(f"[{MODULE_NAME}] Failed to save {central_file}: {e}")
@@ -392,7 +462,10 @@ def aggregate_central_impl(
# Save central file # Save central file
if save_central(central_file, central_dir, central_data): if save_central(central_file, central_dir, central_data):
logger.info( logger.info(
f"[{MODULE_NAME}] SUCCESS: Aggregation complete: {len(all_active)} active, {len(recently_closed)} recently closed" "[%s] SUCCESS: Aggregation complete: %d active, %d recently closed",
MODULE_NAME,
len(all_active),
len(recently_closed),
) )
# Fire trigger event # Fire trigger event
@@ -116,6 +116,7 @@ The PR gate (verified against `.github/workflows/`):
- [ ] **Stay on `dev`. Do not check out `main`.** Local main being behind is fine — `drone @git sync` from dev covers it. - [ ] **Stay on `dev`. Do not check out `main`.** Local main being behind is fine — `drone @git sync` from dev covers it.
- [ ] Never rebase, never reset, never checkout main. - [ ] Never rebase, never reset, never checkout main.
- [ ] Dependabot / other PRs targeting main: they go green once main has the fix + bots rebase — check after the push - [ ] Dependabot / other PRs targeting main: they go green once main has the fix + bots rebase — check after the push
- [ ] **Site drift check (S323):** does this merge change install commands, onboarding flow, agent count, or the platform/CLI story? If YES → note it here and flag that aipass.ai must be updated same-day (devpulse handles the site edit; the site is a projection of the README, never its own source of facts).
## 7. Release tag ## 7. Release tag
+32 -12
View File
@@ -9,14 +9,14 @@ HOOKS -- hook infrastructure owner. Single engine dispatches all hooks across pl
## What I Do ## What I Do
- Own the hook engine -- receives events from platform bridges, routes to handlers, logs everything - Own the hook engine -- receives events from platform bridges, routes to handlers, logs everything
- Maintain 14 native handlers across 4 categories (prompt, security, lifecycle, notification) - Maintain 27 native handlers across 4 categories (prompt, security, lifecycle, notification)
- Bridge platforms -- thin normalization layer per provider (Claude today, Codex planned) - Bridge platforms -- thin normalization layer per provider (Claude today, Codex planned)
- Per-project config -- `.aipass/hooks.json` controls what fires per project - Per-project config -- `.aipass/hooks.json` controls what fires per project
- Log everything -- prax integration + JSONL diagnostics for every hook execution - Log everything -- prax integration + JSONL diagnostics for every hook execution
## What I Don't Do ## What I Don't Do
- Touch provider settings directly -- setup.sh/doctor handles platform config installation - Touch `~/.claude/settings.json` -- personal file, doctor/init syncs it. The manifest (`.claude/provider_manifest.json`) IS mine to maintain
- Manage other branches -- I'm a builder, not an orchestrator - Manage other branches -- I'm a builder, not an orchestrator
- Own handler business logic -- handlers are self-contained, engine just dispatches - Own handler business logic -- handlers are self-contained, engine just dispatches
@@ -40,45 +40,59 @@ apps/
handlers/ handlers/
bridges/ bridges/
claude.py # Claude Code bridge (provider settings entry point) claude.py # Claude Code bridge (provider settings entry point)
prompt/ # Prompt injection hooks codex.py # Codex bridge (planned)
prompt/ # Prompt injection hooks (UserPromptSubmit)
branch_loader.py # Injects aipass_local_prompt.md branch_loader.py # Injects aipass_local_prompt.md
tier0_kernel.py # Injects tier0 kernel prompt (every turn) tier0_kernel.py # Injects tier0 kernel prompt (every turn)
navmap.py # Injects tier1 navmap prompt (periodic) navmap.py # Injects tier1 navmap prompt (periodic)
identity.py # Injects passport identity block identity.py # Injects passport identity block
compass_recall.py # Governance recall injection
feedback_pulse.py # 10-turn cadence feedback nudge (disabled default)
context_gauge.py # Live transcript-fill nudge toward /prep
temporal.py # Weekday/date/time/tz/part-of-day, every turn
persistent_alert.py # Advisory banners for .aipass/alerts.json
security/ # Enforcement hooks security/ # Enforcement hooks
presence_gate.py # Session presence gate (UserPromptSubmit + Stop release)
edit_gate.py # Blocks edits while type errors exist edit_gate.py # Blocks edits while type errors exist
git_gate.py # Enforces git access tiers git_gate.py # Enforces git access tiers
rm_gate.py # Guards destructive rm commands
registry_gate.py # Guards registry-modifying commands
subagent_gate.py # Blocks sub-agent stop until clean subagent_gate.py # Blocks sub-agent stop until clean
lifecycle/ # Session management hooks lifecycle/ # Session management hooks
auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile) auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile)
auto_watchdog.py # Watchdog arming after dispatch auto_watchdog.py # Watchdog arming after dispatch
auto_process.py # Scheduled inbox/task processing
compact.py # Pre-compact memory archival compact.py # Pre-compact memory archival
rollover.py # Pre-compact memory rollover rollover.py # Pre-compact memory rollover
pre_compact_prep.py # Pre-compact snapshot stamp (context/dispatch/plans)
session_start.py # SessionStart cadence reset
notification/ # Alert hooks notification/ # Alert hooks
announce.py # Inbox banner on prompt announce.py # Inbox banner on prompt
email.py # Email notification email.py # Email notification
stop_sound.py # Sound on session stop stop_sound.py # Sound on session stop
tool_sound.py # Sound on tool use tool_sound.py # Sound on tool use
telegram_response.py # Telegram reply delivery on Stop
config/ config/
loader.py # hooks.json discovery + validation loader.py # hooks.json discovery + validation, config-independent trust checks
diagnostics.py # Diagnostics config trust_registry.py # Trusted-project registry (enroll/revoke/hash checks)
diagnostics.py # JSONL diagnostics config
logs/ logs/
engine.jsonl # JSONL diagnostics (every hook execution) engine.jsonl # JSONL diagnostics (every hook execution)
tests/ # 15 test files, 244 tests tests/ # 43 test files, 1249 tests
``` ```
## Handler Categories ## Handler Categories
| Category | Count | Handlers | | Category | Count | Handlers |
|----------|-------|----------| |----------|-------|----------|
| prompt | 4 | branch_loader, tier0_kernel, navmap, identity | | prompt | 9 | branch_loader, tier0_kernel, navmap, identity, compass_recall, feedback_pulse, context_gauge, temporal, persistent_alert |
| security | 3 | edit_gate, git_gate, subagent_gate | | security | 6 | presence_gate, edit_gate, git_gate, rm_gate, registry_gate, subagent_gate |
| lifecycle | 4 | auto_fix, auto_watchdog, compact, rollover | | lifecycle | 7 | auto_fix, auto_watchdog, auto_process, compact, rollover, pre_compact_prep, session_start |
| notification | 4 | announce, email, stop_sound, tool_sound | | notification | 5 | announce, email, stop_sound, tool_sound, telegram_response |
## How It Works ## How It Works
1. Provider settings point ONE bridge entry per event type (e.g., `claude.py UserPromptSubmit`) 1. Provider settings invoke the bridge two ways: `claude.py EventType` (all enabled handlers -- tool events) or `claude.py EventType:handler_name` (one handler per entry -- UserPromptSubmit, PreCompact)
2. Bridge calls `engine.dispatch(event_type, stdin_data, config)` 2. Bridge calls `engine.dispatch(event_type, stdin_data, config)`
3. Engine reads `.aipass/hooks.json` (walks up from CWD) 3. Engine reads `.aipass/hooks.json` (walks up from CWD)
4. Engine runs matching hooks sequentially, logs each to JSONL 4. Engine runs matching hooks sequentially, logs each to JSONL
@@ -86,6 +100,12 @@ tests/ # 15 test files, 244 tests
6. Exit code 2 without JSON = crash (log error, continue to next hook) 6. Exit code 2 without JSON = crash (log error, continue to next hook)
7. All hook stdout concatenated and returned to platform 7. All hook stdout concatenated and returned to platform
## New handler? Check the provider wire
hooks.json alone is not live: UserPromptSubmit + PreCompact are invoked per-handler (`claude.py Event:name`) -- handlers on those events ALSO need a command entry in `.claude/provider_manifest.json` (PreCompact: manual + auto pair). Verify with firing evidence in engine.jsonl, not just the suite.
EVERY reply that adds/renames/moves a handler MUST state either "provider settings update needed: <exact entries>" or "no provider wire needed" -- never silent. Devpulse + Patrick apply live-settings changes; flag it every time, even if the manifest is already updated.
## Integration ## Integration
- **Depends on:** @prax for logging (system_logger for prax monitor visibility) - **Depends on:** @prax for logging (system_logger for prax monitor visibility)
@@ -105,4 +125,4 @@ tests/ # 15 test files, 244 tests
- Exit code 2 has dual meaning: intentional block (with JSON) vs crash (without JSON). Engine distinguishes by checking stdout. - Exit code 2 has dual meaning: intentional block (with JSON) vs crash (without JSON). Engine distinguishes by checking stdout.
- JSONL log lives at `logs/engine.jsonl` -- not in prax. Prax gets a copy via system_logger, but JSONL is the source of truth for hook diagnostics. - JSONL log lives at `logs/engine.jsonl` -- not in prax. Prax gets a copy via system_logger, but JSONL is the source of truth for hook diagnostics.
- Bridge must be the ONLY entry in provider settings per event type. Multiple entries per event = platform calls them all independently, bypassing engine sequencing. - Provider settings carry multiple named bridge entries per event for UserPromptSubmit and PreCompact -- deliberate (per-handler output + timeout). New handlers on those events need their own provider entry.
+110 -78
View File
@@ -4,7 +4,7 @@
"created": "2026-05-18", "created": "2026-05-18",
"updated": "2026-05-28", "updated": "2026-05-28",
"description": "Standards bypass configuration for this branch", "description": "Standards bypass configuration for this branch",
"audit_context": "DPLAN-0191: Full ownership hardening. All handler wiring verified against .aipass/hooks.json + engine.jsonl firing evidence. Dynamic dispatch via engine._run_handler (importlib.import_module + getattr) means handlers are never statically imported \u2014 seedgo's dead_code/unused_function checks are false positives for this architecture." "audit_context": "DPLAN-0191: Full ownership hardening. All handler wiring verified against .aipass/hooks.json + engine.jsonl firing evidence. Dynamic dispatch via engine._run_handler (importlib.import_module + getattr) means handlers are never statically imported — seedgo's dead_code/unused_function checks are false positives for this architecture."
}, },
"bypass": [ "bypass": [
{ {
@@ -14,17 +14,17 @@
{ {
"file": "apps/handlers/bridges/claude.py", "file": "apps/handlers/bridges/claude.py",
"standard": "dead_code", "standard": "dead_code",
"reason": "Bridge called externally by provider settings subprocess \u2014 no internal import. Verified wired in ~/.claude/settings.json hook entries." "reason": "Bridge called externally by provider settings subprocess — no internal import. Verified wired in ~/.claude/settings.json hook entries."
}, },
{ {
"file": "apps/handlers/bridges/claude.py", "file": "apps/handlers/bridges/claude.py",
"standard": "unused_function", "standard": "unused_function",
"reason": "main() called as subprocess entry point from provider settings \u2014 never statically imported." "reason": "main() called as subprocess entry point from provider settings — never statically imported."
}, },
{ {
"file": "apps/handlers/bridges/claude.py", "file": "apps/handlers/bridges/claude.py",
"standard": "handlers", "standard": "handlers",
"reason": "Bridges import engine module by design \u2014 that is their entire purpose." "reason": "Bridges import engine module by design — that is their entire purpose."
}, },
{ {
"file": "apps/handlers/bridges/claude.py", "file": "apps/handlers/bridges/claude.py",
@@ -39,27 +39,27 @@
{ {
"file": "apps/handlers/bridges/claude.py", "file": "apps/handlers/bridges/claude.py",
"standard": "imports", "standard": "imports",
"reason": "Bridge imports engine module by design \u2014 sole purpose." "reason": "Bridge imports engine module by design — sole purpose."
}, },
{ {
"file": "apps/handlers/bridges/codex.py", "file": "apps/handlers/bridges/codex.py",
"standard": "dead_code", "standard": "dead_code",
"reason": "Bridge called externally by Codex hook settings subprocess \u2014 no internal import. Wired in .codex/hooks.json." "reason": "Bridge called externally by Codex hook settings subprocess — no internal import. Wired in .codex/hooks.json."
}, },
{ {
"file": "apps/handlers/bridges/codex.py", "file": "apps/handlers/bridges/codex.py",
"standard": "unused_function", "standard": "unused_function",
"reason": "main() called as subprocess entry point from Codex hook settings \u2014 never statically imported." "reason": "main() called as subprocess entry point from Codex hook settings — never statically imported."
}, },
{ {
"file": "apps/handlers/bridges/codex.py", "file": "apps/handlers/bridges/codex.py",
"standard": "handlers", "standard": "handlers",
"reason": "Bridges import engine module by design \u2014 that is their entire purpose." "reason": "Bridges import engine module by design — that is their entire purpose."
}, },
{ {
"file": "apps/handlers/bridges/codex.py", "file": "apps/handlers/bridges/codex.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Thin entry point using stdlib json for Codex protocol envelope \u2014 no JSON file ops needing json_handler." "reason": "Thin entry point using stdlib json for Codex protocol envelope — no JSON file ops needing json_handler."
}, },
{ {
"file": "apps/handlers/bridges/codex.py", "file": "apps/handlers/bridges/codex.py",
@@ -69,12 +69,12 @@
{ {
"file": "apps/handlers/bridges/codex.py", "file": "apps/handlers/bridges/codex.py",
"standard": "imports", "standard": "imports",
"reason": "Bridge imports engine module by design \u2014 sole purpose." "reason": "Bridge imports engine module by design — sole purpose."
}, },
{ {
"file": "apps/handlers/prompt/identity.py", "file": "apps/handlers/prompt/identity.py",
"standard": "dead_code", "standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.identity.handle' \u2014 not statically imported by design. Verified wired in UserPromptSubmit.identity_injector + fires in engine.jsonl." "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.identity.handle' — not statically imported by design. Verified wired in UserPromptSubmit.identity_injector + fires in engine.jsonl."
}, },
{ {
"file": "apps/handlers/prompt/identity.py", "file": "apps/handlers/prompt/identity.py",
@@ -84,12 +84,12 @@
{ {
"file": "apps/handlers/prompt/identity.py", "file": "apps/handlers/prompt/identity.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Uses stdlib json.loads to read passport.json \u2014 no JSON file ops needing json_handler." "reason": "Uses stdlib json.loads to read passport.json — no JSON file ops needing json_handler."
}, },
{ {
"file": "apps/handlers/prompt/branch_loader.py", "file": "apps/handlers/prompt/branch_loader.py",
"standard": "dead_code", "standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.branch_loader.handle' \u2014 not statically imported by design. Verified wired in UserPromptSubmit.branch_prompt + fires in engine.jsonl." "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.branch_loader.handle' — not statically imported by design. Verified wired in UserPromptSubmit.branch_prompt + fires in engine.jsonl."
}, },
{ {
"file": "apps/handlers/prompt/branch_loader.py", "file": "apps/handlers/prompt/branch_loader.py",
@@ -99,7 +99,7 @@
{ {
"file": "apps/handlers/prompt/branch_loader.py", "file": "apps/handlers/prompt/branch_loader.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "No JSON operations \u2014 reads markdown files and outputs text." "reason": "No JSON operations — reads markdown files and outputs text."
}, },
{ {
"file": "apps/handlers/prompt/tier0_kernel.py", "file": "apps/handlers/prompt/tier0_kernel.py",
@@ -131,10 +131,15 @@
"standard": "json_structure", "standard": "json_structure",
"reason": "No JSON operations - reads markdown file (.aipass/tier1_navmap.md) and outputs text." "reason": "No JSON operations - reads markdown file (.aipass/tier1_navmap.md) and outputs text."
}, },
{
"file": "apps/handlers/prompt/temporal.py",
"standard": "json_structure",
"reason": "No JSON operations - reads the system clock and outputs text. Wired in UserPromptSubmit.temporal (FPLAN-0348)."
},
{ {
"file": "apps/handlers/security/edit_gate.py", "file": "apps/handlers/security/edit_gate.py",
"standard": "dead_code", "standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.edit_gate.handle' \u2014 not statically imported by design. Verified wired in PreToolUse.pre_edit_gate + fires in engine.jsonl." "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.edit_gate.handle' — not statically imported by design. Verified wired in PreToolUse.pre_edit_gate + fires in engine.jsonl."
}, },
{ {
"file": "apps/handlers/security/edit_gate.py", "file": "apps/handlers/security/edit_gate.py",
@@ -144,12 +149,12 @@
{ {
"file": "apps/handlers/security/edit_gate.py", "file": "apps/handlers/security/edit_gate.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler." "reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
}, },
{ {
"file": "apps/handlers/security/git_gate.py", "file": "apps/handlers/security/git_gate.py",
"standard": "dead_code", "standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.git_gate.handle' \u2014 not statically imported by design. Verified wired in PreToolUse.git_gate + fires in engine.jsonl." "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.git_gate.handle' — not statically imported by design. Verified wired in PreToolUse.git_gate + fires in engine.jsonl."
}, },
{ {
"file": "apps/handlers/security/git_gate.py", "file": "apps/handlers/security/git_gate.py",
@@ -159,12 +164,12 @@
{ {
"file": "apps/handlers/security/git_gate.py", "file": "apps/handlers/security/git_gate.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler." "reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
}, },
{ {
"file": "apps/handlers/security/rm_gate.py", "file": "apps/handlers/security/rm_gate.py",
"standard": "dead_code", "standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.rm_gate.handle' \u2014 not statically imported by design. Wired in PreToolUse.rm_gate." "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.rm_gate.handle' — not statically imported by design. Wired in PreToolUse.rm_gate."
}, },
{ {
"file": "apps/handlers/security/rm_gate.py", "file": "apps/handlers/security/rm_gate.py",
@@ -174,12 +179,12 @@
{ {
"file": "apps/handlers/security/rm_gate.py", "file": "apps/handlers/security/rm_gate.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler." "reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
}, },
{ {
"file": "apps/handlers/security/subagent_gate.py", "file": "apps/handlers/security/subagent_gate.py",
"standard": "dead_code", "standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.subagent_gate.handle' \u2014 not statically imported by design. Verified wired in SubagentStop.subagent_stop_gate + fires in engine.jsonl." "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.subagent_gate.handle' — not statically imported by design. Verified wired in SubagentStop.subagent_stop_gate + fires in engine.jsonl."
}, },
{ {
"file": "apps/handlers/security/subagent_gate.py", "file": "apps/handlers/security/subagent_gate.py",
@@ -189,12 +194,12 @@
{ {
"file": "apps/handlers/security/subagent_gate.py", "file": "apps/handlers/security/subagent_gate.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler." "reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
}, },
{ {
"file": "apps/handlers/security/subagent_gate.py", "file": "apps/handlers/security/subagent_gate.py",
"standard": "open_encoding", "standard": "open_encoding",
"reason": "NamedTemporaryFile creates binary wav for Piper TTS \u2014 encoding not applicable to binary audio." "reason": "NamedTemporaryFile creates binary wav for Piper TTS — encoding not applicable to binary audio."
}, },
{ {
"file": "apps/handlers/security/registry_gate.py", "file": "apps/handlers/security/registry_gate.py",
@@ -244,7 +249,7 @@
{ {
"file": "apps/handlers/lifecycle/auto_fix.py", "file": "apps/handlers/lifecycle/auto_fix.py",
"standard": "dead_code", "standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_fix.handle' \u2014 not statically imported by design. Verified wired in PostToolUse.auto_fix_diagnostics + fires in engine.jsonl." "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_fix.handle' — not statically imported by design. Verified wired in PostToolUse.auto_fix_diagnostics + fires in engine.jsonl."
}, },
{ {
"file": "apps/handlers/lifecycle/auto_fix.py", "file": "apps/handlers/lifecycle/auto_fix.py",
@@ -254,12 +259,12 @@
{ {
"file": "apps/handlers/lifecycle/auto_fix.py", "file": "apps/handlers/lifecycle/auto_fix.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Diagnostics handler uses stdlib json for hook protocol responses and state file \u2014 no JSON file ops needing json_handler." "reason": "Diagnostics handler uses stdlib json for hook protocol responses and state file — no JSON file ops needing json_handler."
}, },
{ {
"file": "apps/handlers/lifecycle/auto_watchdog.py", "file": "apps/handlers/lifecycle/auto_watchdog.py",
"standard": "dead_code", "standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_watchdog.handle' \u2014 not statically imported by design. Verified wired in PostToolUse.auto_watchdog + fires in engine.jsonl." "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_watchdog.handle' — not statically imported by design. Verified wired in PostToolUse.auto_watchdog + fires in engine.jsonl."
}, },
{ {
"file": "apps/handlers/lifecycle/auto_watchdog.py", "file": "apps/handlers/lifecycle/auto_watchdog.py",
@@ -269,42 +274,42 @@
{ {
"file": "apps/handlers/lifecycle/auto_watchdog.py", "file": "apps/handlers/lifecycle/auto_watchdog.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Uses stdlib json.dumps to produce additionalContext output \u2014 no JSON file ops needing json_handler." "reason": "Uses stdlib json.dumps to produce additionalContext output — no JSON file ops needing json_handler."
}, },
{ {
"file": "apps/handlers/lifecycle/compact.py", "file": "apps/handlers/lifecycle/compact.py",
"standard": "dead_code", "standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.compact.handle' \u2014 not statically imported by design. Verified wired in PreCompact.pre_compact (PreCompact events are rare \u2014 fires only during context compaction)." "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.compact.handle' — not statically imported by design. Verified wired in PreCompact.pre_compact (PreCompact events are rare — fires only during context compaction)."
}, },
{ {
"file": "apps/handlers/lifecycle/compact.py", "file": "apps/handlers/lifecycle/compact.py",
"standard": "unused_function", "standard": "unused_function",
"reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact \u2014 fires during compaction events." "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact — fires during compaction events."
}, },
{ {
"file": "apps/handlers/lifecycle/compact.py", "file": "apps/handlers/lifecycle/compact.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Uses stdlib json.loads for local.json reading \u2014 no JSON file ops needing json_handler." "reason": "Uses stdlib json.loads for local.json reading — no JSON file ops needing json_handler."
}, },
{ {
"file": "apps/handlers/lifecycle/rollover.py", "file": "apps/handlers/lifecycle/rollover.py",
"standard": "dead_code", "standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.rollover.handle' \u2014 not statically imported by design. Verified wired in PreCompact.pre_compact_rollover (PreCompact events are rare \u2014 fires only during context compaction)." "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.rollover.handle' — not statically imported by design. Verified wired in PreCompact.pre_compact_rollover (PreCompact events are rare — fires only during context compaction)."
}, },
{ {
"file": "apps/handlers/lifecycle/rollover.py", "file": "apps/handlers/lifecycle/rollover.py",
"standard": "unused_function", "standard": "unused_function",
"reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact_rollover \u2014 fires during compaction events." "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact_rollover — fires during compaction events."
}, },
{ {
"file": "apps/handlers/lifecycle/rollover.py", "file": "apps/handlers/lifecycle/rollover.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Uses stdlib json.loads for registry and memory file checks \u2014 no JSON file ops needing json_handler." "reason": "Uses stdlib json.loads for registry and memory file checks — no JSON file ops needing json_handler."
}, },
{ {
"file": "apps/handlers/lifecycle/auto_process.py", "file": "apps/handlers/lifecycle/auto_process.py",
"standard": "dead_code", "standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_process.handle' \u2014 not statically imported by design. Wired in UserPromptSubmit.auto_process + PreCompact.auto_process." "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_process.handle' — not statically imported by design. Wired in UserPromptSubmit.auto_process + PreCompact.auto_process."
}, },
{ {
"file": "apps/handlers/lifecycle/auto_process.py", "file": "apps/handlers/lifecycle/auto_process.py",
@@ -314,12 +319,12 @@
{ {
"file": "apps/handlers/lifecycle/auto_process.py", "file": "apps/handlers/lifecycle/auto_process.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Delegates to @memory's auto_process() via importlib \u2014 no direct JSON file ops needing json_handler." "reason": "Delegates to @memory's auto_process() via importlib — no direct JSON file ops needing json_handler."
}, },
{ {
"file": "apps/handlers/lifecycle/session_start.py", "file": "apps/handlers/lifecycle/session_start.py",
"standard": "dead_code", "standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.session_start.handle' \u2014 not statically imported by design. Wired in SessionStart.cadence_reset." "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.session_start.handle' — not statically imported by design. Wired in SessionStart.cadence_reset."
}, },
{ {
"file": "apps/handlers/lifecycle/session_start.py", "file": "apps/handlers/lifecycle/session_start.py",
@@ -329,12 +334,12 @@
{ {
"file": "apps/handlers/lifecycle/session_start.py", "file": "apps/handlers/lifecycle/session_start.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Delegates to cadence.reset_counter() via importlib \u2014 no direct JSON file ops needing json_handler." "reason": "Delegates to cadence.reset_counter() via importlib — no direct JSON file ops needing json_handler."
}, },
{ {
"file": "apps/modules/cadence.py", "file": "apps/modules/cadence.py",
"standard": "dead_code", "standard": "dead_code",
"reason": "Cadence module \u2014 should_fire() called from tier0_kernel.py, navmap.py, and branch_loader.py guard lines; reset_counter() called from compact.py PreCompact handler. Not statically discoverable because callers are themselves dynamically dispatched." "reason": "Cadence module — should_fire() called from tier0_kernel.py, navmap.py, and branch_loader.py guard lines; reset_counter() called from compact.py PreCompact handler. Not statically discoverable because callers are themselves dynamically dispatched."
}, },
{ {
"file": "apps/modules/cadence.py", "file": "apps/modules/cadence.py",
@@ -344,12 +349,12 @@
{ {
"file": "apps/modules/cadence.py", "file": "apps/modules/cadence.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Uses stdlib json for /tmp state file (turn counter) and hooks_json/custom_config/cadence_config.json config loading \u2014 lightweight ephemeral state, not branch data storage." "reason": "Uses stdlib json for /tmp state file (turn counter) and hooks_json/custom_config/cadence_config.json config loading — lightweight ephemeral state, not branch data storage."
}, },
{ {
"file": "apps/handlers/notification/announce.py", "file": "apps/handlers/notification/announce.py",
"standard": "dead_code", "standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.announce.handle' \u2014 not statically imported by design. Verified wired in Notification.notification_sound + fires in engine.jsonl." "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.announce.handle' — not statically imported by design. Verified wired in Notification.notification_sound + fires in engine.jsonl."
}, },
{ {
"file": "apps/handlers/notification/announce.py", "file": "apps/handlers/notification/announce.py",
@@ -359,12 +364,12 @@
{ {
"file": "apps/handlers/notification/announce.py", "file": "apps/handlers/notification/announce.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Sound handler \u2014 no JSON operations, plays WAV files." "reason": "Sound handler — no JSON operations, plays WAV files."
}, },
{ {
"file": "apps/handlers/notification/email.py", "file": "apps/handlers/notification/email.py",
"standard": "dead_code", "standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.email.handle' \u2014 not statically imported by design. Verified wired in UserPromptSubmit.email_notification + fires in engine.jsonl." "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.email.handle' — not statically imported by design. Verified wired in UserPromptSubmit.email_notification + fires in engine.jsonl."
}, },
{ {
"file": "apps/handlers/notification/email.py", "file": "apps/handlers/notification/email.py",
@@ -374,12 +379,12 @@
{ {
"file": "apps/handlers/notification/email.py", "file": "apps/handlers/notification/email.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Uses stdlib json.loads for inbox parsing \u2014 no JSON file ops needing json_handler." "reason": "Uses stdlib json.loads for inbox parsing — no JSON file ops needing json_handler."
}, },
{ {
"file": "apps/handlers/notification/stop_sound.py", "file": "apps/handlers/notification/stop_sound.py",
"standard": "dead_code", "standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.stop_sound.handle' \u2014 not statically imported by design. Verified wired in Stop.stop_sound + fires in engine.jsonl." "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.stop_sound.handle' — not statically imported by design. Verified wired in Stop.stop_sound + fires in engine.jsonl."
}, },
{ {
"file": "apps/handlers/notification/stop_sound.py", "file": "apps/handlers/notification/stop_sound.py",
@@ -389,12 +394,12 @@
{ {
"file": "apps/handlers/notification/stop_sound.py", "file": "apps/handlers/notification/stop_sound.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Sound handler \u2014 no JSON operations, plays WAV files." "reason": "Sound handler — no JSON operations, plays WAV files."
}, },
{ {
"file": "apps/handlers/notification/tool_sound.py", "file": "apps/handlers/notification/tool_sound.py",
"standard": "dead_code", "standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.tool_sound.handle' \u2014 not statically imported by design. Verified wired in PreToolUse.tool_use_sound + fires in engine.jsonl." "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.tool_sound.handle' — not statically imported by design. Verified wired in PreToolUse.tool_use_sound + fires in engine.jsonl."
}, },
{ {
"file": "apps/handlers/notification/tool_sound.py", "file": "apps/handlers/notification/tool_sound.py",
@@ -404,7 +409,7 @@
{ {
"file": "apps/handlers/notification/tool_sound.py", "file": "apps/handlers/notification/tool_sound.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Sound handler \u2014 no JSON operations, plays WAV files." "reason": "Sound handler — no JSON operations, plays WAV files."
}, },
{ {
"file": "apps/handlers/config/trust_registry.py", "file": "apps/handlers/config/trust_registry.py",
@@ -414,22 +419,22 @@
{ {
"file": "apps/handlers/config/loader.py", "file": "apps/handlers/config/loader.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Config loader does $AIPASS_HOME variable expansion before JSON parse \u2014 json_handler does not support this." "reason": "Config loader does $AIPASS_HOME variable expansion before JSON parse — json_handler does not support this."
}, },
{ {
"file": "apps/handlers/config/diagnostics.py", "file": "apps/handlers/config/diagnostics.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "JSONL append-only diagnostic log \u2014 different pattern from branch json_handler storage." "reason": "JSONL append-only diagnostic log — different pattern from branch json_handler storage."
}, },
{ {
"file": "apps/modules/engine.py", "file": "apps/modules/engine.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Engine uses JSONL diagnostic logging, not branch json_handler \u2014 different purpose." "reason": "Engine uses JSONL diagnostic logging, not branch json_handler — different purpose."
}, },
{ {
"file": "apps/modules/engine.py", "file": "apps/modules/engine.py",
"standard": "modules", "standard": "modules",
"reason": "dispatch() is the engine's core purpose \u2014 it IS the module's primary function, not a handler that belongs elsewhere. The engine exists to dispatch; moving dispatch to handlers/ would leave an empty module." "reason": "dispatch() is the engine's core purpose — it IS the module's primary function, not a handler that belongs elsewhere. The engine exists to dispatch; moving dispatch to handlers/ would leave an empty module."
}, },
{ {
"file": "apps/modules/hook_test.py", "file": "apps/modules/hook_test.py",
@@ -439,32 +444,32 @@
{ {
"file": "apps/modules/hooksound.py", "file": "apps/modules/hooksound.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Sound mute toggle \u2014 touches /tmp/aipass-hooks-muted flag file only, no JSON operations or json_handler storage." "reason": "Sound mute toggle — touches /tmp/aipass-hooks-muted flag file only, no JSON operations or json_handler storage."
}, },
{ {
"file": "apps/modules/hooksound.py", "file": "apps/modules/hooksound.py",
"standard": "trigger", "standard": "trigger",
"reason": "MUTE_FLAG.unlink() removes a /tmp mute flag file for sound toggle \u2014 not a tracked resource or production data deletion. Deliberate user action via 'drone @hooks hooksound on'." "reason": "MUTE_FLAG.unlink() removes a /tmp mute flag file for sound toggle — not a tracked resource or production data deletion. Deliberate user action via 'drone @hooks hooksound on'."
}, },
{ {
"file": "apps/modules/feedback.py", "file": "apps/modules/feedback.py",
"standard": "trigger", "standard": "trigger",
"reason": "sentinel.unlink() removes a .aipass/feedback_off toggle file \u2014 not a tracked resource. Deliberate user action via 'drone @hooks feedback on'." "reason": "sentinel.unlink() removes a .aipass/feedback_off toggle file — not a tracked resource. Deliberate user action via 'drone @hooks feedback on'."
}, },
{ {
"file": "apps/modules/hookstatus.py", "file": "apps/modules/hookstatus.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Read-only config viewer \u2014 delegates JSON loading to config/loader.py, no direct JSON file ops." "reason": "Read-only config viewer — delegates JSON loading to config/loader.py, no direct JSON file ops."
}, },
{ {
"file": "apps/modules/wire_verify.py", "file": "apps/modules/wire_verify.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Reads ~/.claude/settings.json (external provider settings) with stdlib json \u2014 not branch data storage needing json_handler." "reason": "Reads ~/.claude/settings.json (external provider settings) with stdlib json — not branch data storage needing json_handler."
}, },
{ {
"file": "apps/modules/cadence.py", "file": "apps/modules/cadence.py",
"standard": "modules", "standard": "modules",
"reason": "Cadence module reads /tmp state file and hooks_json/custom_config/cadence_config.json with stdlib json \u2014 lightweight ephemeral state (turn counter) + tunable config. json_handler is for persistent branch data, not /tmp session state or config knobs." "reason": "Cadence module reads /tmp state file and hooks_json/custom_config/cadence_config.json with stdlib json — lightweight ephemeral state (turn counter) + tunable config. json_handler is for persistent branch data, not /tmp session state or config knobs."
}, },
{ {
"file": "apps/hooks.py", "file": "apps/hooks.py",
@@ -474,17 +479,17 @@
{ {
"file": "apps/sound.py", "file": "apps/sound.py",
"standard": "unused_function", "standard": "unused_function",
"reason": "play() called by handler files (stop_sound.py, announce.py) that are dynamically dispatched via importlib \u2014 static analysis cannot trace the call chain from hooks.json \u2192 engine \u2192 handler \u2192 sound.play()." "reason": "play() called by handler files (stop_sound.py, announce.py) that are dynamically dispatched via importlib — static analysis cannot trace the call chain from hooks.json → engine → handler → sound.play()."
}, },
{ {
"file": "apps/sound.py", "file": "apps/sound.py",
"standard": "open_encoding", "standard": "open_encoding",
"reason": "NamedTemporaryFile creates binary WAV for Piper TTS \u2014 encoding not applicable to binary audio." "reason": "NamedTemporaryFile creates binary WAV for Piper TTS — encoding not applicable to binary audio."
}, },
{ {
"file": "apps/sound.py", "file": "apps/sound.py",
"standard": "cli_flags", "standard": "cli_flags",
"reason": "Shared utility module imported by handlers \u2014 not a CLI entry point. Has print_introspection() for drone discovery but no handle_command() or user-facing CLI." "reason": "Shared utility module imported by handlers — not a CLI entry point. Has print_introspection() for drone discovery but no handle_command() or user-facing CLI."
}, },
{ {
"standard": "json_handler", "standard": "json_handler",
@@ -492,7 +497,7 @@
}, },
{ {
"standard": "test_quality", "standard": "test_quality",
"reason": "Hooks branch does not use json_handler \u2014 has its own JSONL diagnostic logging (diagnostics.py) and stdlib json for hook protocol I/O. json_handler coverage, mock_json_handler fixture, and exception_contracts (create_default_raises, save_invalid_raises, invalid_mode_raises) are all N/A for a hook dispatch engine architecture." "reason": "Hooks branch does not use json_handler — has its own JSONL diagnostic logging (diagnostics.py) and stdlib json for hook protocol I/O. json_handler coverage, mock_json_handler fixture, and exception_contracts (create_default_raises, save_invalid_raises, invalid_mode_raises) are all N/A for a hook dispatch engine architecture."
}, },
{ {
"file": "tests/conftest.py", "file": "tests/conftest.py",
@@ -502,12 +507,12 @@
{ {
"file": "tests/conftest.py", "file": "tests/conftest.py",
"standard": "json_handler", "standard": "json_handler",
"reason": "Hooks branch does not use json_handler \u2014 has its own JSONL logging and stdlib json for hook protocol. mock_json_handler fixture is N/A." "reason": "Hooks branch does not use json_handler — has its own JSONL logging and stdlib json for hook protocol. mock_json_handler fixture is N/A."
}, },
{ {
"file": "tests/conftest.py", "file": "tests/conftest.py",
"standard": "exception_contracts", "standard": "exception_contracts",
"reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns \u2014 those contracts are N/A for a hook dispatch engine." "reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns — those contracts are N/A for a hook dispatch engine."
}, },
{ {
"file": "tests/test_engine.py", "file": "tests/test_engine.py",
@@ -532,7 +537,7 @@
{ {
"file": "tests/test_engine.py", "file": "tests/test_engine.py",
"standard": "json_handler", "standard": "json_handler",
"reason": "Hooks branch does not use json_handler \u2014 has its own JSONL logging." "reason": "Hooks branch does not use json_handler — has its own JSONL logging."
}, },
{ {
"file": "tests/test_engine.py", "file": "tests/test_engine.py",
@@ -662,12 +667,12 @@
{ {
"file": "tests/test_auto_fix.py", "file": "tests/test_auto_fix.py",
"standard": "commented_logger", "standard": "commented_logger",
"reason": "Test data contains '# logger.debug(msg)' as input to pattern checker under test \u2014 not a commented-out call." "reason": "Test data contains '# logger.debug(msg)' as input to pattern checker under test — not a commented-out call."
}, },
{ {
"file": "tests/test_auto_fix.py", "file": "tests/test_auto_fix.py",
"standard": "trigger", "standard": "trigger",
"reason": "Test cleanup .unlink() removes temporary state files \u2014 not a production file deletion." "reason": "Test cleanup .unlink() removes temporary state files — not a production file deletion."
}, },
{ {
"file": "tests/test_identity.py", "file": "tests/test_identity.py",
@@ -957,47 +962,47 @@
{ {
"file": "apps/modules/sandbox.py", "file": "apps/modules/sandbox.py",
"standard": "json_structure", "standard": "json_structure",
"reason": "Uses stdlib json.dump to write ephemeral srt config to a NamedTemporaryFile \u2014 not a tracked JSON resource needing json_handler." "reason": "Uses stdlib json.dump to write ephemeral srt config to a NamedTemporaryFile — not a tracked JSON resource needing json_handler."
}, },
{ {
"file": "apps/modules/sandbox.py", "file": "apps/modules/sandbox.py",
"standard": "trigger", "standard": "trigger",
"reason": "Path.unlink() removes an ephemeral NamedTemporaryFile (srt config) created seconds earlier in the same function \u2014 not a tracked resource or production data deletion." "reason": "Path.unlink() removes an ephemeral NamedTemporaryFile (srt config) created seconds earlier in the same function — not a tracked resource or production data deletion."
}, },
{ {
"file": "artifacts/sandbox_phase1_demo.py", "file": "artifacts/sandbox_phase1_demo.py",
"standard": "architecture", "standard": "architecture",
"reason": "Live demo artifact for FPLAN-0250 Phase 1 acceptance \u2014 not production code, lives in artifacts/ per the phase brief." "reason": "Live demo artifact for FPLAN-0250 Phase 1 acceptance — not production code, lives in artifacts/ per the phase brief."
}, },
{ {
"file": "artifacts/sandbox_phase1_demo.py", "file": "artifacts/sandbox_phase1_demo.py",
"standard": "debug_print", "standard": "debug_print",
"reason": "Demo script uses print() for human-readable acceptance test output \u2014 not a module with CLI service." "reason": "Demo script uses print() for human-readable acceptance test output — not a module with CLI service."
}, },
{ {
"file": "artifacts/sandbox_phase1_demo.py", "file": "artifacts/sandbox_phase1_demo.py",
"standard": "imports", "standard": "imports",
"reason": "sys.path insert needed to run standalone demo from artifacts/ \u2014 not a pip-installed module entry point." "reason": "sys.path insert needed to run standalone demo from artifacts/ — not a pip-installed module entry point."
}, },
{ {
"file": "artifacts/sandbox_phase1_demo.py", "file": "artifacts/sandbox_phase1_demo.py",
"standard": "documentation", "standard": "documentation",
"reason": "Demo helper function \u2014 docstrings omitted for brevity in a non-production artifact." "reason": "Demo helper function — docstrings omitted for brevity in a non-production artifact."
}, },
{ {
"file": "artifacts/sandbox_phase1_demo.py", "file": "artifacts/sandbox_phase1_demo.py",
"standard": "help_text", "standard": "help_text",
"reason": "Demo run instructions reference python3 as the invocation command \u2014 this is a standalone script, not a drone-routed module." "reason": "Demo run instructions reference python3 as the invocation command — this is a standalone script, not a drone-routed module."
}, },
{ {
"file": "tests/test_sandbox.py", "file": "tests/test_sandbox.py",
"standard": "architecture", "standard": "architecture",
"reason": "Test file lives in tests/ per hooks convention \u2014 not a module or handler." "reason": "Test file lives in tests/ per hooks convention — not a module or handler."
}, },
{ {
"file": "tests/test_sandbox.py", "file": "tests/test_sandbox.py",
"standard": "documentation", "standard": "documentation",
"reason": "Test methods use descriptive names \u2014 docstrings redundant per hooks test convention." "reason": "Test methods use descriptive names — docstrings redundant per hooks test convention."
}, },
{ {
"file": "tests/test_sandbox.py", "file": "tests/test_sandbox.py",
@@ -1007,7 +1012,7 @@
{ {
"file": "apps/modules/sandbox.py", "file": "apps/modules/sandbox.py",
"standard": "modules", "standard": "modules",
"reason": "Read-only passport.json check in _is_devpulse() to detect branch role for policy generation \u2014 not a file-write operation, not a handler-level concern. Module reads sibling passports to determine writable/RO map." "reason": "Read-only passport.json check in _is_devpulse() to detect branch role for policy generation — not a file-write operation, not a handler-level concern. Module reads sibling passports to determine writable/RO map."
}, },
{ {
"file": "apps/modules/sandbox.py", "file": "apps/modules/sandbox.py",
@@ -1248,10 +1253,37 @@
"file": "tests/test_wire_verify.py", "file": "tests/test_wire_verify.py",
"standard": "help_text", "standard": "help_text",
"reason": "Test fixture _BRIDGE_CMD contains 'python3' as part of a mock provider command string — not user-facing help text." "reason": "Test fixture _BRIDGE_CMD contains 'python3' as part of a mock provider command string — not user-facing help text."
},
{
"file": "apps/modules/context_window.py",
"standard": "json_structure",
"reason": "Reads external state (session transcript JSONL via tail, branch .claude/settings.local.json) with stdlib json — not branch data storage needing json_handler. The module IS the transcript/window reader service (DPLAN-0253), same pattern as cc_sessions.py."
},
{
"file": "apps/handlers/lifecycle/pre_compact_prep.py",
"standard": "json_structure",
"reason": "Uses stdlib json for .trinity/local.json read+stamp (own branch's memory file, DPLAN-0253) plus AIPASS_REGISTRY.json/inbox.json/memory.config.json reads (external system + other branches' state) — not this branch's own hooks_json/ triplet storage. Matches compact.py precedent (same local.json access)."
},
{
"file": "apps/handlers/prompt/context_gauge.py",
"standard": "json_structure",
"reason": "Delegates to context_window module for transcript/settings reads — no direct JSON file ops needing json_handler. Same pattern as session_start.py delegating to cadence."
},
{
"file": "apps/handlers/lifecycle/pre_compact_prep.py",
"standard": "handlers",
"reason": "Line 75: lazy cross-branch import of aipass.flow.apps.handlers.plan.get_open_plans — needed for the open-plan count in the AUTO-COMPACT SNAPSHOT. No module-layer equivalent exists to switch to: flow's own modules (close_plan.py) import this same handler directly rather than re-exporting it, and list_plans.py's module is a CLI display orchestrator, not a programmatic data source. Read-only cross-branch primitive read, same authorized pattern as ai_mail's dispatch_monitor.py broker-client import. Call is wrapped in try/except and degrades to None on any failure — never corrupts the snapshot."
},
{
"file": "apps/handlers/prompt/compass_recall.py",
"standard": "handlers",
"reason": "Lines 49, 75, 84: lazy cross-branch imports of aipass.memory.apps.modules.governance and aipass.devpulse.apps.modules.compass. Surfacing other branches' rated decisions at prompt time IS this handler's job — not orchestration bleed but the designed integration point, same authorized cross-branch primitive pattern as ai_mail's dispatch_monitor.py. Whole handler wrapped in try/except and never blocks the prompt on failure."
} }
], ],
"notes": { "notes": {
"removed_2026-05-19": "Stripped 4 illegitimate bypasses \u2014 hooks.py/cli, hooks.py/cli_flags, engine.py/modules, engine.py/introspection. Code fixed to meet standards instead.", "removed_2026-05-19": "Stripped 4 illegitimate bypasses — hooks.py/cli, hooks.py/cli_flags, engine.py/modules, engine.py/introspection. Code fixed to meet standards instead.",
"dplan_0191_2026-05-28": "Added dead_code + unused_function bypasses for all 15 dynamically-dispatched handlers after verifying each is wired in .aipass/hooks.json AND fires in engine.jsonl. Root cause: engine._run_handler (engine.py:60-66) uses importlib.import_module + getattr on hooks.json handler strings \u2014 handlers are never statically imported. Follow-up for @seedgo: teach dead_code/unused_function about dynamic importlib dispatch patterns." "dplan_0191_2026-05-28": "Added dead_code + unused_function bypasses for all 15 dynamically-dispatched handlers after verifying each is wired in .aipass/hooks.json AND fires in engine.jsonl. Root cause: engine._run_handler (engine.py:60-66) uses importlib.import_module + getattr on hooks.json handler strings — handlers are never statically imported. Follow-up for @seedgo: teach dead_code/unused_function about dynamic importlib dispatch patterns.",
"dplan_0253_2026-07-20": "Added json_structure bypasses for context_window.py, pre_compact_prep.py, context_gauge.py — same external-state-read profile as existing compact.py/cc_sessions.py/session_start.py bypasses.",
"dplan_0253_audit_catch_2026-07-21": "Seedgo's handlers checker went AST-based and caught two lazy cross-branch imports missed by the old text-matching version: pre_compact_prep.py (flow.get_open_plans) and compass_recall.py (memory.governance, devpulse.compass). Both bypassed rather than restructured — no clean module-layer alternative exists for the former, and the latter's whole purpose is surfacing cross-branch data at prompt time. Same authorized-primitive precedent as ai_mail dispatch_monitor.py."
} }
} }
+7 -3
View File
@@ -66,6 +66,7 @@ src/aipass/hooks/
│ ├── sound.py # Shared sound utilities (speak, play, mute) │ ├── sound.py # Shared sound utilities (speak, play, mute)
│ ├── modules/ │ ├── modules/
│ │ ├── cadence.py # Prompt injection cadence (every-Nth-turn gating) │ │ ├── cadence.py # Prompt injection cadence (every-Nth-turn gating)
│ │ ├── context_window.py # Transcript usage reader + per-branch compact-window resolver
│ │ ├── hook_test.py # Portable test runner (drone @hooks test) │ │ ├── hook_test.py # Portable test runner (drone @hooks test)
│ │ ├── cc_sessions.py # CC-native session file reader (~/.claude/sessions/<pid>.json) │ │ ├── cc_sessions.py # CC-native session file reader (~/.claude/sessions/<pid>.json)
│ │ ├── engine.py # Core dispatch — routes events to handlers │ │ ├── engine.py # Core dispatch — routes events to handlers
@@ -86,6 +87,8 @@ src/aipass/hooks/
│ │ │ ├── navmap.py # Injects tier1 navmap prompt (periodic) │ │ │ ├── navmap.py # Injects tier1 navmap prompt (periodic)
│ │ │ ├── identity.py # Injects passport identity block │ │ │ ├── identity.py # Injects passport identity block
│ │ │ ├── feedback_pulse.py # Periodic feedback ask (~10 turns, toggleable) │ │ │ ├── feedback_pulse.py # Periodic feedback ask (~10 turns, toggleable)
│ │ │ ├── context_gauge.py # Nudges /prep before auto-compact fires (80%/95% of window)
│ │ │ ├── temporal.py # Injects weekday/date/time/tz/part-of-day, every turn
│ │ │ └── persistent_alert.py # Injects advisory banners from .aipass/alerts.json │ │ │ └── persistent_alert.py # Injects advisory banners from .aipass/alerts.json
│ │ ├── security/ # Enforcement hooks │ │ ├── security/ # Enforcement hooks
│ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics) │ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics)
@@ -98,6 +101,7 @@ src/aipass/hooks/
│ │ │ ├── auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile) │ │ │ ├── auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile)
│ │ │ ├── auto_watchdog.py # Watchdog arming after dispatch │ │ │ ├── auto_watchdog.py # Watchdog arming after dispatch
│ │ │ ├── compact.py # Pre-compact memory archival │ │ │ ├── compact.py # Pre-compact memory archival
│ │ │ ├── pre_compact_prep.py # Mechanical AUTO-COMPACT SNAPSHOT stamp (fill %, git, locks, plans)
│ │ │ ├── rollover.py # Pre-compact memory rollover │ │ │ ├── rollover.py # Pre-compact memory rollover
│ │ │ └── session_start.py # Cadence reset on new chat / clear (SessionStart) │ │ │ └── session_start.py # Cadence reset on new chat / clear (SessionStart)
│ │ └── notification/ # Sound/alert hooks │ │ └── notification/ # Sound/alert hooks
@@ -111,7 +115,7 @@ src/aipass/hooks/
│ └── diagnostics.py # JSONL logging for hook execution │ └── diagnostics.py # JSONL logging for hook execution
├── logs/ ├── logs/
│ └── engine.jsonl # JSONL diagnostics (every hook execution) │ └── engine.jsonl # JSONL diagnostics (every hook execution)
└── tests/ # 1071 tests across 29 test files └── tests/ # 1249 tests across 43 test files
``` ```
## How It Works ## How It Works
@@ -132,13 +136,13 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im
| Event | Hooks | Description | | Event | Hooks | Description |
|---|---|---| |---|---|---|
| UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, feedback_pulse, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + feedback + auto-process + TG mirror | | UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, feedback_pulse, context_gauge, temporal, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + feedback + context gauge + temporal + auto-process + TG mirror |
| PreToolUse | tool_sound, edit_gate, git_gate, rm_gate, registry_gate | Security gates + guardrails + sound | | PreToolUse | tool_sound, edit_gate, git_gate, rm_gate, registry_gate | Security gates + guardrails + sound |
| PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog | | PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog |
| SubagentStop | subagent_gate | Seedgo validation | | SubagentStop | subagent_gate | Seedgo validation |
| Stop | stop_sound, telegram_response, presence_release | Bell + Telegram delivery + presence release | | Stop | stop_sound, telegram_response, presence_release | Bell + Telegram delivery + presence release |
| Notification | announce | Announcement tone | | Notification | announce | Announcement tone |
| PreCompact | compact, rollover | Memory archival + rollover | | PreCompact | compact, rollover, pre_compact_prep | Memory archival + rollover + mechanical snapshot stamp |
## Git Gate ## Git Gate
@@ -10,19 +10,38 @@
"""JSONL diagnostic logging — appends structured entries for hook activity.""" """JSONL diagnostic logging — appends structured entries for hook activity."""
import sys
import tempfile
from pathlib import Path from pathlib import Path
from aipass.prax import append_jsonl from aipass.prax import append_jsonl
from aipass.prax.apps.modules.logger import system_logger as logger from aipass.prax.apps.modules.logger import system_logger as logger
BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent.parent BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent.parent
LOG_FILE = BRANCH_ROOT / "logs" / "engine.jsonl" _PROD_LOG_FILE = BRANCH_ROOT / "logs" / "engine.jsonl"
def _is_pytest_session() -> bool:
"""Detect pytest via sys.modules (immune to patch.dict(os.environ, clear=True))."""
return "_pytest" in sys.modules
def _get_log_file() -> Path:
"""Resolve log path — temp dir during pytest, prod path otherwise."""
if _is_pytest_session():
p = Path(tempfile.gettempdir()) / "aipass_test_logs" / "hooks"
p.mkdir(parents=True, exist_ok=True)
return p / "engine.jsonl"
return _PROD_LOG_FILE
LOG_FILE = _PROD_LOG_FILE
def log_entry(entry: dict) -> None: def log_entry(entry: dict) -> None:
"""Append a JSONL log entry for detailed diagnostics.""" """Append a JSONL log entry for detailed diagnostics."""
try: try:
append_jsonl(LOG_FILE, entry) append_jsonl(_get_log_file(), entry)
except OSError as exc: except OSError as exc:
logger.error("[HOOKS] log write failed: %s", exc) logger.error("[HOOKS] log write failed: %s", exc)
@@ -56,3 +56,37 @@ def find_project_config() -> dict | None:
return None return None
search = search.parent search = search.parent
return None return None
def trust_break_banner() -> str | None:
"""Loud, config-independent check for a stale trust enrollment.
find_project_config() goes silent on a hash mismatch (logs one WARNING,
returns None) — the fallback config downstream then has no event_type
keys at all, so every hook including this one's own dispatch path goes
dark. This check does its own walk-up and hash comparison, never touches
hooks.json content, and does not depend on the project being trusted —
so it still works precisely when trust is broken. Returns None when
nothing is broken, or when the project was simply never enrolled (normal
first-run state, not a break).
"""
from aipass.hooks.apps.handlers.config.trust_registry import is_hash_mismatch
search = Path.cwd()
home = Path.home()
while search != home and search.parent != search:
config_file = search / ".aipass" / "hooks.json"
if config_file.exists():
if is_hash_mismatch(str(search)):
return (
"# TRUST BREAK — ALL AIPASS HOOKS DISABLED\n\n"
f"{search}/.aipass/hooks.json no longer matches its enrolled hash. "
"Every hook for this project (including this warning's own delivery "
"path) is silently skipped until a human re-enrolls.\n\n"
"Fix: drone @hooks trust enroll (or: aipass init update)\n"
"This does not auto-heal — re-enrollment is a deliberate human "
"checkpoint, not a bug."
)
return None
search = search.parent
return None
@@ -100,6 +100,25 @@ def is_trusted(project_dir: str) -> bool:
return current_hash == entry.get("config_hash", "") return current_hash == entry.get("config_hash", "")
def is_hash_mismatch(project_dir: str) -> bool:
"""True only when a project WAS enrolled but its hooks.json hash has since changed.
Distinct from is_trusted()==False for a never-enrolled project (normal
first-run state, not a break). This flags a genuine trust break — an
existing enrollment gone stale — so callers can warn loudly instead of
treating it the same as "not set up yet".
"""
project_path = str(Path(project_dir).resolve())
registry = read_registry()
entry = registry["projects"].get(project_path)
if entry is None:
return False
config_path = Path(project_dir).resolve() / ".aipass" / "hooks.json"
if not config_path.exists():
return False
return _hash_file(config_path) != entry.get("config_hash", "")
def bootstrap() -> bool: def bootstrap() -> bool:
"""Bootstrap the registry with ONLY the AIPass install. Returns True on success. """Bootstrap the registry with ONLY the AIPass install. Returns True on success.
@@ -67,7 +67,7 @@ def handle(hook_data: dict) -> dict:
logger.info("[HOOKS] auto_process: no-op (nothing to process)") logger.info("[HOOKS] auto_process: no-op (nothing to process)")
_mark_session_ran() _mark_session_ran()
return {"stdout": "", "exit_code": 0} return {"stdout": "", "exit_code": 0, "sound": "auto process"}
except Exception as exc: except Exception as exc:
logger.error("[HOOKS] auto_process: error: %s", exc) logger.error("[HOOKS] auto_process: error: %s", exc)
@@ -0,0 +1,246 @@
# =================== AIPass ====================
# Name: pre_compact_prep.py
# Version: 1.0.0
# Description: Stamps a mechanical AUTO-COMPACT SNAPSHOT into the compacting branch's memory (PreCompact)
# Branch: hooks
# Layer: apps/handlers/lifecycle
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""Mechanical /prep AT compact time (DPLAN-0253).
Resolves the compacting branch from cwd, then prepends a session entry to its
.trinity/local.json — context fill, in-flight dispatch locks across the
system, this branch's open plan count, git state, and inbox unread count.
Templated from live state, no model turn needed. The judgment layer (todo
reconcile, thoughtful summary) is handled separately by the context gauge
nudge, which fires early enough for a live model turn to run /prep.
Defensive: a missing or malformed .trinity/local.json is logged and skipped —
this handler must never corrupt memory or raise out of PreCompact."""
import importlib
import json
import subprocess
from datetime import date
from pathlib import Path
from aipass.prax.apps.modules.logger import system_logger as logger
_DEFAULT_SUMMARY_CAP = 300
def _find_repo_root(start: Path) -> Path | None:
for parent in [start, *start.parents]:
if (parent / "AIPASS_REGISTRY.json").exists():
return parent
return None
def _context_fill_pct(hook_data: dict, cwd: str) -> str | None:
context_window = importlib.import_module("aipass.hooks.apps.modules.context_window")
usage = context_window.read_latest_usage(hook_data.get("transcript_path", ""))
if usage is None:
return None
fill = context_window.context_fill_tokens(usage)
window = context_window.resolve_compact_window(cwd)
if window <= 0:
return None
pct = round(fill / window * 100)
return f"~{fill // 1000}k/{window // 1000}k ({pct}%)"
def _count_active_dispatch_locks(repo_root: Path | None) -> int | None:
if repo_root is None:
return None
registry_path = repo_root / "AIPASS_REGISTRY.json"
try:
data = json.loads(registry_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.info("[HOOKS] pre_compact_prep: registry read failed: %s", exc)
return None
count = 0
for branch in data.get("branches", []):
branch_path = repo_root / branch.get("path", "")
lock_path = branch_path / ".ai_mail.local" / ".dispatch.lock"
if lock_path.is_file():
count += 1
return count
def _count_open_plans(branch_dir: Path) -> int | None:
try:
from aipass.flow.apps.handlers.plan.get_open_plans import get_open_plans
target = str(branch_dir.resolve())
return sum(1 for _num, info in get_open_plans() if info.get("location") == target)
except Exception as exc:
logger.info("[HOOKS] pre_compact_prep: open plans read failed: %s", exc)
return None
def _git_snapshot(branch_dir: Path) -> str | None:
try:
branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
timeout=5,
cwd=str(branch_dir),
)
last_commit = subprocess.run(
["git", "log", "-1", "--format=%h %s"],
capture_output=True,
text=True,
timeout=5,
cwd=str(branch_dir),
)
dirty = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True,
text=True,
timeout=5,
cwd=str(branch_dir),
)
except Exception as exc:
logger.info("[HOOKS] pre_compact_prep: git snapshot failed: %s", exc)
return None
bits: list[str] = []
if branch.returncode == 0 and branch.stdout.strip():
bits.append(branch.stdout.strip())
if last_commit.returncode == 0 and last_commit.stdout.strip():
bits.append(last_commit.stdout.strip())
if dirty.returncode == 0:
dirty_lines = [ln for ln in dirty.stdout.strip().split("\n") if ln]
bits.append(f"{len(dirty_lines)} dirty")
return " / ".join(bits) if bits else None
def _inbox_unread(branch_dir: Path) -> int | None:
inbox_path = branch_dir / ".ai_mail.local" / "inbox.json"
if not inbox_path.is_file():
return None
try:
data = json.loads(inbox_path.read_text(encoding="utf-8"))
unread = data.get("unread_count")
return unread if isinstance(unread, int) else None
except (OSError, json.JSONDecodeError) as exc:
logger.info("[HOOKS] pre_compact_prep: inbox read failed: %s", exc)
return None
def _summary_cap(repo_root: Path | None) -> int:
if repo_root is None:
return _DEFAULT_SUMMARY_CAP
config_path = repo_root / "src" / "aipass" / "memory" / "memory_json" / "custom_config" / "memory.config.json"
try:
data = json.loads(config_path.read_text(encoding="utf-8"))
cap = data["entry_limits"]["entry_types"]["sessions"]["max_chars"]
if isinstance(cap, int) and cap > 0:
return cap
except (OSError, json.JSONDecodeError, KeyError, TypeError) as exc:
logger.info("[HOOKS] pre_compact_prep: entry_limits read failed, using default: %s", exc)
return _DEFAULT_SUMMARY_CAP
def _build_snapshot(hook_data: dict, branch_dir: Path, repo_root: Path | None) -> str:
cwd = hook_data.get("cwd", "") or str(branch_dir)
parts: list[str] = []
fill = _context_fill_pct(hook_data, cwd)
if fill:
parts.append(f"context {fill}")
locks = _count_active_dispatch_locks(repo_root)
if locks is not None:
parts.append(f"{locks} active dispatch(es)")
open_plans = _count_open_plans(branch_dir)
if open_plans is not None:
parts.append(f"{open_plans} open plan(s)")
git_info = _git_snapshot(branch_dir)
if git_info:
parts.append(f"git: {git_info}")
unread = _inbox_unread(branch_dir)
if unread is not None:
parts.append(f"{unread} unread")
body = ", ".join(parts) if parts else "no live state available"
return f"AUTO-COMPACT SNAPSHOT: {body}"
def _stamp_session_entry(branch_dir: Path, summary: str, cap: int) -> bool:
local_path = branch_dir / ".trinity" / "local.json"
if not local_path.is_file():
logger.info("[HOOKS] pre_compact_prep: no local.json at %s — skipping stamp", local_path)
return False
try:
data = json.loads(local_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.warning("[HOOKS] pre_compact_prep: local.json unreadable, skipping stamp: %s", exc)
return False
if not isinstance(data, dict):
logger.warning("[HOOKS] pre_compact_prep: local.json malformed (not a dict), skipping stamp")
return False
sessions = data.get("sessions")
if not isinstance(sessions, list):
logger.warning("[HOOKS] pre_compact_prep: sessions container malformed, skipping stamp")
return False
truncated = summary if len(summary) <= cap else summary[: cap - 1].rstrip() + "…"
existing_numbers = [
entry.get("number", 0) for entry in sessions if isinstance(entry, dict) and isinstance(entry.get("number"), int)
]
next_number = max(existing_numbers, default=0) + 1
sessions.insert(
0,
{
"date": date.today().isoformat(),
"summary": truncated,
"status": "auto-compact",
"number": next_number,
},
)
data["sessions"] = sessions
try:
local_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
except OSError as exc:
logger.warning("[HOOKS] pre_compact_prep: local.json write failed: %s", exc)
return False
return True
def handle(hook_data: dict) -> dict:
"""Stamp a mechanical AUTO-COMPACT SNAPSHOT into the compacting branch's memory."""
try:
cwd = hook_data.get("cwd", "") or str(Path.cwd())
context_window = importlib.import_module("aipass.hooks.apps.modules.context_window")
branch_dir = context_window.find_branch_dir(cwd)
if branch_dir is None:
logger.info("[HOOKS] pre_compact_prep: no branch dir resolved from cwd=%s", cwd)
return {"stdout": "", "exit_code": 0}
repo_root = _find_repo_root(branch_dir)
cap = _summary_cap(repo_root)
snapshot = _build_snapshot(hook_data, branch_dir, repo_root)
stamped = _stamp_session_entry(branch_dir, snapshot, cap)
logger.info("[HOOKS] pre_compact_prep: snapshot stamped=%s branch=%s", stamped, branch_dir.name)
return {"stdout": snapshot, "exit_code": 0, "sound": "pre compact prep"}
except Exception as exc:
logger.info("[HOOKS] pre_compact_prep: unexpected error: %s", exc)
return {"stdout": "", "exit_code": 0}
@@ -37,5 +37,6 @@ def handle(hook_data: dict) -> dict:
logger.info("[HOOKS] session_start: cadence reset (source=%s)", source) logger.info("[HOOKS] session_start: cadence reset (source=%s)", source)
except Exception as exc: except Exception as exc:
logger.info("[HOOKS] session_start: cadence reset failed: %s", exc) logger.info("[HOOKS] session_start: cadence reset failed: %s", exc)
return {"stdout": "", "exit_code": 0} return {"stdout": "", "exit_code": 0}
return {"stdout": "", "exit_code": 0, "sound": "cadence reset"}
@@ -23,6 +23,8 @@ import json
import os import os
import re import re
import subprocess import subprocess
import sys
import tempfile
import time import time
from pathlib import Path from pathlib import Path
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
@@ -35,7 +37,19 @@ PENDING_DIR = Path.home() / ".aipass" / "telegram_pending"
MIRROR_DIR = Path.home() / ".aipass" / "telegram_bots" MIRROR_DIR = Path.home() / ".aipass" / "telegram_bots"
PENDING_TTL = 3600 PENDING_TTL = 3600
TELEGRAM_CHAR_LIMIT = 4096 TELEGRAM_CHAR_LIMIT = 4096
_DELIVERY_LOG = Path(__file__).resolve().parent.parent.parent.parent / "logs" / "telegram_delivery.jsonl" _PROD_DELIVERY_LOG = Path(__file__).resolve().parent.parent.parent.parent / "logs" / "telegram_delivery.jsonl"
def _get_delivery_log() -> Path:
"""Resolve delivery log path — temp dir during pytest, prod path otherwise."""
if "_pytest" in sys.modules:
p = Path(tempfile.gettempdir()) / "aipass_test_logs" / "hooks"
p.mkdir(parents=True, exist_ok=True)
return p / "telegram_delivery.jsonl"
return _PROD_DELIVERY_LOG
_DELIVERY_LOG = _PROD_DELIVERY_LOG
def _is_expired(data: dict) -> bool: def _is_expired(data: dict) -> bool:
@@ -585,7 +599,7 @@ def handle(hook_data: dict) -> dict:
else: else:
logger.error("[HOOKS] telegram: delivery failed — keeping pending for retry") logger.error("[HOOKS] telegram: delivery failed — keeping pending for retry")
return {"stdout": "", "exit_code": 0} return {"stdout": "", "exit_code": 0, "sound": "telegram response"}
def _extract_response(hook_data: dict, transcript_path: str, pending_data: dict) -> str | None: def _extract_response(hook_data: dict, transcript_path: str, pending_data: dict) -> str | None:
@@ -771,6 +785,6 @@ def _write_delivery_log(intended_text: str, chunks: list[str], chunk_results: li
record["culprit"] = culprit record["culprit"] = culprit
try: try:
append_jsonl(_DELIVERY_LOG, record) append_jsonl(_get_delivery_log(), record)
except OSError as e: except OSError as e:
logger.warning("[HOOKS] telegram: delivery log write failed: %s", e) logger.warning("[HOOKS] telegram: delivery log write failed: %s", e)
@@ -110,7 +110,7 @@ def handle(hook_data: dict) -> dict:
mark_surfaced([c["id"] for c in approved]) mark_surfaced([c["id"] for c in approved])
json_handler.log_operation("compass_recall", {"count": len(approved)}) json_handler.log_operation("compass_recall", {"count": len(approved)})
return {"stdout": "\n".join(lines), "exit_code": 0} return {"stdout": "\n".join(lines), "exit_code": 0, "sound": "compass recall"}
except Exception as exc: except Exception as exc:
logger.info("[HOOKS] compass_recall_unreachable: %s", exc) logger.info("[HOOKS] compass_recall_unreachable: %s", exc)
@@ -0,0 +1,106 @@
# =================== AIPass ====================
# Name: context_gauge.py
# Version: 1.0.0
# Description: Nudges the model to run /prep before auto-compact fires (UserPromptSubmit, DPLAN-0253)
# Branch: hooks
# Layer: apps/handlers/prompt
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""Early-warning nudge so the model runs /prep before the compact ceiling —
memory should never be at the mercy of an auto-compact firing mid-work.
Reads live transcript usage every prompt (cheap tail read), resolves the
branch's compact trigger (window * 0.9), and injects a hard line once fill
crosses 80%/95% of that trigger. Independent of the cadence system — its own
per-session, per-threshold guard file in tempdir, same idiom as
feedback_pulse.py / auto_process.py, so it isn't gated by turn count."""
import importlib
import os
import tempfile
from pathlib import Path
from aipass.prax.apps.modules.logger import system_logger as logger
_GUARD_DIR = Path(tempfile.gettempdir())
_TRIGGER_RATIO = 0.9
_NUDGE_THRESHOLD_PCT = 80
_ESCALATE_THRESHOLD_PCT = 95
def _guard_path(session_id: str, threshold: str) -> Path | None:
if not session_id:
return None
return _GUARD_DIR / f"aipass-context-gauge-{session_id}-{threshold}"
def _already_fired(session_id: str, threshold: str) -> bool:
path = _guard_path(session_id, threshold)
return path is not None and path.exists()
def _mark_fired(session_id: str, threshold: str) -> None:
path = _guard_path(session_id, threshold)
if path is not None:
try:
path.touch()
except OSError as exc:
logger.info("[HOOKS] context_gauge: guard write failed: %s", exc)
def handle(hook_data: dict) -> dict:
"""Inject a context-fill nudge once per threshold per session."""
try:
session_id = hook_data.get("session_id", "") or os.environ.get("CLAUDE_CODE_SESSION_ID", "")
transcript_path = hook_data.get("transcript_path", "")
if not transcript_path:
return {"stdout": "", "exit_code": 0}
context_window = importlib.import_module("aipass.hooks.apps.modules.context_window")
usage = context_window.read_latest_usage(transcript_path)
if usage is None:
return {"stdout": "", "exit_code": 0}
fill = context_window.context_fill_tokens(usage)
cwd = hook_data.get("cwd", "") or str(Path.cwd())
window = context_window.resolve_compact_window(cwd)
trigger = window * _TRIGGER_RATIO
if trigger <= 0:
return {"stdout": "", "exit_code": 0}
pct = fill / trigger * 100
fill_k = fill // 1000
trigger_k = int(trigger) // 1000
if pct >= _ESCALATE_THRESHOLD_PCT and not _already_fired(session_id, "95"):
_mark_fired(session_id, "95")
_mark_fired(session_id, "80")
logger.info("[HOOKS] context_gauge: escalate fired at %.0f%% session=%s", pct, session_id[:8])
return {
"stdout": (
f"CONTEXT GAUGE: ~{fill_k}k/{trigger_k}k ({pct:.0f}%) — run /prep NOW "
"AND wrap up the current work item. Auto-compact is imminent."
),
"exit_code": 0,
"sound": "context gauge",
}
if pct >= _NUDGE_THRESHOLD_PCT and not _already_fired(session_id, "80"):
_mark_fired(session_id, "80")
logger.info("[HOOKS] context_gauge: nudge fired at %.0f%% session=%s", pct, session_id[:8])
return {
"stdout": (
f"CONTEXT GAUGE: ~{fill_k}k/{trigger_k}k ({pct:.0f}%) — run /prep NOW, "
"before auto-compact takes the choice away."
),
"exit_code": 0,
"sound": "context gauge",
}
return {"stdout": "", "exit_code": 0}
except Exception as exc:
logger.info("[HOOKS] context_gauge: unexpected error: %s", exc)
return {"stdout": "", "exit_code": 0}
@@ -87,6 +87,7 @@ def handle(hook_data: dict) -> dict:
cwd = hook_data.get("cwd", "") cwd = hook_data.get("cwd", "")
if _is_disabled(cwd or None): if _is_disabled(cwd or None):
logger.info("[HOOKS] feedback_pulse: skipped (disabled via .aipass/feedback_off sentinel)")
return {"stdout": "", "exit_code": 0} return {"stdout": "", "exit_code": 0}
json_handler.log_operation("feedback_pulse", {"turn": turn}) json_handler.log_operation("feedback_pulse", {"turn": turn})
@@ -11,13 +11,16 @@
"""Injects advisory banners for active alerts from .aipass/alerts.json.""" """Injects advisory banners for active alerts from .aipass/alerts.json."""
import json import json
import os
import tempfile
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from aipass.hooks.apps.handlers.json import json_handler from aipass.hooks.apps.handlers.json import json_handler
from aipass.prax.apps.modules.logger import system_logger as logger from aipass.prax.apps.modules.logger import system_logger as logger
_announced: set[str] = set() _GUARD_DIR = Path(tempfile.gettempdir())
_MAX_ALERTS_SHOWN = 10
def _find_aipass_dir() -> Path | None: def _find_aipass_dir() -> Path | None:
@@ -77,10 +80,33 @@ def _load_and_clean(alerts_path: Path) -> list[dict]:
return active return active
def _guard_path(session_id: str, alert_id: str) -> Path | None:
if not session_id:
return None
return _GUARD_DIR / f"aipass-persistent-alert-{session_id}-{alert_id}"
def _already_announced(session_id: str, alert_id: str) -> bool:
path = _guard_path(session_id, alert_id)
return path is not None and path.exists()
def _mark_announced(session_id: str, alert_id: str) -> None:
path = _guard_path(session_id, alert_id)
if path is not None:
try:
path.touch()
except OSError as exc:
logger.info("[HOOKS] persistent_alert: guard write failed: %s", exc)
def _format_banner(alerts: list[dict]) -> str: def _format_banner(alerts: list[dict]) -> str:
"""Format alert banners for prompt injection.""" """Format alert banners for prompt injection, capped at _MAX_ALERTS_SHOWN."""
shown = alerts[:_MAX_ALERTS_SHOWN]
hidden = len(alerts) - len(shown)
lines = [] lines = []
for alert in alerts: for alert in shown:
severity = alert.get("severity", "warning").upper() severity = alert.get("severity", "warning").upper()
title = alert.get("title", "Untitled alert") title = alert.get("title", "Untitled alert")
body = alert.get("body", "") body = alert.get("body", "")
@@ -89,6 +115,8 @@ def _format_banner(alerts: list[dict]) -> str:
lines.append(f"[{severity}] {title} (from @{source}, id: {alert_id})") lines.append(f"[{severity}] {title} (from @{source}, id: {alert_id})")
if body: if body:
lines.append(f" {body}") lines.append(f" {body}")
if hidden > 0:
lines.append(f"...and {hidden} more (dismiss some to see the rest)")
header = "# Active Alerts" header = "# Active Alerts"
dismiss_hint = "Dismiss with: drone @hooks dismiss <alert-id>" dismiss_hint = "Dismiss with: drone @hooks dismiss <alert-id>"
return "\n".join([header, ""] + lines + ["", dismiss_hint]) return "\n".join([header, ""] + lines + ["", dismiss_hint])
@@ -117,10 +145,12 @@ def handle(hook_data: dict) -> dict:
banner = _format_banner(alerts) banner = _format_banner(alerts)
new_ids = [a["id"] for a in alerts if a.get("id") and a["id"] not in _announced] session_id = hook_data.get("session_id", "") or os.environ.get("CLAUDE_CODE_SESSION_ID", "")
new_ids = [a["id"] for a in alerts if a.get("id") and not _already_announced(session_id, a["id"])]
sound = "" sound = ""
if new_ids: if new_ids:
_announced.update(new_ids) for alert_id in new_ids:
_mark_announced(session_id, alert_id)
count = len(alerts) count = len(alerts)
plural = "s" if count != 1 else "" plural = "s" if count != 1 else ""
sound = f"alert: {count} active alert{plural}" sound = f"alert: {count} active alert{plural}"
@@ -0,0 +1,51 @@
# =================== AIPass ====================
# Name: temporal.py
# Version: 1.0.0
# Description: Every-turn local date/time/weekday/part-of-day injection (UserPromptSubmit)
# Branch: hooks
# Layer: apps/handlers/prompt
# Created: 2026-07-21
# Modified: 2026-07-21
# =============================================
"""Tiny always-on temporal grounding line — no cadence gating, fires every
turn. Reads the live clock each fire (no caching) and injects one short line:
weekday, date, 24h time, tz abbreviation, part of day. Timezone comes from
the host system (astimezone()) — never hardcoded, so clones running outside
Vancouver still show their own local time. Keep it to one line — every-turn
cost matters."""
from datetime import datetime
from aipass.prax.apps.modules.logger import system_logger as logger
def _part_of_day(hour: int) -> str:
"""Bucket an hour (0-23) into morning/afternoon/evening/night."""
if 5 <= hour < 12:
return "morning"
if 12 <= hour < 17:
return "afternoon"
if 17 <= hour < 22:
return "evening"
return "night"
def _format_line(now: datetime) -> str:
weekday = now.strftime("%a")
date = now.strftime("%Y-%m-%d")
time_str = now.strftime("%H:%M")
tz = now.strftime("%Z")
part = _part_of_day(now.hour)
clock = f"{weekday} {date} {time_str} {tz}" if tz else f"{weekday} {date} {time_str}"
return f"Temporal: {clock} ({part})"
def handle(hook_data: dict) -> dict:
"""Inject the current local temporal line — every turn, no cadence."""
try:
line = _format_line(datetime.now().astimezone())
return {"stdout": line, "exit_code": 0, "sound": "temporal"}
except Exception as exc:
logger.info("[HOOKS] temporal: unexpected error: %s", exc)
return {"stdout": "", "exit_code": 0}
@@ -1,11 +1,11 @@
# =================== AIPass ==================== # =================== AIPass ====================
# Name: presence_gate.py # Name: presence_gate.py
# Version: 3.0.0 # Version: 3.0.1
# Description: Single-session gate — blocks duplicate Claude runtimes per branch # Description: Single-session gate — blocks duplicate Claude runtimes per branch
# Branch: hooks # Branch: hooks
# Layer: apps/handlers/security # Layer: apps/handlers/security
# Created: 2026-06-29 # Created: 2026-06-29
# Modified: 2026-07-13 # Modified: 2026-07-21
# ============================================= # =============================================
"""Single-session gate — blocks duplicate Claude runtimes per branch. """Single-session gate — blocks duplicate Claude runtimes per branch.
@@ -27,6 +27,12 @@ including background sessions with agent_type "claude".
Ships in OBSERVE-ONLY mode: logs would-block decisions to engine.jsonl Ships in OBSERVE-ONLY mode: logs would-block decisions to engine.jsonl
but never actually blocks. Flip _OBSERVE_ONLY to False after soak period but never actually blocks. Flip _OBSERVE_ONLY to False after soak period
confirms zero false positives. confirms zero false positives.
Occupant PID claims are identity-checked, not just liveness-checked:
cc_sessions.find_live_for_cwd() cross-verifies each session's recorded
procStart against the live process's actual start time before treating
a PID as a genuine occupant, so a PID recycled after the original
session died can never satisfy a stale claim.
""" """
import importlib import importlib
@@ -131,7 +137,7 @@ def handle(hook_data: dict) -> dict:
if _OBSERVE_ONLY: if _OBSERVE_ONLY:
logger.warning("[presence_gate] OBSERVE-ONLY would-block: %s", reason) logger.warning("[presence_gate] OBSERVE-ONLY would-block: %s", reason)
return _ALLOW return {**_ALLOW, "sound": "presence gate"}
logger.warning("[presence_gate] BLOCKED: %s", reason) logger.warning("[presence_gate] BLOCKED: %s", reason)
return { return {
+59 -6
View File
@@ -1,11 +1,11 @@
# =================== AIPass ==================== # =================== AIPass ====================
# Name: cc_sessions.py # Name: cc_sessions.py
# Version: 3.0.0 # Version: 3.1.0
# Description: CC-native session discovery, listing, and reclaim # Description: CC-native session discovery, listing, and reclaim
# Branch: hooks # Branch: hooks
# Layer: apps/modules # Layer: apps/modules
# Created: 2026-06-30 # Created: 2026-06-30
# Modified: 2026-07-14 # Modified: 2026-07-21
# ============================================= # =============================================
"""Read Claude Code native session files (~/.claude/sessions/<pid>.json). """Read Claude Code native session files (~/.claude/sessions/<pid>.json).
@@ -68,6 +68,50 @@ def _pid_alive_windows(pid: int) -> bool:
kernel32.CloseHandle(handle) kernel32.CloseHandle(handle)
def _proc_start_ticks(pid: int) -> str | None:
"""Read a live process's start time (field 22 of /proc/<pid>/stat), in
clock ticks since boot. Linux only — returns None elsewhere or on any
read failure, so callers can fall back to liveness-only checking.
This is the identity half of the occupant check: os.kill(pid, 0) proves
*a* process holds this PID right now, not that it's the *same* process
that wrote the session file. A PID recycled by the kernel after the
original session died would otherwise pass silently.
"""
if sys.platform != "linux":
return None
try:
raw = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
# comm (field 2) is parenthesized and may itself contain ')' or
# whitespace — split on the LAST ')' to get past it safely.
fields_after_comm = raw.rsplit(")", 1)[1].split()
return fields_after_comm[19] # field 22 overall, starttime
except (OSError, IndexError) as exc:
logger.info("[CC_SESSIONS] Cannot read /proc/%d/stat: %s", pid, exc)
return None
def _session_pid_matches(session: dict) -> bool:
"""Verify the live PID is still the same process that wrote this session
file — not an unrelated process that later reused the same PID number.
Compares the session's recorded procStart (written by CC at session
start) against the live process's current start time. Sessions without
a recorded procStart (older/malformed files) or non-Linux hosts can't be
checked — falls back to liveness-only, the pre-hardening behavior.
"""
recorded = session.get("procStart")
if not recorded:
return True
pid = session.get("pid")
if not isinstance(pid, int):
return True
live_start = _proc_start_ticks(pid)
if live_start is None:
return True
return str(live_start) == str(recorded)
def _is_pid_alive(pid: int) -> bool: def _is_pid_alive(pid: int) -> bool:
"""Check if a process with the given PID exists.""" """Check if a process with the given PID exists."""
if pid <= 1: if pid <= 1:
@@ -146,7 +190,9 @@ def find_live_for_cwd(cwd: str) -> list[dict]:
"""Find all live CC sessions whose cwd matches the given directory. """Find all live CC sessions whose cwd matches the given directory.
Compares resolved paths for robustness (symlinks, trailing slashes). Compares resolved paths for robustness (symlinks, trailing slashes).
Only returns sessions whose PID is still alive. Only returns sessions whose PID is both alive and identity-checked
against procStart, so a PID recycled after the original session died
is never mistaken for a live occupant.
""" """
target = str(Path(cwd).resolve()) target = str(Path(cwd).resolve())
live = [] live = []
@@ -157,14 +203,21 @@ def find_live_for_cwd(cwd: str) -> list[dict]:
if str(Path(session_cwd).resolve()) != target: if str(Path(session_cwd).resolve()) != target:
continue continue
pid = session.get("pid") pid = session.get("pid")
if pid and _is_pid_alive(pid): if not pid or not _is_pid_alive(pid):
live.append(session)
else:
logger.info( logger.info(
"[CC_SESSIONS] Stale session file for PID %s at %s", "[CC_SESSIONS] Stale session file for PID %s at %s",
pid, pid,
session_cwd, session_cwd,
) )
continue
if not _session_pid_matches(session):
logger.warning(
"[CC_SESSIONS] PID %s reused (procStart mismatch) — treating stale session as dead at %s",
pid,
session_cwd,
)
continue
live.append(session)
return live return live
@@ -0,0 +1,135 @@
# =================== AIPass ====================
# Name: context_window.py
# Version: 1.0.0
# Description: Shared transcript-usage reader + per-branch compact-window resolver (DPLAN-0253)
# Branch: hooks
# Layer: apps/modules
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""Reads live context fill from a session transcript and resolves the branch's
auto-compact window — shared by pre_compact_prep.py and context_gauge.py.
Live context size ≈ input_tokens + cache_read_input_tokens + cache_creation_input_tokens
of the most recent assistant turn (proven readable S326)."""
import json
import os
from pathlib import Path
from aipass.cli.apps.modules import err_console
from aipass.prax.apps.modules.logger import system_logger as logger
CONSOLE = err_console
_DEFAULT_WINDOW = 200_000
_TAIL_BYTES = 50_000
def find_branch_dir(cwd: str) -> Path | None:
"""Resolve the branch root (src/aipass/<name>) from a cwd. Same walk as compact.py."""
parts = Path(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
if (Path(cwd) / ".trinity").is_dir():
return Path(cwd)
return None
def read_latest_usage(transcript_path: str, tail_bytes: int = _TAIL_BYTES) -> dict | None:
"""Tail a transcript JSONL and return the most recent assistant message's usage dict."""
if not transcript_path:
return None
path = Path(transcript_path)
try:
size = path.stat().st_size
with path.open("rb") as fh:
if size > tail_bytes:
fh.seek(size - tail_bytes)
chunk = fh.read()
except OSError as exc:
logger.info("[HOOKS] context_window: transcript read failed: %s", exc)
return None
lines = [ln for ln in chunk.decode("utf-8", errors="replace").splitlines() if ln.strip()]
for line in reversed(lines):
try:
entry = json.loads(line)
except json.JSONDecodeError as exc:
logger.info("[HOOKS] context_window: skipping unparsable transcript line: %s", exc)
continue
if entry.get("type") != "assistant":
continue
usage = entry.get("message", {}).get("usage")
if isinstance(usage, dict):
return usage
return None
def context_fill_tokens(usage: dict) -> int:
"""Live context size ≈ input + cache_read + cache_creation of one turn."""
return (
usage.get("input_tokens", 0)
+ usage.get("cache_read_input_tokens", 0)
+ usage.get("cache_creation_input_tokens", 0)
)
def resolve_compact_window(cwd: str) -> int:
"""Resolve the auto-compact window: env var > branch settings.local.json > default.
Mirrors CC's own precedence (env beats settings)."""
env_window = os.environ.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "")
if env_window:
try:
return int(env_window)
except ValueError:
logger.info("[HOOKS] context_window: bad env window value %r", env_window)
branch_dir = find_branch_dir(cwd)
if branch_dir is not None:
settings_path = branch_dir / ".claude" / "settings.local.json"
if settings_path.is_file():
try:
data = json.loads(settings_path.read_text(encoding="utf-8"))
window = data.get("autoCompactWindow")
if isinstance(window, int) and window > 0:
return window
except (json.JSONDecodeError, OSError) as exc:
logger.info("[HOOKS] context_window: settings read failed: %s", exc)
return _DEFAULT_WINDOW
# =============================================================================
# MODULE INTERFACE (drone @hooks routing)
# =============================================================================
def print_introspection() -> None:
"""Print context_window config and the resolved window for CWD."""
CONSOLE.print("[bold cyan]context_window[/bold cyan] Module")
CONSOLE.print(f" Default window: {_DEFAULT_WINDOW:,}")
CONSOLE.print(f" Transcript tail read: {_TAIL_BYTES:,} bytes")
window = resolve_compact_window(str(Path.cwd()))
CONSOLE.print(f" Resolved window (CWD): {window:,}")
def handle_command(command: str, args: list) -> bool:
"""Route context_window commands from drone @hooks."""
if command in ("--help", "-h", "help"):
CONSOLE.print("[bold cyan]context_window[/bold cyan] — Transcript usage + compact-window resolver")
CONSOLE.print()
CONSOLE.print(" drone @hooks context_window Show default window, tail size, resolved window for CWD")
return True
if command == "context_window":
if not args:
print_introspection()
return True
return False
+75 -6
View File
@@ -15,6 +15,7 @@ import json
import os import os
import subprocess import subprocess
import tempfile import tempfile
import threading
import time import time
from pathlib import Path from pathlib import Path
@@ -61,8 +62,14 @@ def _run_hook(hook_cmd: str, stdin_data: str, timeout_s: int = 30) -> dict:
return {"exit_code": -1, "stdout": "", "stderr": str(exc), "elapsed_ms": round(elapsed_ms, 1)} return {"exit_code": -1, "stdout": "", "stderr": str(exc), "elapsed_ms": round(elapsed_ms, 1)}
def _run_handler(handler_path: str, hook_data: dict) -> dict: def _run_handler(handler_path: str, hook_data: dict, timeout_s: int = 30) -> dict:
"""Call a handler function directly (no subprocess). Module imports handler.""" """Call a handler function directly (no subprocess). Module imports handler.
Runs the handler on a daemon thread and joins with a timeout so a hung
handler can never stall the event — the calling thread returns control
on expiry instead of blocking forever. The orphaned thread (if any) is
left to die with the process; it never blocks interpreter exit.
"""
start = time.monotonic() start = time.monotonic()
try: try:
module_path, func_name = handler_path.rsplit(".", 1) module_path, func_name = handler_path.rsplit(".", 1)
@@ -80,7 +87,34 @@ def _run_handler(handler_path: str, hook_data: dict) -> dict:
} }
module = importlib.import_module(module_path) module = importlib.import_module(module_path)
handler_func = getattr(module, func_name) handler_func = getattr(module, func_name)
result = handler_func(hook_data)
outcome = {}
def _call():
try:
outcome["result"] = handler_func(hook_data)
except Exception as exc:
logger.info("[HOOKS] handler %s raised on worker thread: %s", handler_path, exc)
outcome["error"] = exc # re-raised on the calling thread below
worker = threading.Thread(target=_call, daemon=True)
worker.start()
worker.join(timeout_s)
if worker.is_alive():
elapsed_ms = (time.monotonic() - start) * 1000
logger.error("[HOOKS] handler timeout after %ds: %s", timeout_s, handler_path)
return {
"exit_code": -1,
"stdout": "",
"stderr": "TIMEOUT",
"elapsed_ms": round(elapsed_ms, 1),
}
if "error" in outcome:
raise outcome["error"]
result = outcome["result"]
elapsed_ms = (time.monotonic() - start) * 1000 elapsed_ms = (time.monotonic() - start) * 1000
return { return {
"exit_code": result.get("exit_code", 0), "exit_code": result.get("exit_code", 0),
@@ -167,6 +201,16 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]:
return "", 0 return "", 0
event_hooks = config.get(event_type, {}) event_hooks = config.get(event_type, {})
if event_type == "UserPromptSubmit" and "presence_gate" in event_hooks:
from aipass.hooks.apps.handlers.config.loader import trust_break_banner
banner = trust_break_banner()
if banner:
logger.error("[HOOKS] trust break detected — emitting loud banner")
_log({"ts": time.time(), "event": event_type, "action": "trust_break_banner"})
return banner, 0
if not event_hooks: if not event_hooks:
_log({"ts": time.time(), "event": event_type, "action": "no_hooks_configured"}) _log({"ts": time.time(), "event": event_type, "action": "no_hooks_configured"})
return "", 0 return "", 0
@@ -247,12 +291,37 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]:
) )
continue continue
if handler:
result = _run_handler(handler, parsed)
else:
hook_timeout = hook_def.get("timeout", 30) hook_timeout = hook_def.get("timeout", 30)
if handler:
result = _run_handler(handler, parsed, timeout_s=hook_timeout)
else:
result = _run_hook(command, stdin_data, timeout_s=hook_timeout) result = _run_hook(command, stdin_data, timeout_s=hook_timeout)
if result.get("stderr") == "TIMEOUT":
logger.error(
"[HOOKS] %s.%s TIMED OUT after %ds — never silently swallowed",
event_type,
hook_name,
hook_timeout,
)
_log(
{
"ts": time.time(),
"event": event_type,
"hook": hook_name,
"action": "timeout",
"timeout_s": hook_timeout,
"elapsed_ms": result["elapsed_ms"],
}
)
try:
from aipass.hooks.apps.sound import speak
speak(f"{hook_name.replace('_', ' ')} timed out")
except Exception as exc:
logger.info("[HOOKS] sound playback failed for timeout %s.%s: %s", event_type, hook_name, exc)
continue
logger.info( logger.info(
"[HOOKS] %s.%s agent=%s exit=%d out=%db %dms", "[HOOKS] %s.%s agent=%s exit=%d out=%db %dms",
event_type, event_type,
@@ -43,6 +43,7 @@ class TestAutoProcessHandler:
assert result["exit_code"] == 0 assert result["exit_code"] == 0
assert result["stdout"] == "" assert result["stdout"] == ""
assert result["sound"] == "auto process"
def test_calls_memory_auto_process_module(self): def test_calls_memory_auto_process_module(self):
from aipass.hooks.apps.handlers.lifecycle.auto_process import handle from aipass.hooks.apps.handlers.lifecycle.auto_process import handle
@@ -168,6 +169,7 @@ class TestAutoProcessHandler:
result = handle({"tool_name": "Bash", "cwd": "/tmp"}) result = handle({"tool_name": "Bash", "cwd": "/tmp"})
assert result["exit_code"] == 0 assert result["exit_code"] == 0
assert result["sound"] == "auto process"
class TestSessionGuard: class TestSessionGuard:
+100
View File
@@ -2,8 +2,11 @@
import json import json
import os import os
import sys
from unittest.mock import patch from unittest.mock import patch
import pytest
from aipass.hooks.apps.modules import cc_sessions from aipass.hooks.apps.modules import cc_sessions
@@ -29,6 +32,51 @@ class TestIsPidAlive:
assert cc_sessions._is_pid_alive(42) is False assert cc_sessions._is_pid_alive(42) is False
class TestProcStartTicks:
@pytest.mark.skipif(sys.platform != "linux", reason="reads the real /proc filesystem")
def test_current_process_returns_value_on_linux(self):
result = cc_sessions._proc_start_ticks(os.getpid())
assert result is not None
assert result.isdigit()
@pytest.mark.skipif(sys.platform != "linux", reason="reads the real /proc filesystem")
def test_matches_raw_proc_stat_field(self):
result = cc_sessions._proc_start_ticks(os.getpid())
from pathlib import Path
raw = Path(f"/proc/{os.getpid()}/stat").read_text(encoding="utf-8")
expected = raw.rsplit(")", 1)[1].split()[19]
assert result == expected
def test_non_linux_returns_none(self):
with patch("sys.platform", "win32"):
assert cc_sessions._proc_start_ticks(os.getpid()) is None
def test_missing_pid_returns_none(self):
with patch("sys.platform", "linux"):
assert cc_sessions._proc_start_ticks(999999999) is None
class TestSessionPidMatches:
def test_no_procstart_recorded_passes(self):
assert cc_sessions._session_pid_matches({"pid": os.getpid()}) is True
def test_non_int_pid_passes(self):
assert cc_sessions._session_pid_matches({"pid": "not-an-int", "procStart": "123"}) is True
def test_matching_procstart_passes(self):
with patch.object(cc_sessions, "_proc_start_ticks", return_value="11277752"):
assert cc_sessions._session_pid_matches({"pid": 123, "procStart": "11277752"}) is True
def test_mismatched_procstart_fails(self):
with patch.object(cc_sessions, "_proc_start_ticks", return_value="99999999"):
assert cc_sessions._session_pid_matches({"pid": 123, "procStart": "11277752"}) is False
def test_unreadable_live_start_falls_back_to_pass(self):
with patch.object(cc_sessions, "_proc_start_ticks", return_value=None):
assert cc_sessions._session_pid_matches({"pid": 123, "procStart": "11277752"}) is True
class TestReadAllSessions: class TestReadAllSessions:
def test_reads_pid_files(self, tmp_path): def test_reads_pid_files(self, tmp_path):
session = {"pid": 1234, "sessionId": "abc", "cwd": "/tmp/branch", "kind": "interactive"} session = {"pid": 1234, "sessionId": "abc", "cwd": "/tmp/branch", "kind": "interactive"}
@@ -103,6 +151,47 @@ class TestFindLiveForCwd:
result = cc_sessions.find_live_for_cwd("/tmp/hooks") result = cc_sessions.find_live_for_cwd("/tmp/hooks")
assert result == [] assert result == []
def test_excludes_reused_pid_with_mismatched_procstart(self, tmp_path):
s = {
"pid": os.getpid(),
"sessionId": "reused",
"cwd": "/tmp/hooks",
"kind": "interactive",
"procStart": "1",
}
(tmp_path / f"{os.getpid()}.json").write_text(json.dumps(s))
with (
patch.object(cc_sessions, "CC_SESSIONS_DIR", tmp_path),
patch.object(cc_sessions, "_proc_start_ticks", return_value="999999999"),
):
result = cc_sessions.find_live_for_cwd("/tmp/hooks")
assert result == []
def test_includes_session_with_matching_procstart(self, tmp_path):
s = {
"pid": os.getpid(),
"sessionId": "genuine",
"cwd": "/tmp/hooks",
"kind": "interactive",
"procStart": "42",
}
(tmp_path / f"{os.getpid()}.json").write_text(json.dumps(s))
with (
patch.object(cc_sessions, "CC_SESSIONS_DIR", tmp_path),
patch.object(cc_sessions, "_proc_start_ticks", return_value="42"),
):
result = cc_sessions.find_live_for_cwd("/tmp/hooks")
assert len(result) == 1
assert result[0]["sessionId"] == "genuine"
def test_includes_session_without_procstart_field(self, tmp_path):
s = {"pid": os.getpid(), "sessionId": "no-procstart", "cwd": "/tmp/hooks", "kind": "interactive"}
(tmp_path / f"{os.getpid()}.json").write_text(json.dumps(s))
with patch.object(cc_sessions, "CC_SESSIONS_DIR", tmp_path):
result = cc_sessions.find_live_for_cwd("/tmp/hooks")
assert len(result) == 1
assert result[0]["sessionId"] == "no-procstart"
class TestFindOccupant: class TestFindOccupant:
def test_no_occupant_when_free(self, tmp_path): def test_no_occupant_when_free(self, tmp_path):
@@ -135,6 +224,17 @@ class TestFindOccupant:
result = cc_sessions.find_occupant("/tmp/hooks") result = cc_sessions.find_occupant("/tmp/hooks")
assert result is not None assert result is not None
def test_reused_pid_never_reported_as_occupant(self, tmp_path):
my_pid = os.getpid()
s = {"pid": my_pid, "sessionId": "stale-claim", "cwd": "/tmp/hooks", "kind": "interactive", "procStart": "1"}
(tmp_path / f"{my_pid}.json").write_text(json.dumps(s))
with (
patch.object(cc_sessions, "CC_SESSIONS_DIR", tmp_path),
patch.object(cc_sessions, "_proc_start_ticks", return_value="999999999"),
):
result = cc_sessions.find_occupant("/tmp/hooks", exclude_pid=99999)
assert result is None
class TestReclaim: class TestReclaim:
def test_reclaim_stops_live_sessions(self, tmp_path): def test_reclaim_stops_live_sessions(self, tmp_path):
@@ -99,6 +99,7 @@ class TestCompassRecallHandler:
assert result["exit_code"] == 0 assert result["exit_code"] == 0
assert "[GOOD] #56:" in result["stdout"] assert "[GOOD] #56:" in result["stdout"]
assert "Never hardcode config in prompts" in result["stdout"] assert "Never hardcode config in prompts" in result["stdout"]
assert result["sound"] == "compass recall"
mock_mark.assert_called_once_with([56]) mock_mark.assert_called_once_with([56])
def test_formats_bad_rating(self, tmp_path): def test_formats_bad_rating(self, tmp_path):
@@ -0,0 +1,134 @@
# =================== AIPass ====================
# Name: test_context_gauge.py
# Version: 1.0.0
# Description: Tests for context_gauge prompt handler
# Branch: hooks
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""Tests for handlers/prompt/context_gauge.py."""
import json
from unittest.mock import patch
import pytest
MODULE = "aipass.hooks.apps.handlers.prompt.context_gauge"
def _write_transcript(path, input_tokens=0, cache_read=0, cache_creation=0):
entry = {
"type": "assistant",
"message": {
"usage": {
"input_tokens": input_tokens,
"cache_read_input_tokens": cache_read,
"cache_creation_input_tokens": cache_creation,
}
},
}
path.write_text(json.dumps(entry), encoding="utf-8")
class TestContextGaugeHandle:
def test_no_transcript_path_is_a_noop(self):
from aipass.hooks.apps.handlers.prompt.context_gauge import handle
result = handle({"session_id": "s1"})
assert result == {"stdout": "", "exit_code": 0}
def test_below_nudge_threshold_is_silent(self, tmp_path, monkeypatch):
from aipass.hooks.apps.handlers.prompt.context_gauge import handle
monkeypatch.setenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "200000")
transcript = tmp_path / "t.jsonl"
_write_transcript(transcript, cache_read=50_000)
with patch(f"{MODULE}._GUARD_DIR", tmp_path):
result = handle({"session_id": "s-below", "transcript_path": str(transcript), "cwd": str(tmp_path)})
assert result == {"stdout": "", "exit_code": 0}
def test_fires_nudge_at_80_percent_of_trigger(self, tmp_path, monkeypatch):
from aipass.hooks.apps.handlers.prompt.context_gauge import handle
monkeypatch.setenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "200000")
transcript = tmp_path / "t.jsonl"
# trigger = 200000 * 0.9 = 180000; 80% of that = 144000
_write_transcript(transcript, cache_read=145_000)
with patch(f"{MODULE}._GUARD_DIR", tmp_path):
result = handle({"session_id": "s-nudge", "transcript_path": str(transcript), "cwd": str(tmp_path)})
assert result["exit_code"] == 0
assert "CONTEXT GAUGE" in result["stdout"]
assert "run /prep NOW" in result["stdout"]
assert "wrap up the current work item" not in result["stdout"]
assert result["sound"] == "context gauge"
def test_fires_escalate_at_95_percent_of_trigger(self, tmp_path, monkeypatch):
from aipass.hooks.apps.handlers.prompt.context_gauge import handle
monkeypatch.setenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "200000")
transcript = tmp_path / "t.jsonl"
# 95% of 180000 trigger = 171000
_write_transcript(transcript, cache_read=175_000)
with patch(f"{MODULE}._GUARD_DIR", tmp_path):
result = handle({"session_id": "s-escalate", "transcript_path": str(transcript), "cwd": str(tmp_path)})
assert result["exit_code"] == 0
assert "CONTEXT GAUGE" in result["stdout"]
assert "wrap up the current work item" in result["stdout"]
assert result["sound"] == "context gauge"
def test_fires_once_per_threshold_per_session(self, tmp_path, monkeypatch):
from aipass.hooks.apps.handlers.prompt.context_gauge import handle
monkeypatch.setenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "200000")
transcript = tmp_path / "t.jsonl"
_write_transcript(transcript, cache_read=145_000)
hook_data = {"session_id": "s-once", "transcript_path": str(transcript), "cwd": str(tmp_path)}
with patch(f"{MODULE}._GUARD_DIR", tmp_path):
first = handle(hook_data)
second = handle(hook_data)
assert "CONTEXT GAUGE" in first["stdout"]
assert second == {"stdout": "", "exit_code": 0}
def test_different_sessions_fire_independently(self, tmp_path, monkeypatch):
from aipass.hooks.apps.handlers.prompt.context_gauge import handle
monkeypatch.setenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "200000")
transcript = tmp_path / "t.jsonl"
_write_transcript(transcript, cache_read=145_000)
with patch(f"{MODULE}._GUARD_DIR", tmp_path):
first = handle({"session_id": "s-indep-a", "transcript_path": str(transcript), "cwd": str(tmp_path)})
second = handle({"session_id": "s-indep-b", "transcript_path": str(transcript), "cwd": str(tmp_path)})
assert "CONTEXT GAUGE" in first["stdout"]
assert "CONTEXT GAUGE" in second["stdout"]
def test_missing_usage_is_a_noop(self, tmp_path):
from aipass.hooks.apps.handlers.prompt.context_gauge import handle
transcript = tmp_path / "t.jsonl"
transcript.write_text(json.dumps({"type": "user", "message": {}}), encoding="utf-8")
result = handle({"session_id": "s-nousage", "transcript_path": str(transcript), "cwd": str(tmp_path)})
assert result == {"stdout": "", "exit_code": 0}
def test_never_raises_on_unexpected_error(self, monkeypatch):
from aipass.hooks.apps.handlers.prompt import context_gauge
from aipass.hooks.apps.modules import context_window
def _boom(*_args, **_kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(context_window, "read_latest_usage", _boom)
result = context_gauge.handle({"session_id": "s-err", "transcript_path": "/tmp/x.jsonl"})
assert result == {"stdout": "", "exit_code": 0}
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,144 @@
# =================== AIPass ====================
# Name: test_context_window.py
# Version: 1.0.0
# Description: Tests for shared context_window module
# Branch: hooks
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""Tests for apps/modules/context_window.py."""
import json
import pytest
class TestFindBranchDir:
def test_finds_branch_dir_from_nested_cwd(self, tmp_path):
from aipass.hooks.apps.modules.context_window import find_branch_dir
branch_dir = tmp_path / "src" / "aipass" / "widget"
nested = branch_dir / "apps" / "handlers"
nested.mkdir(parents=True)
result = find_branch_dir(str(nested))
assert result == branch_dir
def test_falls_back_to_trinity_dir(self, tmp_path):
from aipass.hooks.apps.modules.context_window import find_branch_dir
(tmp_path / ".trinity").mkdir()
result = find_branch_dir(str(tmp_path))
assert result == tmp_path
def test_returns_none_when_unresolvable(self, tmp_path):
from aipass.hooks.apps.modules.context_window import find_branch_dir
result = find_branch_dir(str(tmp_path))
assert result is None
class TestReadLatestUsage:
def test_returns_latest_assistant_usage(self, tmp_path):
from aipass.hooks.apps.modules.context_window import read_latest_usage
transcript = tmp_path / "t.jsonl"
lines = [
json.dumps(
{"type": "assistant", "message": {"usage": {"input_tokens": 1, "cache_read_input_tokens": 100}}}
),
json.dumps({"type": "user", "message": {}}),
json.dumps(
{"type": "assistant", "message": {"usage": {"input_tokens": 2, "cache_read_input_tokens": 200}}}
),
]
transcript.write_text("\n".join(lines), encoding="utf-8")
usage = read_latest_usage(str(transcript))
assert usage == {"input_tokens": 2, "cache_read_input_tokens": 200}
def test_returns_none_for_missing_file(self, tmp_path):
from aipass.hooks.apps.modules.context_window import read_latest_usage
usage = read_latest_usage(str(tmp_path / "nope.jsonl"))
assert usage is None
def test_returns_none_for_empty_path(self):
from aipass.hooks.apps.modules.context_window import read_latest_usage
assert read_latest_usage("") is None
def test_tail_read_skips_earlier_content(self, tmp_path):
"""Only the last ~tail_bytes are read — a partial leading line is tolerated."""
from aipass.hooks.apps.modules.context_window import read_latest_usage
transcript = tmp_path / "t.jsonl"
filler = json.dumps({"type": "assistant", "message": {"usage": {"input_tokens": 1}}}) + "\n"
target = json.dumps({"type": "assistant", "message": {"usage": {"input_tokens": 99}}})
transcript.write_text(filler * 2000 + target, encoding="utf-8")
usage = read_latest_usage(str(transcript), tail_bytes=200)
assert usage == {"input_tokens": 99}
def test_ignores_non_assistant_entries_without_usage(self, tmp_path):
from aipass.hooks.apps.modules.context_window import read_latest_usage
transcript = tmp_path / "t.jsonl"
lines = [
json.dumps({"type": "assistant", "message": {"usage": {"input_tokens": 5}}}),
json.dumps({"type": "assistant", "message": {}}),
"not json at all",
]
transcript.write_text("\n".join(lines), encoding="utf-8")
usage = read_latest_usage(str(transcript))
assert usage == {"input_tokens": 5}
class TestContextFillTokens:
def test_sums_all_three_fields(self):
from aipass.hooks.apps.modules.context_window import context_fill_tokens
usage = {"input_tokens": 1, "cache_read_input_tokens": 2, "cache_creation_input_tokens": 3}
assert context_fill_tokens(usage) == 6
def test_missing_fields_default_to_zero(self):
from aipass.hooks.apps.modules.context_window import context_fill_tokens
assert context_fill_tokens({}) == 0
class TestResolveCompactWindow:
def test_env_var_takes_precedence(self, tmp_path, monkeypatch):
from aipass.hooks.apps.modules.context_window import resolve_compact_window
monkeypatch.setenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "350000")
assert resolve_compact_window(str(tmp_path)) == 350000
def test_reads_branch_settings_when_no_env(self, tmp_path, monkeypatch):
from aipass.hooks.apps.modules.context_window import resolve_compact_window
monkeypatch.delenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW", raising=False)
branch_dir = tmp_path / "src" / "aipass" / "widget"
claude_dir = branch_dir / ".claude"
claude_dir.mkdir(parents=True)
(claude_dir / "settings.local.json").write_text(json.dumps({"autoCompactWindow": 350000}), encoding="utf-8")
assert resolve_compact_window(str(branch_dir)) == 350000
def test_defaults_to_200k_when_nothing_found(self, tmp_path, monkeypatch):
from aipass.hooks.apps.modules.context_window import resolve_compact_window
monkeypatch.delenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW", raising=False)
assert resolve_compact_window(str(tmp_path)) == 200_000
def test_bad_env_value_falls_through(self, tmp_path, monkeypatch):
from aipass.hooks.apps.modules.context_window import resolve_compact_window
monkeypatch.setenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "not-a-number")
assert resolve_compact_window(str(tmp_path)) == 200_000
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+259 -16
View File
@@ -272,6 +272,54 @@ class TestDispatch:
assert "ok" in result[0] assert "ok" in result[0]
assert result[1] == 0 assert result[1] == 0
def test_trust_break_banner_short_circuits_presence_gate_dispatch(self, mock_logger):
"""A hash-mismatch banner must reach the user even though presence_gate's own hook_def is empty."""
config = {"hooks_enabled": True, "UserPromptSubmit": {"presence_gate": {}}}
with (
patch("aipass.hooks.apps.modules.engine._log"),
patch(
"aipass.hooks.apps.handlers.config.loader.trust_break_banner",
return_value="# TRUST BREAK — ALL AIPASS HOOKS DISABLED",
),
patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run,
):
result = dispatch("UserPromptSubmit", "{}", config)
mock_run.assert_not_called()
assert result == ("# TRUST BREAK — ALL AIPASS HOOKS DISABLED", 0)
def test_no_trust_break_falls_through_to_normal_dispatch(self, mock_logger):
config = {"hooks_enabled": True, "UserPromptSubmit": {"presence_gate": {}}}
with (
patch("aipass.hooks.apps.modules.engine._log"),
patch("aipass.hooks.apps.handlers.config.loader.trust_break_banner", return_value=None),
):
result = dispatch("UserPromptSubmit", "{}", config)
assert result == ("", 0)
def test_trust_break_check_skipped_when_presence_gate_not_dispatched(self, mock_logger):
"""The check is scoped to the presence_gate-filtered bridge call, not every UserPromptSubmit dispatch."""
config = {
"hooks_enabled": True,
"UserPromptSubmit": {"other_hook": {"enabled": True, "command": "echo hi", "matcher": ""}},
}
with (
patch("aipass.hooks.apps.modules.engine._log"),
patch("aipass.hooks.apps.handlers.config.loader.trust_break_banner") as mock_banner,
patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run,
):
mock_run.return_value = {"exit_code": 0, "stdout": "hi", "stderr": "", "elapsed_ms": 5}
dispatch("UserPromptSubmit", "{}", config)
mock_banner.assert_not_called()
def test_trust_break_check_skipped_for_non_prompt_events(self, mock_logger):
config = {"hooks_enabled": True, "PreToolUse": {"presence_gate": {}}}
with (
patch("aipass.hooks.apps.modules.engine._log"),
patch("aipass.hooks.apps.handlers.config.loader.trust_break_banner") as mock_banner,
):
dispatch("PreToolUse", "{}", config)
mock_banner.assert_not_called()
class TestFindProjectConfig: class TestFindProjectConfig:
"""Tests for find_project_config() CWD walk.""" """Tests for find_project_config() CWD walk."""
@@ -320,7 +368,7 @@ class TestLog:
def test_writes_jsonl_entry(self, temp_test_dir, mock_logger): def test_writes_jsonl_entry(self, temp_test_dir, mock_logger):
log_file = temp_test_dir / "test.jsonl" log_file = temp_test_dir / "test.jsonl"
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
_log({"event": "Test", "action": "test_write"}) _log({"event": "Test", "action": "test_write"})
lines = log_file.read_text().strip().split("\n") lines = log_file.read_text().strip().split("\n")
assert len(lines) == 1 assert len(lines) == 1
@@ -330,13 +378,13 @@ class TestLog:
def test_creates_parent_directory(self, temp_test_dir, mock_logger): def test_creates_parent_directory(self, temp_test_dir, mock_logger):
log_file = temp_test_dir / "subdir" / "test.jsonl" log_file = temp_test_dir / "subdir" / "test.jsonl"
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
_log({"event": "Test"}) _log({"event": "Test"})
assert log_file.exists() assert log_file.exists()
def test_appends_multiple_entries(self, temp_test_dir, mock_logger): def test_appends_multiple_entries(self, temp_test_dir, mock_logger):
log_file = temp_test_dir / "test.jsonl" log_file = temp_test_dir / "test.jsonl"
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
_log({"event": "A"}) _log({"event": "A"})
_log({"event": "B"}) _log({"event": "B"})
lines = log_file.read_text().strip().split("\n") lines = log_file.read_text().strip().split("\n")
@@ -435,10 +483,12 @@ class TestErrorResilience:
config = find_project_config() config = find_project_config()
assert config is None assert config is None
def test_log_write_failure_does_not_crash(self, mock_logger): def test_log_write_failure_does_not_crash(self, tmp_path, mock_logger):
# A bare-Mock LOG_FILE leaks a real MagicMock/LOG_FILE/ dir into the CWD
# via append_jsonl's Path(...).parent.mkdir — use a real tmp path.
log_file = tmp_path / "engine.jsonl"
with patch("builtins.open", side_effect=OSError("disk full")): with patch("builtins.open", side_effect=OSError("disk full")):
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE") as mock_path: with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
mock_path.parent.mkdir = MagicMock()
_log({"event": "Test"}) _log({"event": "Test"})
@@ -456,7 +506,7 @@ class TestDataStructureContracts:
def test_log_entry_has_required_fields(self, temp_test_dir, mock_logger): def test_log_entry_has_required_fields(self, temp_test_dir, mock_logger):
log_file = temp_test_dir / "test.jsonl" log_file = temp_test_dir / "test.jsonl"
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
_log({"ts": 123.0, "event": "Test", "action": "check"}) _log({"ts": 123.0, "event": "Test", "action": "check"})
entry = json.loads(log_file.read_text().strip()) entry = json.loads(log_file.read_text().strip())
assert "ts" in entry assert "ts" in entry
@@ -505,7 +555,7 @@ class TestInitProvisioning:
def test_log_auto_creates_directory(self, temp_test_dir, mock_logger): def test_log_auto_creates_directory(self, temp_test_dir, mock_logger):
log_file = temp_test_dir / "new_dir" / "engine.jsonl" log_file = temp_test_dir / "new_dir" / "engine.jsonl"
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
_log({"event": "init_test"}) _log({"event": "init_test"})
assert log_file.parent.exists() assert log_file.parent.exists()
@@ -529,7 +579,7 @@ class TestInitProvisioning:
def test_log_no_overwrite_on_append(self, temp_test_dir, mock_logger): def test_log_no_overwrite_on_append(self, temp_test_dir, mock_logger):
log_file = temp_test_dir / "test.jsonl" log_file = temp_test_dir / "test.jsonl"
log_file.write_text('{"existing": true}\n') log_file.write_text('{"existing": true}\n')
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
_log({"event": "new"}) _log({"event": "new"})
lines = log_file.read_text().strip().split("\n") lines = log_file.read_text().strip().split("\n")
assert len(lines) == 2 assert len(lines) == 2
@@ -664,7 +714,7 @@ class TestConfigDataContracts:
def test_data_keys_in_log_entry(self, temp_test_dir, mock_logger): def test_data_keys_in_log_entry(self, temp_test_dir, mock_logger):
log_file = temp_test_dir / "test.jsonl" log_file = temp_test_dir / "test.jsonl"
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
_log({"ts": 1.0, "event": "Test", "hook": "test_hook", "exit_code": 0}) _log({"ts": 1.0, "event": "Test", "hook": "test_hook", "exit_code": 0})
entry = json.loads(log_file.read_text().strip()) entry = json.loads(log_file.read_text().strip())
assert "ts" in entry assert "ts" in entry
@@ -708,7 +758,7 @@ class TestErrorResilienceExtended:
def test_missing_file_in_log_path(self, temp_test_dir, mock_logger): def test_missing_file_in_log_path(self, temp_test_dir, mock_logger):
missing = temp_test_dir / "missing" / "nonexistent.jsonl" missing = temp_test_dir / "missing" / "nonexistent.jsonl"
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", missing): with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=missing):
_log({"event": "test_missing"}) _log({"event": "test_missing"})
assert missing.exists() assert missing.exists()
@@ -887,19 +937,212 @@ class TestLayerATrustEnforcement:
assert "ok" in result[0] assert "ok" in result[0]
class TestRunHandlerTimeout:
"""DPLAN-0256 backlog #1: handler-type hooks had no engine-side timeout — a hung
handler could stall the whole event. _run_handler now runs the handler on a
daemon thread and joins with a timeout instead of calling it inline."""
def test_handler_completes_within_timeout(self, mock_logger):
from aipass.hooks.apps.modules.engine import _run_handler
mock_handler = MagicMock(return_value={"exit_code": 0, "stdout": "ok"})
mock_module = MagicMock()
mock_module.handle = mock_handler
with patch("importlib.import_module", return_value=mock_module):
result = _run_handler("aipass.hooks.apps.handlers.notification.stop_sound.handle", {}, timeout_s=1)
assert result["exit_code"] == 0
assert result["stdout"] == "ok"
def test_handler_exceeding_timeout_returns_timeout_marker(self, mock_logger):
import time as time_module
from aipass.hooks.apps.modules.engine import _run_handler
def _slow_handler(_data):
time_module.sleep(1.3)
return {"exit_code": 0, "stdout": "too late"}
mock_module = MagicMock()
mock_module.handle = _slow_handler
with patch("importlib.import_module", return_value=mock_module):
result = _run_handler("aipass.hooks.apps.handlers.fake.handle", {}, timeout_s=1)
assert result["exit_code"] == -1
assert result["stderr"] == "TIMEOUT"
def test_handler_timeout_does_not_block_caller(self, mock_logger):
"""Regression: the caller must return promptly even if the handler thread never finishes."""
import time as time_module
from aipass.hooks.apps.modules.engine import _run_handler
def _hangs_much_longer_than_timeout(_data):
time_module.sleep(3)
return {"exit_code": 0, "stdout": "should never see this"}
mock_module = MagicMock()
mock_module.handle = _hangs_much_longer_than_timeout
with patch("importlib.import_module", return_value=mock_module):
start = time_module.monotonic()
result = _run_handler("aipass.hooks.apps.handlers.fake.handle", {}, timeout_s=1)
elapsed = time_module.monotonic() - start
assert elapsed < 2.0
assert result["stderr"] == "TIMEOUT"
def test_default_handler_timeout_is_30(self):
import inspect
from aipass.hooks.apps.modules.engine import _run_handler
sig = inspect.signature(_run_handler)
assert sig.parameters["timeout_s"].default == 30
def test_handler_exception_still_surfaces_as_error_not_timeout(self, mock_logger):
from aipass.hooks.apps.modules.engine import _run_handler
def _boom(_data):
raise RuntimeError("handler blew up")
mock_module = MagicMock()
mock_module.handle = _boom
with patch("importlib.import_module", return_value=mock_module):
result = _run_handler("aipass.hooks.apps.handlers.fake.handle", {}, timeout_s=1)
assert result["exit_code"] == -1
assert "handler blew up" in result["stderr"]
assert result["stderr"] != "TIMEOUT"
class TestDispatchHandlerTimeout:
"""Dispatch-level wiring for DPLAN-0256 backlog #1 — hooks.json timeout fields
are honored for handler-type hooks, a sane default applies when absent, and a
timeout fails loud (log + sound) without ever silently swallowing or stalling
the rest of the event."""
def test_handler_timeout_propagates_hook_def_value(self, mock_logger):
config = {
"hooks_enabled": True,
"UserPromptSubmit": {
"slow_handler": {
"enabled": True,
"handler": "aipass.hooks.apps.handlers.fake.handle",
"matcher": "",
"timeout": 5,
}
},
}
with (
patch("aipass.hooks.apps.modules.engine._log"),
patch("aipass.hooks.apps.modules.engine._run_handler") as mock_run,
):
mock_run.return_value = {"exit_code": 0, "stdout": "ok", "stderr": "", "elapsed_ms": 5}
dispatch("UserPromptSubmit", "{}", config)
mock_run.assert_called_once_with("aipass.hooks.apps.handlers.fake.handle", {}, timeout_s=5)
def test_handler_default_timeout_is_30_when_unset(self, mock_logger):
config = {
"hooks_enabled": True,
"UserPromptSubmit": {
"no_timeout_handler": {
"enabled": True,
"handler": "aipass.hooks.apps.handlers.fake.handle",
"matcher": "",
}
},
}
with (
patch("aipass.hooks.apps.modules.engine._log"),
patch("aipass.hooks.apps.modules.engine._run_handler") as mock_run,
):
mock_run.return_value = {"exit_code": 0, "stdout": "ok", "stderr": "", "elapsed_ms": 5}
dispatch("UserPromptSubmit", "{}", config)
mock_run.assert_called_once_with("aipass.hooks.apps.handlers.fake.handle", {}, timeout_s=30)
def test_timeout_logs_speaks_and_lets_dispatch_continue(self, mock_logger):
config = {
"hooks_enabled": True,
"UserPromptSubmit": {
"hung_handler": {
"enabled": True,
"handler": "aipass.hooks.apps.handlers.fake.handle",
"matcher": "",
},
"next_handler": {
"enabled": True,
"handler": "aipass.hooks.apps.handlers.fake2.handle",
"matcher": "",
},
},
}
with (
patch("aipass.hooks.apps.modules.engine._log") as mock_log,
patch("aipass.hooks.apps.modules.engine._run_handler") as mock_run,
patch("aipass.hooks.apps.sound.speak") as mock_speak,
):
mock_run.side_effect = [
{"exit_code": -1, "stdout": "", "stderr": "TIMEOUT", "elapsed_ms": 30000},
{"exit_code": 0, "stdout": "survived", "stderr": "", "elapsed_ms": 5},
]
result = dispatch("UserPromptSubmit", "{}", config)
assert "survived" in result[0]
assert result[1] == 0
mock_speak.assert_called_once()
log_calls = [c[0][0] for c in mock_log.call_args_list]
assert any(e.get("action") == "timeout" for e in log_calls if isinstance(e, dict))
def test_timed_out_handler_produces_no_output(self, mock_logger):
config = {
"hooks_enabled": True,
"UserPromptSubmit": {
"hung_handler": {
"enabled": True,
"handler": "aipass.hooks.apps.handlers.fake.handle",
"matcher": "",
},
},
}
with (
patch("aipass.hooks.apps.modules.engine._log"),
patch("aipass.hooks.apps.modules.engine._run_handler") as mock_run,
patch("aipass.hooks.apps.sound.speak"),
):
mock_run.return_value = {"exit_code": -1, "stdout": "", "stderr": "TIMEOUT", "elapsed_ms": 30000}
result = dispatch("UserPromptSubmit", "{}", config)
assert result == ("", 0)
def test_command_type_timeout_also_fails_loud(self, mock_logger):
"""The fail-loud path is shared by both hook types — command-type timeouts already
existed (subprocess timeout=), but were silent past the generic per-hook log line."""
config = {
"hooks_enabled": True,
"PreToolUse": {
"slow_cmd": {"enabled": True, "command": "sleep 999", "matcher": ""},
},
}
with (
patch("aipass.hooks.apps.modules.engine._log") as mock_log,
patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run,
patch("aipass.hooks.apps.sound.speak") as mock_speak,
):
mock_run.return_value = {"exit_code": -1, "stdout": "", "stderr": "TIMEOUT", "elapsed_ms": 30000}
result = dispatch("PreToolUse", '{"tool_name":"Edit"}', config)
assert result == ("", 0)
mock_speak.assert_called_once()
log_calls = [c[0][0] for c in mock_log.call_args_list]
assert any(e.get("action") == "timeout" for e in log_calls if isinstance(e, dict))
class TestJsonHandlerNotApplicable: class TestJsonHandlerNotApplicable:
"""Hooks uses JSONL logging, not json_handler. These verify the log equivalent.""" """Hooks uses JSONL logging, not json_handler. These verify the log equivalent."""
def test_log_default_factory(self, temp_test_dir, mock_logger): def test_log_default_factory(self, temp_test_dir, mock_logger):
log_file = temp_test_dir / "factory.jsonl" log_file = temp_test_dir / "factory.jsonl"
assert not log_file.exists() assert not log_file.exists()
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
_log({"event": "factory_test"}) _log({"event": "factory_test"})
assert log_file.exists() assert log_file.exists()
def test_log_validate_json_output(self, temp_test_dir, mock_logger): def test_log_validate_json_output(self, temp_test_dir, mock_logger):
log_file = temp_test_dir / "validate.jsonl" log_file = temp_test_dir / "validate.jsonl"
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
_log({"event": "A", "ts": 1.0}) _log({"event": "A", "ts": 1.0})
_log({"event": "B", "ts": 2.0}) _log({"event": "B", "ts": 2.0})
for line in log_file.read_text().strip().split("\n"): for line in log_file.read_text().strip().split("\n"):
@@ -914,13 +1157,13 @@ class TestJsonHandlerNotApplicable:
def test_log_ensure_exists(self, temp_test_dir, mock_logger): def test_log_ensure_exists(self, temp_test_dir, mock_logger):
log_file = temp_test_dir / "new_dir" / "ensure.jsonl" log_file = temp_test_dir / "new_dir" / "ensure.jsonl"
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
_log({"ensure": True}) _log({"ensure": True})
assert log_file.parent.exists() assert log_file.parent.exists()
def test_log_save_entry(self, temp_test_dir, mock_logger): def test_log_save_entry(self, temp_test_dir, mock_logger):
log_file = temp_test_dir / "save.jsonl" log_file = temp_test_dir / "save.jsonl"
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
_log({"saved": True, "value": 42}) _log({"saved": True, "value": 42})
entry = json.loads(log_file.read_text().strip()) entry = json.loads(log_file.read_text().strip())
assert entry["saved"] is True assert entry["saved"] is True
@@ -935,7 +1178,7 @@ class TestJsonHandlerNotApplicable:
def test_log_operation_recorded(self, temp_test_dir, mock_logger): def test_log_operation_recorded(self, temp_test_dir, mock_logger):
log_file = temp_test_dir / "ops.jsonl" log_file = temp_test_dir / "ops.jsonl"
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
_log({"event": "PreToolUse", "hook": "test", "exit_code": 0}) _log({"event": "PreToolUse", "hook": "test", "exit_code": 0})
entry = json.loads(log_file.read_text().strip()) entry = json.loads(log_file.read_text().strip())
assert entry["event"] == "PreToolUse" assert entry["event"] == "PreToolUse"
@@ -107,6 +107,25 @@ class TestFeedbackPulseHandler:
assert result["stdout"] == "" assert result["stdout"] == ""
def test_disabled_logs_distinguishing_line(self, tmp_path, caplog):
import logging
with (
patch(
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
tmp_path,
),
patch(
"aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled",
return_value=True,
),
caplog.at_level(logging.INFO),
):
for i in range(11):
self._handler()({"session_id": "test-disabled-log"})
assert "skipped (disabled via .aipass/feedback_off sentinel)" in caplog.text
def test_output_is_one_line(self, tmp_path): def test_output_is_one_line(self, tmp_path):
result = {"stdout": "", "exit_code": 0} result = {"stdout": "", "exit_code": 0}
with ( with (
+110 -20
View File
@@ -257,19 +257,20 @@ class TestExpiredAlertCleanup:
class TestAlertSound: class TestAlertSound:
"""Sound fires on first injection only.""" """Sound fires once per alert per session (session-keyed tempdir guard)."""
def test_sound_on_first_injection(self, tmp_path): def test_sound_on_first_injection(self, tmp_path):
from aipass.hooks.apps.handlers.prompt import persistent_alert from aipass.hooks.apps.handlers.prompt import persistent_alert
persistent_alert._announced.clear()
aipass_dir = tmp_path / ".aipass" aipass_dir = tmp_path / ".aipass"
aipass_dir.mkdir() aipass_dir.mkdir()
_write_alerts(aipass_dir, [_make_alert(alert_id="snd-001")]) _write_alerts(aipass_dir, [_make_alert(alert_id="snd-001")])
with patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir): with (
result = persistent_alert.handle({}) patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir),
patch.object(persistent_alert, "_GUARD_DIR", tmp_path),
):
result = persistent_alert.handle({"session_id": "s-snd-001"})
assert "sound" in result assert "sound" in result
assert "1 active alert" in result["sound"] assert "1 active alert" in result["sound"]
@@ -277,29 +278,59 @@ class TestAlertSound:
def test_no_sound_on_repeat_injection(self, tmp_path): def test_no_sound_on_repeat_injection(self, tmp_path):
from aipass.hooks.apps.handlers.prompt import persistent_alert from aipass.hooks.apps.handlers.prompt import persistent_alert
persistent_alert._announced.clear()
aipass_dir = tmp_path / ".aipass" aipass_dir = tmp_path / ".aipass"
aipass_dir.mkdir() aipass_dir.mkdir()
_write_alerts(aipass_dir, [_make_alert(alert_id="snd-002")]) _write_alerts(aipass_dir, [_make_alert(alert_id="snd-002")])
with patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir): with (
persistent_alert.handle({}) patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir),
result = persistent_alert.handle({}) patch.object(persistent_alert, "_GUARD_DIR", tmp_path),
):
persistent_alert.handle({"session_id": "s-snd-002"})
result = persistent_alert.handle({"session_id": "s-snd-002"})
assert "sound" not in result assert "sound" not in result
def test_sound_on_new_alert_added(self, tmp_path): def test_fresh_process_same_session_still_dedupes(self, tmp_path):
"""Regression: module-global set used to reset every process (real bridge calls
are fresh processes each time) so sound fired on every prompt. Guard file persists
across separate handle() calls even after re-importing the module fresh."""
import importlib
from aipass.hooks.apps.handlers.prompt import persistent_alert from aipass.hooks.apps.handlers.prompt import persistent_alert
persistent_alert._announced.clear() aipass_dir = tmp_path / ".aipass"
aipass_dir.mkdir()
_write_alerts(aipass_dir, [_make_alert(alert_id="snd-fresh")])
with (
patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir),
patch.object(persistent_alert, "_GUARD_DIR", tmp_path),
):
persistent_alert.handle({"session_id": "s-fresh"})
fresh_module = importlib.reload(persistent_alert)
with (
patch.object(fresh_module, "_find_aipass_dir", return_value=aipass_dir),
patch.object(fresh_module, "_GUARD_DIR", tmp_path),
):
result = fresh_module.handle({"session_id": "s-fresh"})
assert "sound" not in result
importlib.reload(persistent_alert)
def test_sound_on_new_alert_added(self, tmp_path):
from aipass.hooks.apps.handlers.prompt import persistent_alert
aipass_dir = tmp_path / ".aipass" aipass_dir = tmp_path / ".aipass"
aipass_dir.mkdir() aipass_dir.mkdir()
_write_alerts(aipass_dir, [_make_alert(alert_id="snd-003")]) _write_alerts(aipass_dir, [_make_alert(alert_id="snd-003")])
with patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir): with (
persistent_alert.handle({}) patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir),
patch.object(persistent_alert, "_GUARD_DIR", tmp_path),
):
persistent_alert.handle({"session_id": "s-snd-003"})
_write_alerts( _write_alerts(
aipass_dir, aipass_dir,
@@ -309,27 +340,86 @@ class TestAlertSound:
], ],
) )
with patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir): with (
result = persistent_alert.handle({}) patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir),
patch.object(persistent_alert, "_GUARD_DIR", tmp_path),
):
result = persistent_alert.handle({"session_id": "s-snd-003"})
assert "sound" in result assert "sound" in result
assert "2 active alerts" in result["sound"] assert "2 active alerts" in result["sound"]
def test_no_sound_when_no_alerts(self, tmp_path): def test_different_sessions_both_hear_sound(self, tmp_path):
from aipass.hooks.apps.handlers.prompt import persistent_alert from aipass.hooks.apps.handlers.prompt import persistent_alert
persistent_alert._announced.clear() aipass_dir = tmp_path / ".aipass"
aipass_dir.mkdir()
_write_alerts(aipass_dir, [_make_alert(alert_id="snd-indep")])
with (
patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir),
patch.object(persistent_alert, "_GUARD_DIR", tmp_path),
):
first = persistent_alert.handle({"session_id": "s-a"})
second = persistent_alert.handle({"session_id": "s-b"})
assert "sound" in first
assert "sound" in second
def test_no_sound_when_no_alerts(self, tmp_path):
from aipass.hooks.apps.handlers.prompt import persistent_alert
aipass_dir = tmp_path / ".aipass" aipass_dir = tmp_path / ".aipass"
aipass_dir.mkdir() aipass_dir.mkdir()
_write_alerts(aipass_dir, []) _write_alerts(aipass_dir, [])
with patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir): with (
result = persistent_alert.handle({}) patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir),
patch.object(persistent_alert, "_GUARD_DIR", tmp_path),
):
result = persistent_alert.handle({"session_id": "s-none"})
assert "sound" not in result assert "sound" not in result
class TestAlertBannerCap:
"""Banner truncates at _MAX_ALERTS_SHOWN with a hidden-count note."""
def test_cap_truncates_and_notes_hidden_count(self, tmp_path):
from aipass.hooks.apps.handlers.prompt import persistent_alert
aipass_dir = tmp_path / ".aipass"
aipass_dir.mkdir()
alerts = [_make_alert(alert_id=f"cap-{i}", title=f"Alert {i}") for i in range(13)]
_write_alerts(aipass_dir, alerts)
with (
patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir),
patch.object(persistent_alert, "_GUARD_DIR", tmp_path),
):
result = persistent_alert.handle({"session_id": "s-cap"})
assert "Alert 0" in result["stdout"]
assert "Alert 9" in result["stdout"]
assert "Alert 10" not in result["stdout"]
assert "...and 3 more" in result["stdout"]
def test_under_cap_no_hidden_note(self, tmp_path):
from aipass.hooks.apps.handlers.prompt import persistent_alert
aipass_dir = tmp_path / ".aipass"
aipass_dir.mkdir()
_write_alerts(aipass_dir, [_make_alert(alert_id="under-cap")])
with (
patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir),
patch.object(persistent_alert, "_GUARD_DIR", tmp_path),
):
result = persistent_alert.handle({"session_id": "s-under-cap"})
assert "more (dismiss some" not in result["stdout"]
class TestAlertDismiss: class TestAlertDismiss:
"""drone @hooks dismiss behavior.""" """drone @hooks dismiss behavior."""
@@ -0,0 +1,211 @@
# =================== AIPass ====================
# Name: test_pre_compact_prep.py
# Version: 1.0.0
# Description: Tests for pre_compact_prep lifecycle handler
# Branch: hooks
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""Tests for handlers/lifecycle/pre_compact_prep.py."""
import json
from unittest.mock import patch
import pytest
MODULE = "aipass.hooks.apps.handlers.lifecycle.pre_compact_prep"
def _make_branch(tmp_path, with_local=True, sessions=None):
branch_dir = tmp_path / "src" / "aipass" / "widget"
branch_dir.mkdir(parents=True)
if with_local:
trinity = branch_dir / ".trinity"
trinity.mkdir()
(trinity / "local.json").write_text(
json.dumps({"sessions": sessions if sessions is not None else []}), encoding="utf-8"
)
return branch_dir
class TestHandle:
def test_stamps_session_entry_and_returns_stdout(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
branch_dir = _make_branch(tmp_path, sessions=[{"number": 3, "date": "2026-01-01", "summary": "old"}])
with patch(f"{MODULE}._find_repo_root", return_value=None):
result = handle({"cwd": str(branch_dir)})
assert result["exit_code"] == 0
assert "AUTO-COMPACT SNAPSHOT" in result["stdout"]
assert result["sound"] == "pre compact prep"
data = json.loads((branch_dir / ".trinity" / "local.json").read_text(encoding="utf-8"))
assert len(data["sessions"]) == 2
newest = data["sessions"][0]
assert newest["number"] == 4
assert newest["status"] == "auto-compact"
assert "AUTO-COMPACT SNAPSHOT" in newest["summary"]
def test_prepends_number_as_max_plus_one(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
branch_dir = _make_branch(
tmp_path,
sessions=[
{"number": 10, "date": "2026-01-01", "summary": "a"},
{"number": 7, "date": "2026-01-01", "summary": "b"},
],
)
with patch(f"{MODULE}._find_repo_root", return_value=None):
handle({"cwd": str(branch_dir)})
data = json.loads((branch_dir / ".trinity" / "local.json").read_text(encoding="utf-8"))
assert data["sessions"][0]["number"] == 11
def test_no_branch_dir_resolved_is_a_noop(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
result = handle({"cwd": str(tmp_path)})
assert result == {"stdout": "", "exit_code": 0}
def test_missing_local_json_skips_write_but_still_returns_snapshot(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
branch_dir = _make_branch(tmp_path, with_local=False)
with patch(f"{MODULE}._find_repo_root", return_value=None):
result = handle({"cwd": str(branch_dir)})
assert result["exit_code"] == 0
assert "AUTO-COMPACT SNAPSHOT" in result["stdout"]
def test_malformed_local_json_never_raises_and_is_untouched(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
branch_dir = tmp_path / "src" / "aipass" / "widget"
trinity = branch_dir / ".trinity"
trinity.mkdir(parents=True)
local_path = trinity / "local.json"
local_path.write_text("{not valid json", encoding="utf-8")
with patch(f"{MODULE}._find_repo_root", return_value=None):
result = handle({"cwd": str(branch_dir)})
assert result["exit_code"] == 0
assert local_path.read_text(encoding="utf-8") == "{not valid json"
def test_sessions_not_a_list_skips_write(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
branch_dir = tmp_path / "src" / "aipass" / "widget"
trinity = branch_dir / ".trinity"
trinity.mkdir(parents=True)
local_path = trinity / "local.json"
local_path.write_text(json.dumps({"sessions": "not-a-list"}), encoding="utf-8")
with patch(f"{MODULE}._find_repo_root", return_value=None):
result = handle({"cwd": str(branch_dir)})
assert result["exit_code"] == 0
data = json.loads(local_path.read_text(encoding="utf-8"))
assert data["sessions"] == "not-a-list"
def test_summary_truncated_to_cap(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
branch_dir = _make_branch(tmp_path, sessions=[])
with patch(f"{MODULE}._find_repo_root", return_value=None):
with patch(f"{MODULE}._build_snapshot", return_value="X" * 500):
handle({"cwd": str(branch_dir)})
data = json.loads((branch_dir / ".trinity" / "local.json").read_text(encoding="utf-8"))
assert len(data["sessions"][0]["summary"]) == 300
def test_never_raises_on_unexpected_error(self):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
with patch("aipass.hooks.apps.modules.context_window.find_branch_dir", side_effect=RuntimeError("boom")):
result = handle({"cwd": "/tmp"})
assert result == {"stdout": "", "exit_code": 0}
class TestCountOpenPlans:
def test_counts_only_matching_location(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import _count_open_plans
branch_dir = tmp_path / "src" / "aipass" / "widget"
branch_dir.mkdir(parents=True)
fake_plans = [
("0001", {"location": str(branch_dir.resolve())}),
("0002", {"location": "/somewhere/else"}),
("0003", {"location": str(branch_dir.resolve())}),
]
with patch("aipass.flow.apps.handlers.plan.get_open_plans.get_open_plans", return_value=fake_plans):
count = _count_open_plans(branch_dir)
assert count == 2
def test_returns_none_on_import_failure(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import _count_open_plans
branch_dir = tmp_path / "src" / "aipass" / "widget"
branch_dir.mkdir(parents=True)
with patch("aipass.flow.apps.handlers.plan.get_open_plans.get_open_plans", side_effect=RuntimeError("no")):
count = _count_open_plans(branch_dir)
assert count is None
class TestCountActiveDispatchLocks:
def test_counts_lock_files_across_branches(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import _count_active_dispatch_locks
(tmp_path / "src" / "aipass" / "a" / ".ai_mail.local").mkdir(parents=True)
(tmp_path / "src" / "aipass" / "a" / ".ai_mail.local" / ".dispatch.lock").write_text("{}")
(tmp_path / "src" / "aipass" / "b" / ".ai_mail.local").mkdir(parents=True)
registry = {
"branches": [
{"name": "A", "path": "src/aipass/a"},
{"name": "B", "path": "src/aipass/b"},
]
}
(tmp_path / "AIPASS_REGISTRY.json").write_text(json.dumps(registry), encoding="utf-8")
count = _count_active_dispatch_locks(tmp_path)
assert count == 1
def test_returns_none_without_repo_root(self):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import _count_active_dispatch_locks
assert _count_active_dispatch_locks(None) is None
class TestInboxUnread:
def test_reads_unread_count(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import _inbox_unread
branch_dir = tmp_path / "widget"
mail_dir = branch_dir / ".ai_mail.local"
mail_dir.mkdir(parents=True)
(mail_dir / "inbox.json").write_text(json.dumps({"unread_count": 4}), encoding="utf-8")
assert _inbox_unread(branch_dir) == 4
def test_returns_none_when_missing(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import _inbox_unread
assert _inbox_unread(tmp_path / "widget") is None
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -170,6 +170,7 @@ class TestHandle:
): ):
result = presence_gate.handle({}) result = presence_gate.handle({})
assert result["exit_code"] == 0 assert result["exit_code"] == 0
assert result["sound"] == "presence gate"
def test_gate_error_allows(self): def test_gate_error_allows(self):
with patch.dict(os.environ, {"AIPASS_SESSION_TYPE": "interactive"}, clear=True): with patch.dict(os.environ, {"AIPASS_SESSION_TYPE": "interactive"}, clear=True):
@@ -49,6 +49,7 @@ class TestSessionStartHandler:
result = handle({"source": "startup", "session_id": "test-session"}) result = handle({"source": "startup", "session_id": "test-session"})
assert result["exit_code"] == 0 assert result["exit_code"] == 0
assert result["sound"] == "cadence reset"
data = json.loads(state_file.read_text()) data = json.loads(state_file.read_text())
assert data["turn"] == -1 assert data["turn"] == -1
@@ -857,7 +857,7 @@ class TestHandleIntegration:
} }
) )
assert result == {"stdout": "", "exit_code": 0} assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"}
assert pending_file.exists() assert pending_file.exists()
updated = json.loads(pending_file.read_text(encoding="utf-8")) updated = json.loads(pending_file.read_text(encoding="utf-8"))
assert updated["delivered"] is True assert updated["delivered"] is True
@@ -902,7 +902,7 @@ class TestHandleIntegration:
} }
) )
assert result == {"stdout": "", "exit_code": 0} assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"}
assert pending_file.exists() assert pending_file.exists()
updated = json.loads(pending_file.read_text(encoding="utf-8")) updated = json.loads(pending_file.read_text(encoding="utf-8"))
assert "delivered" not in updated assert "delivered" not in updated
@@ -996,7 +996,7 @@ class TestHandleIntegration:
} }
) )
assert result == {"stdout": "", "exit_code": 0} assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"}
assert extract_call_count >= 2 assert extract_call_count >= 2
assert pending_file.exists() assert pending_file.exists()
updated = json.loads(pending_file.read_text(encoding="utf-8")) updated = json.loads(pending_file.read_text(encoding="utf-8"))
@@ -1035,7 +1035,7 @@ class TestHandleIntegration:
} }
) )
assert result == {"stdout": "", "exit_code": 0} assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"}
assert not pending_file.exists() assert not pending_file.exists()
def test_already_delivered_skips_fallback(self, tmp_path): def test_already_delivered_skips_fallback(self, tmp_path):
@@ -1532,7 +1532,7 @@ class TestWriteDeliveryLog:
log_path = tmp_path / "delivery.jsonl" log_path = tmp_path / "delivery.jsonl"
with patch(LOGGER_PATCH), patch(f"{MOD}._DELIVERY_LOG", log_path): with patch(LOGGER_PATCH), patch(f"{MOD}._get_delivery_log", return_value=log_path):
_write_delivery_log( _write_delivery_log(
"hello", "hello",
["hello"], ["hello"],
@@ -1552,7 +1552,7 @@ class TestWriteDeliveryLog:
log_path = tmp_path / "delivery.jsonl" log_path = tmp_path / "delivery.jsonl"
with patch(LOGGER_PATCH), patch(f"{MOD}._DELIVERY_LOG", log_path): with patch(LOGGER_PATCH), patch(f"{MOD}._get_delivery_log", return_value=log_path):
_write_delivery_log( _write_delivery_log(
"**bold text**", "**bold text**",
["**bold text**"], ["**bold text**"],
@@ -1569,7 +1569,7 @@ class TestWriteDeliveryLog:
log_path = tmp_path / "delivery.jsonl" log_path = tmp_path / "delivery.jsonl"
with patch(LOGGER_PATCH), patch(f"{MOD}._DELIVERY_LOG", log_path): with patch(LOGGER_PATCH), patch(f"{MOD}._get_delivery_log", return_value=log_path):
_write_delivery_log( _write_delivery_log(
"hello", "hello",
["hello"], ["hello"],
@@ -1585,7 +1585,7 @@ class TestWriteDeliveryLog:
from aipass.hooks.apps.handlers.notification.telegram_response import _write_delivery_log from aipass.hooks.apps.handlers.notification.telegram_response import _write_delivery_log
impossible = Path("/dev/null/impossible/log.jsonl") impossible = Path("/dev/null/impossible/log.jsonl")
with patch(LOGGER_PATCH), patch(f"{MOD}._DELIVERY_LOG", impossible): with patch(LOGGER_PATCH), patch(f"{MOD}._get_delivery_log", return_value=impossible):
_write_delivery_log("hi", ["hi"], [{"idx": 0, "ok": True, "text": "hi"}], "s") _write_delivery_log("hi", ["hi"], [{"idx": 0, "ok": True, "text": "hi"}], "s")
@@ -1989,7 +1989,7 @@ class TestHandleMirrorIntegration:
} }
) )
assert result == {"stdout": "", "exit_code": 0} assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"}
assert len(sent_texts) == 1 assert len(sent_texts) == 1
assert "You: What is this?" in sent_texts[0] assert "You: What is this?" in sent_texts[0]
assert "This is the answer." in sent_texts[0] assert "This is the answer." in sent_texts[0]
+144
View File
@@ -0,0 +1,144 @@
# =================== AIPass ====================
# Name: test_temporal.py
# Version: 1.0.0
# Description: Tests for temporal prompt handler
# Branch: hooks
# Created: 2026-07-21
# Modified: 2026-07-21
# =============================================
"""Tests for handlers/prompt/temporal.py."""
from datetime import datetime, timedelta, timezone
import pytest
_PDT = timezone(timedelta(hours=-7), "PDT")
_UTC = timezone(timedelta(hours=0), "UTC")
class TestPartOfDay:
def test_morning_lower_bound(self):
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
assert _part_of_day(5) == "morning"
def test_morning_upper_bound(self):
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
assert _part_of_day(11) == "morning"
def test_afternoon_lower_bound(self):
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
assert _part_of_day(12) == "afternoon"
def test_afternoon_upper_bound(self):
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
assert _part_of_day(16) == "afternoon"
def test_evening_lower_bound(self):
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
assert _part_of_day(17) == "evening"
def test_evening_upper_bound(self):
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
assert _part_of_day(21) == "evening"
def test_night_wraps_midnight_upper(self):
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
assert _part_of_day(22) == "night"
def test_night_wraps_midnight_lower(self):
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
assert _part_of_day(0) == "night"
def test_night_upper_bound(self):
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
assert _part_of_day(4) == "night"
@pytest.mark.parametrize("hour", range(24))
def test_every_hour_has_exactly_one_bucket(self, hour):
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
assert _part_of_day(hour) in {"morning", "afternoon", "evening", "night"}
class TestFormatLine:
def test_shape_with_tz(self):
from aipass.hooks.apps.handlers.prompt.temporal import _format_line
line = _format_line(datetime(2026, 7, 21, 9, 42, tzinfo=_PDT))
assert line == "Temporal: Tue 2026-07-21 09:42 PDT (morning)"
def test_afternoon_example_different_tz(self):
from aipass.hooks.apps.handlers.prompt.temporal import _format_line
line = _format_line(datetime(2026, 7, 21, 14, 5, tzinfo=_UTC))
assert line == "Temporal: Tue 2026-07-21 14:05 UTC (afternoon)"
def test_night_example_after_midnight(self):
from aipass.hooks.apps.handlers.prompt.temporal import _format_line
line = _format_line(datetime(2026, 7, 22, 0, 30, tzinfo=_PDT))
assert line == "Temporal: Wed 2026-07-22 00:30 PDT (night)"
def test_naive_datetime_omits_tz(self):
from aipass.hooks.apps.handlers.prompt.temporal import _format_line
line = _format_line(datetime(2026, 7, 21, 9, 42))
assert line == "Temporal: Tue 2026-07-21 09:42 (morning)"
def test_is_a_single_line(self):
from aipass.hooks.apps.handlers.prompt.temporal import _format_line
line = _format_line(datetime(2026, 7, 21, 9, 42, tzinfo=_PDT))
assert "\n" not in line
class TestHandle:
def test_injects_temporal_line_every_call(self):
from aipass.hooks.apps.handlers.prompt.temporal import handle
result = handle({})
assert result["exit_code"] == 0
assert result["stdout"].startswith("Temporal: ")
assert result["sound"] == "temporal"
def test_uses_host_local_tz_not_a_hardcoded_one(self):
# Test host's own zone — never assume PDT/UTC/anything specific,
# a clone running in another zone must see its own local time.
from aipass.hooks.apps.handlers.prompt.temporal import handle
expected_tz = datetime.now().astimezone().strftime("%Z")
result = handle({})
if expected_tz:
assert expected_tz in result["stdout"]
def test_no_cadence_gating_fires_on_repeated_calls(self):
from aipass.hooks.apps.handlers.prompt.temporal import handle
first = handle({"session_id": "s1"})
second = handle({"session_id": "s1"})
assert first["stdout"] != ""
assert second["stdout"] != ""
def test_never_raises_on_unexpected_error(self, monkeypatch):
from aipass.hooks.apps.handlers.prompt import temporal
def _boom(*_args, **_kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(temporal, "_format_line", _boom)
result = temporal.handle({"session_id": "s-err"})
assert result == {"stdout": "", "exit_code": 0}
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+106 -1
View File
@@ -17,11 +17,12 @@ from aipass.hooks.apps.handlers.config.trust_registry import (
_hash_file, _hash_file,
bootstrap, bootstrap,
enroll, enroll,
is_hash_mismatch,
is_trusted, is_trusted,
read_registry, read_registry,
revoke, revoke,
) )
from aipass.hooks.apps.handlers.config.loader import find_project_config from aipass.hooks.apps.handlers.config.loader import find_project_config, trust_break_banner
class TestRegistryHelpers: class TestRegistryHelpers:
@@ -151,6 +152,110 @@ class TestIsTrusted:
assert is_trusted(str(project)) is False assert is_trusted(str(project)) is False
class TestIsHashMismatch:
"""Unit tests for is_hash_mismatch() — distinguishes a break from never-enrolled."""
def test_never_enrolled_is_not_a_mismatch(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
assert is_hash_mismatch("/not/registered") is False
def test_enrolled_matching_hash_is_not_a_mismatch(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
project = temp_test_dir / "myproject"
project.mkdir()
(project / ".aipass").mkdir()
(project / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
enroll(str(project))
assert is_hash_mismatch(str(project)) is False
def test_enrolled_changed_hash_is_a_mismatch(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
project = temp_test_dir / "myproject"
project.mkdir()
(project / ".aipass").mkdir()
hooks_file = project / ".aipass" / "hooks.json"
hooks_file.write_text('{"hooks_enabled": true}')
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
enroll(str(project))
hooks_file.write_text('{"hooks_enabled": true, "tampered": true}')
assert is_hash_mismatch(str(project)) is True
def test_enrolled_but_config_deleted_is_not_a_mismatch(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
project = temp_test_dir / "myproject"
project.mkdir()
(project / ".aipass").mkdir()
hooks_file = project / ".aipass" / "hooks.json"
hooks_file.write_text('{"hooks_enabled": true}')
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
enroll(str(project))
hooks_file.unlink()
assert is_hash_mismatch(str(project)) is False
class TestTrustBreakBanner:
"""Tests for loader.trust_break_banner() — the loud, config-independent signal."""
def test_no_hooks_json_anywhere_is_none(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
empty_dir = temp_test_dir / "no_project"
empty_dir.mkdir()
with (
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=empty_dir),
patch("aipass.hooks.apps.handlers.config.loader.Path.home", return_value=temp_test_dir),
):
assert trust_break_banner() is None
def test_trusted_project_is_none(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
project = temp_test_dir / "trusted_project"
project.mkdir()
(project / ".aipass").mkdir()
(project / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
enroll(str(project))
with (
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=project),
):
assert trust_break_banner() is None
def test_never_enrolled_is_none_not_a_break(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
reg_path.write_text('{"version": 1, "projects": {}}')
project = temp_test_dir / "fresh_project"
project.mkdir()
(project / ".aipass").mkdir()
(project / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
with (
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=project),
):
assert trust_break_banner() is None
def test_hash_mismatch_returns_loud_banner(self, temp_test_dir, mock_logger):
reg_path = temp_test_dir / "registry.json"
project = temp_test_dir / "tampered_project"
project.mkdir()
(project / ".aipass").mkdir()
hooks_file = project / ".aipass" / "hooks.json"
hooks_file.write_text('{"hooks_enabled": true}')
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
enroll(str(project))
hooks_file.write_text('{"hooks_enabled": true, "tampered": true}')
with (
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=project),
):
banner = trust_break_banner()
assert banner is not None
assert "TRUST BREAK" in banner
assert "trust enroll" in banner
class TestBootstrap: class TestBootstrap:
"""Tests for bootstrap() — enrolls ONLY AIPASS_HOME.""" """Tests for bootstrap() — enrolls ONLY AIPASS_HOME."""
-25
View File
@@ -759,31 +759,6 @@
"standard": "meta", "standard": "meta",
"reason": "Test file — META block present at lines 1-7; hook false-positive on test file format." "reason": "Test file — META block present at lines 1-7; hook false-positive on test file format."
}, },
{
"file": "tools/migrate_entries.py",
"standard": "architecture",
"reason": "Standalone utility script in tools/ — intentionally outside 3-layer apps/ structure."
},
{
"file": "tools/strip_trinity_limits.py",
"standard": "architecture",
"reason": "One-time migration script in tools/ — intentionally outside 3-layer apps/ structure."
},
{
"file": "tools/strip_trinity_limits.py",
"standard": "log_visibility",
"reason": "Standalone script — cannot import prax logger. Uses stdlib logging."
},
{
"file": "tools/migrate_entries.py",
"standard": "silent_catch",
"reason": "Standalone script — cannot import prax logger. Errors reported via stderr print + result dict."
},
{
"file": "tools/migrate_entries.py",
"standard": "debug_print",
"reason": "Standalone script — print(stderr) is the error reporting mechanism. No prax available."
},
{ {
"file": "tests/test_detector.py", "file": "tests/test_detector.py",
"standard": "architecture", "standard": "architecture",
+7 -1
View File
@@ -3,7 +3,7 @@
"version": "2.0.0", "version": "2.0.0",
"created": "2026-03-07T22:43:24.315842", "created": "2026-03-07T22:43:24.315842",
"description": "Standards bypass configuration for prax branch", "description": "Standards bypass configuration for prax branch",
"last_updated": "2026-04-26T00:06:19.911705" "last_updated": "2026-07-21T00:00:00.000000"
}, },
"bypass": [ "bypass": [
{ {
@@ -511,6 +511,12 @@
"file": "tests/test_logging.py", "file": "tests/test_logging.py",
"standard": "log_structure", "standard": "log_structure",
"reason": "Test assertions reference _AIPASS_PKG_ROOT which resolves to /home/ paths at runtime. Not log config \u2014 test path constants." "reason": "Test assertions reference _AIPASS_PKG_ROOT which resolves to /home/ paths at runtime. Not log config \u2014 test path constants."
},
{
"file": "apps/handlers/monitoring/commons_feed.py",
"standard": "cli",
"pattern": "console.print(",
"reason": "This IS the display handler for the commons live feed (DPLAN-0257). commons_feed.py is a self-contained view \u2014 polling, formatting, and terminal rendering together, same shape as unified_stream.py. console.print() is its designated purpose, not a violation."
} }
], ],
"notes": { "notes": {
+8 -2
View File
@@ -31,6 +31,7 @@ import inspect
import json import json
import logging import logging
import os import os
import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
@@ -60,6 +61,11 @@ def _find_repo_root() -> Path:
return Path.cwd() return Path.cwd()
def _is_pytest_session() -> bool:
"""Detect pytest session via sys.modules (immune to patch.dict(os.environ, clear=True))."""
return "_pytest" in sys.modules
# Lazy SYSTEM_LOGS_DIR — resolved on first access, not at import time. # Lazy SYSTEM_LOGS_DIR — resolved on first access, not at import time.
# Callers should use get_system_logs_dir() for guaranteed initialization. # Callers should use get_system_logs_dir() for guaranteed initialization.
_system_logs_dir_cache: Path | None = None _system_logs_dir_cache: Path | None = None
@@ -76,7 +82,7 @@ def get_system_logs_dir() -> Path:
p = Path(test_log_dir) / "system" p = Path(test_log_dir) / "system"
p.mkdir(parents=True, exist_ok=True) p.mkdir(parents=True, exist_ok=True)
return p return p
if os.environ.get("PYTEST_CURRENT_TEST"): if os.environ.get("PYTEST_CURRENT_TEST") or _is_pytest_session():
p = Path(tempfile.gettempdir()) / "aipass_test_logs" / "system" p = Path(tempfile.gettempdir()) / "aipass_test_logs" / "system"
p.mkdir(parents=True, exist_ok=True) p.mkdir(parents=True, exist_ok=True)
return p return p
@@ -138,7 +144,7 @@ def get_module_logs_dir(module_name: Optional[str] = None) -> Path:
p = Path(test_log_dir) / module_name p = Path(test_log_dir) / module_name
p.mkdir(parents=True, exist_ok=True) p.mkdir(parents=True, exist_ok=True)
return p return p
if os.environ.get("PYTEST_CURRENT_TEST"): if os.environ.get("PYTEST_CURRENT_TEST") or _is_pytest_session():
p = Path(tempfile.gettempdir()) / "aipass_test_logs" / module_name p = Path(tempfile.gettempdir()) / "aipass_test_logs" / module_name
p.mkdir(parents=True, exist_ok=True) p.mkdir(parents=True, exist_ok=True)
return p return p
@@ -0,0 +1,474 @@
# =================== AIPass ====================
# Name: commons_feed.py
# Description: Commons Live Social Feed (read-only monitor view)
# Version: 1.0.0
# Created: 2026-07-21
# Modified: 2026-07-21
# =============================================
"""
PRAX Monitor — Commons Live Feed
`drone @prax monitor run commons` streams The Commons' social chatter
(posts, comments, votes, reactions) room-tagged, monitor-style, instead of
tailing commons' technical prax logs (still reachable via `--logs`).
Read-only by construction: connects to commons.db via a `mode=ro` URI, so
the feed can never write — commons stays the only writer. Polls with per-table
id cursors, showing only genuinely new rows each cycle.
"""
import os
import sqlite3
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.cli.apps.modules import console, header, error, warning
from aipass.prax.apps.handlers.json import json_handler
from aipass.prax.apps.handlers.monitoring.event_queue import MonitoringEvent
from aipass.prax.apps.handlers.monitoring.telegram_relay import (
init_relay,
relay_event,
stop_relay,
is_relay_enabled_by_env,
)
# =============================================================================
# CONFIGURATION
# =============================================================================
POLL_INTERVAL = 1.5
BACKFILL_LIMIT = 10
_PRAX_ROOT = Path(__file__).resolve().parents[3] # monitoring/ -> handlers/ -> apps/ -> prax/
_ECOSYSTEM_ROOT = _PRAX_ROOT.parent
CURSOR_TABLES = ("posts", "comments", "votes", "reactions")
_CURSOR_TABLE_FOR_KIND = {
"post": "posts",
"comment": "comments",
"vote": "votes",
"reaction": "reactions",
}
MOOD_COLORS = {
"welcoming": "green",
"focused": "cyan",
"relaxed": "yellow",
"formal": "blue",
"creative": "magenta",
"neutral": "white",
}
DEFAULT_ROOM_COLOR = "white"
ROOM_LABEL_WIDTH = 18
_stop_event = threading.Event()
# =============================================================================
# DB ACCESS (read-only)
# =============================================================================
def _get_commons_db_path() -> Path:
"""Resolve commons.db — sibling branch under the same ecosystem root.
AIPASS_COMMONS_DB_PATH overrides for tests, mirroring AIPASS_TEST_LOG_DIR.
"""
override = os.environ.get("AIPASS_COMMONS_DB_PATH")
if override:
return Path(override)
return _ECOSYSTEM_ROOT / "commons" / "commons.db"
def connect_readonly(db_path: Path) -> sqlite3.Connection:
"""Open commons.db strictly read-only via a mode=ro URI — writes always fail."""
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
def _load_room_moods(conn: sqlite3.Connection) -> Dict[str, str]:
"""Load room name -> mood, for cheap color tagging."""
rows = conn.execute("SELECT name, mood FROM rooms").fetchall()
return {row["name"]: row["mood"] for row in rows}
def initial_cursors(conn: sqlite3.Connection) -> Dict[str, int]:
"""Snapshot the current max id per event table — the poll starting line."""
cursors = {}
for table in CURSOR_TABLES:
row = conn.execute(f"SELECT COALESCE(MAX(id), 0) AS m FROM {table}").fetchone()
cursors[table] = row["m"]
return cursors
def _fetch_new_posts(conn: sqlite3.Connection, since_id: int) -> List[dict]:
rows = conn.execute(
"SELECT id, room_name, author, title, content, created_at FROM posts WHERE id > ? ORDER BY id ASC",
(since_id,),
).fetchall()
return [dict(r) for r in rows]
def _fetch_new_comments(conn: sqlite3.Connection, since_id: int) -> List[dict]:
rows = conn.execute(
"SELECT c.id, c.post_id, c.parent_id, c.author, c.content, c.created_at, "
"p.room_name AS room_name, p.author AS post_author "
"FROM comments c JOIN posts p ON c.post_id = p.id "
"WHERE c.id > ? ORDER BY c.id ASC",
(since_id,),
).fetchall()
return [dict(r) for r in rows]
def _fetch_new_votes(conn: sqlite3.Connection, since_id: int) -> List[dict]:
rows = conn.execute(
"SELECT v.id, v.agent_name, v.target_id, v.target_type, v.direction, v.created_at, "
"COALESCE(p1.room_name, p2.room_name) AS room_name "
"FROM votes v "
"LEFT JOIN posts p1 ON v.target_type = 'post' AND v.target_id = p1.id "
"LEFT JOIN comments c ON v.target_type = 'comment' AND v.target_id = c.id "
"LEFT JOIN posts p2 ON c.post_id = p2.id "
"WHERE v.id > ? ORDER BY v.id ASC",
(since_id,),
).fetchall()
return [dict(r) for r in rows]
def _fetch_new_reactions(conn: sqlite3.Connection, since_id: int) -> List[dict]:
rows = conn.execute(
"SELECT r.id, r.agent_name, r.post_id, r.comment_id, r.reaction, r.created_at, "
"COALESCE(p1.room_name, p2.room_name) AS room_name "
"FROM reactions r "
"LEFT JOIN posts p1 ON r.post_id = p1.id "
"LEFT JOIN comments c ON r.comment_id = c.id "
"LEFT JOIN posts p2 ON c.post_id = p2.id "
"WHERE r.id > ? ORDER BY r.id ASC",
(since_id,),
).fetchall()
return [dict(r) for r in rows]
_NEW_FETCHERS = {
"post": _fetch_new_posts,
"comment": _fetch_new_comments,
"vote": _fetch_new_votes,
"reaction": _fetch_new_reactions,
}
def fetch_new_events(conn: sqlite3.Connection, cursors: Dict[str, int]) -> Tuple[List[dict], Dict[str, int]]:
"""Fetch rows newer than each cursor, tag with kind, return sorted events + advanced cursors."""
events: List[dict] = []
new_cursors = dict(cursors)
for kind, table in _CURSOR_TABLE_FOR_KIND.items():
rows = _NEW_FETCHERS[kind](conn, cursors[table])
if rows:
events.extend({**row, "kind": kind} for row in rows)
new_cursors[table] = rows[-1]["id"]
events.sort(key=lambda e: (e["created_at"], e["id"]))
return events, new_cursors
def fetch_backfill(conn: sqlite3.Connection, cursors: Dict[str, int], limit: int = BACKFILL_LIMIT) -> List[dict]:
"""Fetch the last `limit` events at/under the given cursors — startup context."""
events: List[dict] = []
queries = {
"post": "SELECT id, room_name, author, title, content, created_at FROM posts "
"WHERE id <= ? ORDER BY id DESC LIMIT ?",
"comment": "SELECT c.id, c.post_id, c.parent_id, c.author, c.content, c.created_at, "
"p.room_name AS room_name, p.author AS post_author FROM comments c "
"JOIN posts p ON c.post_id = p.id WHERE c.id <= ? ORDER BY c.id DESC LIMIT ?",
"vote": "SELECT v.id, v.agent_name, v.target_id, v.target_type, v.direction, v.created_at, "
"COALESCE(p1.room_name, p2.room_name) AS room_name FROM votes v "
"LEFT JOIN posts p1 ON v.target_type = 'post' AND v.target_id = p1.id "
"LEFT JOIN comments c ON v.target_type = 'comment' AND v.target_id = c.id "
"LEFT JOIN posts p2 ON c.post_id = p2.id WHERE v.id <= ? ORDER BY v.id DESC LIMIT ?",
"reaction": "SELECT r.id, r.agent_name, r.post_id, r.comment_id, r.reaction, r.created_at, "
"COALESCE(p1.room_name, p2.room_name) AS room_name FROM reactions r "
"LEFT JOIN posts p1 ON r.post_id = p1.id "
"LEFT JOIN comments c ON r.comment_id = c.id "
"LEFT JOIN posts p2 ON c.post_id = p2.id WHERE r.id <= ? ORDER BY r.id DESC LIMIT ?",
}
for kind, sql in queries.items():
table = _CURSOR_TABLE_FOR_KIND[kind]
rows = conn.execute(sql, (cursors[table], limit)).fetchall()
events.extend({**dict(row), "kind": kind} for row in rows)
events.sort(key=lambda e: (e["created_at"], e["id"]))
return events[-limit:]
# =============================================================================
# DISPLAY
# =============================================================================
def _snippet(text: Optional[str], length: int = 100) -> str:
"""Collapse whitespace and truncate to ~length chars with an ellipsis."""
if not text:
return ""
collapsed = " ".join(text.split())
if len(collapsed) <= length:
return collapsed
return collapsed[: length - 1].rstrip() + "…"
def event_room(event: dict) -> str:
"""Room name for an event, or 'commons' when it can't be resolved (orphaned row)."""
return event.get("room_name") or "commons"
def format_event(event: dict) -> str:
"""Plain-text (no Rich markup) description of an event — shared by console + relay."""
kind = event["kind"]
if kind == "post":
return f'{event["author"]} posted: "{event["title"]}" — {_snippet(event["content"])}'
if kind == "comment":
target = event.get("post_author") or "?"
return f"{event['author']} replied to {target}: {_snippet(event['content'])}"
if kind == "vote":
direction = "up" if (event.get("direction") or 0) > 0 else "down"
return f"{event['agent_name']} voted {direction} on {event['target_type']} #{event['target_id']}"
if kind == "reaction":
target_kind = "post" if event.get("post_id") else "comment"
reaction = event.get("reaction") or ""
return f"{event['agent_name']} reacted {reaction} to a {target_kind}"
return str(event)
def _print_feed_event(room: str, message: str, mood: Optional[str]) -> None:
"""Print one feed line — room tag colored by mood, monitor-style timestamp."""
ts = datetime.now().strftime("%H:%M:%S")
color = MOOD_COLORS.get(mood or "", DEFAULT_ROOM_COLOR)
label = f"[{room}]"
console.print(f"[dim]{ts}[/dim] [{color}]{label:<{ROOM_LABEL_WIDTH}}[/{color}] {message}")
def _emit(event: dict, room_moods: Dict[str, str]) -> None:
"""Render an event to the console and relay it (relay is a no-op when inactive)."""
room = event_room(event)
message = format_event(event)
_print_feed_event(room, message, room_moods.get(room))
relay_event(
MonitoringEvent(
priority=3,
event_type="log",
branch=room.upper(),
message=message,
level="info",
)
)
# =============================================================================
# FEED STATE + INTERACTIVE COMMANDS
# =============================================================================
class FeedState:
"""Tracks rooms/agents/events seen and the active room filter."""
def __init__(self) -> None:
self.rooms_seen: set = set()
self.agents_seen: set = set()
self.events_count: int = 0
self.room_filter: Optional[set] = None
def record(self, event: dict) -> None:
"""Count an event toward status stats — independent of the display filter."""
self.rooms_seen.add(event_room(event))
agent = event.get("author") or event.get("agent_name")
if agent:
self.agents_seen.add(agent)
self.events_count += 1
def visible(self, event: dict) -> bool:
"""Whether the active room filter allows this event to display."""
if not self.room_filter:
return True
return event_room(event) in self.room_filter
def _feed_help_text() -> str:
return """
Available Commands:
filter <room>[,<room>...] - Show only these rooms
filter clear - Remove the room filter
status - Show feed stats (rooms/agents/events)
help - Show this help
quit/exit - Stop the feed
"""
def _print_feed_status(state: FeedState) -> None:
console.print()
console.print("[bold cyan]Commons Feed Status:[/bold cyan]")
console.print(f" [green]Rooms seen:[/green] {len(state.rooms_seen)}")
console.print(f" [green]Agents seen:[/green] {len(state.agents_seen)}")
console.print(f" [green]Events streamed:[/green] {state.events_count}")
if state.room_filter:
console.print(f" [yellow]Filter:[/yellow] {', '.join(sorted(state.room_filter))}")
console.print()
def _handle_feed_cmd(cmd: str, cmd_args: List[str], state: FeedState) -> None:
"""Dispatch an interactive feed command."""
if cmd == "help":
console.print(_feed_help_text())
return
if cmd == "status":
_print_feed_status(state)
return
if cmd == "filter":
if not cmd_args or cmd_args[0] in ("clear", "all"):
state.room_filter = None
warning("Filter cleared — showing all rooms")
return
rooms = {room.strip() for token in cmd_args for room in token.split(",") if room.strip()}
state.room_filter = rooms
warning(f"Filtering to rooms: {', '.join(sorted(rooms))}")
return
error(f"Unknown command: {cmd}")
console.print("[dim]Type 'help' for available commands[/dim]")
def _interactive_feed_loop(state: FeedState) -> None:
"""Interactive command loop for the feed — mirrors the branch monitor's loop shape."""
if not sys.stdin.isatty():
logger.info("[commons_feed] No TTY detected - passive mode (Ctrl+C to stop)")
try:
while not _stop_event.is_set():
time.sleep(0.5)
except KeyboardInterrupt:
logger.info("[commons_feed] Stopped by user (passive mode)")
console.print("\n[yellow]Stopping feed...[/yellow]")
return
from aipass.prax.apps.handlers.monitoring.interactive_filter import parse_command
while not _stop_event.is_set():
try:
user_input = input().strip()
if not user_input:
continue
cmd, cmd_args = parse_command(user_input)
if not cmd:
continue
if cmd in ("quit", "exit", "q"):
console.print("[yellow]Stopping feed...[/yellow]")
break
_handle_feed_cmd(cmd, cmd_args, state)
except KeyboardInterrupt:
logger.info("[commons_feed] Stopped by user")
console.print("\n[yellow]Stopping feed...[/yellow]")
break
except EOFError:
logger.info("[commons_feed] EOF received, stopping interactive loop")
break
# =============================================================================
# POLL WORKER
# =============================================================================
def _poll_worker(
conn: sqlite3.Connection, cursors: Dict[str, int], state: FeedState, room_moods: Dict[str, str]
) -> None:
"""Background thread: poll for new rows, display + relay the visible ones."""
while not _stop_event.is_set():
try:
events, new_cursors = fetch_new_events(conn, cursors)
cursors.update(new_cursors)
for event in events:
state.record(event)
if state.visible(event):
_emit(event, room_moods)
except sqlite3.Error as exc:
logger.warning("[commons_feed] Poll error: %s", exc)
_stop_event.wait(POLL_INTERVAL)
# =============================================================================
# ENTRY POINT
# =============================================================================
def run_commons_feed(args: List[str], relay_config: Optional[dict] = None) -> bool:
"""Launch the commons live social feed — read-only, room-tagged, monitor-style.
relay_config is loaded by monitor.py (the module layer, which owns the
cross-branch @api secrets lookup) and passed in — this handler never
reaches outside its own branch.
"""
_stop_event.clear()
json_handler.log_operation("commons_feed_started", {"args": args})
relay_enabled = "--relay" in args or is_relay_enabled_by_env()
init_relay(relay_enabled, relay_config if relay_enabled else None)
if relay_enabled:
console.print("[green]monitor → Telegram relay ON (prax_monitor)[/green]")
db_path = _get_commons_db_path()
if not db_path.exists():
error(f"commons.db not found at {db_path}")
stop_relay()
return True
try:
conn = connect_readonly(db_path)
cursors = initial_cursors(conn)
except sqlite3.Error as exc:
logger.warning("[commons_feed] Could not open commons.db read-only: %s", exc)
error(f"Could not open commons.db read-only: {exc}")
stop_relay()
return True
state = FeedState()
room_moods = _load_room_moods(conn)
console.print()
header("PRAX Mission Control - Commons Live Feed")
console.print()
console.print("[green]Live — read-only feed of The Commons (posts, comments, votes, reactions)[/green]")
is_tty = sys.stdin.isatty()
console.print("[dim]Type 'help' for commands[/dim]" if is_tty else "[dim]Ctrl+C to stop[/dim]")
console.print()
for event in fetch_backfill(conn, cursors):
state.record(event)
_emit(event, room_moods)
poll_thread = threading.Thread(target=_poll_worker, args=(conn, cursors, state, room_moods), daemon=True)
poll_thread.start()
try:
_interactive_feed_loop(state)
except KeyboardInterrupt:
logger.info("[commons_feed] KeyboardInterrupt escaped interactive loop")
console.print("\n[yellow]Feed stopped.[/yellow]")
_stop_event.set()
poll_thread.join(timeout=POLL_INTERVAL + 2.0)
stop_relay()
conn.close()
# sys.exit(0) prevents drone's post-execution json_handler from running
# after the feed exits, avoiding a json.load crash on Ctrl+C (matches monitor.py).
sys.exit(0)
+31 -1
View File
@@ -108,6 +108,14 @@ def print_help():
"drone @prax monitor run [branches]", "drone @prax monitor run [branches]",
"Monitor specific branches (comma-separated)\n Example: drone @prax monitor run seedgo,cli,flow", "Monitor specific branches (comma-separated)\n Example: drone @prax monitor run seedgo,cli,flow",
), ),
(
"drone @prax monitor run commons",
"Live social feed of The Commons (posts, comments, votes, reactions), read-only",
),
(
"drone @prax monitor run commons --logs",
"Old behavior — tail commons branch's technical prax logs instead of the feed",
),
( (
"drone @prax monitor run --relay", "drone @prax monitor run --relay",
"Enable Telegram relay (mirrors feed to prax_monitor bot)" "Enable Telegram relay (mirrors feed to prax_monitor bot)"
@@ -153,7 +161,7 @@ def handle_command(command: str, args: List[str]) -> bool:
# Subcommand routing # Subcommand routing
subcmd = args[0] subcmd = args[0]
if subcmd == "run": if subcmd == "run":
return _run_monitor(args[1:]) return _dispatch_run(args[1:])
# Unknown subcommand # Unknown subcommand
error(f"Unknown monitor subcommand: {subcmd}") error(f"Unknown monitor subcommand: {subcmd}")
@@ -161,6 +169,28 @@ def handle_command(command: str, args: List[str]) -> bool:
return True return True
def _dispatch_run(run_args: List[str]) -> bool:
"""Route 'monitor run' — a bare 'commons' target opens the live social feed.
'commons --logs' escapes back to the branch-log tail, and mixed lists
(e.g. 'seedgo,commons') keep treating commons as a branch (feed is
standalone-only in v1).
"""
positional = [a for a in run_args if not a.startswith("--")]
target = positional[0] if positional else None
logs_escape = "--logs" in run_args
if target == "commons" and not logs_escape:
from aipass.prax.apps.handlers.monitoring.commons_feed import run_commons_feed
feed_args = [a for a in run_args if a != target]
relay_enabled = "--relay" in feed_args or is_relay_enabled_by_env()
relay_config = _load_relay_config() if relay_enabled else None
return run_commons_feed(feed_args, relay_config=relay_config)
return _run_monitor([a for a in run_args if a != "--logs"])
def _load_relay_config() -> Optional[dict]: def _load_relay_config() -> Optional[dict]:
"""Load Telegram relay config from @api secrets.""" """Load Telegram relay config from @api secrets."""
try: try:
+366
View File
@@ -0,0 +1,366 @@
# =================== AIPass ====================
# Name: test_commons_feed.py
# Description: Tests for the commons live feed handler
# Version: 1.0.0
# Created: 2026-07-21
# Modified: 2026-07-21
# =============================================
"""Tests for apps/handlers/monitoring/commons_feed.py (DPLAN-0257)
Covers:
- connect_readonly: mode=ro connection actually refuses writes
- initial_cursors / fetch_new_events: only-new-row cursor semantics
- fetch_backfill: last-N-events context on start, bounded by cursor
- display formatting: format_event, event_room, _snippet
- FeedState: record/visible room filtering
- _get_commons_db_path: env override
"""
import importlib
import sqlite3
import pytest
feed = importlib.import_module("aipass.prax.apps.handlers.monitoring.commons_feed")
SCHEMA = """
CREATE TABLE rooms (
name TEXT PRIMARY KEY,
mood TEXT DEFAULT 'neutral'
);
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_name TEXT NOT NULL,
author TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER NOT NULL,
parent_id INTEGER,
author TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_name TEXT NOT NULL,
target_id INTEGER NOT NULL,
target_type TEXT NOT NULL,
direction INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_name TEXT NOT NULL,
post_id INTEGER,
comment_id INTEGER,
reaction TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
@pytest.fixture
def db_path(tmp_path):
"""A throwaway commons.db with the tables the feed queries."""
path = tmp_path / "commons.db"
conn = sqlite3.connect(str(path))
conn.executescript(SCHEMA)
conn.execute("INSERT INTO rooms (name, mood) VALUES ('general', 'welcoming')")
conn.commit()
conn.close()
return path
def _seed_post(path, room="general", author="alice", title="Hello", content="world", ts="2026-07-21T10:00:00Z"):
conn = sqlite3.connect(str(path))
cur = conn.execute(
"INSERT INTO posts (room_name, author, title, content, created_at) VALUES (?, ?, ?, ?, ?)",
(room, author, title, content, ts),
)
conn.commit()
post_id = cur.lastrowid
conn.close()
return post_id
# =============================================================================
# connect_readonly — write refusal
# =============================================================================
class TestConnectReadonly:
"""The feed's connection must never be able to write to commons.db."""
def test_read_succeeds(self, db_path):
"""A mode=ro connection can still read existing rows."""
_seed_post(db_path)
conn = feed.connect_readonly(db_path)
try:
rows = conn.execute("SELECT * FROM posts").fetchall()
assert len(rows) == 1
finally:
conn.close()
def test_write_attempt_raises_operational_error(self, db_path):
"""INSERT on a mode=ro connection raises instead of succeeding."""
conn = feed.connect_readonly(db_path)
try:
with pytest.raises(sqlite3.OperationalError):
conn.execute(
"INSERT INTO posts (room_name, author, title, created_at) VALUES ('general', 'bob', 'x', 'y')"
)
finally:
conn.close()
def test_write_attempt_leaves_db_unchanged(self, db_path):
"""A refused DELETE leaves the underlying rows untouched."""
conn = feed.connect_readonly(db_path)
try:
with pytest.raises(sqlite3.OperationalError):
conn.execute("DELETE FROM posts")
finally:
conn.close()
verify = sqlite3.connect(str(db_path))
count = verify.execute("SELECT COUNT(*) FROM rooms").fetchone()[0]
verify.close()
assert count == 1
# =============================================================================
# Cursor logic — only new rows
# =============================================================================
class TestCursorLogic:
"""Cursors should only ever surface rows newer than the snapshot."""
def test_initial_cursors_snapshot_current_max(self, db_path):
"""initial_cursors captures the current max id per table."""
_seed_post(db_path)
_seed_post(db_path, title="Second")
conn = feed.connect_readonly(db_path)
try:
cursors = feed.initial_cursors(conn)
finally:
conn.close()
assert cursors == {"posts": 2, "comments": 0, "votes": 0, "reactions": 0}
def test_fetch_new_events_empty_when_nothing_past_cursor(self, db_path):
"""No rows inserted since the cursor snapshot means no events."""
_seed_post(db_path)
conn = feed.connect_readonly(db_path)
try:
cursors = feed.initial_cursors(conn)
events, new_cursors = feed.fetch_new_events(conn, cursors)
finally:
conn.close()
assert events == []
assert new_cursors == cursors
def test_fetch_new_events_returns_only_rows_after_cursor(self, db_path):
"""Rows inserted before the cursor snapshot are excluded."""
_seed_post(db_path, title="Before cursor")
conn = feed.connect_readonly(db_path)
cursors = feed.initial_cursors(conn)
_seed_post(db_path, title="After cursor", ts="2026-07-21T11:00:00Z")
events, new_cursors = feed.fetch_new_events(conn, cursors)
conn.close()
assert len(events) == 1
assert events[0]["title"] == "After cursor"
assert new_cursors["posts"] == 2
def test_fetch_new_events_advances_cursor_only_for_kinds_with_new_rows(self, db_path):
"""Cursors for tables with no new rows stay put; posts' cursor advances."""
_seed_post(db_path)
conn = feed.connect_readonly(db_path)
cursors = feed.initial_cursors(conn)
_seed_post(db_path, title="Another", ts="2026-07-21T11:00:00Z")
_, new_cursors = feed.fetch_new_events(conn, cursors)
conn.close()
assert new_cursors["posts"] == 2
assert new_cursors["comments"] == 0
assert new_cursors["votes"] == 0
assert new_cursors["reactions"] == 0
# =============================================================================
# Backfill — startup context
# =============================================================================
class TestFetchBackfill:
"""On start the feed shows recent history for context, bounded by cursor + limit."""
def test_backfill_returns_events_up_to_limit(self, db_path):
"""Backfill returns only the most recent `limit` events, oldest first."""
for i in range(15):
_seed_post(db_path, title=f"Post {i}", ts=f"2026-07-21T10:{i:02d}:00Z")
conn = feed.connect_readonly(db_path)
cursors = feed.initial_cursors(conn)
events = feed.fetch_backfill(conn, cursors, limit=10)
conn.close()
assert len(events) == 10
assert [e["title"] for e in events] == [f"Post {i}" for i in range(5, 15)]
def test_backfill_respects_cursor_bound(self, db_path):
"""Backfill never includes rows that came in after the cursor snapshot.
Rows inserted after the cursor is taken belong to the live poll, not
the startup backfill — including them here would double-emit.
"""
_seed_post(db_path, title="Old")
conn = feed.connect_readonly(db_path)
cursors = feed.initial_cursors(conn)
_seed_post(db_path, title="New", ts="2026-07-21T12:00:00Z")
events = feed.fetch_backfill(conn, cursors, limit=10)
conn.close()
assert [e["title"] for e in events] == ["Old"]
def test_backfill_empty_db_returns_empty(self, db_path):
"""An empty database backfills to an empty list, not an error."""
conn = feed.connect_readonly(db_path)
cursors = feed.initial_cursors(conn)
events = feed.fetch_backfill(conn, cursors)
conn.close()
assert events == []
# =============================================================================
# Display formatting
# =============================================================================
class TestFormatEvent:
def test_post_event(self):
"""A post event formats as author + quoted title + content snippet."""
event = {"kind": "post", "author": "alice", "title": "Hello world", "content": "some content here"}
line = feed.format_event(event)
assert line == 'alice posted: "Hello world" — some content here'
def test_comment_event(self):
"""A comment event formats as author replying to the post's author."""
event = {"kind": "comment", "author": "bob", "content": "nice one", "post_author": "alice"}
line = feed.format_event(event)
assert line == "bob replied to alice: nice one"
def test_comment_event_missing_post_author(self):
"""A missing post_author falls back to '?' rather than crashing."""
event = {"kind": "comment", "author": "bob", "content": "nice one", "post_author": None}
line = feed.format_event(event)
assert line == "bob replied to ?: nice one"
def test_vote_event_up(self):
"""A positive direction renders as 'voted up'."""
event = {"kind": "vote", "agent_name": "bob", "direction": 1, "target_type": "post", "target_id": 5}
assert feed.format_event(event) == "bob voted up on post #5"
def test_vote_event_down(self):
"""A negative direction renders as 'voted down'."""
event = {"kind": "vote", "agent_name": "bob", "direction": -1, "target_type": "comment", "target_id": 7}
assert feed.format_event(event) == "bob voted down on comment #7"
def test_reaction_event_on_post(self):
"""A reaction with post_id set targets 'a post'."""
event = {"kind": "reaction", "agent_name": "carol", "post_id": 3, "comment_id": None, "reaction": "agree"}
assert feed.format_event(event) == "carol reacted agree to a post"
def test_reaction_event_on_comment(self):
"""A reaction with comment_id set targets 'a comment'."""
event = {"kind": "reaction", "agent_name": "carol", "post_id": None, "comment_id": 9, "reaction": "thinking"}
assert feed.format_event(event) == "carol reacted thinking to a comment"
class TestEventRoom:
def test_returns_room_name_when_present(self):
"""A populated room_name passes through unchanged."""
assert feed.event_room({"room_name": "dev"}) == "dev"
def test_falls_back_to_commons_when_missing(self):
"""A None or absent room_name falls back to 'commons'."""
assert feed.event_room({"room_name": None}) == "commons"
assert feed.event_room({}) == "commons"
class TestSnippet:
def test_short_text_unchanged(self):
"""Text under the length limit passes through unchanged."""
assert feed._snippet("short text") == "short text"
def test_collapses_whitespace(self):
"""Runs of whitespace (including newlines) collapse to single spaces."""
assert feed._snippet("a b\n\nc") == "a b c"
def test_truncates_long_text_with_ellipsis(self):
"""Text over the length limit is truncated with a trailing ellipsis."""
text = "x" * 150
result = feed._snippet(text, length=100)
assert len(result) == 100
assert result.endswith("…")
def test_none_returns_empty_string(self):
"""None input returns an empty string rather than raising."""
assert feed._snippet(None) == ""
# =============================================================================
# FeedState
# =============================================================================
class TestFeedState:
def test_record_tracks_rooms_agents_and_count(self):
"""record() accumulates distinct rooms, distinct agents, and a running count."""
state = feed.FeedState()
state.record({"room_name": "dev", "author": "alice"})
state.record({"room_name": "dev", "agent_name": "bob"})
assert state.rooms_seen == {"dev"}
assert state.agents_seen == {"alice", "bob"}
assert state.events_count == 2
def test_visible_true_when_no_filter(self):
"""With no active room filter, every event is visible."""
state = feed.FeedState()
assert state.visible({"room_name": "dev"}) is True
def test_visible_respects_room_filter(self):
"""With an active room filter, only matching-room events are visible."""
state = feed.FeedState()
state.room_filter = {"dev"}
assert state.visible({"room_name": "dev"}) is True
assert state.visible({"room_name": "general"}) is False
# =============================================================================
# _get_commons_db_path — env override
# =============================================================================
class TestGetCommonsDbPath:
def test_env_override(self, monkeypatch, tmp_path):
"""AIPASS_COMMONS_DB_PATH overrides the default sibling-branch path."""
override = tmp_path / "custom.db"
monkeypatch.setenv("AIPASS_COMMONS_DB_PATH", str(override))
assert feed._get_commons_db_path() == override
def test_default_is_sibling_commons_branch(self, monkeypatch):
"""Without an env override, the path resolves to the commons branch's db."""
monkeypatch.delenv("AIPASS_COMMONS_DB_PATH", raising=False)
path = feed._get_commons_db_path()
assert path.parts[-2:] == ("commons", "commons.db")
+2
View File
@@ -50,6 +50,8 @@ def _fresh_import_load(monkeypatch, tmp_path):
# Clear test log redirects so tests exercise real path resolution # Clear test log redirects so tests exercise real path resolution
monkeypatch.delenv("AIPASS_TEST_LOG_DIR", raising=False) monkeypatch.delenv("AIPASS_TEST_LOG_DIR", raising=False)
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
# Suppress sys.modules-based pytest detection so tests can verify prod paths
monkeypatch.setattr(load_mod, "_is_pytest_session", lambda: False)
return load_mod return load_mod
@@ -116,6 +116,78 @@ class TestHandleCommand:
mod.error.assert_called() mod.error.assert_called()
# ---------------------------------------------------------------------------
# _dispatch_run routing tests (DPLAN-0257 — commons feed vs. branch log tail)
# ---------------------------------------------------------------------------
_COMMONS_FEED_TARGET = "aipass.prax.apps.handlers.monitoring.commons_feed.run_commons_feed"
class TestDispatchRun:
"""'run commons' opens the live social feed; --logs and mixed lists stay branch tail."""
def test_bare_commons_routes_to_feed(self):
"""A standalone 'commons' target opens the feed, not the branch monitor."""
mod = _import_monitor()
mod.is_relay_enabled_by_env.return_value = False
with (
patch(_COMMONS_FEED_TARGET, return_value=True) as mock_feed,
patch.object(mod, "_run_monitor") as mock_branch,
):
result = mod.handle_command("monitor", ["run", "commons"])
assert result is True
mock_feed.assert_called_once_with([], relay_config=None)
mock_branch.assert_not_called()
def test_commons_logs_escape_routes_to_branch_tail(self):
"""'commons --logs' is the escape hatch back to the old branch-log tail."""
mod = _import_monitor()
with (
patch(_COMMONS_FEED_TARGET) as mock_feed,
patch.object(mod, "_run_monitor", return_value=True) as mock_branch,
):
result = mod.handle_command("monitor", ["run", "commons", "--logs"])
assert result is True
mock_branch.assert_called_once_with(["commons"])
mock_feed.assert_not_called()
def test_mixed_branch_list_with_commons_stays_branch_tail(self):
"""A mixed list (e.g. 'seedgo,commons') treats commons as a branch — feed is standalone-only."""
mod = _import_monitor()
with (
patch(_COMMONS_FEED_TARGET) as mock_feed,
patch.object(mod, "_run_monitor", return_value=True) as mock_branch,
):
result = mod.handle_command("monitor", ["run", "seedgo,commons"])
assert result is True
mock_branch.assert_called_once_with(["seedgo,commons"])
mock_feed.assert_not_called()
def test_commons_feed_loads_relay_config_when_relay_flag_set(self):
"""--relay on the feed path loads config via monitor.py's own _load_relay_config."""
mod = _import_monitor()
mod.is_relay_enabled_by_env.return_value = False
with (
patch(_COMMONS_FEED_TARGET, return_value=True) as mock_feed,
patch.object(mod, "_load_relay_config", return_value={"bot_token": "x", "chat_id": 1}) as mock_load,
):
mod.handle_command("monitor", ["run", "commons", "--relay"])
mock_load.assert_called_once()
mock_feed.assert_called_once_with(["--relay"], relay_config={"bot_token": "x", "chat_id": 1})
def test_commons_feed_no_relay_config_when_relay_disabled(self):
"""Without --relay (and no env flag), relay_config stays None."""
mod = _import_monitor()
mod.is_relay_enabled_by_env.return_value = False
with (
patch(_COMMONS_FEED_TARGET, return_value=True) as mock_feed,
patch.object(mod, "_load_relay_config") as mock_load,
):
mod.handle_command("monitor", ["run", "commons"])
mock_load.assert_not_called()
mock_feed.assert_called_once_with([], relay_config=None)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _get_watch_directories tests # _get_watch_directories tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
-5
View File
@@ -195,11 +195,6 @@
"standard": "deep_nesting", "standard": "deep_nesting",
"reason": "scan() depth 6 — state machine tracking file types across standards with nested conditionals, inherent to triplet completeness detection" "reason": "scan() depth 6 — state machine tracking file types across standards with nested conditionals, inherent to triplet completeness detection"
}, },
{
"file": "tools/triplet_scanner.py",
"standard": "deep_nesting",
"reason": "scan() depth 6 — state machine tracking file types across standards with nested conditionals, inherent to triplet completeness detection"
},
{ {
"file": "apps/handlers/aipass_standards/introspection_check.py", "file": "apps/handlers/aipass_standards/introspection_check.py",
"standard": "deep_nesting", "standard": "deep_nesting",
+4 -2
View File
@@ -120,9 +120,9 @@ seedgo/
│ │ ├── branch_audit.py # Per-branch scoring engine │ │ ├── branch_audit.py # Per-branch scoring engine
│ │ ├── discovery.py # Branch discovery (CWD-first registry) │ │ ├── discovery.py # Branch discovery (CWD-first registry)
│ │ └── audit_display.py # Rich result formatting │ │ └── audit_display.py # Rich result formatting
│ ├── bypass/ # Bypass system │ ├── bypass/ # Bypass + ignore systems
│ │ ├── bypass_handler.py # .seedgo/bypass.json loader │ │ ├── bypass_handler.py # .seedgo/bypass.json loader
│ │ └── ignore_handler.py # .seedgo/ignore patterns │ │ └── ignore_handler.py # Audit ignore patterns + .seedgoignore engine
│ ├── config/ # Configuration handlers │ ├── config/ # Configuration handlers
│ ├── diagnostics/ # Pyright integration + branch discovery │ ├── diagnostics/ # Pyright integration + branch discovery
│ ├── json/ # JSON tracking (json_handler) │ ├── json/ # JSON tracking (json_handler)
@@ -146,6 +146,8 @@ seedgo/
**Bypass system:** `.seedgo/bypass.json` per branch. Each entry has file, standard, optional lines, and required reason. Checkers call `is_bypassed()` per violation. Bypass is intentional documented deviation, not ignoring. **Bypass system:** `.seedgo/bypass.json` per branch. Each entry has file, standard, optional lines, and required reason. Checkers call `is_bypassed()` per violation. Bypass is intentional documented deviation, not ignoring.
**`.seedgoignore` (throwaway paths, no reason required):** Drop a `.seedgoignore` file into any directory to exclude matching files/dirs from scans, audits, and the per-file checklist — same gitignore-style patterns and per-directory nesting semantics as a real `.gitignore` (via `pathspec`). Scope is exactly that directory's subtree; a nested `.seedgoignore` adds further excludes on top of any ancestor's, it doesn't replace them. A global default (`tools/`) applies fleet-wide with zero setup, since every branch's `apps/tools/` is deliberate throwaway prototyping space — quick scripts for fast answers, not standards-compliant by design. Unlike bypass (documented exception to a specific standard on a specific file), `.seedgoignore` removes the file from consideration entirely and needs no reason. It does **not** touch diagnostics (ruff/pyright) — those keep running on ignored files so auto-fix still catches real errors while you write; only standards checks skip them. See `ignore_handler.load_ignore_entries()` / `is_seedgo_ignored()`.
--- ---
## The 40 Standards ## The 40 Standards
@@ -24,6 +24,7 @@ from pathlib import Path
from aipass.prax import logger from aipass.prax import logger
from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.json import json_handler
from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored, load_ignore_entries
from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file
AUDIT_SCOPE = "branch_level" AUDIT_SCOPE = "branch_level"
@@ -42,12 +43,17 @@ _SKIP_DIRS = SOURCE_SKIP_DIRS | {"tests", "json_templates"}
# ============================================= # =============================================
def _should_skip(path: Path) -> bool: def _should_skip(path: Path, branch_root: Path, ignore_entries: list) -> bool:
"""Check whether any parent directory component is in the skip set or file is disabled.""" """Check whether any parent directory component is in the skip set, file is
return any(part in _SKIP_DIRS for part in path.parts) or is_disabled_file(path.name) disabled, or the path is excluded via .seedgoignore / the global default."""
return (
any(part in _SKIP_DIRS for part in path.parts)
or is_disabled_file(path.name)
or is_seedgo_ignored(str(path), branch_root, ignore_entries)
)
def _collect_scannable_files(apps_dir: Path) -> list[Path]: def _collect_scannable_files(apps_dir: Path, branch_root: Path, ignore_entries: list) -> list[Path]:
""" """
Collect .py files from apps/modules/ and apps/handlers/ that should be Collect .py files from apps/modules/ and apps/handlers/ that should be
checked for usage. Skips __init__.py, __pycache__, .archive, etc. checked for usage. Skips __init__.py, __pycache__, .archive, etc.
@@ -60,20 +66,20 @@ def _collect_scannable_files(apps_dir: Path) -> list[Path]:
for py_file in subdir.rglob("*.py"): for py_file in subdir.rglob("*.py"):
if py_file.name == "__init__.py": if py_file.name == "__init__.py":
continue continue
if _should_skip(py_file): if _should_skip(py_file, branch_root, ignore_entries):
continue continue
targets.append(py_file) targets.append(py_file)
return sorted(targets) return sorted(targets)
def _collect_source_text(apps_dir: Path) -> str: def _collect_source_text(apps_dir: Path, branch_root: Path, ignore_entries: list) -> str:
""" """
Read ALL .py files under apps/ into a single string for searching. Read ALL .py files under apps/ into a single string for searching.
This is the corpus we search for references. This is the corpus we search for references.
""" """
parts: list[str] = [] parts: list[str] = []
for py_file in apps_dir.rglob("*.py"): for py_file in apps_dir.rglob("*.py"):
if _should_skip(py_file): if _should_skip(py_file, branch_root, ignore_entries):
continue continue
try: try:
parts.append(py_file.read_text(encoding="utf-8", errors="ignore")) parts.append(py_file.read_text(encoding="utf-8", errors="ignore"))
@@ -266,7 +272,8 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict:
entry_point_name = branch_name entry_point_name = branch_name
# Collect scannable files # Collect scannable files
targets = _collect_scannable_files(apps_dir) ignore_entries = load_ignore_entries(bp)
targets = _collect_scannable_files(apps_dir, bp, ignore_entries)
if not targets: if not targets:
result = { result = {
"passed": True, "passed": True,
@@ -287,7 +294,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict:
return result return result
# Build the source corpus (all .py content from apps/) # Build the source corpus (all .py content from apps/)
source_text = _collect_source_text(apps_dir) source_text = _collect_source_text(apps_dir, bp, ignore_entries)
# Check each target for references # Check each target for references
total_files = len(targets) total_files = len(targets)
@@ -28,6 +28,11 @@ from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
# Audit scope: all Python files # Audit scope: all Python files
AUDIT_SCOPE = "all_files" AUDIT_SCOPE = "all_files"
# Cross-branch/cross-package handler imports are import-statement checks, so a
# violation hiding in a package marker file must not be invisible to the audit
# the way it would be for content checkers (dead code, naming, etc.).
INCLUDE_INIT_FILES = True
def _find_registry() -> Path: def _find_registry() -> Path:
"""Find AIPASS_REGISTRY.json by walking up from this file's location.""" """Find AIPASS_REGISTRY.json by walking up from this file's location."""
@@ -13,9 +13,10 @@ Validates handler compliance with AIPass handler standards.
Checks handler independence, auto-detection pattern, no orchestration. Checks handler independence, auto-detection pattern, no orchestration.
""" """
import ast
import re import re
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, Iterator, List, Optional, Tuple
from aipass.prax import logger from aipass.prax import logger
from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.json import json_handler
@@ -24,6 +25,11 @@ from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
# Audit scope: all Python files # Audit scope: all Python files
AUDIT_SCOPE = "all_files" AUDIT_SCOPE = "all_files"
# Cross-handler/orchestration imports are purely import-statement checks, so a
# violation hiding in a package marker file must not be invisible to the audit
# the way it would be for content checkers (dead code, naming, etc.).
INCLUDE_INIT_FILES = True
def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: def check_module(module_path: str, bypass_rules: list | None = None) -> Dict:
""" """
@@ -121,6 +127,39 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict:
return {"passed": overall_passed, "checks": checks, "score": score, "standard": "HANDLERS"} return {"passed": overall_passed, "checks": checks, "score": score, "standard": "HANDLERS"}
def _iter_import_modules(content: str) -> Iterator[Tuple[int, str, List[str]]]:
"""
Parse content and yield (line_number, dotted_module, imported_names) for every
real import statement — string literals, comments and docstrings can never
produce a hit because the AST only contains actual import nodes.
Relative imports (from . import x) are omitted entirely: they can't reference
an absolute dotted path like "apps.handlers"/"apps.modules" and are always
same-package, so callers don't need to special-case them.
"""
try:
tree = ast.parse(content)
except SyntaxError as e:
logger.info("Skipping import scan: SyntaxError during parse: %s", e)
return
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
if node.level:
continue
yield node.lineno, node.module or "", [alias.name for alias in node.names]
elif isinstance(node, ast.Import):
for alias in node.names:
yield node.lineno, alias.name, []
def _line_text(lines: List[str], lineno: int, fallback: str) -> str:
"""Return the source line at 1-indexed lineno, or a fallback if out of range."""
if 0 < lineno <= len(lines):
return lines[lineno - 1].strip()
return fallback
def check_handler_independence(content: str, lines: List[str], module_path: str) -> Dict: def check_handler_independence(content: str, lines: List[str], module_path: str) -> Dict:
""" """
Check handler independence - no cross-handler imports except defaults Check handler independence - no cross-handler imports except defaults
@@ -141,58 +180,26 @@ def check_handler_independence(content: str, lines: List[str], module_path: str)
own_package = path_parts[i + 1] own_package = path_parts[i + 1]
break break
in_docstring = False for lineno, module, names in _iter_import_modules(content):
for i, line in enumerate(lines, 1): if "apps.handlers" not in module:
stripped = line.strip()
# Track docstrings (handle both single-line and multi-line)
if stripped.startswith('"""') or stripped.startswith("'''"):
quote_marker = '"""' if stripped.startswith('"""') else "'''"
# Count occurrences of the quote marker
quote_count = stripped.count(quote_marker)
if quote_count >= 2:
# Single-line docstring (opening and closing on same line)
continue # Skip this line but don't toggle state
else:
# Multi-line docstring boundary
in_docstring = not in_docstring
# Skip docstrings, comments and empty lines
if in_docstring or not stripped or stripped.startswith("#"):
continue
# Check for handler imports
if "apps.handlers" in stripped and ("from " in stripped or "import " in stripped):
# Skip if in a string (rough check)
if '"from ' in stripped or "'from " in stripped:
continue
# Extract code part (before comment)
code_part = stripped.split("#")[0] if "#" in stripped else stripped
if "apps.handlers" not in code_part:
continue continue
# Allowed: Default handlers (json_handler) # Allowed: Default handlers (json_handler)
if "handlers.json import json_handler" in code_part: if module.endswith("handlers.json") and "json_handler" in names:
continue
# Allowed: Same package imports (relative imports like "from .decorators")
if code_part.strip().startswith("from ."):
continue continue
# Allowed: Same package absolute imports # Allowed: Same package absolute imports
if own_package and f"handlers.{own_package}" in code_part: if own_package and f"handlers.{own_package}" in module:
continue continue
# Forbidden: Cross-handler imports # Forbidden: Cross-handler imports
forbidden_imports.append(f"line {i}: {stripped}") forbidden_imports.append(f"line {lineno}: {_line_text(lines, lineno, module)}")
if forbidden_imports: if forbidden_imports:
return { return {
"name": "Handler independence", "name": "Handler independence",
"passed": False, "passed": False,
"message": f"Cross-handler imports detected (except defaults): {forbidden_imports[0]}", "message": "Cross-handler imports detected (except defaults): " + "; ".join(forbidden_imports),
} }
return {"name": "Handler independence", "passed": True, "message": "No forbidden cross-handler imports detected"} return {"name": "Handler independence", "passed": True, "message": "No forbidden cross-handler imports detected"}
@@ -239,50 +246,22 @@ def check_no_orchestration(content: str, lines: List[str]) -> Optional[Dict]:
""" """
module_imports = [] module_imports = []
in_docstring = False for lineno, module, _names in _iter_import_modules(content):
for i, line in enumerate(lines, 1): if "apps.modules" not in module:
stripped = line.strip()
# Track docstrings (handle both single-line and multi-line)
if stripped.startswith('"""') or stripped.startswith("'''"):
quote_marker = '"""' if stripped.startswith('"""') else "'''"
# Count occurrences of the quote marker
quote_count = stripped.count(quote_marker)
if quote_count >= 2:
# Single-line docstring (opening and closing on same line)
continue # Skip this line but don't toggle state
else:
# Multi-line docstring boundary
in_docstring = not in_docstring
# Skip docstrings, comments and empty lines
if in_docstring or not stripped or stripped.startswith("#"):
continue
# Check for module imports
if "apps.modules" in stripped and ("from " in stripped or "import " in stripped):
# Skip if in a string
if '"from ' in stripped or "'from " in stripped:
continue
# Extract code part
code_part = stripped.split("#")[0] if "#" in stripped else stripped
if "apps.modules" not in code_part:
continue continue
# Allowed: Service imports (prax.apps.modules.logger, cli.apps.modules) # Allowed: Service imports (prax.apps.modules.logger, cli.apps.modules)
if "prax.apps.modules.logger" in code_part or "cli.apps.modules" in code_part: if "prax.apps.modules.logger" in module or "cli.apps.modules" in module:
continue continue
# Forbidden: Module imports (orchestration) # Forbidden: Module imports (orchestration)
module_imports.append(f"line {i}: {stripped}") module_imports.append(f"line {lineno}: {_line_text(lines, lineno, module)}")
if module_imports: if module_imports:
return { return {
"name": "No orchestration", "name": "No orchestration",
"passed": False, "passed": False,
"message": f"Handler imports modules (orchestration): {module_imports[0]}", "message": "Handler imports modules (orchestration): " + "; ".join(module_imports),
} }
return {"name": "No orchestration", "passed": True, "message": "No module imports detected (pure implementation)"} return {"name": "No orchestration", "passed": True, "message": "No module imports detected (pure implementation)"}
@@ -31,6 +31,7 @@ from aipass.prax import logger
from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.json import json_handler
from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file
from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored, load_ignore_entries
# Audit scope: entry points only (apps/{name}.py) # Audit scope: entry points only (apps/{name}.py)
AUDIT_SCOPE = "entry_point" AUDIT_SCOPE = "entry_point"
@@ -503,12 +504,16 @@ def _extract_test_counts(content: str) -> List[int]:
def _count_test_functions(tests_dir: Path) -> int: def _count_test_functions(tests_dir: Path) -> int:
"""Count `def test_` functions in all test_*.py files under tests/.""" """Count `def test_` functions in all test_*.py files under tests/."""
count = 0 count = 0
branch_root = tests_dir.parent
ignore_entries = load_ignore_entries(branch_root)
test_func_pattern = re.compile(r"^\s*def\s+test_", re.MULTILINE) test_func_pattern = re.compile(r"^\s*def\s+test_", re.MULTILINE)
for test_file in tests_dir.rglob("test_*.py"): for test_file in tests_dir.rglob("test_*.py"):
if any(part in SOURCE_SKIP_DIRS for part in test_file.relative_to(tests_dir).parts): if any(part in SOURCE_SKIP_DIRS for part in test_file.relative_to(tests_dir).parts):
continue continue
if is_disabled_file(test_file.name): if is_disabled_file(test_file.name):
continue continue
if is_seedgo_ignored(str(test_file), branch_root, ignore_entries):
continue
try: try:
source = test_file.read_text(encoding="utf-8") source = test_file.read_text(encoding="utf-8")
count += len(test_func_pattern.findall(source)) count += len(test_func_pattern.findall(source))
@@ -30,6 +30,7 @@ from aipass.prax import logger
from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.json import json_handler
from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file
from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored, load_ignore_entries
AUDIT_SCOPE = "branch_level" AUDIT_SCOPE = "branch_level"
@@ -180,7 +181,7 @@ def _should_skip_file(name: str) -> bool:
return is_disabled_file(name) return is_disabled_file(name)
def _find_test_files_broad(branch_path: Path) -> list[Path]: def _find_test_files_broad(branch_path: Path, ignore_entries: list) -> list[Path]:
"""Find all test files for module coverage analysis. """Find all test files for module coverage analysis.
Broader than _find_all_test_files — also finds scattered test files Broader than _find_all_test_files — also finds scattered test files
@@ -197,6 +198,8 @@ def _find_test_files_broad(branch_path: Path) -> list[Path]:
continue continue
if any(_should_skip_dir(part) for part in py_file.relative_to(tests_dir).parts): if any(_should_skip_dir(part) for part in py_file.relative_to(tests_dir).parts):
continue continue
if is_seedgo_ignored(str(py_file), branch_path, ignore_entries):
continue
resolved = py_file.resolve() resolved = py_file.resolve()
if resolved not in seen: if resolved not in seen:
seen.add(resolved) seen.add(resolved)
@@ -208,6 +211,8 @@ def _find_test_files_broad(branch_path: Path) -> list[Path]:
continue continue
if py_file.name in ("__init__.py", "conftest.py") or _should_skip_file(py_file.name): if py_file.name in ("__init__.py", "conftest.py") or _should_skip_file(py_file.name):
continue continue
if is_seedgo_ignored(str(py_file), branch_path, ignore_entries):
continue
if py_file.name.startswith("test_") or py_file.name.endswith("_test.py"): if py_file.name.startswith("test_") or py_file.name.endswith("_test.py"):
resolved = py_file.resolve() resolved = py_file.resolve()
if resolved not in seen: if resolved not in seen:
@@ -234,7 +239,7 @@ def _analyze_test_file_imports(source: str) -> set[str]:
return tested_modules return tested_modules
def _collect_testable_modules(branch_path: Path) -> set[str]: def _collect_testable_modules(branch_path: Path, ignore_entries: list) -> set[str]:
"""Collect module names from apps/modules/ and apps/handlers/. """Collect module names from apps/modules/ and apps/handlers/.
Returns set of module names: Returns set of module names:
@@ -255,17 +260,21 @@ def _collect_testable_modules(branch_path: Path) -> set[str]:
and item.suffix == ".py" and item.suffix == ".py"
and item.name != "__init__.py" and item.name != "__init__.py"
and not _should_skip_file(item.name) and not _should_skip_file(item.name)
and not is_seedgo_ignored(str(item), branch_path, ignore_entries)
): ):
modules.add(item.stem) modules.add(item.stem)
handlers_dir = apps_dir / "handlers" handlers_dir = apps_dir / "handlers"
if handlers_dir.is_dir(): if handlers_dir.is_dir():
for item in sorted(handlers_dir.iterdir()): for item in sorted(handlers_dir.iterdir()):
if _should_skip_dir(item.name): if _should_skip_dir(item.name) or is_seedgo_ignored(str(item), branch_path, ignore_entries):
continue continue
if item.is_dir() and item.name != "__pycache__": if item.is_dir() and item.name != "__pycache__":
has_py = any( has_py = any(
f.suffix == ".py" and f.name != "__init__.py" and not _should_skip_file(f.name) f.suffix == ".py"
and f.name != "__init__.py"
and not _should_skip_file(f.name)
and not is_seedgo_ignored(str(f), branch_path, ignore_entries)
for f in item.iterdir() for f in item.iterdir()
if f.is_file() if f.is_file()
) )
@@ -282,7 +291,7 @@ def _collect_testable_modules(branch_path: Path) -> set[str]:
return modules return modules
def _find_all_test_files(branch_path: Path) -> list[Path]: def _find_all_test_files(branch_path: Path, ignore_entries: list) -> list[Path]:
"""Find all test files and conftest.py in the branch's tests/ directory. """Find all test files and conftest.py in the branch's tests/ directory.
Scans for any test_*.py file plus conftest.py -- no naming requirements. Scans for any test_*.py file plus conftest.py -- no naming requirements.
@@ -295,6 +304,8 @@ def _find_all_test_files(branch_path: Path) -> list[Path]:
for p in sorted(tests_dir.iterdir()): for p in sorted(tests_dir.iterdir()):
if not p.is_file() or p.suffix != ".py": if not p.is_file() or p.suffix != ".py":
continue continue
if is_seedgo_ignored(str(p), branch_path, ignore_entries):
continue
if p.name.startswith("test_") or p.name == "conftest.py": if p.name.startswith("test_") or p.name == "conftest.py":
results.append(p) results.append(p)
@@ -395,7 +406,8 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict:
} }
# Phase 1: Find all test files # Phase 1: Find all test files
test_files = _find_all_test_files(bp) ignore_entries = load_ignore_entries(bp)
test_files = _find_all_test_files(bp, ignore_entries)
if not test_files: if not test_files:
checks.append( checks.append(
@@ -470,7 +482,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict:
# Phase 4: Module coverage (category 11 — from test_coverage_check.py) # Phase 4: Module coverage (category 11 — from test_coverage_check.py)
# Uses broader file discovery + import-based module mapping # Uses broader file discovery + import-based module mapping
broad_test_files = _find_test_files_broad(bp) broad_test_files = _find_test_files_broad(bp, ignore_entries)
total_tests = 0 total_tests = 0
tested_modules: set[str] = set() tested_modules: set[str] = set()
for tf in broad_test_files: for tf in broad_test_files:
@@ -483,7 +495,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict:
if total_tests == 0: if total_tests == 0:
tested_modules = set() tested_modules = set()
all_modules = _collect_testable_modules(bp) all_modules = _collect_testable_modules(bp, ignore_entries)
total_modules = len(all_modules) total_modules = len(all_modules)
# 3 module coverage items # 3 module coverage items
@@ -32,6 +32,7 @@ from pathlib import Path
from aipass.prax import logger from aipass.prax import logger
from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.json import json_handler
from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored, load_ignore_entries
from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file
AUDIT_SCOPE = "branch_level" AUDIT_SCOPE = "branch_level"
@@ -60,18 +61,23 @@ _MAIN_BLOCK_RE = re.compile(
# -- File collection ---------------------------------------------------------- # -- File collection ----------------------------------------------------------
def _should_skip(path: Path) -> bool: def _should_skip(path: Path, branch_root: Path, ignore_entries: list) -> bool:
"""Return True if any path component is in the skip set or file is disabled.""" """Return True if any path component is in the skip set, file is disabled,
return any(part in SKIP_DIRS for part in path.parts) or is_disabled_file(path.name) or the path is excluded via .seedgoignore / the global default."""
return (
any(part in SKIP_DIRS for part in path.parts)
or is_disabled_file(path.name)
or is_seedgo_ignored(str(path), branch_root, ignore_entries)
)
def _collect_python_files(branch_path: Path) -> list[Path]: def _collect_python_files(branch_path: Path, ignore_entries: list) -> list[Path]:
"""Collect all .py files in the branch, skipping irrelevant dirs.""" """Collect all .py files in the branch, skipping irrelevant dirs."""
files: list[Path] = [] files: list[Path] = []
if not branch_path.is_dir(): if not branch_path.is_dir():
return files return files
for py_file in branch_path.rglob("*.py"): for py_file in branch_path.rglob("*.py"):
if _should_skip(py_file): if _should_skip(py_file, branch_path, ignore_entries):
continue continue
files.append(py_file) files.append(py_file)
return sorted(files) return sorted(files)
@@ -216,7 +222,8 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict:
} }
# Phase 1: Collect all .py files # Phase 1: Collect all .py files
py_files = _collect_python_files(branch) ignore_entries = load_ignore_entries(branch)
py_files = _collect_python_files(branch, ignore_entries)
if not py_files: if not py_files:
json_handler.log_operation( json_handler.log_operation(
"check_completed", "check_completed",
@@ -42,19 +42,27 @@ def discover_checkers(pack_path: Path | None = None) -> Dict[str, Any]:
return checkers return checkers
def _collect_py_files(branch_path: Path) -> List[Dict[str, str]]: def _collect_py_files(branch_path: Path, include_init: bool = False) -> List[Dict[str, str]]:
"""Collect auditable .py files from apps/, respecting ignore patterns.""" """Collect auditable .py files from apps/, respecting ignore patterns.
__init__.py package markers are excluded by default — most checkers are
content-focused (dead code, naming, nesting) and __init__.py is typically
boilerplate. Pass include_init=True for import-statement checkers, where a
real cross-handler import hiding in a package marker must not go unseen.
"""
apps_dir = branch_path / "apps" apps_dir = branch_path / "apps"
if not apps_dir.exists(): if not apps_dir.exists():
return [] return []
ign = ignore_handler.get_audit_ignore_patterns() ign = ignore_handler.get_audit_ignore_patterns()
ignore_entries = ignore_handler.load_ignore_entries(branch_path)
return [ return [
{"file": str(f), "name": f.name} {"file": str(f), "name": f.name}
for f in apps_dir.rglob("*.py") for f in apps_dir.rglob("*.py")
if f.name != "__init__.py" if (include_init or f.name != "__init__.py")
and not is_disabled_file(f.name) and not is_disabled_file(f.name)
and not is_throwaway_path(str(f)) and not is_throwaway_path(str(f))
and not any(p in str(f).lower() for p in ign) and not any(p in str(f).lower() for p in ign)
and not ignore_handler.is_seedgo_ignored(str(f), branch_path, ignore_entries)
] ]
@@ -132,6 +140,7 @@ def audit_branch(branch: Dict[str, str], bypass_rules: list, pack_path: Path | N
"""Audit a branch for standards compliance. Returns backward-compatible dict.""" """Audit a branch for standards compliance. Returns backward-compatible dict."""
entry_file, branch_path = branch["entry_file"], Path(branch["path"]) entry_file, branch_path = branch["entry_file"], Path(branch["path"])
checkers, all_files = discover_checkers(pack_path), _collect_py_files(branch_path) checkers, all_files = discover_checkers(pack_path), _collect_py_files(branch_path)
files_with_init: List[Dict[str, str]] | None = None
# Discover diagnostics checker from handlers/diagnostics/ (outside pack dirs) # Discover diagnostics checker from handlers/diagnostics/ (outside pack dirs)
diag_mod = _load_diagnostics_checker() diag_mod = _load_diagnostics_checker()
@@ -161,7 +170,12 @@ def audit_branch(branch: Dict[str, str], bypass_rules: list, pack_path: Path | N
results[name], scores[name] = {"passed": False, "score": 0, "error": str(e)}, 0 results[name], scores[name] = {"passed": False, "score": 0, "error": str(e)}, 0
# All-files scope: scan every .py file, override score with average # All-files scope: scan every .py file, override score with average
if scope == "all_files" and all_files: if scope == "all_files" and all_files:
v, s = _run_all_files(checker, name, all_files, bypass_rules) scan_files = all_files
if getattr(checker, "INCLUDE_INIT_FILES", False):
if files_with_init is None:
files_with_init = _collect_py_files(branch_path, include_init=True)
scan_files = files_with_init
v, s = _run_all_files(checker, name, scan_files, bypass_rules)
all_violations[name] = v all_violations[name] = v
if s: if s:
avg_score = int(sum(s) / len(s)) avg_score = int(sum(s) / len(s))
@@ -11,14 +11,23 @@ Ignore Pattern Configuration Handler
Provides ignore patterns for audit file filtering, template baseline checking, Provides ignore patterns for audit file filtering, template baseline checking,
and deprecated pattern tracking. Pure configuration with helper functions. and deprecated pattern tracking. Pure configuration with helper functions.
Also provides the .seedgoignore engine — a gitignore-style dotfile droppable
into any directory (per-directory scope, same nesting semantics as .gitignore)
plus a global default so agents' tools/ dirs are ignored fleet-wide with zero
per-branch setup. See DEFAULT_IGNORE_PATTERNS / is_seedgo_ignored().
""" """
# ============================================= # =============================================
# IMPORTS # IMPORTS
# ============================================= # =============================================
from typing import List from pathlib import Path
from typing import List, Optional, Tuple
import pathspec
from aipass.prax import logger
from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.json import json_handler
# ============================================= # =============================================
@@ -111,6 +120,95 @@ def get_deprecated_patterns() -> dict:
return DEPRECATED_PATTERNS.copy() return DEPRECATED_PATTERNS.copy()
# =============================================
# SEEDGO_IGNORE — gitignore-style, per-directory
# =============================================
# Dotfile name — droppable into any directory in a branch, gitignore-style
# patterns (via pathspec), scoped to that directory's subtree exactly like a
# real .gitignore.
IGNORE_FILENAME = ".seedgoignore"
# Global default — applied to every branch with zero per-branch setup.
# Agents' tools/ dirs are deliberate throwaway prototyping space (quick
# scripts for fast answers) — not standards-compliant by design, and that's
# fine and wanted (Patrick ruling).
DEFAULT_IGNORE_PATTERNS: List[str] = [
"tools/",
]
def _iter_seedgo_ignore_files(branch_root: Path) -> List[Path]:
"""Return every .seedgoignore file under branch_root, shallowest first."""
return sorted(branch_root.rglob(IGNORE_FILENAME), key=lambda p: len(p.parts))
def load_ignore_entries(branch_root: Path) -> List[Tuple[str, "pathspec.PathSpec"]]:
"""Build the ordered (scope, PathSpec) list for a branch.
scope "" is the global default and applies branch-wide. Each discovered
.seedgoignore file adds a scope equal to its own directory (relative to
branch_root) — its patterns are relative to that directory and only
match within its subtree, same nesting semantics as a real .gitignore.
Args:
branch_root: Absolute path to the branch root.
Returns:
Ordered list of (scope, PathSpec) pairs, global default first.
"""
root = Path(branch_root).resolve()
entries: List[Tuple[str, "pathspec.PathSpec"]] = [
("", pathspec.PathSpec.from_lines("gitignore", DEFAULT_IGNORE_PATTERNS))
]
for ignore_file in _iter_seedgo_ignore_files(root):
scope = ignore_file.parent.relative_to(root).as_posix()
if scope == ".":
scope = ""
try:
lines = ignore_file.read_text(encoding="utf-8").splitlines()
except OSError as exc:
logger.info("[ignore_handler] Cannot read %s: %s", ignore_file, exc)
continue
entries.append((scope, pathspec.PathSpec.from_lines("gitignore", lines)))
json_handler.log_operation("seedgo_ignore_loaded", {"branch_root": str(root), "files": len(entries) - 1})
return entries
def is_seedgo_ignored(
file_path: str, branch_root: Path, entries: Optional[List[Tuple[str, "pathspec.PathSpec"]]] = None
) -> bool:
"""Check whether file_path is ignored via .seedgoignore or the global default.
Args:
file_path: Absolute path to the candidate file.
branch_root: Absolute path to the branch root.
entries: Pre-loaded result of load_ignore_entries(); loaded fresh if omitted.
Returns:
True when scans/audits/checklists/checker-style enforcement should skip this path.
"""
root = Path(branch_root).resolve()
try:
rel = Path(file_path).resolve().relative_to(root).as_posix()
except ValueError as exc:
logger.info("[ignore_handler] %s not under branch root %s: %s", file_path, root, exc)
return False
if entries is None:
entries = load_ignore_entries(root)
for scope, spec in entries:
if scope and rel != scope and not rel.startswith(scope + "/"):
continue
sub_rel = rel[len(scope) + 1 :] if scope else rel
if spec.match_file(sub_rel):
return True
return False
# ============================================= # =============================================
# MODULE INITIALIZATION # MODULE INITIALIZATION
# ============================================= # =============================================
+19 -4
View File
@@ -45,6 +45,9 @@ from aipass.seedgo.apps.handlers.bypass.bypass_handler import (
# Throwaway / prototype detection # Throwaway / prototype detection
from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import is_prototype_file, is_throwaway_path from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import is_prototype_file, is_throwaway_path
# .seedgoignore — gitignore-style per-directory + global default (tools/)
from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored
# JSON handler for tracking # JSON handler for tracking
from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.json import json_handler
@@ -140,6 +143,10 @@ def run_checklist(file_path: str, pack_name: str = "aipass", prototype: bool = F
if prototype or is_prototype_file(resolved): if prototype or is_prototype_file(resolved):
return [{"standard": "(skip)", "passed": True, "detail": "Prototype mode — standards skipped"}] return [{"standard": "(skip)", "passed": True, "detail": "Prototype mode — standards skipped"}]
branch_path = _resolve_branch_path(resolved)
if branch_path is not None and is_seedgo_ignored(resolved, branch_path):
return [{"standard": "(skip)", "passed": True, "detail": "Ignored via .seedgoignore"}]
# Discover pack path # Discover pack path
pack_path = _resolve_pack_path(pack_name) pack_path = _resolve_pack_path(pack_name)
if pack_path is None: if pack_path is None:
@@ -191,20 +198,28 @@ def _resolve_pack_path(pack_name: str) -> Optional[Path]:
return None return None
def _load_bypass_for_file(file_path: str) -> list: def _resolve_branch_path(file_path: str) -> Optional[Path]:
"""Load bypass rules for the branch containing file_path.""" """Resolve the absolute branch root containing file_path, or None."""
branch = get_branch_from_path(file_path) branch = get_branch_from_path(file_path)
if branch is None: if branch is None:
return [] return None
raw_path = branch.get("path", "") raw_path = branch.get("path", "")
if not raw_path: if not raw_path:
return [] return None
bp = Path(raw_path) bp = Path(raw_path)
if not bp.is_absolute(): if not bp.is_absolute():
from aipass.seedgo.apps.handlers.bypass.bypass_handler import _find_registry from aipass.seedgo.apps.handlers.bypass.bypass_handler import _find_registry
registry_path = _find_registry() registry_path = _find_registry()
bp = (registry_path.parent / bp).resolve() bp = (registry_path.parent / bp).resolve()
return bp
def _load_bypass_for_file(file_path: str) -> list:
"""Load bypass rules for the branch containing file_path."""
bp = _resolve_branch_path(file_path)
if bp is None:
return []
return load_bypass_rules(str(bp)) return load_bypass_rules(str(bp))
+99 -29
View File
@@ -1,5 +1,21 @@
{ {
"UserPromptSubmit": [ "UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:presence_gate"
}
]
},
{
"hooks": [
{
"type": "command",
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:persistent_alert"
}
]
},
{ {
"hooks": [ "hooks": [
{ {
@@ -40,6 +56,22 @@
} }
] ]
}, },
{
"hooks": [
{
"type": "command",
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:compass_recall"
}
]
},
{
"hooks": [
{
"type": "command",
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:feedback_pulse"
}
]
},
{ {
"hooks": [ "hooks": [
{ {
@@ -48,15 +80,53 @@
"timeout": 120 "timeout": 120
} }
] ]
} },
],
"PreToolUse": [
{ {
"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task",
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse" "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:context_gauge",
"timeout": 30
}
]
},
{
"hooks": [
{
"type": "command",
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:temporal",
"timeout": 30
}
]
}
],
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Notification"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Stop"
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py SessionStart:cadence_reset",
"timeout": 30
} }
] ]
} }
@@ -72,6 +142,17 @@
] ]
} }
], ],
"PreToolUse": [
{
"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task",
"hooks": [
{
"type": "command",
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse"
}
]
}
],
"SubagentStop": [ "SubagentStop": [
{ {
"hooks": [ "hooks": [
@@ -82,26 +163,6 @@
] ]
} }
], ],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Stop"
}
]
}
],
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Notification"
}
]
}
],
"PreCompact": [ "PreCompact": [
{ {
"matcher": "manual", "matcher": "manual",
@@ -162,14 +223,23 @@
"timeout": 120 "timeout": 120
} }
] ]
} },
],
"SessionStart": [
{ {
"matcher": "manual",
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py SessionStart:cadence_reset", "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_prep",
"timeout": 30
}
]
},
{
"matcher": "auto",
"hooks": [
{
"type": "command",
"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_prep",
"timeout": 30 "timeout": 30
} }
] ]
+105
View File
@@ -221,6 +221,111 @@ def test_get_deprecated_patterns_returns_dict():
assert isinstance(value, str) assert isinstance(value, str)
# ---------------------------------------------------------------------------
# Tests -- .seedgoignore engine (load_ignore_entries / is_seedgo_ignored)
# ---------------------------------------------------------------------------
def test_global_default_ignores_tools_dir_with_no_dotfile(tmp_path):
"""Global default (tools/) applies branch-wide even with zero .seedgoignore files."""
from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored
tools_file = tmp_path / "apps" / "tools" / "scratch.py"
tools_file.parent.mkdir(parents=True)
tools_file.write_text("pass", encoding="utf-8")
normal_file = tmp_path / "apps" / "modules" / "real.py"
normal_file.parent.mkdir(parents=True)
normal_file.write_text("pass", encoding="utf-8")
assert is_seedgo_ignored(str(tools_file), tmp_path) is True
assert is_seedgo_ignored(str(normal_file), tmp_path) is False
def test_seedgo_ignore_dotfile_scoped_to_its_own_directory(tmp_path):
"""A .seedgoignore dropped in a subdir only affects that subdir's subtree, not siblings."""
from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored
scratch_dir = tmp_path / "apps" / "handlers" / "experiment"
scratch_dir.mkdir(parents=True)
(scratch_dir / ".seedgoignore").write_text("*.draft.py\n", encoding="utf-8")
(scratch_dir / "wip.draft.py").write_text("pass", encoding="utf-8")
sibling_dir = tmp_path / "apps" / "handlers" / "other"
sibling_dir.mkdir(parents=True)
(sibling_dir / "wip.draft.py").write_text("pass", encoding="utf-8")
assert is_seedgo_ignored(str(scratch_dir / "wip.draft.py"), tmp_path) is True
assert is_seedgo_ignored(str(sibling_dir / "wip.draft.py"), tmp_path) is False
def test_seedgo_ignore_supports_gitignore_style_patterns(tmp_path):
"""Comments, blank lines, and negation follow standard gitignore semantics."""
from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored
(tmp_path / ".seedgoignore").write_text(
"\n".join(["# comment", "", "scratch/", "!scratch/keep_me.py"]),
encoding="utf-8",
)
scratch_dir = tmp_path / "scratch"
scratch_dir.mkdir()
(scratch_dir / "throwaway.py").write_text("pass", encoding="utf-8")
(scratch_dir / "keep_me.py").write_text("pass", encoding="utf-8")
assert is_seedgo_ignored(str(scratch_dir / "throwaway.py"), tmp_path) is True
assert is_seedgo_ignored(str(scratch_dir / "keep_me.py"), tmp_path) is False
def test_seedgo_ignore_nested_scopes_both_apply(tmp_path):
"""A nested .seedgoignore adds to (not replaces) any ancestor .seedgoignore scopes."""
from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored
(tmp_path / ".seedgoignore").write_text("*.rootskip\n", encoding="utf-8")
child_dir = tmp_path / "apps" / "child"
child_dir.mkdir(parents=True)
(child_dir / ".seedgoignore").write_text("*.childskip\n", encoding="utf-8")
(child_dir / "a.rootskip").write_text("pass", encoding="utf-8")
(child_dir / "b.childskip").write_text("pass", encoding="utf-8")
(child_dir / "c.py").write_text("pass", encoding="utf-8")
assert is_seedgo_ignored(str(child_dir / "a.rootskip"), tmp_path) is True
assert is_seedgo_ignored(str(child_dir / "b.childskip"), tmp_path) is True
assert is_seedgo_ignored(str(child_dir / "c.py"), tmp_path) is False
def test_is_seedgo_ignored_path_outside_branch_root_returns_false(tmp_path):
"""A file outside branch_root cannot be resolved to a relative path — treated as not ignored."""
from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored
branch_root = tmp_path / "branch"
branch_root.mkdir()
outside_file = tmp_path / "elsewhere" / "file.py"
outside_file.parent.mkdir()
outside_file.write_text("pass", encoding="utf-8")
assert is_seedgo_ignored(str(outside_file), branch_root) is False
def test_load_ignore_entries_default_only_when_no_dotfiles(tmp_path):
"""With no .seedgoignore files present, only the global default scope is returned."""
from aipass.seedgo.apps.handlers.bypass.ignore_handler import load_ignore_entries
entries = load_ignore_entries(tmp_path)
assert len(entries) == 1
assert entries[0][0] == ""
def test_is_seedgo_ignored_accepts_precomputed_entries(tmp_path):
"""Passing pre-loaded entries skips the internal reload — same result as omitting it."""
from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored, load_ignore_entries
tools_file = tmp_path / "tools" / "scratch.py"
tools_file.parent.mkdir(parents=True)
tools_file.write_text("pass", encoding="utf-8")
entries = load_ignore_entries(tmp_path)
assert is_seedgo_ignored(str(tools_file), tmp_path, entries) is True
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tests -- utils.is_bypassed name-scoped bypass # Tests -- utils.is_bypassed name-scoped bypass
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -158,6 +158,42 @@ from aipass.seedgo.apps.modules.scanner import scan_all
result = check_handlers(fp, bypass_rules=bypass) result = check_handlers(fp, bypass_rules=bypass)
assert result["score"] == 100 assert result["score"] == 100
def test_handlers_reports_all_forbidden_imports(self, tmp_path: Path) -> None:
"""A file with two cross-handler imports must report both, not just the first."""
handler_dir = tmp_path / "apps" / "handlers" / "mypack"
handler_dir.mkdir(parents=True)
code = """\
from aipass.seedgo.apps.handlers.error import error_handler
from aipass.seedgo.apps.handlers.file import file_handler
def do_stuff():
return True
"""
fp = str(handler_dir / "double_violation.py")
Path(fp).write_text(code, encoding="utf-8")
result = check_handlers(fp)
independence = next(c for c in result["checks"] if c["name"] == "Handler independence")
assert "line 1" in independence["message"]
assert "line 2" in independence["message"]
def test_handlers_reports_all_orchestration_imports(self, tmp_path: Path) -> None:
"""A file with two module (orchestration) imports must report both, not just the first."""
handler_dir = tmp_path / "apps" / "handlers" / "mypack"
handler_dir.mkdir(parents=True)
code = """\
from aipass.seedgo.apps.modules.scanner import scan_all
from aipass.seedgo.apps.modules.reporter import report_all
def do_stuff():
return scan_all()
"""
fp = str(handler_dir / "double_orchestration.py")
Path(fp).write_text(code, encoding="utf-8")
result = check_handlers(fp)
orchestration = next(c for c in result["checks"] if c["name"] == "No orchestration")
assert "line 1" in orchestration["message"]
assert "line 2" in orchestration["message"]
# =================================================================== # ===================================================================
# 3. hardcoded_key_check # 3. hardcoded_key_check
@@ -238,6 +238,21 @@ class TestDeadCodeCheck:
assert result["score"] == 100 assert result["score"] == 100
assert result["passed"] is True assert result["passed"] is True
def test_dead_code_ignores_tools_dir_by_default(self, mock_json, tmp_path: Path) -> None:
"""Orphan modules under apps/tools/ are skipped via the global .seedgoignore default."""
branch = _make_branch(tmp_path)
_write_file(
branch / "apps" / "tools" / "scratch.py",
"def lonely_function():\n return None\n",
)
_write_file(
branch / "apps" / (branch.name + ".py"),
"def handle_command(): pass\n",
)
result = dead_code_check_branch(str(branch))
assert result["score"] == 100
assert result["passed"] is True
# ============================================= # =============================================
# 5. test_quality_check (check_branch) # 5. test_quality_check (check_branch)
@@ -466,3 +481,16 @@ class TestUnusedFunctionCheck:
result = unused_function_check_branch(str(branch), bypass_rules=bypass) result = unused_function_check_branch(str(branch), bypass_rules=bypass)
assert result["score"] == 100 assert result["score"] == 100
assert result["passed"] is True assert result["passed"] is True
def test_unused_function_respects_seedgo_ignore_dotfile(self, mock_json, tmp_path: Path) -> None:
"""A .seedgoignore dropped alongside an unused function's file suppresses the flag."""
branch = _make_branch(tmp_path)
experiment_dir = branch / "apps" / "handlers" / "experiment"
_write_file(experiment_dir / ".seedgoignore", "*\n")
_write_file(
experiment_dir / "scratch.py",
"def never_called():\n pass\n",
)
result = unused_function_check_branch(str(branch))
assert result["score"] == 100
assert result["passed"] is True

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