diff --git a/.aipass/hooks.json b/.aipass/hooks.json index 566a6c17..2b05404a 100644 --- a/.aipass/hooks.json +++ b/.aipass/hooks.json @@ -55,6 +55,17 @@ "matcher": "", "timeout": 120 }, + "context_gauge": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.prompt.context_gauge.handle", + "matcher": "", + "timeout": 30 + }, + "temporal": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.prompt.temporal.handle", + "matcher": "" + }, "user_message_relay": { "enabled": true, "handler": "aipass.skills.lib.telegram.apps.handlers.user_message_relay.handle", @@ -158,6 +169,12 @@ "handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle", "matcher": "", "timeout": 120 + }, + "pre_compact_prep": { + "enabled": true, + "handler": "aipass.hooks.apps.handlers.lifecycle.pre_compact_prep.handle", + "matcher": "", + "timeout": 30 } }, diff --git a/.claude/commands/prep.md b/.claude/commands/prep.md index 26ed82fe..b1f865e2 100644 --- a/.claude/commands/prep.md +++ b/.claude/commands/prep.md @@ -54,6 +54,7 @@ Quick checks beat assumptions: `ls`/`find` for files, `git ls-files`/`grep` for - Run `drone @ai_mail inbox 2>/dev/null` — report any unread emails - Close any that were already processed but not formally closed +- Run `drone @devpulse feedback 2>/dev/null` — report unread cross-project feedback; view/reply/clear anything NEW (S304 F46: this box sat unread for 3 months because nothing invoked it) ## 5. Compass Review (Devpulse only) diff --git a/.claude/provider_manifest.json b/.claude/provider_manifest.json index 8e3eda28..d5f31e1f 100644 --- a/.claude/provider_manifest.json +++ b/.claude/provider_manifest.json @@ -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:feedback_pulse", "event": "UserPromptSubmit"}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:auto_process", "event": "UserPromptSubmit"}, + {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:context_gauge", "event": "UserPromptSubmit"}, + {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:temporal", "event": "UserPromptSubmit"}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:user_message_relay", "event": "UserPromptSubmit"}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse", "event": "PostToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"}, @@ -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": "auto", "timeout": 60}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "manual", "timeout": 120}, - {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "auto", "timeout": 120} + {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "auto", "timeout": 120}, + {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_prep", "event": "PreCompact", "matcher": "manual", "timeout": 30}, + {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_prep", "event": "PreCompact", "matcher": "auto", "timeout": 30} ], "env": { "AIPASS_HOME": "{{REPO_ROOT}}", diff --git a/.gitignore b/.gitignore index 3d1db0c5..6761922e 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,7 @@ README_ORIGINAL_DISABLED.md *.bak test/ sandbox_test/ + +# SQLite WAL sidecars (runtime artifacts — commons.db ro-feed etc.) +*.db-wal +*.db-shm diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e26369f..9d86a21e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//stat` field 22 +so a kernel-recycled PID can't impersonate a dead session — closes the gap +before observe-only ever flips to enforcement. Missing procStart / non-Linux +falls back to liveness-only, logged. 15 new tests, suite 1272 green, +seedgo 31/31 both files. Built by @hooks. + +**fix(trigger)** — runaway-log alerts get the 24h TTL every other mute already +had (DPLAN-0256 backlog clear): `_write_alert()` hardcoded `expires_at: None`, +so alerts.json entries nagged forever while medic branch mutes self-expired. +New `DEFAULT_ALERT_TTL_SECONDS = 86400` (matches medic_state's +`DEFAULT_MUTE_SECONDS`) with a `forever` escape hatch threaded through +`handle_runaway_log_detected()`. 2 new tests, trigger suite 621 green, +audit 100%. Built by @trigger. + +**feat(drone)** — joint-decision gate on `drone @git merge` (DPLAN-0256, +Patrick ruling S330: merges are always done together, never accidental). +The gate sits in `_handle_merge` before the plugin import — `merge_pr()` is +unreachable without confirmation. A real terminal gets an interactive y/N +prompt; headless callers (agent Bash) are refused unless `--confirm` is +passed explicitly. Every gate decision (confirm / tty-yes / tty-abort / +headless-refused) is logged via json_handler. 6 new tests (86 green), +live-fired refusal verified, seedgo 31/31. + +**feat(skills)** — telegram user_message_relay joins the sound layer: relay +events now carry their own sound key so an inbound user message is audible +like every other hook event (59/59 + 252 green). + +**fix(devpulse)** — watchdog stall threshold 120s → 300s: the 120s +no-JSONL-activity heuristic fired `[watchdog.stall]` on healthy agents doing +long tool calls; 300s matches observed real-stall behavior (verified live +S330). Branch `.claude/settings.local.json` carries the devpulse +`autoCompactWindow: 350000` dial (Patrick ruling S326 — devpulse compacts +~292k, dispatched agents stay pinned at 200k). + +**fix(seedgo)** — checker accuracy arc (S330): AST-based import analysis +lands in the checkers (dead_code, encapsulation, handlers, readme, +test_quality, unused_function) — 13 false positives eliminated fleet-wide, +2 real hooks imports that legitimately bypass the pattern documented instead +of suppressed. branch_audit, checklist and ignore_handler aligned; provider +hooks snapshot fixture refreshed; stale bypass entries for deleted tools +purged across branches (devpulse, memory, seedgo, hooks). Fleet audit 100%. + +**feat(hooks)** — hook sound layer + temporal grounding. Sounds now mirror +the log across the hook fleet (prompt, lifecycle, notification, security +handlers) — audible liveness for the whole layer, verified live (2465 green, +audit 100). New `prompt/temporal.py`: tiny always-on UserPromptSubmit handler +injecting one line of local date/time/weekday/part-of-day every turn — live +clock each fire, host timezone via `astimezone()` (clones see their own local +time). Wired on both wires (`.aipass/hooks.json` + provider manifest). + +**feat(aipass)** — `aipass adopt` + shared scaffold refactor: adopt turns an +existing `projects/` directory into a full AIPass project (registry, resident +agent, `.aipass`/`.claude` scaffold) — every write additive, nothing existing +overwritten; unlike `aipass new` it starts from a directory with its own +content and git history. New `shared/` package (`project_home.py`, +`scaffold_content.py`) gives init/new/adopt one source of truth per helper — +`handlers/init/scaffold_content.py` moved there, no per-command copies to +drift. Proven live adopting aipass-site (doctor 31/0). Spawn template registry +synced (template bug chain me→spawn→aipass, fixed S329). 786 tests green, +audit-clean. + +## [2026-07-20] + +**docs(projects)** — `projects/README.md`: the projects section now ships in +the repo (the `!projects/README.md` gitignore whitelist existed since the +aipass-new design but the file was never written). Explains the project model: +**private by default** — each project is its own local git repo, fully ignored +by the AIPass repo, and publishing is an explicit opt-in step (Patrick ruling +2026-07-20). Opens the public roster with **Earmark** +([AIOSAI/earmark](https://github.com/AIOSAI/earmark)), the first public AIPass +project — a VS Code read-aloud extension with local Piper TTS and true +pause/resume, born, built, and published 2026-07-20. + +**fix(hooks)** — persistent_alert dedup + loud trust-break banner (5-agent +trace round follow-ups, DPLAN-0253 tail): + +- persistent_alert's once-per-session sound dedup lived in a module-global set, + but every bridge call is a fresh process — TTS would have announced on every + prompt while any alert was active. Replaced with session+alert-keyed tempdir + guard files (context_gauge idiom); banner capped at 10 alerts with an + "...and N more" note. +- Trust-registry breaks are now LOUD: any `.aipass/hooks.json` change breaks + the enrolled hash and silently disabled the entire hook layer (bit us live + for 2+ hours — tier prompts, security gates, everything dark, one log-file + WARNING as the only signal). New `is_hash_mismatch()` distinguishes a + genuine break from never-enrolled; `trust_break_banner()` does a + config-independent walk+hash check; the engine emits a full-width banner + once per prompt via the presence_gate bridge call. No auto-heal — + re-enrollment stays a deliberate human checkpoint. Live-fired: hash broken → + banner; restored → healthy. 16 new tests, suite 1206 green, seedgo 100%. +- Go-live day for the whole handler roster: 11/12 manifest entries wired into + provider settings by devpulse with Patrick accepting (user_message_relay + held: synchronous Telegram call + full prompt text off-machine — needs a + background send and an explicit call first). @hooks branch prompt corrected + and hardened: two-wires checklist + mandatory provider-wire flag in every + build reply. + +**feat(hooks)** — auto-compact prep: context gauge + mechanical snapshot +(DPLAN-0253, built by @hooks, two rounds): + +- `context_gauge` (UserPromptSubmit) — reads live context fill from the session + transcript every prompt (cheap 50KB tail), resolves the branch's compact + window (env > branch `settings.local.json` `autoCompactWindow` > 200k), and + injects a "run /prep NOW" nudge at 80% of the compact trigger, escalating at + 95% — once per threshold per session. Memory prep happens before auto-compact + takes the choice away, on every branch including dispatched agents. +- `pre_compact_prep` (PreCompact) — stamps a mechanical AUTO-COMPACT SNAPSHOT + session entry into the compacting branch's `.trinity/local.json`: context + fill %, active dispatch locks, open plans, git state, inbox unread. Templated + from live state, defensive (malformed memory = log + skip, never raises). +- Shared `context_window` module: bounded transcript tail reader + per-branch + window resolver. 36 new tests; suite 1190 green; seedgo 100%. +- Round 2 root-cause fix: handlers wired only in `.aipass/hooks.json` never + fire on name-scoped events — UserPromptSubmit and PreCompact invoke the + bridge per-handler from provider settings. Both handlers now have + `provider_manifest.json` entries; @hooks' branch prompt corrected (it taught + the old one-entry-per-event model) with a "new handler? check the provider + wire" reminder. Go-live needs the user's `~/.claude/settings.json` synced + from the manifest + fresh sessions. + +## [2026-07-19] + +**feat(ai_mail)** — dispatched agents default to Sonnet 5 (Patrick ruling S326): + +- wake.py model resolution passes bare aliases (`sonnet`/`opus`/`haiku`) + straight to the Claude CLI, which resolves latest-in-class — the pinned-ID + MODEL_MAP is gone and can never go stale again. Default flips opus → sonnet. +- dispatch_monitor pins `CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000` on every + spawned agent — Sonnet 5 is 1M-context native, and without the pin every + dispatched agent would silently inherit a 1M window. E2E-proven: a live + dispatched probe reported `claude-sonnet-5` + `WINDOW=200000` from inside. +- Follow-up landed same morning: the "daemon gap" was a name collision — + the unpatched `spawn_agent()` was ai_mail's own inbox-poller + (`handlers/dispatch/daemon.py`), not the @daemon branch. It now passes + `--model DEFAULT_MODEL` (imported from wake.py, single source); the 200k pin + was already covered via the shared dispatch_monitor wrapper. @daemon's + scheduled wakes import `wake_branch` directly and were covered from the start. + +**fix(skills)** — Telegram poll 5xx now triggers network backoff (medic loop, +autonomous @skills fix): `HTTPError` ≥500 in `poll_updates` raises +`_NetworkPollError` instead of falling through to rapid-fire retry; 4xx still +logs and returns. Three new tests (502/503 backoff, 429 stays out). + +**fix(spawn, commons, prax, hooks)** — S304 audit fix campaign, Track A +(DPLAN-0250, four owner dispatches verified + committed by devpulse): + +- **spawn** — shared `is_protected()` (infrastructure floor / registry owner / + active passport) now guards both pollution repair and branch delete; repair no + longer flags the live `src/aipass/aipass` branch, and deleting a protected or + actively-passported branch is refused with the reason. +- **commons** — branch-name resolution lowercased across all five ops sites + (trade/artifact/profile/welcome/search) to match identity normalization; + gifting and trading work again (guards had never matched since mid-June). +- **prax** — pytest detection now also checks `_pytest` in `sys.modules`, so + `patch.dict(os.environ, clear=True)` in test suites can no longer strip the + guard and freeze prod-path log handlers into the setup cache. +- **hooks** — the two static-path JSONL writers (engine diagnostics, telegram + delivery log) resolve their path per-write and route to the tmp test dir + under pytest; a full 1154-test suite run now adds zero lines to prod logs + (marker-bounded proof). + +Also: `/prep` now checks the cross-project feedback box every run — the S304 +"unread since April" backlog (F46) is processed to zero and can't silently +rot again. + +**fix(aipass)** — `aipass doctor` no longer invents errors on a healthy repo +(S304 F14-17): `.backup` and spawn `templates` dirs excluded from the agent +scan, relative registry paths anchored against the project root instead of the +caller's CWD (running doctor from inside a branch dir showed 5 fake +missing-branch errors), and info-severity findings now render as warnings +instead of pass checkmarks. Remaining findings on this repo are genuine. + +**fix(flow)** — registry aggregate writes are lock+atomic (S304 F85 residual): +`save_branch_registry` and `save_central` were bare `open`+`json.dump`; both now +use the O_EXCL lockfile + temp-file + `os.replace` pattern from the earlier +`save_registry.py` fix. Proven with a 6-process concurrent-write hammer. Also +investigated (N1): FPLAN-0313/0314 closed blank because +`is_template_content()`'s line-count threshold fires before its bracket-marker +check on default templates — guard fix queued, registry annotation is the +maintainer's call. + ## [2026-07-18] +**fix(tests)** — the immortal `MagicMock/LOG_FILE/` directory is dead: a hooks +engine test patched `diagnostics.LOG_FILE` with a bare Mock, and prax's +`append_jsonl` turned the mock's fspath into a real `mkdir` — every test run +re-minted an empty `MagicMock/LOG_FILE/` in the runner's CWD (found in repo +root, devpulse, and hooks). Test now patches a real tmp path; 100/100 green, +clean-CWD run verified to mint nothing. + +**docs** — merge playbook gains a site drift-check step (DPLAN-0249 follow-on): +every merge run now asks whether install commands, onboarding flow, agent count, +or the platform/CLI story changed — if yes, aipass.ai must be updated the same +day. Codifies the S323 ruling that the site is a projection of the README, never +its own source of facts. Template edit by @flow, one checklist line in the +post-merge section. + **docs** — root README v3 restructure (DPLAN-0249): single-funnel story with zero duplicated commands (install, `aipass new`/`init run`, trees, drone examples each taught exactly once), hero link line to aipass.ai/PyPI/r/AIPass, diff --git a/projects/README.md b/projects/README.md new file mode 100644 index 00000000..e600dbef --- /dev/null +++ b/projects/README.md @@ -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 ` 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///`, 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 # empty template + resident agent +aipass new --template python +aipass new --no-agent +``` + +The project lands in `projects/`, git-initialized, registry sealed, agent seated — and private until you decide otherwise. + +--- + +[← Back to AIPass](../README.md) diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py b/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py index 76212590..3cb671c5 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py @@ -34,6 +34,7 @@ from aipass.ai_mail.apps.handlers.dispatch.status import log_dispatch from aipass.ai_mail.apps.handlers.paths import find_repo_root from aipass.ai_mail.apps.handlers.dispatch.test_token import scan_and_ack_test_emails +from aipass.ai_mail.apps.handlers.dispatch.wake import DEFAULT_MODEL # Infrastructure paths @@ -408,6 +409,8 @@ def spawn_agent( "-c", "-p", prompt, + "--model", + DEFAULT_MODEL, "--max-turns", str(max_turns), "--permission-mode", diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py b/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py index 60d32d4e..bcdf12ca 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py @@ -449,6 +449,9 @@ def main(): for key in list(spawn_env.keys()): if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID": spawn_env.pop(key) + # Pin agent context window to 200k (Sonnet 5 is 1M native; without this, + # agents inherit 1M which causes cost + runaway risk). + spawn_env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = "200000" # Strip caller identity vars to prevent dispatch context leakage. spawn_env.pop("AIPASS_CALLER_BRANCH", None) spawn_env.pop("AIPASS_CALLER_CWD", None) diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py index 9501aac7..aacc7b8b 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py @@ -63,13 +63,9 @@ def _find_claude_bin() -> str: # Default prompt when no custom message provided DEFAULT_PROMPT = "Hi. Check inbox, process new emails, update memories when done." -# Model shorthand mapping -MODEL_MAP = { - "sonnet": "claude-sonnet-4-6", - "opus": "claude-opus-4-6", - "haiku": "claude-haiku-4-5-20251001", -} -DEFAULT_MODEL = "opus" +# Model aliases — passed directly to claude CLI which resolves latest-in-class. +KNOWN_MODEL_ALIASES: frozenset = frozenset({"sonnet", "opus", "haiku"}) +DEFAULT_MODEL = "sonnet" # Branches that cannot be woken manually by cross-branch drone commands. # Dispatch-send path (dispatch.py._orchestrate_dispatch_send) bypasses this check. @@ -591,8 +587,8 @@ def wake_branch( config = _load_config() max_turns = config.get("max_turns_per_wake", 100) - # Resolve model: shorthand -> full ID, or pass through if already a full ID - resolved_model = MODEL_MAP.get(model or DEFAULT_MODEL, model or MODEL_MAP[DEFAULT_MODEL]) + # Pass model directly to CLI — aliases resolve latest-in-class automatically + resolved_model = model or DEFAULT_MODEL lock_file_path = str(branch_path / ".ai_mail.local" / ".dispatch.lock") if custom_message: @@ -781,7 +777,7 @@ def wake_branch( print(" --fresh Start fresh session (claude -p) instead of resuming (claude -c -p)") print(" --auto Respect autonomous_pause (used by daemon). Manual wake ignores it.") print(" --sender @branch Set return-to-sender for bounce emails (default: @devpulse)") - print(" --model NAME Model to use: opus (default), sonnet, haiku, or full model ID") + print(" --model NAME Model to use: sonnet (default), opus, haiku, or full model ID") print() print("Output: Step-by-step status of the dispatch pipeline:") print(" ✅ resolve → @branch found at /path/to/branch") diff --git a/src/aipass/ai_mail/tests/test_daemon.py b/src/aipass/ai_mail/tests/test_daemon.py index 96497fb1..5e9df6cc 100644 --- a/src/aipass/ai_mail/tests/test_daemon.py +++ b/src/aipass/ai_mail/tests/test_daemon.py @@ -1317,6 +1317,48 @@ def capture_popen(*args, **kwargs): assert captured_env.get("AIPASS_SESSION_TYPE") == "daemon" +def test_spawn_agent_claude_cmd_includes_model_flag(tmp_path): + """Poller-triggered spawn passes --model DEFAULT_MODEL to the claude invocation.""" + branch_path = tmp_path / "branch" + branch_path.mkdir() + (branch_path / "logs").mkdir() + + message = {"from": "@devpulse", "id": "msg1", "subject": "Test task"} + config = {"max_turns_per_wake": 50} + state = {"daily_counts": {}, "session_cycles": {}} + + captured_args = [] + + def capture_popen(*args, **kwargs): + captured_args.append(args[0] if args else kwargs.get("args")) + mock_proc = MagicMock() + mock_proc.pid = 22222 + return mock_proc + + with ( + patch( + "aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen", + side_effect=capture_popen, + ), + patch( + "aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock", + return_value=(True, "Lock acquired"), + ), + patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"), + patch( + "aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification", + create=True, + ), + ): + result = spawn_agent(branch_path, "@testbranch", message, config, state) + + assert result is True + monitor_cmd = captured_args[0] + assert "--model" in monitor_cmd + model_idx = monitor_cmd.index("--model") + assert monitor_cmd[model_idx + 1] == daemon_mod.DEFAULT_MODEL + + def test_spawn_agent_prompt_includes_reply_id(tmp_path): """Prompt includes explicit reply command with the dispatch email ID.""" branch_path = tmp_path / "branch" diff --git a/src/aipass/ai_mail/tests/test_wake.py b/src/aipass/ai_mail/tests/test_wake.py index e7cef1de..eda7b98d 100644 --- a/src/aipass/ai_mail/tests/test_wake.py +++ b/src/aipass/ai_mail/tests/test_wake.py @@ -29,7 +29,7 @@ _find_claude_bin, resolve_branch, DispatchStatus, - MODEL_MAP, + KNOWN_MODEL_ALIASES, DEFAULT_MODEL, _acquire_lock, _load_config, @@ -572,24 +572,22 @@ def _fake_open(path, *args, **kwargs): # --- Model flag tests --------------------------------------------------- -def test_model_map_has_expected_entries(): - """MODEL_MAP should contain sonnet, opus, haiku shorthand mappings.""" - assert "sonnet" in MODEL_MAP - assert "opus" in MODEL_MAP - assert "haiku" in MODEL_MAP - assert "claude-sonnet-4-6" in MODEL_MAP["sonnet"] - assert "claude-opus-4-6" in MODEL_MAP["opus"] +def test_known_model_aliases_has_expected_entries(): + """KNOWN_MODEL_ALIASES should contain sonnet, opus, haiku.""" + assert "sonnet" in KNOWN_MODEL_ALIASES + assert "opus" in KNOWN_MODEL_ALIASES + assert "haiku" in KNOWN_MODEL_ALIASES -def test_default_model_is_opus(): - """Default model should be opus.""" - assert DEFAULT_MODEL == "opus" +def test_default_model_is_sonnet(): + """Default model should be sonnet.""" + assert DEFAULT_MODEL == "sonnet" -def test_model_map_values_are_full_ids(): - """All MODEL_MAP values should be full claude model IDs.""" - for key, value in MODEL_MAP.items(): - assert value.startswith("claude-"), f"{key} -> {value} doesn't start with 'claude-'" +def test_known_model_aliases_are_bare_names(): + """All KNOWN_MODEL_ALIASES should be bare alias names (no 'claude-' prefix).""" + for alias in KNOWN_MODEL_ALIASES: + assert not alias.startswith("claude-"), f"{alias} should be a bare alias" # --- _find_claude_bin tests ------------------------------------------ diff --git a/src/aipass/aipass/README.md b/src/aipass/aipass/README.md index 898c6ed4..dcac76da 100644 --- a/src/aipass/aipass/README.md +++ b/src/aipass/aipass/README.md @@ -9,6 +9,7 @@ aipass # Show available commands aipass doctor # Check system health aipass help what does drone do # Search branch documentation aipass new myapp --template python # Create a new project +aipass adopt myapp --dry-run # Preview adopting an existing projects/ dir aipass init # Guided setup (10 stages, resumable) ``` @@ -34,6 +35,7 @@ aipass/ │ │ ├── init_flow.py # 10-stage guided setup │ │ ├── install.py # aipass install — one-command bootstrap (clone + setup + init) │ │ ├── new_project.py # aipass new — create projects inside the installation +│ │ ├── adopt.py # aipass adopt — bring an existing projects/ dir into AIPass │ │ ├── profile.py # User profile read/write │ │ ├── trust.py # Trust registry — aipass trust / aipass revoke │ │ └── feedback.py # Feedback pulse toggle — aipass feedback on/off @@ -42,6 +44,7 @@ aipass/ │ │ ├── handoff_platform/ # Platform-specific handoff detection │ │ ├── init/ # bootstrap.py, scaffold_content.py │ │ ├── new_project/ # Project creation logic (registry, template, scaffold, git init) +│ │ │ └── adopt.py # Project adoption logic (additive scaffold onto an existing dir) │ │ ├── json/ # JSON read/write utilities │ │ ├── ping_sweep/ # Branch reachability verification │ │ ├── provider_reconcile.py # Stale deny-rule detection + fix @@ -50,7 +53,7 @@ aipass/ │ │ ├── system_detect/ # OS, shell, Python, RAM, CPU │ │ └── ui/ # Progress bars, menus, banners │ └── plugins/ -├── tests/ # 756 passing +├── tests/ # 785 passing ├── requirements.project.txt # Project-specific Python dependencies ├── .trinity/ # Identity + session history + observations └── README.md @@ -74,6 +77,9 @@ aipass/ | `aipass new ` | Create a project in projects/ — own git repo, AIPass scaffold, resident agent | | `aipass new --template python` | Create with Python template (pyproject + src/) | | `aipass new --no-agent` | Create without resident agent | +| `aipass adopt ` | Turn an existing `projects/` directory into a full project — additive scaffold only | +| `aipass adopt --no-agent` | Adopt without a resident agent | +| `aipass adopt --dry-run` | Preview what adoption would do, writes nothing | | `aipass trust [path]` | Show enrolled projects or enroll a project in the trust registry | | `aipass revoke ` | Remove a project from the trust registry | | `aipass feedback on/off` | Toggle the feedback reminder pulse (delegates to @hooks) | diff --git a/src/aipass/aipass/apps/aipass.py b/src/aipass/aipass/apps/aipass.py index 0f22bbd5..81468771 100644 --- a/src/aipass/aipass/apps/aipass.py +++ b/src/aipass/aipass/apps/aipass.py @@ -43,6 +43,7 @@ # ============================================================================= _PUBLIC_COMMANDS = { + "adopt": "Turn an existing projects/ directory into a full project", "doctor": "System health — structure, registry, hooks, tests", "help": "README-backed Q&A — ask about any branch", "init": "Guided setup for new users (10 stages, resumable)", @@ -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]" ) console.print(" [green]new [/green] [dim]Create a project inside AIPass[/dim]") + console.print( + " [green]adopt [/green] " + "[dim]Turn an existing projects/ directory into a full project[/dim]" + ) console.print(" [green]profile[/green] [dim]Show/edit user profile[/dim]") console.print( " [green]trust[/green] [dim][path][/dim] [dim]Trust registry — enroll/revoke projects[/dim]" @@ -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 help what does drone do[/green] [dim]Search documentation[/dim]") console.print(" [green]aipass new myapp --template python[/green] [dim]Create a Python project[/dim]") + console.print(" [green]aipass adopt myapp --dry-run[/green] [dim]Preview adopting projects/myapp[/dim]") console.print(" [green]aipass init[/green] [dim]Start guided setup[/dim]") console.print() diff --git a/src/aipass/aipass/apps/handlers/init/__init__.py b/src/aipass/aipass/apps/handlers/init/__init__.py index 4074df4b..98c2ce96 100644 --- a/src/aipass/aipass/apps/handlers/init/__init__.py +++ b/src/aipass/aipass/apps/handlers/init/__init__.py @@ -14,7 +14,7 @@ is_projects_child, update_project, ) -from aipass.aipass.apps.handlers.init.scaffold_content import ( +from aipass.aipass.shared.scaffold_content import ( global_prompt_md, inbox_json, prep_md, diff --git a/src/aipass/aipass/apps/handlers/init/bootstrap.py b/src/aipass/aipass/apps/handlers/init/bootstrap.py index 7452ef27..8a7464c8 100644 --- a/src/aipass/aipass/apps/handlers/init/bootstrap.py +++ b/src/aipass/aipass/apps/handlers/init/bootstrap.py @@ -30,18 +30,23 @@ - No hardcoded paths """ -import importlib.util import json import logging -import os import re import shutil -import tempfile import uuid from datetime import date from pathlib import Path -from aipass.aipass.apps.handlers.init import scaffold_content as sc +from aipass.aipass.shared import scaffold_content as sc +from aipass.aipass.shared.project_home import ( + _claude_local_settings, + _claude_settings, + _detect_aipass_home, + _enroll_project, + is_projects_child, + is_throwaway_path, +) logger = logging.getLogger(__name__) @@ -50,25 +55,6 @@ ] -def is_throwaway_path(path: str | Path) -> bool: - """True if path is under a temp dir or Claude Code scratchpad.""" - resolved = str(Path(path).resolve()) - tmp_roots = [tempfile.gettempdir()] - if os.name == "posix": - tmp_roots.append("/tmp") - for root in tmp_roots: - try: - r = str(Path(root).resolve()) - except OSError: - logger.info("is_throwaway_path: could not resolve %s", root) - continue - if resolved == r or resolved.startswith(r + os.sep): - return True - if "scratchpad" in resolved.lower(): - return True - return False - - def _sanitize_name(raw: str) -> str: """Sanitize a project name for use in filenames. @@ -78,23 +64,6 @@ def _sanitize_name(raw: str) -> str: return re.sub(r"[^A-Z0-9_-]", "_", raw.upper()).strip("_") -def _detect_aipass_home() -> str | None: - """Detect the AIPass installation root from the aipass package location. - - Returns the parent of the src/ directory (the repo root). - Returns None if detection fails. - """ - try: - spec = importlib.util.find_spec("aipass") - if spec and spec.origin: - # aipass/__init__.py lives at src/aipass/__init__.py - # parent = src/aipass/, parent.parent = src/, parent.parent.parent = AIPass root - return str(Path(spec.origin).resolve().parent.parent.parent) - except Exception as exc: - logger.info("AIPASS_HOME detection skipped: %s", exc) - return None - - def _hook_fingerprint(hook_entry: dict) -> str: """Extract a comparable fingerprint from a hook entry.""" commands = [] @@ -133,10 +102,11 @@ def _merge_settings(existing: dict, generated: dict) -> dict: if cleaned_hooks: merged["hooks"] = cleaned_hooks - # Merge env: generated wins for AIPASS_HOME, preserve user additions - existing_env = existing.get("env", {}) - generated_env = generated.get("env", {}) - merged["env"] = {**existing_env, **generated_env} + # env: AIPASS_HOME is machine-local — never tracked (see settings.local.json). + # Strip it from any previously-tracked settings.json; preserve other user vars. + existing_env = {k: v for k, v in existing.get("env", {}).items() if k != "AIPASS_HOME"} + if existing_env: + merged["env"] = existing_env # Merge permissions: union deny/ask lists existing_perms = existing.get("permissions", {}) @@ -210,75 +180,6 @@ def _merge_hooks_json(existing: dict, template: dict) -> dict: return merged -def _claude_settings(aipass_home: str | None = None) -> str: - """Generate .claude/settings.json — env and permissions only. - - Hooks are NOT wired at the project level. All AIPass hooks - (prompt injection, identity, email, pre-compact, edit gates) fire - from provider settings (~/.claude/settings.json), installed by - setup.sh. Provider hooks use CWD-walking patterns that work from - any directory in any project. - - Project settings only contain: - - env.AIPASS_HOME (so hooks can find the AIPass installation) - - permissions.deny (basic safety rails) - - Args: - aipass_home: Optional AIPass installation root to add as env.AIPASS_HOME. - """ - data: dict = {} - - data["permissions"] = { - "deny": [ - "Bash(git push --force*)", - "Bash(git reset --hard*)", - "EnterPlanMode", - ], - } - - if aipass_home and not is_throwaway_path(aipass_home): - data["env"] = {"AIPASS_HOME": aipass_home} - elif aipass_home: - logger.warning( - "AIPASS_HOME '%s' is a throwaway path — not writing to settings", - aipass_home, - ) - return json.dumps(data, indent=2, ensure_ascii=False) + "\n" - - -def _enroll_project(target: Path) -> None: - """Enroll a project in the trusted-project registry (DPLAN-0244). - - Lazy import to keep bootstrap.py free of prax/module-level deps. - """ - try: - from aipass.hooks.apps.handlers.config.trust_registry import enroll - - if enroll(str(target)): - logger.info("Enrolled project in trust registry: %s", target) - else: - logger.warning("Trust enrollment failed for %s", target) - except ImportError as exc: - logger.info("Trust registry unavailable, skipping enrollment: %s", exc) - - -def is_projects_child(target: Path) -> bool: - """True if *target* is ``/projects/`` — a valid nested project path. - - The host is identified by having a ``*_REGISTRY.json`` in the grandparent - of target (i.e. target's parent is named ``projects``). - """ - resolved = target.resolve() - if resolved.parent.name != "projects": - return False - host = resolved.parent.parent - try: - return any(f.is_file() and f.name.endswith("_REGISTRY.json") for f in host.iterdir()) - except OSError as exc: - logger.info("is_projects_child: could not read host dir %s: %s", host, exc) - return False - - def _guard_init(target: Path, *, allow_projects_child: bool = False) -> None: """Block init if target is inside an agent branch or existing project. @@ -447,16 +348,23 @@ def init_project( gitignore_path.write_text(sc.gitignore(), encoding="utf-8") created.append(str(gitignore_path)) - # 9. .claude/settings.json + # 9. .claude/settings.json — tracked, permissions only (no machine-local paths) claude_dir = target / ".claude" claude_dir.mkdir(exist_ok=True) settings_path = claude_dir / "settings.json" if not settings_path.exists(): - settings_path.write_text(_claude_settings(aipass_home), encoding="utf-8") + settings_path.write_text(_claude_settings(), encoding="utf-8") created.append(str(settings_path)) - # 9b. .claude/commands/prep.md — /prep session wrap-up slash command + # 9b. .claude/settings.local.json — machine-local AIPASS_HOME (gitignored) + if aipass_home and not is_throwaway_path(aipass_home): + local_settings_path = claude_dir / "settings.local.json" + if not local_settings_path.exists(): + local_settings_path.write_text(_claude_local_settings(aipass_home), encoding="utf-8") + created.append(str(local_settings_path)) + + # 9c. .claude/commands/prep.md — /prep session wrap-up slash command # Only prep.md here — memo.md belongs at provider level (~/.claude/commands/) commands_dir = claude_dir / "commands" commands_dir.mkdir(exist_ok=True) @@ -580,11 +488,11 @@ def update_project(target: Path) -> dict: elif tier_dest.exists(): already_current.append(str(tier_dest)) - # settings.json — smart merge: preserve user hooks + env, update AIPass hooks + # settings.json — smart merge: preserve user hooks, update AIPass permissions. + # AIPASS_HOME never lives here — machine-local paths go in settings.local.json. settings_path = claude_dir / "settings.json" if not settings_path.exists(): - aipass_home = _detect_aipass_home() - settings_path.write_text(_claude_settings(aipass_home), encoding="utf-8") + settings_path.write_text(_claude_settings(), encoding="utf-8") updated.append(str(settings_path)) else: existing_content = settings_path.read_text(encoding="utf-8") @@ -593,9 +501,7 @@ def update_project(target: Path) -> dict: except json.JSONDecodeError as exc: logger.info("settings.json parse failed, rebuilding: %s", exc) existing = {} - existing_env = existing.get("env", {}) - aipass_home = existing_env.get("AIPASS_HOME") or _detect_aipass_home() - generated = json.loads(_claude_settings(aipass_home)) + generated = json.loads(_claude_settings()) merged = _merge_settings(existing, generated) merged_content = json.dumps(merged, indent=2, ensure_ascii=False) + "\n" if existing != merged: @@ -604,6 +510,15 @@ def update_project(target: Path) -> dict: else: already_current.append(str(settings_path)) + # settings.local.json — machine-local AIPASS_HOME (gitignored, create if missing) + if aipass_home and not is_throwaway_path(aipass_home): + local_settings_path = claude_dir / "settings.local.json" + if not local_settings_path.exists(): + local_settings_path.write_text(_claude_local_settings(aipass_home), encoding="utf-8") + updated.append(str(local_settings_path)) + else: + already_current.append(str(local_settings_path)) + # hooks.json — union-merge: preserve user enabled, add new hooks from template hooks_json_path = aipass_dir / "hooks.json" hook_home = aipass_home or _detect_aipass_home() diff --git a/src/aipass/aipass/apps/handlers/new_project/__init__.py b/src/aipass/aipass/apps/handlers/new_project/__init__.py index 3fc48b94..25514b0f 100644 --- a/src/aipass/aipass/apps/handlers/new_project/__init__.py +++ b/src/aipass/aipass/apps/handlers/new_project/__init__.py @@ -29,6 +29,7 @@ from datetime import date from pathlib import Path +from aipass.aipass.shared import scaffold_content as sc from aipass.prax import logger from aipass.spawn import spawn_agent @@ -117,10 +118,7 @@ def _write_template(target: Path, name: str, template: str) -> list[str]: ) created.append("README.md") - (target / ".gitignore").write_text( - "__pycache__/\n*.pyc\n.venv\n.trinity/\n.ai_mail.local/\n*.local.json\n*.local/\nlogs/\n.*_REGISTRY.lock\n", - encoding="utf-8", - ) + (target / ".gitignore").write_text(sc.gitignore(), encoding="utf-8") created.append(".gitignore") 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]: """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, _detect_aipass_home, _enroll_project, + is_throwaway_path, ) - from aipass.aipass.apps.handlers.init import scaffold_content as sc created: list[str] = [] 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") created.append(md_name) - # .claude/settings.json + # .claude/settings.json — tracked, permissions only (no machine-local paths) claude_dir = target / ".claude" claude_dir.mkdir(exist_ok=True) (claude_dir / "settings.json").write_text( - _claude_settings(aipass_home), + _claude_settings(), encoding="utf-8", ) created.append(".claude/settings.json") + # .claude/settings.local.json — machine-local AIPASS_HOME (gitignored) + if aipass_home and not is_throwaway_path(aipass_home): + (claude_dir / "settings.local.json").write_text( + _claude_local_settings(aipass_home), + encoding="utf-8", + ) + created.append(".claude/settings.local.json") + # .claude/commands/prep.md commands_dir = claude_dir / "commands" commands_dir.mkdir(exist_ok=True) diff --git a/src/aipass/aipass/apps/handlers/new_project/adopt.py b/src/aipass/aipass/apps/handlers/new_project/adopt.py new file mode 100644 index 00000000..87cd0700 --- /dev/null +++ b/src/aipass/aipass/apps/handlers/new_project/adopt.py @@ -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 +/projects/ 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 /projects/ as a full AIPass project. + + Args: + target: Existing directory to adopt — must be /projects/. + 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 /projects/. `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, + } diff --git a/src/aipass/aipass/apps/handlers/structure_scan/structure_scanner.py b/src/aipass/aipass/apps/handlers/structure_scan/structure_scanner.py index be1b2092..7685e165 100644 --- a/src/aipass/aipass/apps/handlers/structure_scan/structure_scanner.py +++ b/src/aipass/aipass/apps/handlers/structure_scan/structure_scanner.py @@ -93,7 +93,7 @@ def find_project_root(start: Path) -> Optional[Path]: # ============================================================================= -_SCAN_SKIP_DIRS = {".archive", ".venv", ".git", "__pycache__", "node_modules", ".chroma"} +_SCAN_SKIP_DIRS = {".archive", ".backup", ".venv", ".git", "__pycache__", "node_modules", ".chroma", "templates"} def scan_agents(project_root: Path) -> List[AgentInfo]: @@ -286,12 +286,15 @@ def detect_pollution(agents: List[AgentInfo]) -> List[PollutionHit]: def check_registry_consistency( registry_path: Path, agents: List[AgentInfo], + project_root: Optional[Path] = None, ) -> List[RegistryIssue]: """Validate that registry branches[].path entries match actual filesystem. Returns: List of RegistryIssue for each problem found. """ + anchor = (project_root or registry_path.parent).resolve() + try: data = json.loads(registry_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as exc: @@ -309,7 +312,8 @@ def check_registry_consistency( issues.append(RegistryIssue(name, "", "missing")) continue - reg_path = Path(path_str).resolve() + p = Path(path_str) + reg_path = p.resolve() if p.is_absolute() else (anchor / p).resolve() if not reg_path.exists(): issues.append(RegistryIssue(name, path_str, "missing")) elif str(reg_path) not in agent_paths: diff --git a/src/aipass/aipass/apps/modules/adopt.py b/src/aipass/aipass/apps/modules/adopt.py new file mode 100644 index 00000000..2a47f14f --- /dev/null +++ b/src/aipass/aipass/apps/modules/adopt.py @@ -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 [--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 [/green] [dim]# Adopt /projects/[/dim]") + console.print(" [green]aipass adopt [/green] [dim]# Adopt by relative/absolute path[/dim]") + console.print(" [green]aipass adopt --no-agent[/green] [dim]# Skip resident agent[/dim]") + console.print(" [green]aipass adopt --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 /projects/.") + 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 diff --git a/src/aipass/aipass/apps/modules/doctor.py b/src/aipass/aipass/apps/modules/doctor.py index b74ff049..260669a0 100644 --- a/src/aipass/aipass/apps/modules/doctor.py +++ b/src/aipass/aipass/apps/modules/doctor.py @@ -670,7 +670,7 @@ def _check_structure() -> List[CheckResult]: # Registry consistency reg_path = _discover_registry(start_path=project_root) if reg_path and reg_path.exists(): - reg_issues = check_registry_consistency(reg_path, agents) + reg_issues = check_registry_consistency(reg_path, agents, project_root=project_root) if reg_issues: for issue in reg_issues: glyph = GLYPH_FAIL if issue.problem == "missing" else GLYPH_WARN @@ -686,7 +686,7 @@ def _check_structure() -> List[CheckResult]: root_hits = check_root_artifacts(project_root) if root_hits: for hit in root_hits: - glyph = GLYPH_WARN if hit.severity == "warn" else GLYPH_PASS + glyph = GLYPH_PASS if hit.severity == "pass" else GLYPH_WARN results.append(CheckResult(f"root: {hit.name}", glyph, hit.description, "")) else: results.append(CheckResult("root artifacts", GLYPH_PASS, "none misplaced", "")) diff --git a/src/aipass/aipass/shared/project_home.py b/src/aipass/aipass/shared/project_home.py new file mode 100644 index 00000000..2ea1832b --- /dev/null +++ b/src/aipass/aipass/shared/project_home.py @@ -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 ``/projects/`` — 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 diff --git a/src/aipass/aipass/apps/handlers/init/scaffold_content.py b/src/aipass/aipass/shared/scaffold_content.py similarity index 96% rename from src/aipass/aipass/apps/handlers/init/scaffold_content.py rename to src/aipass/aipass/shared/scaffold_content.py index e423bdb0..9584ff87 100644 --- a/src/aipass/aipass/apps/handlers/init/scaffold_content.py +++ b/src/aipass/aipass/shared/scaffold_content.py @@ -1,17 +1,20 @@ # =================== AIPass ==================== # Name: scaffold_content.py -# Description: Template content generators for aipass init scaffold +# Description: Shared template content generators for project scaffolding # Version: 1.0.0 # Created: 2026-04-22 -# Modified: 2026-04-22 +# Modified: 2026-07-21 # ============================================= """ -Scaffold Content — template generators for `aipass init` +Scaffold Content — template generators for `aipass init`, `aipass new`, `aipass adopt` Pure string-returning functions that produce the content for each scaffold -file (CLAUDE.md, AGENTS.md, etc.). Extracted from bootstrap.py to keep -the handler under 700 lines. +file (CLAUDE.md, AGENTS.md, .gitignore, etc.). Shared across every command +that mints AIPass project files, so there is exactly one source of truth +per template — no per-command copies to drift apart. + +Dependency-free: uses only stdlib. Importable before drone/prax exist. RULES: - Pure Python only (no module/prax/cli imports) @@ -251,7 +254,7 @@ def gitignore() -> str: "*.egg-info/\n" "dist/\n" "build/\n" - ".venv/\n" + ".venv\n" "venv/\n" "\n" "# IDE\n" @@ -267,6 +270,9 @@ def gitignore() -> str: "\n" "# Disabled files\n" "*(disabled)*\n" + "\n" + "# Registry lock\n" + ".*_REGISTRY.lock\n" ) diff --git a/src/aipass/aipass/tests/test_adopt.py b/src/aipass/aipass/tests/test_adopt.py new file mode 100644 index 00000000..43eaef3e --- /dev/null +++ b/src/aipass/aipass/tests/test_adopt.py @@ -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("\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 /projects/"): + 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 diff --git a/src/aipass/aipass/tests/test_bootstrap.py b/src/aipass/aipass/tests/test_bootstrap.py index 8d793c66..ebcc77cc 100644 --- a/src/aipass/aipass/tests/test_bootstrap.py +++ b/src/aipass/aipass/tests/test_bootstrap.py @@ -20,7 +20,6 @@ import pytest # pyright: ignore[reportMissingImports] -from aipass.aipass.apps.handlers.init import scaffold_content as sc from aipass.aipass.apps.handlers.init.bootstrap import ( _merge_hooks_json, _sanitize_name, @@ -28,6 +27,7 @@ init_project, update_project, ) +from aipass.aipass.shared import scaffold_content as sc # --------------------------------------------------------------------------- @@ -293,7 +293,7 @@ def test_init_project_settings_no_hooks(tmp_path, monkeypatch): data = json.loads(settings_path.read_text(encoding="utf-8")) assert "hooks" not in data, "Project settings should not contain hooks" - assert "env" in data + assert "env" not in data, "AIPASS_HOME is machine-local — belongs in settings.local.json" assert "permissions" in data @@ -547,9 +547,10 @@ def test_update_project_creates_missing_managed_dirs(tmp_path): result = update_project(target) assert (target / ".claude" / "settings.json").exists() - # Managed files in deleted dirs re-written (tier0_kernel, tier1_navmap, hooks.json, settings, prep) + # Managed files in deleted dirs re-written (tier0_kernel, tier1_navmap, + # hooks.json, settings.json, settings.local.json, prep) if result["aipass_home"]: - assert len(result["updated_files"]) == 5 + assert len(result["updated_files"]) == 6 else: assert len(result["updated_files"]) == 2 assert len(result["already_current"]) >= 2 @@ -584,7 +585,7 @@ def test_init_project_returns_aipass_home(tmp_path): def test_init_project_settings_has_aipass_home_when_detected(tmp_path, monkeypatch): - """When AIPASS_HOME is detected, settings.json includes env.AIPASS_HOME.""" + """When AIPASS_HOME is detected, settings.local.json (not settings.json) includes env.AIPASS_HOME.""" monkeypatch.setattr( "aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path", lambda _: False, @@ -598,8 +599,10 @@ def test_init_project_settings_has_aipass_home_when_detected(tmp_path, monkeypat pytest.skip("AIPASS_HOME not detectable in this environment") settings = json.loads((target / ".claude" / "settings.json").read_text(encoding="utf-8")) - assert "env" in settings - assert settings["env"]["AIPASS_HOME"] == result["aipass_home"] + assert "env" not in settings + + local_settings = json.loads((target / ".claude" / "settings.local.json").read_text(encoding="utf-8")) + assert local_settings["env"]["AIPASS_HOME"] == result["aipass_home"] def test_update_project_returns_aipass_home(tmp_path): @@ -615,7 +618,7 @@ def test_update_project_returns_aipass_home(tmp_path): def test_update_project_adds_aipass_home_if_missing(tmp_path, monkeypatch): - """update_project injects AIPASS_HOME into settings.json if env section is absent.""" + """update_project recreates settings.local.json with AIPASS_HOME if missing.""" monkeypatch.setattr( "aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path", lambda _: False, @@ -624,17 +627,15 @@ def test_update_project_adds_aipass_home_if_missing(tmp_path, monkeypatch): target.mkdir() init_project(target, project_name="addenv") - settings_path = target / ".claude" / "settings.json" - data = json.loads(settings_path.read_text(encoding="utf-8")) - data.pop("env", None) - settings_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + local_settings_path = target / ".claude" / "settings.local.json" + local_settings_path.unlink(missing_ok=True) result = update_project(target) if result["aipass_home"] is not None: - new_data = json.loads(settings_path.read_text(encoding="utf-8")) + new_data = json.loads(local_settings_path.read_text(encoding="utf-8")) assert new_data.get("env", {}).get("AIPASS_HOME") == result["aipass_home"] - assert str(settings_path) in result["updated_files"] + assert str(local_settings_path) in result["updated_files"] # --------------------------------------------------------------------------- @@ -1256,10 +1257,49 @@ def test_throwaway_path_allows_project(): assert not is_throwaway_path(str(Path.home() / "Projects" / "myapp")) -def test_settings_omits_throwaway_aipass_home(tmp_path): - """_claude_settings refuses to write AIPASS_HOME when it's a throwaway path.""" - from aipass.aipass.apps.handlers.init.bootstrap import _claude_settings +def test_settings_omits_throwaway_aipass_home(tmp_path, monkeypatch): + """init_project skips settings.local.json when detected AIPASS_HOME is a throwaway path.""" + from aipass.aipass.apps.handlers.init import bootstrap + + monkeypatch.setattr(bootstrap, "_detect_aipass_home", lambda: str(tmp_path)) + + target = tmp_path / "proj" + target.mkdir() + bootstrap.init_project(target, project_name="alpha") + + assert not (target / ".claude" / "settings.local.json").exists() + + +# Directory/file-name fragments that .gitignore excludes from git tracking — +# mirrors scaffold_content.gitignore(). Tracked-file scans must skip these. +_GITIGNORED_PARTS = (".trinity", ".ai_mail.local", "logs", ".venv", "venv", ".git") + + +def test_minted_tracked_files_have_no_absolute_paths(tmp_path, monkeypatch): + """No file init_project writes into a TRACKED path may contain the machine-local AIPASS_HOME. - content = _claude_settings(str(tmp_path)) - data = json.loads(content) - assert "AIPASS_HOME" not in data.get("env", {}) + 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" diff --git a/src/aipass/aipass/tests/test_new_project.py b/src/aipass/aipass/tests/test_new_project.py index ab4edc90..9bfe275c 100644 --- a/src/aipass/aipass/tests/test_new_project.py +++ b/src/aipass/aipass/tests/test_new_project.py @@ -183,11 +183,11 @@ def test_create_project_empty_template(host_env, monkeypatch): with ( patch("subprocess.run", side_effect=_mock_git_run), patch( - "aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", + "aipass.aipass.shared.project_home._detect_aipass_home", return_value=None, ), 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) @@ -208,11 +208,11 @@ def test_create_project_python_template(host_env, monkeypatch): with ( patch("subprocess.run", side_effect=_mock_git_run), patch( - "aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", + "aipass.aipass.shared.project_home._detect_aipass_home", return_value=None, ), 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) @@ -261,10 +261,10 @@ def _fail_git(args, **kwargs): with ( patch("subprocess.run", side_effect=_fail_git), patch( - "aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", + "aipass.aipass.shared.project_home._detect_aipass_home", 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"), ): create_project("failproj", no_agent=True) @@ -299,10 +299,10 @@ def track_template(target, name, template): side_effect=track_template, ), patch( - "aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", + "aipass.aipass.shared.project_home._detect_aipass_home", 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) @@ -399,10 +399,10 @@ def test_create_project_with_agent(host_env, monkeypatch): with ( patch("subprocess.run", side_effect=_mock_git_run), patch( - "aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", + "aipass.aipass.shared.project_home._detect_aipass_home", return_value=None, ), - patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"), + patch("aipass.aipass.shared.project_home._enroll_project"), patch( "aipass.aipass.apps.handlers.new_project.spawn_agent", return_value=spawn_ok, @@ -426,10 +426,10 @@ def test_create_project_spawn_failure_cleans_up(host_env, monkeypatch): with ( patch("subprocess.run", side_effect=_mock_git_run), patch( - "aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", + "aipass.aipass.shared.project_home._detect_aipass_home", return_value=None, ), - patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"), + patch("aipass.aipass.shared.project_home._enroll_project"), patch( "aipass.aipass.apps.handlers.new_project.spawn_agent", return_value={"success": False, "error": "template missing"}, @@ -449,10 +449,10 @@ def test_create_project_no_agent_next_steps(host_env, monkeypatch): with ( patch("subprocess.run", side_effect=_mock_git_run), patch( - "aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", + "aipass.aipass.shared.project_home._detect_aipass_home", 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, ): handle_command("new", ["cosmtest", "--template", "empty", "--no-agent"]) @@ -466,10 +466,10 @@ def test_create_project_no_agent_flag(host_env, monkeypatch): with ( patch("subprocess.run", side_effect=_mock_git_run), patch( - "aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", + "aipass.aipass.shared.project_home._detect_aipass_home", 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) @@ -645,10 +645,10 @@ def test_tty_auto_launches_agent(host_env, monkeypatch): patch("subprocess.run", side_effect=_mock_git_run), patch("builtins.input", return_value=""), patch( - "aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", + "aipass.aipass.shared.project_home._detect_aipass_home", return_value=None, ), - patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"), + patch("aipass.aipass.shared.project_home._enroll_project"), patch( "aipass.aipass.apps.handlers.new_project.spawn_agent", 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("builtins.input", return_value=""), patch( - "aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", + "aipass.aipass.shared.project_home._detect_aipass_home", return_value=None, ), - patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"), + patch("aipass.aipass.shared.project_home._enroll_project"), patch( "aipass.aipass.apps.handlers.new_project.spawn_agent", return_value=spawn_ok, @@ -708,10 +708,10 @@ def test_no_agent_skips_auto_launch(host_env, monkeypatch): with ( patch("subprocess.run", side_effect=_mock_git_run), patch( - "aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", + "aipass.aipass.shared.project_home._detect_aipass_home", 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.sys") as mock_sys, patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch, diff --git a/src/aipass/aipass/tests/test_shared_bootstrap_safety.py b/src/aipass/aipass/tests/test_shared_bootstrap_safety.py index 5a0928b4..cc7d0645 100644 --- a/src/aipass/aipass/tests/test_shared_bootstrap_safety.py +++ b/src/aipass/aipass/tests/test_shared_bootstrap_safety.py @@ -24,7 +24,9 @@ import aipass.aipass.shared.json_handler import aipass.aipass.shared.json_ops +import aipass.aipass.shared.project_home import aipass.aipass.shared.registry_discovery +import aipass.aipass.shared.scaffold_content bad = [] for name in sorted(sys.modules): diff --git a/src/aipass/commons/apps/handlers/artifacts/artifact_ops.py b/src/aipass/commons/apps/handlers/artifacts/artifact_ops.py index f68d9607..595db77d 100644 --- a/src/aipass/commons/apps/handlers/artifacts/artifact_ops.py +++ b/src/aipass/commons/apps/handlers/artifacts/artifact_ops.py @@ -75,8 +75,8 @@ def _validate_metadata(metadata_str: str) -> Optional[dict]: def _resolve_branch_name(mention: str) -> Optional[str]: - """Resolve a @mention to a branch name.""" - name = mention.lstrip("@").upper() + """Resolve a @mention to a branch name (lowercase-normalized).""" + name = mention.lstrip("@").lower() if not os.path.exists(BRANCH_REGISTRY_PATH): return None @@ -85,7 +85,7 @@ def _resolve_branch_name(mention: str) -> Optional[str]: with open(BRANCH_REGISTRY_PATH, encoding="utf-8") as f: registry = json.load(f) for branch in registry.get("branches", []): - if branch.get("name") == name: + if branch.get("name", "").lower() == name: return name return None except Exception: diff --git a/src/aipass/commons/apps/handlers/artifacts/trade_ops.py b/src/aipass/commons/apps/handlers/artifacts/trade_ops.py index e90f06df..5128cf73 100644 --- a/src/aipass/commons/apps/handlers/artifacts/trade_ops.py +++ b/src/aipass/commons/apps/handlers/artifacts/trade_ops.py @@ -54,8 +54,8 @@ def _find_registry_path() -> str: def _resolve_branch_name(mention: str) -> Optional[str]: - """Resolve a @mention to a branch name.""" - name = mention.lstrip("@").upper() + """Resolve a @mention to a branch name (lowercase-normalized).""" + name = mention.lstrip("@").lower() if not os.path.exists(BRANCH_REGISTRY_PATH): return None @@ -64,7 +64,7 @@ def _resolve_branch_name(mention: str) -> Optional[str]: with open(BRANCH_REGISTRY_PATH, encoding="utf-8") as f: registry = json.load(f) for branch in registry.get("branches", []): - if branch.get("name") == name: + if branch.get("name", "").lower() == name: return name return None except Exception: diff --git a/src/aipass/commons/apps/handlers/profiles/profile_ops.py b/src/aipass/commons/apps/handlers/profiles/profile_ops.py index b0b75958..f7bddbb5 100644 --- a/src/aipass/commons/apps/handlers/profiles/profile_ops.py +++ b/src/aipass/commons/apps/handlers/profiles/profile_ops.py @@ -58,7 +58,7 @@ def show_profile(args: List[str]) -> dict: # Determine which branch to show if args: - target_branch = args[0].upper() + target_branch = args[0].lower() else: caller = get_caller_branch() if not caller: diff --git a/src/aipass/commons/apps/handlers/search/search_ops.py b/src/aipass/commons/apps/handlers/search/search_ops.py index 581a7a6a..9401db72 100644 --- a/src/aipass/commons/apps/handlers/search/search_ops.py +++ b/src/aipass/commons/apps/handlers/search/search_ops.py @@ -62,7 +62,7 @@ def _parse_search_args(args: List[str]) -> dict: result["room"] = remaining[i + 1].lower() i += 2 elif flag == "--author" and i + 1 < len(remaining): - result["author"] = remaining[i + 1].upper() + result["author"] = remaining[i + 1].lower() i += 2 elif flag == "--type" and i + 1 < len(remaining): search_type = remaining[i + 1].lower() diff --git a/src/aipass/commons/apps/handlers/welcome/welcome_ops.py b/src/aipass/commons/apps/handlers/welcome/welcome_ops.py index 5573a523..4b972f38 100644 --- a/src/aipass/commons/apps/handlers/welcome/welcome_ops.py +++ b/src/aipass/commons/apps/handlers/welcome/welcome_ops.py @@ -55,7 +55,7 @@ def run_welcome(args: List[str]) -> dict: conn = get_db() if filtered_args: - branch_name = filtered_args[0].upper() + branch_name = filtered_args[0].lower() if dry_run: already = has_been_welcomed(conn, branch_name) close_db(conn) @@ -114,7 +114,7 @@ def _welcome_specific(conn, branch_name: str) -> dict: Returns: Dict with success and welcome result """ - agent = conn.execute("SELECT branch_name FROM agents WHERE branch_name = ?", (branch_name,)).fetchone() + agent = conn.execute("SELECT branch_name FROM agents WHERE LOWER(branch_name) = ?", (branch_name,)).fetchone() if not agent: return {"success": False, "error": f"Branch '{branch_name}' not found in The Commons."} diff --git a/src/aipass/commons/tests/test_curation_explore_welcome_ops.py b/src/aipass/commons/tests/test_curation_explore_welcome_ops.py index 81ecb46b..5a90e252 100644 --- a/src/aipass/commons/tests/test_curation_explore_welcome_ops.py +++ b/src/aipass/commons/tests/test_curation_explore_welcome_ops.py @@ -993,7 +993,7 @@ def test_run_welcome_dry_run_specific_branch( """run_welcome --dry-run reports whether the branch would be welcomed.""" from aipass.commons.apps.handlers.welcome.welcome_ops import run_welcome - _seed_agent(initialized_db, "GAMMA") + _seed_agent(initialized_db, "gamma") mock_get_db.return_value = initialized_db mock_close.side_effect = lambda c: None @@ -1001,7 +1001,7 @@ def test_run_welcome_dry_run_specific_branch( result = run_welcome(["gamma", "--dry-run"]) assert result["success"] is True assert result["dry_run"] is True - assert result["branch"] == "GAMMA" + assert result["branch"] == "gamma" assert result["would_welcome"] is True @@ -1076,7 +1076,7 @@ def test_run_welcome_specific_branch_success( """run_welcome creates a welcome post for that branch.""" from aipass.commons.apps.handlers.welcome.welcome_ops import run_welcome - _seed_agent(initialized_db, "EPSILON") + _seed_agent(initialized_db, "epsilon") mock_get_db.return_value = initialized_db mock_close.side_effect = lambda c: None @@ -1085,7 +1085,7 @@ def test_run_welcome_specific_branch_success( assert result["success"] is True assert result["action"] == "specific" assert result["already_welcomed"] is False - assert result["branch"] == "EPSILON" + assert result["branch"] == "epsilon" assert result["post_id"] is not None diff --git a/src/aipass/commons/tests/test_search.py b/src/aipass/commons/tests/test_search.py index 4ff57dc8..9c400656 100644 --- a/src/aipass/commons/tests/test_search.py +++ b/src/aipass/commons/tests/test_search.py @@ -58,10 +58,10 @@ def test_parse_search_args_room_flag(): def test_parse_search_args_author_flag(): - """The --author flag should set the author filter and uppercase it.""" + """The --author flag should set the author filter and lowercase it.""" result = _parse_search_args(["test", "--author", "drone"]) assert result["query"] == "test" - assert result["author"] == "DRONE" + assert result["author"] == "drone" def test_parse_search_args_type_flag_valid(): @@ -94,7 +94,7 @@ def test_parse_search_args_all_flags(): ) assert result["query"] == "registry" assert result["room"] == "dev" - assert result["author"] == "FLOW" + assert result["author"] == "flow" assert result["search_type"] == "posts" diff --git a/src/aipass/devpulse/.claude/settings.local.json b/src/aipass/devpulse/.claude/settings.local.json index 540cb317..5038e46e 100644 --- a/src/aipass/devpulse/.claude/settings.local.json +++ b/src/aipass/devpulse/.claude/settings.local.json @@ -1,4 +1,5 @@ { + "autoCompactWindow": 350000, "permissions": { "allow": [], "deny": [ diff --git a/src/aipass/devpulse/.seedgo/bypass.json b/src/aipass/devpulse/.seedgo/bypass.json index 9ed1f7f9..8659c1d5 100644 --- a/src/aipass/devpulse/.seedgo/bypass.json +++ b/src/aipass/devpulse/.seedgo/bypass.json @@ -70,71 +70,6 @@ "file": "tests/test_owner_guard.py", "reason": "Unit test imports the owner guard handler (its SUT) and patches spawn.registry's get_owner/is_owner — the guard is a shared handler primitive with no apps/modules/ command entry point. Same direct-SUT pattern as test_compass_store.py. #681." }, - { - "standard": "encapsulation", - "file": "tools/spot_check.py", - "reason": "Standalone diagnostic tool script, not a handler." - }, - { - "standard": "debug_print", - "file": "tools/spot_check.py", - "reason": "Standalone CLI tool — print() is the intended output method." - }, - { - "standard": "silent_catch", - "file": "tools/hook_engine_poc/engine.py", - "reason": "POC hook engine — stdlib only, no prax logger. Uses sys.stderr for error reporting." - }, - { - "standard": "error_handling", - "file": "tools/hook_engine_poc/engine.py", - "reason": "POC hook engine — exception handlers write to stderr, not prax logger." - }, - { - "standard": "silent_catch", - "file": "tools/hook_engine_poc/test_engine.py", - "reason": "POC test harness — catches test exceptions to report pass/fail, writes to stderr." - }, - { - "standard": "imports", - "file": "tools/hook_engine_poc/test_engine.py", - "reason": "POC test harness — sys.path needed to import engine.py from same directory." - }, - { - "standard": "trigger", - "file": "tools/hook_engine_poc/test_engine.py", - "reason": "POC test harness — .unlink() clears ephemeral JSONL log between tests, not a tracked file." - }, - { - "standard": "help_text", - "file": "tools/hook_engine_poc/test_engine.py", - "reason": "POC test harness — usage example in docstring." - }, - { - "standard": "debug_print", - "file": "tools/rm_shim/redteam_suite.py", - "reason": "Standalone red-team diagnostic runner (FPLAN-0250 Phase 6) — print() IS the report output, same as broker_acceptance_test.py." - }, - { - "standard": "encapsulation", - "file": "tools/rm_shim/redteam_suite.py", - "reason": "Red-team tool imports the real broker daemon/client + sandbox module directly to exercise them under live conditions — that is the point of an integration probe, not a handler." - }, - { - "standard": "imports", - "file": "tools/rm_shim/redteam_suite.py", - "reason": "Standalone script run via 'python tools/...' — sys.path insert lets it import the production modules it red-teams without being pip-installed." - }, - { - "standard": "help_text", - "file": "tools/rm_shim/redteam_suite.py", - "reason": "Diagnostic script — docstring shows the 'python tools/...' invocation; it is not a drone-routed module." - }, - { - "standard": "documentation", - "file": "tools/rm_shim/redteam_suite.py", - "reason": "Result.ok/bad are 2-line internal report helpers in a diagnostic script — self-evident, docstrings redundant." - }, { "standard": "encapsulation", "file": "tests/test_compass_store.py", diff --git a/src/aipass/devpulse/apps/handlers/watchdog/agent.py b/src/aipass/devpulse/apps/handlers/watchdog/agent.py index 03835bc9..8d6c8ddf 100644 --- a/src/aipass/devpulse/apps/handlers/watchdog/agent.py +++ b/src/aipass/devpulse/apps/handlers/watchdog/agent.py @@ -423,7 +423,12 @@ class StallTracker: (#634 part 2). The verbose trail stays on ``_stderr`` + logger. """ - STALL_THRESHOLD = 120.0 + # Quiet spans with zero JSONL output are routine, not stalls: a model composing + # a long response writes nothing until the turn completes, and compaction is one + # multi-minute silent API call. 120s false-fired on every stall of S329 (8/8); + # 300s clears normal turns and most compactions while a wedged agent still + # surfaces well inside a long watch. + STALL_THRESHOLD = 300.0 # A single tool call held in-flight this long is surfaced as a soft advisory # (heavy op or a hung tool). Below the 600s default timeout so long watches # get a mid-flight heads-up instead of waiting on the timeout. @@ -516,7 +521,7 @@ def watch_agent( poll_interval: Seconds between checks. Default 5.0 — the per-tick work (lock stat, PID liveness, one-dir JSONL size scan) is cheap, so a tight cadence just burns CPU. 5s keeps completion latency invisible on multi-minute - dispatches while the 120s stall threshold has ample resolution. + dispatches while the 300s stall threshold has ample resolution. Returns: dict with keys: woke, reason, elapsed, agent_state, exit_code, agent_id. diff --git a/src/aipass/devpulse/docs/discovery/S324_scan_report.html b/src/aipass/devpulse/docs/discovery/S324_scan_report.html new file mode 100644 index 00000000..2886220b --- /dev/null +++ b/src/aipass/devpulse/docs/discovery/S324_scan_report.html @@ -0,0 +1,173 @@ +S324 Advisory Scan — AIPass + + + +
+
+

Session S324 · Advisory Scan · Read-only

+

The scanners CI never runs said 1,419 problems. Verification melted it to a handful.

+

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.

+

Date 2026-07-18Agent @devpulseScanners fallback · stale-terms · readme-freshnessBranches 17/17Files changed 0

+
+ +
+
1,419
raw findings, all scanners
+
16
fallbacks actually in live code
+
2/2
sampled live hits = defensible on read
+
1
genuine surprise (MagicMock leak)
+
+ + +
+

How the numbers melt

raw → verified
+
+ + + + + +
ScannerRawAfter contextVerdict
Silent fallbacks6116 live · 45 = devpulse dropbox/ quarantine copiesmostly archive noise
  ↳ of the 16 live16@trigger 11 · @cli 2 · @skills 2 (tests) · @spawn 1 (template)@trigger needs owner triage
Stale terminology1,358~600 genuinely dead names · rest = valid .ai_mail.local/ refsneeds live-code filter
README freshness15/17 staledates lag the front-door code weekinformational
+
+

Why sampling flipped the verdict

+

Both @trigger hits I read in full are recursion-safe meta-logging guards — "if logging this warning fails, don't crash the error pipeline" — one even carries a # seedgo:bypass meta-logging marker. The @spawn template hit is a two-prefix import probe 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.

+
+
+ + +
+

What actually surfaced

5 findings
+
+ +
+
S324-1Medium · real · unowned + A test leaked a Mock onto disk: MagicMock/LOG_FILE/ sits in devpulse's branch root
+

Empty, untracked, born Jul 11 03:31 (a night-shift test run). Something called mkdir on a path built from an unpatched Mock().LOG_FILE. Four candidate offenders mock a LOG_FILE attribute: ai_mail test_daemon.py / test_dispatch_status.py, hooks test_engine.py, seedgo test_checkers_batch3.py. 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.

+
+ +
+
S324-2Medium · owner call + @trigger: 11 bare except Exception: pass in live event handlers — 9 still unread
+

The error-detection branch swallowing exceptions is worth eyes on principle. The 2 I sampled are legitimate meta-logging guards; the other 9 (in error_detected, runaway_handler, plan_file, pr_status_sync, memory_pool, registry, startup) deserve the same context read by their owner. Concern, not prescription — @trigger designs the fix if any is real.

+
+ +
+
S324-3Low · tooling debt + The advisory scanners are aging out of the conventions they audit
+

April-era tools: the fallback scanner doesn't honor seedgo:bypass markers (false alarms on acquitted code), the stale scanner counts valid .ai_mail.local/ usage in its headline, and none of them exclude dropbox/ / .archive/ quarantine dirs — which is where 45 of 61 "fallbacks" came from. A bypass-aware, archive-excluding refresh would make raw counts trustworthy again.

+
+ +
+
S324-4Low · unfiltered + ~600 dead-name references still in the tree: dev central 243 · branch_registry 211 · ai_central 131 · cortex 13
+

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.

+
+ +
+
S324-5Verified good + Where it counts, the tree is clean
+

Devpulse live code: zero silent fallbacks — all 45 flagged were quarantined pollution captures in dropbox/, doing exactly what quarantine is for. The 10 hard checkers born from this same tools folder already run inside drone @seedgo audit, where all 17 branches sit at 100%. The advisory layer found no criticals, no highs, nothing live-broken.

+
+ +
+
+ + +
+

Report lineage

where this fits
+

Sibling of docs/discovery/S304_report.html (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 artifacts/ux_flow_graph.html (3.5 MB, the v4 install-to-chat UX graph). Same-day context: PR #703 (merge-playbook drift-check step) is CI-green and awaiting Patrick's merge word; gif session prepped in artifacts/gif_recording_plan.md.

+
+ +
+ read-only scan · zero files changed · scanners: fallback_scanner_v1, stale_scanner_v1, readme_freshness_scanner_v1 · @devpulse S324 · 2026-07-18 +
+
diff --git a/src/aipass/devpulse/tests/test_watchdog_agent.py b/src/aipass/devpulse/tests/test_watchdog_agent.py index 8a51b48a..4e50a8e7 100644 --- a/src/aipass/devpulse/tests/test_watchdog_agent.py +++ b/src/aipass/devpulse/tests/test_watchdog_agent.py @@ -312,7 +312,7 @@ def test_stalltracker_inflight_tool_prevents_stall(monkeypatch, capsys): monkeypatch.setattr(agent_handler, "_last_entry_is_inflight_tool", lambda *a, **kw: True) t = agent_handler.StallTracker("@x", Path("/nope"), {}, now=0.0, pid=123) - for now in (60.0, 120.0, 180.0, 240.0): + for now in (120.0, 240.0, 360.0, 480.0): t.observe(now=now) out = capsys.readouterr().out @@ -391,7 +391,8 @@ def test_watch_agent_surfaces_stall_to_stdout(monkeypatch, tmp_path, capsys): monkeypatch.setattr(agent_handler, "_pid_alive", lambda pid: True) monkeypatch.setattr(agent_handler, "_has_jsonl_activity", lambda *a, **kw: False) monkeypatch.setattr(agent_handler, "_last_entry_is_inflight_tool", lambda *a, **kw: False) - _fake_clock_sleep(agent_handler, monkeypatch, lock_file) + # Lock must outlive STALL_THRESHOLD (300s) or the watch completes stall-free. + _fake_clock_sleep(agent_handler, monkeypatch, lock_file, unlink_at=400.0) result = agent_handler.watch_agent("@fakebranch", timeout_seconds=100000, poll_interval=0.01) out = capsys.readouterr().out diff --git a/src/aipass/drone/apps/modules/git_module.py b/src/aipass/drone/apps/modules/git_module.py index 52b09922..e45bf52c 100644 --- a/src/aipass/drone/apps/modules/git_module.py +++ b/src/aipass/drone/apps/modules/git_module.py @@ -17,6 +17,7 @@ import json import subprocess +import sys from pathlib import Path from aipass.prax import logger @@ -302,10 +303,47 @@ def _handle_close_pr(args: list[str]) -> dict: return {"stdout": "", "stderr": result["message"], "exit_code": 1} +def _confirm_merge(pr_number: str, caller: str, confirmed: bool) -> dict | None: + """Joint-decision gate: merges must never happen accidentally (DPLAN-0256). + + Returns None when the merge may proceed, or a refusal/abort result dict. + Order matters: an explicit --confirm always passes; otherwise a real + terminal gets an interactive y/N prompt; headless callers are refused. + """ + if confirmed: + json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "--confirm"}) + return None + + if sys.stdin.isatty(): + answer = input(f"Merge PR #{pr_number}? Merges are a joint decision. [y/N] ") + if answer.strip().lower() in ("y", "yes"): + json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "tty-yes"}) + return None + json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "tty-abort"}) + return {"stdout": "", "stderr": f"Merge of PR #{pr_number} aborted at prompt.", "exit_code": 1} + + json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "headless-refused"}) + logger.info("merge gate: refused headless merge of PR #%s by %s (no --confirm)", pr_number, caller) + return { + "stdout": "", + "stderr": ( + f"Merge of PR #{pr_number} requires explicit confirmation — merges are a joint decision.\n" + f"Re-run once agreed: drone @git merge {pr_number} --confirm" + ), + "exit_code": 1, + } + + def _handle_merge(args: list[str], caller: str) -> dict: """Handle the merge subcommand (owner-tier, auth pre-checked).""" - if not args: - return {"stdout": "", "stderr": "Usage: drone @git merge ", "exit_code": 1} + confirmed = "--confirm" in args + pr_args = [a for a in args if not a.startswith("--")] + if not pr_args: + return {"stdout": "", "stderr": "Usage: drone @git merge [--confirm]", "exit_code": 1} + + refusal = _confirm_merge(pr_args[0], caller, confirmed) + if refusal is not None: + return refusal try: from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr @@ -313,7 +351,7 @@ def _handle_merge(args: list[str], caller: str) -> dict: logger.error("Failed to import devpulse_ops merge plugin: %s", exc) return {"stdout": "", "stderr": f"devpulse_ops plugin not available: {exc}", "exit_code": 1} - result = merge_pr(args[0], caller) + result = merge_pr(pr_args[0], caller) if result["success"]: return {"stdout": result["message"], "stderr": "", "exit_code": 0} return {"stdout": "", "stderr": result["message"], "exit_code": 1} @@ -637,8 +675,10 @@ def get_help(command: str | None = None) -> str: return "git unlock --force — Force-release the PR lock [owner]\n" if command == "merge": return ( - "git merge — Merge a PR and sync local main [owner]\n" + "git merge [--confirm] — Merge a PR and sync local main [owner]\n" " Runs gh pr merge --merge --delete-branch, then git pull --rebase.\n" + " Joint-decision gate: terminal sessions get a y/N prompt; headless\n" + " callers must pass --confirm or the merge is refused.\n" ) if command == "smart-sync": return "git smart-sync — Fetch origin and rebase if behind [owner]\n" @@ -686,7 +726,7 @@ def get_help(command: str | None = None) -> str: " dev-pr Push dev and create PR to main\n" " delete-branch Delete a remote branch\n" " close-pr Close a PR\n" - " merge Merge a PR\n" + " merge [--confirm] Merge a PR (gated: y/N prompt or --confirm)\n" " sync [--autostash] Sync with origin/main (FF on dev)\n" " smart-sync Fetch + rebase if behind\n" " unlock --force Force-release the PR lock\n" diff --git a/src/aipass/drone/tests/test_devpulse_plugins.py b/src/aipass/drone/tests/test_devpulse_plugins.py index fbfa6fb6..eae35c6f 100644 --- a/src/aipass/drone/tests/test_devpulse_plugins.py +++ b/src/aipass/drone/tests/test_devpulse_plugins.py @@ -552,7 +552,9 @@ def test_handle_merge_routes_correctly( proc.stdout = "ok\n" mock_run.return_value = proc - result = handle_command("merge", ["42"]) + # --confirm satisfies the joint-decision gate (DPLAN-0256); headless + # merge without it is refused before routing reaches merge_pr. + result = handle_command("merge", ["42", "--confirm"]) assert result["exit_code"] == 0 @patch( diff --git a/src/aipass/drone/tests/test_git_module.py b/src/aipass/drone/tests/test_git_module.py index 21a0a95c..bdaa9cae 100644 --- a/src/aipass/drone/tests/test_git_module.py +++ b/src/aipass/drone/tests/test_git_module.py @@ -1471,3 +1471,91 @@ def test_diff_all_no_footer(self, _mock_auth: MagicMock, tmp_path: Path, monkeyp result = handle_command("diff", ["--all"]) assert "showing drone scope" not in result["stdout"] + + +# =========================================================================== +# Merge joint-decision gate (DPLAN-0256) +# =========================================================================== + +_MERGE_PR = "aipass.drone.apps.plugins.devpulse_ops.merge_plugin.merge_pr" +_MERGE_OK = { + "success": True, + "pr_number": "123", + "title": "t", + "merge_commit": "abc123", + "message": "PR #123 merged: t (abc123)", +} + + +class TestMergeGate: + """merge must never run without explicit confirmation.""" + + @patch(_AUTH, return_value="devpulse") + def test_headless_without_confirm_refused(self, _mock_auth: MagicMock) -> None: + """Non-TTY caller without --confirm is refused before the plugin loads.""" + with patch(_MERGE_PR) as mock_merge: + with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin: + mock_stdin.isatty.return_value = False + result = handle_command("merge", ["123"]) + + assert result["exit_code"] == 1 + assert "requires explicit confirmation" in result["stderr"] + assert "--confirm" in result["stderr"] + mock_merge.assert_not_called() + + @patch(_AUTH, return_value="devpulse") + def test_confirm_flag_proceeds(self, _mock_auth: MagicMock) -> None: + """--confirm merges without prompting, even headless.""" + with patch(_MERGE_PR, return_value=dict(_MERGE_OK)) as mock_merge: + with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin: + mock_stdin.isatty.return_value = False + result = handle_command("merge", ["123", "--confirm"]) + + assert result["exit_code"] == 0 + mock_merge.assert_called_once_with("123", "devpulse") + + @patch(_AUTH, return_value="devpulse") + def test_confirm_flag_position_agnostic(self, _mock_auth: MagicMock) -> None: + """--confirm before the PR number still resolves the right PR.""" + with patch(_MERGE_PR, return_value=dict(_MERGE_OK)) as mock_merge: + with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin: + mock_stdin.isatty.return_value = False + result = handle_command("merge", ["--confirm", "123"]) + + assert result["exit_code"] == 0 + mock_merge.assert_called_once_with("123", "devpulse") + + @patch(_AUTH, return_value="devpulse") + def test_tty_yes_proceeds(self, _mock_auth: MagicMock) -> None: + """Interactive terminal answering y merges.""" + with patch(_MERGE_PR, return_value=dict(_MERGE_OK)) as mock_merge: + with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin: + mock_stdin.isatty.return_value = True + with patch("builtins.input", return_value="y"): + result = handle_command("merge", ["123"]) + + assert result["exit_code"] == 0 + mock_merge.assert_called_once_with("123", "devpulse") + + @patch(_AUTH, return_value="devpulse") + def test_tty_default_aborts(self, _mock_auth: MagicMock) -> None: + """Interactive terminal hitting enter (default N) aborts.""" + with patch(_MERGE_PR) as mock_merge: + with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin: + mock_stdin.isatty.return_value = True + with patch("builtins.input", return_value=""): + result = handle_command("merge", ["123"]) + + assert result["exit_code"] == 1 + assert "aborted" in result["stderr"] + mock_merge.assert_not_called() + + @patch(_AUTH, return_value="devpulse") + def test_confirm_without_pr_number_is_usage_error(self, _mock_auth: MagicMock) -> None: + """--confirm alone (no PR number) is a usage error, not a merge.""" + with patch(_MERGE_PR) as mock_merge: + result = handle_command("merge", ["--confirm"]) + + assert result["exit_code"] == 1 + assert "Usage" in result["stderr"] + mock_merge.assert_not_called() diff --git a/src/aipass/flow/apps/handlers/mbank/process.py b/src/aipass/flow/apps/handlers/mbank/process.py index 7fd427e6..07a1ef6a 100644 --- a/src/aipass/flow/apps/handlers/mbank/process.py +++ b/src/aipass/flow/apps/handlers/mbank/process.py @@ -184,11 +184,15 @@ def get_closed_plans() -> List[Dict[str, Any]]: def is_template_content(content: str) -> bool: - """Check if plan content is still unedited template (v4.0) + """Check if plan content is still unedited template (v5.0) - Uses bracket placeholders only (not section headers) to detect untouched - templates. Also checks for user-added content in key sections — if any - real work was added, the plan is NOT a template even if placeholders remain. + Priority order: + 1. User-content signals (checked checkboxes) — strongest evidence of + real work, overrides everything. + 2. Real content in Notes section — overrides bracket markers. + 3. Non-boilerplate Execution Log entries — user-written entries + override bracket markers; template boilerplate is ignored. + 4. Bracket-marker check — 3+ template placeholders = template. Args: content: Plan file content @@ -196,37 +200,38 @@ def is_template_content(content: str) -> bool: Returns: True if plan is essentially an untouched template """ - # User content indicators — if ANY of these are found, plan has real work + import re + + # --- 1. User content signals override everything --- user_content_signals = [ - "- [x] Agent deployed", # Checked execution log item - "- [x] Agent completed", # Checked execution log item - "- [x] Seedgo checklist", # Checked completion item - "- [x] All goals achieved", # Checked completion item + "- [x] Agent deployed", + "- [x] Agent completed", + "- [x] Seedgo checklist", + "- [x] All goals achieved", ] for signal in user_content_signals: if signal in content: return False - # Check Notes section for user content (not just the placeholder) - import re - + # --- 2. Real Notes content → not template --- notes_match = re.search(r"## Notes\s*\n(.*?)(?=\n---|\n## |\Z)", content, re.DOTALL) if notes_match: notes_content = notes_match.group(1).strip() - # If notes has content beyond the template placeholder, it's real work if notes_content and notes_content != "[Working notes, issues encountered, decisions made]": return False - # Check Execution Log for user-added entries beyond template + # --- 3. Non-boilerplate Exec Log entries → not template --- + _exec_boilerplate = ["**Log Pattern:**", "[task]", "[outcome]", "[file]"] exec_match = re.search(r"## Execution Log\s*\n(.*?)(?=\n---|\n## |\Z)", content, re.DOTALL) if exec_match: exec_content = exec_match.group(1).strip() - lines = [line.strip() for line in exec_content.split("\n") if line.strip()] - # Template has ~6 lines (date header + checkbox items). More = user added content. - if len(lines) > 8: - return False + is_boilerplate = any(m in exec_content for m in _exec_boilerplate) + if not is_boilerplate: + lines = [line.strip() for line in exec_content.split("\n") if line.strip()] + if len(lines) > 8: + return False - # Bracket placeholders only (no section headers — those persist in real plans) + # --- 4. Bracket markers: 3+ from any type = template --- default_markers = [ "[What do you want to achieve? Specific end state.]", "[How will agents tackle this? What instructions will they need?]", @@ -250,7 +255,6 @@ def is_template_content(content: str) -> bool: "[Any other branches, services, or approvals needed?]", ] - # 3+ bracket placeholders from any type = template for markers in [default_markers, master_markers, proposal_markers]: found = sum(1 for m in markers if m in content) if found >= 3: diff --git a/src/aipass/flow/apps/handlers/plan/aggregate_ops.py b/src/aipass/flow/apps/handlers/plan/aggregate_ops.py index 3da1d55f..c8698f1f 100644 --- a/src/aipass/flow/apps/handlers/plan/aggregate_ops.py +++ b/src/aipass/flow/apps/handlers/plan/aggregate_ops.py @@ -17,6 +17,8 @@ """ import json +import os +import time from pathlib import Path from datetime import datetime, timezone from typing import Dict, Any, List, Tuple, Optional @@ -32,6 +34,34 @@ MODULE_NAME = "aggregate_central" +_LOCK_RETRIES = 10 +_LOCK_BACKOFF_BASE = 0.05 + + +def _acquire_lock(lock_path: Path) -> bool: + """Atomically acquire a lockfile via O_CREAT|O_EXCL with retry+backoff.""" + for attempt in range(_LOCK_RETRIES): + try: + fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, str(os.getpid()).encode()) + os.close(fd) + return True + except FileExistsError: + logger.info("[%s] Lock contention on %s, retry %d", MODULE_NAME, lock_path, attempt + 1) + time.sleep(_LOCK_BACKOFF_BASE * (2**attempt)) + except OSError as exc: + logger.warning("[%s] Lock creation failed for %s: %s", MODULE_NAME, lock_path, exc) + return False + return False + + +def _release_lock(lock_path: Path) -> None: + """Remove lockfile, tolerating already-removed.""" + try: + lock_path.unlink(missing_ok=True) + except OSError as exc: + logger.warning("[%s] Could not release lock %s: %s", MODULE_NAME, lock_path, exc) + # ============================================= # HELPER FUNCTIONS @@ -101,13 +131,36 @@ def save_branch_registry(registry_path: Path, registry: Dict[str, Any]) -> bool: Returns: True on success, False on failure """ + lock_path = registry_path.with_suffix(".lock") try: - registry["last_updated"] = datetime.now(timezone.utc).isoformat() - with open(registry_path, "w", encoding="utf-8") as f: - json.dump(registry, f, indent=2, ensure_ascii=False) + registry_path.parent.mkdir(parents=True, exist_ok=True) + + if not _acquire_lock(lock_path): + logger.error( + "[%s] Could not acquire lock for %s after %d retries", + MODULE_NAME, + registry_path, + _LOCK_RETRIES, + ) + return False + + try: + registry["last_updated"] = datetime.now(timezone.utc).isoformat() + tmp_path = registry_path.with_suffix(".tmp") + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(registry, f, indent=2, ensure_ascii=False) + os.replace(str(tmp_path), str(registry_path)) + finally: + _release_lock(lock_path) + return True except Exception as e: - logger.error(f"[{MODULE_NAME}] Failed to save registry {registry_path}: {e}") + logger.error( + "[%s] Failed to save registry %s: %s", + MODULE_NAME, + registry_path, + e, + ) return False @@ -265,10 +318,27 @@ def save_central(central_file: Path, central_dir: Path, central_data: Dict[str, Returns: True on success, False on failure """ + lock_path = central_file.with_suffix(".lock") try: central_dir.mkdir(parents=True, exist_ok=True) - with open(central_file, "w", encoding="utf-8") as f: - json.dump(central_data, f, indent=2, ensure_ascii=False) + + if not _acquire_lock(lock_path): + logger.error( + "[%s] Could not acquire lock for %s after %d retries", + MODULE_NAME, + central_file, + _LOCK_RETRIES, + ) + return False + + try: + tmp_path = central_file.with_suffix(".tmp") + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(central_data, f, indent=2, ensure_ascii=False) + os.replace(str(tmp_path), str(central_file)) + finally: + _release_lock(lock_path) + return True except Exception as e: logger.error(f"[{MODULE_NAME}] Failed to save {central_file}: {e}") @@ -392,7 +462,10 @@ def aggregate_central_impl( # Save central file if save_central(central_file, central_dir, central_data): logger.info( - f"[{MODULE_NAME}] SUCCESS: Aggregation complete: {len(all_active)} active, {len(recently_closed)} recently closed" + "[%s] SUCCESS: Aggregation complete: %d active, %d recently closed", + MODULE_NAME, + len(all_active), + len(recently_closed), ) # Fire trigger event diff --git a/src/aipass/flow/templates/playbook_plans/merge.md b/src/aipass/flow/templates/playbook_plans/merge.md index 743d3a2c..bacf12a3 100644 --- a/src/aipass/flow/templates/playbook_plans/merge.md +++ b/src/aipass/flow/templates/playbook_plans/merge.md @@ -116,6 +116,7 @@ The PR gate (verified against `.github/workflows/`): - [ ] **Stay on `dev`. Do not check out `main`.** Local main being behind is fine — `drone @git sync` from dev covers it. - [ ] Never rebase, never reset, never checkout main. - [ ] Dependabot / other PRs targeting main: they go green once main has the fix + bots rebase — check after the push +- [ ] **Site drift check (S323):** does this merge change install commands, onboarding flow, agent count, or the platform/CLI story? If YES → note it here and flag that aipass.ai must be updated same-day (devpulse handles the site edit; the site is a projection of the README, never its own source of facts). ## 7. Release tag diff --git a/src/aipass/hooks/.aipass/aipass_local_prompt.md b/src/aipass/hooks/.aipass/aipass_local_prompt.md index 6c398ba5..c1114f91 100644 --- a/src/aipass/hooks/.aipass/aipass_local_prompt.md +++ b/src/aipass/hooks/.aipass/aipass_local_prompt.md @@ -9,14 +9,14 @@ HOOKS -- hook infrastructure owner. Single engine dispatches all hooks across pl ## What I Do - Own the hook engine -- receives events from platform bridges, routes to handlers, logs everything -- Maintain 14 native handlers across 4 categories (prompt, security, lifecycle, notification) +- Maintain 27 native handlers across 4 categories (prompt, security, lifecycle, notification) - Bridge platforms -- thin normalization layer per provider (Claude today, Codex planned) - Per-project config -- `.aipass/hooks.json` controls what fires per project - Log everything -- prax integration + JSONL diagnostics for every hook execution ## What I Don't Do -- Touch provider settings directly -- setup.sh/doctor handles platform config installation +- Touch `~/.claude/settings.json` -- personal file, doctor/init syncs it. The manifest (`.claude/provider_manifest.json`) IS mine to maintain - Manage other branches -- I'm a builder, not an orchestrator - Own handler business logic -- handlers are self-contained, engine just dispatches @@ -40,45 +40,59 @@ apps/ handlers/ bridges/ claude.py # Claude Code bridge (provider settings entry point) - prompt/ # Prompt injection hooks + codex.py # Codex bridge (planned) + prompt/ # Prompt injection hooks (UserPromptSubmit) branch_loader.py # Injects aipass_local_prompt.md tier0_kernel.py # Injects tier0 kernel prompt (every turn) navmap.py # Injects tier1 navmap prompt (periodic) identity.py # Injects passport identity block + compass_recall.py # Governance recall injection + feedback_pulse.py # 10-turn cadence feedback nudge (disabled default) + context_gauge.py # Live transcript-fill nudge toward /prep + temporal.py # Weekday/date/time/tz/part-of-day, every turn + persistent_alert.py # Advisory banners for .aipass/alerts.json security/ # Enforcement hooks + presence_gate.py # Session presence gate (UserPromptSubmit + Stop release) edit_gate.py # Blocks edits while type errors exist git_gate.py # Enforces git access tiers + rm_gate.py # Guards destructive rm commands + registry_gate.py # Guards registry-modifying commands subagent_gate.py # Blocks sub-agent stop until clean lifecycle/ # Session management hooks auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile) auto_watchdog.py # Watchdog arming after dispatch + auto_process.py # Scheduled inbox/task processing compact.py # Pre-compact memory archival rollover.py # Pre-compact memory rollover + pre_compact_prep.py # Pre-compact snapshot stamp (context/dispatch/plans) + session_start.py # SessionStart cadence reset notification/ # Alert hooks announce.py # Inbox banner on prompt email.py # Email notification stop_sound.py # Sound on session stop tool_sound.py # Sound on tool use + telegram_response.py # Telegram reply delivery on Stop config/ - loader.py # hooks.json discovery + validation - diagnostics.py # Diagnostics config + loader.py # hooks.json discovery + validation, config-independent trust checks + trust_registry.py # Trusted-project registry (enroll/revoke/hash checks) + diagnostics.py # JSONL diagnostics config logs/ engine.jsonl # JSONL diagnostics (every hook execution) -tests/ # 15 test files, 244 tests +tests/ # 43 test files, 1249 tests ``` ## Handler Categories | Category | Count | Handlers | |----------|-------|----------| -| prompt | 4 | branch_loader, tier0_kernel, navmap, identity | -| security | 3 | edit_gate, git_gate, subagent_gate | -| lifecycle | 4 | auto_fix, auto_watchdog, compact, rollover | -| notification | 4 | announce, email, stop_sound, tool_sound | +| prompt | 9 | branch_loader, tier0_kernel, navmap, identity, compass_recall, feedback_pulse, context_gauge, temporal, persistent_alert | +| security | 6 | presence_gate, edit_gate, git_gate, rm_gate, registry_gate, subagent_gate | +| lifecycle | 7 | auto_fix, auto_watchdog, auto_process, compact, rollover, pre_compact_prep, session_start | +| notification | 5 | announce, email, stop_sound, tool_sound, telegram_response | ## How It Works -1. Provider settings point ONE bridge entry per event type (e.g., `claude.py UserPromptSubmit`) +1. Provider settings invoke the bridge two ways: `claude.py EventType` (all enabled handlers -- tool events) or `claude.py EventType:handler_name` (one handler per entry -- UserPromptSubmit, PreCompact) 2. Bridge calls `engine.dispatch(event_type, stdin_data, config)` 3. Engine reads `.aipass/hooks.json` (walks up from CWD) 4. Engine runs matching hooks sequentially, logs each to JSONL @@ -86,6 +100,12 @@ tests/ # 15 test files, 244 tests 6. Exit code 2 without JSON = crash (log error, continue to next hook) 7. All hook stdout concatenated and returned to platform +## New handler? Check the provider wire + +hooks.json alone is not live: UserPromptSubmit + PreCompact are invoked per-handler (`claude.py Event:name`) -- handlers on those events ALSO need a command entry in `.claude/provider_manifest.json` (PreCompact: manual + auto pair). Verify with firing evidence in engine.jsonl, not just the suite. + +EVERY reply that adds/renames/moves a handler MUST state either "provider settings update needed: " or "no provider wire needed" -- never silent. Devpulse + Patrick apply live-settings changes; flag it every time, even if the manifest is already updated. + ## Integration - **Depends on:** @prax for logging (system_logger for prax monitor visibility) @@ -105,4 +125,4 @@ tests/ # 15 test files, 244 tests - Exit code 2 has dual meaning: intentional block (with JSON) vs crash (without JSON). Engine distinguishes by checking stdout. - JSONL log lives at `logs/engine.jsonl` -- not in prax. Prax gets a copy via system_logger, but JSONL is the source of truth for hook diagnostics. -- Bridge must be the ONLY entry in provider settings per event type. Multiple entries per event = platform calls them all independently, bypassing engine sequencing. +- Provider settings carry multiple named bridge entries per event for UserPromptSubmit and PreCompact -- deliberate (per-handler output + timeout). New handlers on those events need their own provider entry. diff --git a/src/aipass/hooks/.seedgo/bypass.json b/src/aipass/hooks/.seedgo/bypass.json index 712caaa2..1b9f4ad0 100644 --- a/src/aipass/hooks/.seedgo/bypass.json +++ b/src/aipass/hooks/.seedgo/bypass.json @@ -4,7 +4,7 @@ "created": "2026-05-18", "updated": "2026-05-28", "description": "Standards bypass configuration for this branch", - "audit_context": "DPLAN-0191: Full ownership hardening. All handler wiring verified against .aipass/hooks.json + engine.jsonl firing evidence. Dynamic dispatch via engine._run_handler (importlib.import_module + getattr) means handlers are never statically imported \u2014 seedgo's dead_code/unused_function checks are false positives for this architecture." + "audit_context": "DPLAN-0191: Full ownership hardening. All handler wiring verified against .aipass/hooks.json + engine.jsonl firing evidence. Dynamic dispatch via engine._run_handler (importlib.import_module + getattr) means handlers are never statically imported — seedgo's dead_code/unused_function checks are false positives for this architecture." }, "bypass": [ { @@ -14,17 +14,17 @@ { "file": "apps/handlers/bridges/claude.py", "standard": "dead_code", - "reason": "Bridge called externally by provider settings subprocess \u2014 no internal import. Verified wired in ~/.claude/settings.json hook entries." + "reason": "Bridge called externally by provider settings subprocess — no internal import. Verified wired in ~/.claude/settings.json hook entries." }, { "file": "apps/handlers/bridges/claude.py", "standard": "unused_function", - "reason": "main() called as subprocess entry point from provider settings \u2014 never statically imported." + "reason": "main() called as subprocess entry point from provider settings — never statically imported." }, { "file": "apps/handlers/bridges/claude.py", "standard": "handlers", - "reason": "Bridges import engine module by design \u2014 that is their entire purpose." + "reason": "Bridges import engine module by design — that is their entire purpose." }, { "file": "apps/handlers/bridges/claude.py", @@ -39,27 +39,27 @@ { "file": "apps/handlers/bridges/claude.py", "standard": "imports", - "reason": "Bridge imports engine module by design \u2014 sole purpose." + "reason": "Bridge imports engine module by design — sole purpose." }, { "file": "apps/handlers/bridges/codex.py", "standard": "dead_code", - "reason": "Bridge called externally by Codex hook settings subprocess \u2014 no internal import. Wired in .codex/hooks.json." + "reason": "Bridge called externally by Codex hook settings subprocess — no internal import. Wired in .codex/hooks.json." }, { "file": "apps/handlers/bridges/codex.py", "standard": "unused_function", - "reason": "main() called as subprocess entry point from Codex hook settings \u2014 never statically imported." + "reason": "main() called as subprocess entry point from Codex hook settings — never statically imported." }, { "file": "apps/handlers/bridges/codex.py", "standard": "handlers", - "reason": "Bridges import engine module by design \u2014 that is their entire purpose." + "reason": "Bridges import engine module by design — that is their entire purpose." }, { "file": "apps/handlers/bridges/codex.py", "standard": "json_structure", - "reason": "Thin entry point using stdlib json for Codex protocol envelope \u2014 no JSON file ops needing json_handler." + "reason": "Thin entry point using stdlib json for Codex protocol envelope — no JSON file ops needing json_handler." }, { "file": "apps/handlers/bridges/codex.py", @@ -69,12 +69,12 @@ { "file": "apps/handlers/bridges/codex.py", "standard": "imports", - "reason": "Bridge imports engine module by design \u2014 sole purpose." + "reason": "Bridge imports engine module by design — sole purpose." }, { "file": "apps/handlers/prompt/identity.py", "standard": "dead_code", - "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.identity.handle' \u2014 not statically imported by design. Verified wired in UserPromptSubmit.identity_injector + fires in engine.jsonl." + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.identity.handle' — not statically imported by design. Verified wired in UserPromptSubmit.identity_injector + fires in engine.jsonl." }, { "file": "apps/handlers/prompt/identity.py", @@ -84,12 +84,12 @@ { "file": "apps/handlers/prompt/identity.py", "standard": "json_structure", - "reason": "Uses stdlib json.loads to read passport.json \u2014 no JSON file ops needing json_handler." + "reason": "Uses stdlib json.loads to read passport.json — no JSON file ops needing json_handler." }, { "file": "apps/handlers/prompt/branch_loader.py", "standard": "dead_code", - "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.branch_loader.handle' \u2014 not statically imported by design. Verified wired in UserPromptSubmit.branch_prompt + fires in engine.jsonl." + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.branch_loader.handle' — not statically imported by design. Verified wired in UserPromptSubmit.branch_prompt + fires in engine.jsonl." }, { "file": "apps/handlers/prompt/branch_loader.py", @@ -99,7 +99,7 @@ { "file": "apps/handlers/prompt/branch_loader.py", "standard": "json_structure", - "reason": "No JSON operations \u2014 reads markdown files and outputs text." + "reason": "No JSON operations — reads markdown files and outputs text." }, { "file": "apps/handlers/prompt/tier0_kernel.py", @@ -131,10 +131,15 @@ "standard": "json_structure", "reason": "No JSON operations - reads markdown file (.aipass/tier1_navmap.md) and outputs text." }, + { + "file": "apps/handlers/prompt/temporal.py", + "standard": "json_structure", + "reason": "No JSON operations - reads the system clock and outputs text. Wired in UserPromptSubmit.temporal (FPLAN-0348)." + }, { "file": "apps/handlers/security/edit_gate.py", "standard": "dead_code", - "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.edit_gate.handle' \u2014 not statically imported by design. Verified wired in PreToolUse.pre_edit_gate + fires in engine.jsonl." + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.edit_gate.handle' — not statically imported by design. Verified wired in PreToolUse.pre_edit_gate + fires in engine.jsonl." }, { "file": "apps/handlers/security/edit_gate.py", @@ -144,12 +149,12 @@ { "file": "apps/handlers/security/edit_gate.py", "standard": "json_structure", - "reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler." + "reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler." }, { "file": "apps/handlers/security/git_gate.py", "standard": "dead_code", - "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.git_gate.handle' \u2014 not statically imported by design. Verified wired in PreToolUse.git_gate + fires in engine.jsonl." + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.git_gate.handle' — not statically imported by design. Verified wired in PreToolUse.git_gate + fires in engine.jsonl." }, { "file": "apps/handlers/security/git_gate.py", @@ -159,12 +164,12 @@ { "file": "apps/handlers/security/git_gate.py", "standard": "json_structure", - "reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler." + "reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler." }, { "file": "apps/handlers/security/rm_gate.py", "standard": "dead_code", - "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.rm_gate.handle' \u2014 not statically imported by design. Wired in PreToolUse.rm_gate." + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.rm_gate.handle' — not statically imported by design. Wired in PreToolUse.rm_gate." }, { "file": "apps/handlers/security/rm_gate.py", @@ -174,12 +179,12 @@ { "file": "apps/handlers/security/rm_gate.py", "standard": "json_structure", - "reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler." + "reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler." }, { "file": "apps/handlers/security/subagent_gate.py", "standard": "dead_code", - "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.subagent_gate.handle' \u2014 not statically imported by design. Verified wired in SubagentStop.subagent_stop_gate + fires in engine.jsonl." + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.subagent_gate.handle' — not statically imported by design. Verified wired in SubagentStop.subagent_stop_gate + fires in engine.jsonl." }, { "file": "apps/handlers/security/subagent_gate.py", @@ -189,12 +194,12 @@ { "file": "apps/handlers/security/subagent_gate.py", "standard": "json_structure", - "reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler." + "reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler." }, { "file": "apps/handlers/security/subagent_gate.py", "standard": "open_encoding", - "reason": "NamedTemporaryFile creates binary wav for Piper TTS \u2014 encoding not applicable to binary audio." + "reason": "NamedTemporaryFile creates binary wav for Piper TTS — encoding not applicable to binary audio." }, { "file": "apps/handlers/security/registry_gate.py", @@ -244,7 +249,7 @@ { "file": "apps/handlers/lifecycle/auto_fix.py", "standard": "dead_code", - "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_fix.handle' \u2014 not statically imported by design. Verified wired in PostToolUse.auto_fix_diagnostics + fires in engine.jsonl." + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_fix.handle' — not statically imported by design. Verified wired in PostToolUse.auto_fix_diagnostics + fires in engine.jsonl." }, { "file": "apps/handlers/lifecycle/auto_fix.py", @@ -254,12 +259,12 @@ { "file": "apps/handlers/lifecycle/auto_fix.py", "standard": "json_structure", - "reason": "Diagnostics handler uses stdlib json for hook protocol responses and state file \u2014 no JSON file ops needing json_handler." + "reason": "Diagnostics handler uses stdlib json for hook protocol responses and state file — no JSON file ops needing json_handler." }, { "file": "apps/handlers/lifecycle/auto_watchdog.py", "standard": "dead_code", - "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_watchdog.handle' \u2014 not statically imported by design. Verified wired in PostToolUse.auto_watchdog + fires in engine.jsonl." + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_watchdog.handle' — not statically imported by design. Verified wired in PostToolUse.auto_watchdog + fires in engine.jsonl." }, { "file": "apps/handlers/lifecycle/auto_watchdog.py", @@ -269,42 +274,42 @@ { "file": "apps/handlers/lifecycle/auto_watchdog.py", "standard": "json_structure", - "reason": "Uses stdlib json.dumps to produce additionalContext output \u2014 no JSON file ops needing json_handler." + "reason": "Uses stdlib json.dumps to produce additionalContext output — no JSON file ops needing json_handler." }, { "file": "apps/handlers/lifecycle/compact.py", "standard": "dead_code", - "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.compact.handle' \u2014 not statically imported by design. Verified wired in PreCompact.pre_compact (PreCompact events are rare \u2014 fires only during context compaction)." + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.compact.handle' — not statically imported by design. Verified wired in PreCompact.pre_compact (PreCompact events are rare — fires only during context compaction)." }, { "file": "apps/handlers/lifecycle/compact.py", "standard": "unused_function", - "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact \u2014 fires during compaction events." + "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact — fires during compaction events." }, { "file": "apps/handlers/lifecycle/compact.py", "standard": "json_structure", - "reason": "Uses stdlib json.loads for local.json reading \u2014 no JSON file ops needing json_handler." + "reason": "Uses stdlib json.loads for local.json reading — no JSON file ops needing json_handler." }, { "file": "apps/handlers/lifecycle/rollover.py", "standard": "dead_code", - "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.rollover.handle' \u2014 not statically imported by design. Verified wired in PreCompact.pre_compact_rollover (PreCompact events are rare \u2014 fires only during context compaction)." + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.rollover.handle' — not statically imported by design. Verified wired in PreCompact.pre_compact_rollover (PreCompact events are rare — fires only during context compaction)." }, { "file": "apps/handlers/lifecycle/rollover.py", "standard": "unused_function", - "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact_rollover \u2014 fires during compaction events." + "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact_rollover — fires during compaction events." }, { "file": "apps/handlers/lifecycle/rollover.py", "standard": "json_structure", - "reason": "Uses stdlib json.loads for registry and memory file checks \u2014 no JSON file ops needing json_handler." + "reason": "Uses stdlib json.loads for registry and memory file checks — no JSON file ops needing json_handler." }, { "file": "apps/handlers/lifecycle/auto_process.py", "standard": "dead_code", - "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_process.handle' \u2014 not statically imported by design. Wired in UserPromptSubmit.auto_process + PreCompact.auto_process." + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_process.handle' — not statically imported by design. Wired in UserPromptSubmit.auto_process + PreCompact.auto_process." }, { "file": "apps/handlers/lifecycle/auto_process.py", @@ -314,12 +319,12 @@ { "file": "apps/handlers/lifecycle/auto_process.py", "standard": "json_structure", - "reason": "Delegates to @memory's auto_process() via importlib \u2014 no direct JSON file ops needing json_handler." + "reason": "Delegates to @memory's auto_process() via importlib — no direct JSON file ops needing json_handler." }, { "file": "apps/handlers/lifecycle/session_start.py", "standard": "dead_code", - "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.session_start.handle' \u2014 not statically imported by design. Wired in SessionStart.cadence_reset." + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.session_start.handle' — not statically imported by design. Wired in SessionStart.cadence_reset." }, { "file": "apps/handlers/lifecycle/session_start.py", @@ -329,12 +334,12 @@ { "file": "apps/handlers/lifecycle/session_start.py", "standard": "json_structure", - "reason": "Delegates to cadence.reset_counter() via importlib \u2014 no direct JSON file ops needing json_handler." + "reason": "Delegates to cadence.reset_counter() via importlib — no direct JSON file ops needing json_handler." }, { "file": "apps/modules/cadence.py", "standard": "dead_code", - "reason": "Cadence module \u2014 should_fire() called from tier0_kernel.py, navmap.py, and branch_loader.py guard lines; reset_counter() called from compact.py PreCompact handler. Not statically discoverable because callers are themselves dynamically dispatched." + "reason": "Cadence module — should_fire() called from tier0_kernel.py, navmap.py, and branch_loader.py guard lines; reset_counter() called from compact.py PreCompact handler. Not statically discoverable because callers are themselves dynamically dispatched." }, { "file": "apps/modules/cadence.py", @@ -344,12 +349,12 @@ { "file": "apps/modules/cadence.py", "standard": "json_structure", - "reason": "Uses stdlib json for /tmp state file (turn counter) and hooks_json/custom_config/cadence_config.json config loading \u2014 lightweight ephemeral state, not branch data storage." + "reason": "Uses stdlib json for /tmp state file (turn counter) and hooks_json/custom_config/cadence_config.json config loading — lightweight ephemeral state, not branch data storage." }, { "file": "apps/handlers/notification/announce.py", "standard": "dead_code", - "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.announce.handle' \u2014 not statically imported by design. Verified wired in Notification.notification_sound + fires in engine.jsonl." + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.announce.handle' — not statically imported by design. Verified wired in Notification.notification_sound + fires in engine.jsonl." }, { "file": "apps/handlers/notification/announce.py", @@ -359,12 +364,12 @@ { "file": "apps/handlers/notification/announce.py", "standard": "json_structure", - "reason": "Sound handler \u2014 no JSON operations, plays WAV files." + "reason": "Sound handler — no JSON operations, plays WAV files." }, { "file": "apps/handlers/notification/email.py", "standard": "dead_code", - "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.email.handle' \u2014 not statically imported by design. Verified wired in UserPromptSubmit.email_notification + fires in engine.jsonl." + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.email.handle' — not statically imported by design. Verified wired in UserPromptSubmit.email_notification + fires in engine.jsonl." }, { "file": "apps/handlers/notification/email.py", @@ -374,12 +379,12 @@ { "file": "apps/handlers/notification/email.py", "standard": "json_structure", - "reason": "Uses stdlib json.loads for inbox parsing \u2014 no JSON file ops needing json_handler." + "reason": "Uses stdlib json.loads for inbox parsing — no JSON file ops needing json_handler." }, { "file": "apps/handlers/notification/stop_sound.py", "standard": "dead_code", - "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.stop_sound.handle' \u2014 not statically imported by design. Verified wired in Stop.stop_sound + fires in engine.jsonl." + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.stop_sound.handle' — not statically imported by design. Verified wired in Stop.stop_sound + fires in engine.jsonl." }, { "file": "apps/handlers/notification/stop_sound.py", @@ -389,12 +394,12 @@ { "file": "apps/handlers/notification/stop_sound.py", "standard": "json_structure", - "reason": "Sound handler \u2014 no JSON operations, plays WAV files." + "reason": "Sound handler — no JSON operations, plays WAV files." }, { "file": "apps/handlers/notification/tool_sound.py", "standard": "dead_code", - "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.tool_sound.handle' \u2014 not statically imported by design. Verified wired in PreToolUse.tool_use_sound + fires in engine.jsonl." + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.tool_sound.handle' — not statically imported by design. Verified wired in PreToolUse.tool_use_sound + fires in engine.jsonl." }, { "file": "apps/handlers/notification/tool_sound.py", @@ -404,7 +409,7 @@ { "file": "apps/handlers/notification/tool_sound.py", "standard": "json_structure", - "reason": "Sound handler \u2014 no JSON operations, plays WAV files." + "reason": "Sound handler — no JSON operations, plays WAV files." }, { "file": "apps/handlers/config/trust_registry.py", @@ -414,22 +419,22 @@ { "file": "apps/handlers/config/loader.py", "standard": "json_structure", - "reason": "Config loader does $AIPASS_HOME variable expansion before JSON parse \u2014 json_handler does not support this." + "reason": "Config loader does $AIPASS_HOME variable expansion before JSON parse — json_handler does not support this." }, { "file": "apps/handlers/config/diagnostics.py", "standard": "json_structure", - "reason": "JSONL append-only diagnostic log \u2014 different pattern from branch json_handler storage." + "reason": "JSONL append-only diagnostic log — different pattern from branch json_handler storage." }, { "file": "apps/modules/engine.py", "standard": "json_structure", - "reason": "Engine uses JSONL diagnostic logging, not branch json_handler \u2014 different purpose." + "reason": "Engine uses JSONL diagnostic logging, not branch json_handler — different purpose." }, { "file": "apps/modules/engine.py", "standard": "modules", - "reason": "dispatch() is the engine's core purpose \u2014 it IS the module's primary function, not a handler that belongs elsewhere. The engine exists to dispatch; moving dispatch to handlers/ would leave an empty module." + "reason": "dispatch() is the engine's core purpose — it IS the module's primary function, not a handler that belongs elsewhere. The engine exists to dispatch; moving dispatch to handlers/ would leave an empty module." }, { "file": "apps/modules/hook_test.py", @@ -439,32 +444,32 @@ { "file": "apps/modules/hooksound.py", "standard": "json_structure", - "reason": "Sound mute toggle \u2014 touches /tmp/aipass-hooks-muted flag file only, no JSON operations or json_handler storage." + "reason": "Sound mute toggle — touches /tmp/aipass-hooks-muted flag file only, no JSON operations or json_handler storage." }, { "file": "apps/modules/hooksound.py", "standard": "trigger", - "reason": "MUTE_FLAG.unlink() removes a /tmp mute flag file for sound toggle \u2014 not a tracked resource or production data deletion. Deliberate user action via 'drone @hooks hooksound on'." + "reason": "MUTE_FLAG.unlink() removes a /tmp mute flag file for sound toggle — not a tracked resource or production data deletion. Deliberate user action via 'drone @hooks hooksound on'." }, { "file": "apps/modules/feedback.py", "standard": "trigger", - "reason": "sentinel.unlink() removes a .aipass/feedback_off toggle file \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", "standard": "json_structure", - "reason": "Read-only config viewer \u2014 delegates JSON loading to config/loader.py, no direct JSON file ops." + "reason": "Read-only config viewer — delegates JSON loading to config/loader.py, no direct JSON file ops." }, { "file": "apps/modules/wire_verify.py", "standard": "json_structure", - "reason": "Reads ~/.claude/settings.json (external provider settings) with stdlib json \u2014 not branch data storage needing json_handler." + "reason": "Reads ~/.claude/settings.json (external provider settings) with stdlib json — not branch data storage needing json_handler." }, { "file": "apps/modules/cadence.py", "standard": "modules", - "reason": "Cadence module reads /tmp state file and hooks_json/custom_config/cadence_config.json with stdlib json \u2014 lightweight ephemeral state (turn counter) + tunable config. json_handler is for persistent branch data, not /tmp session state or config knobs." + "reason": "Cadence module reads /tmp state file and hooks_json/custom_config/cadence_config.json with stdlib json — lightweight ephemeral state (turn counter) + tunable config. json_handler is for persistent branch data, not /tmp session state or config knobs." }, { "file": "apps/hooks.py", @@ -474,17 +479,17 @@ { "file": "apps/sound.py", "standard": "unused_function", - "reason": "play() called by handler files (stop_sound.py, announce.py) that are dynamically dispatched via importlib \u2014 static analysis cannot trace the call chain from hooks.json \u2192 engine \u2192 handler \u2192 sound.play()." + "reason": "play() called by handler files (stop_sound.py, announce.py) that are dynamically dispatched via importlib — static analysis cannot trace the call chain from hooks.json → engine → handler → sound.play()." }, { "file": "apps/sound.py", "standard": "open_encoding", - "reason": "NamedTemporaryFile creates binary WAV for Piper TTS \u2014 encoding not applicable to binary audio." + "reason": "NamedTemporaryFile creates binary WAV for Piper TTS — encoding not applicable to binary audio." }, { "file": "apps/sound.py", "standard": "cli_flags", - "reason": "Shared utility module imported by handlers \u2014 not a CLI entry point. Has print_introspection() for drone discovery but no handle_command() or user-facing CLI." + "reason": "Shared utility module imported by handlers — not a CLI entry point. Has print_introspection() for drone discovery but no handle_command() or user-facing CLI." }, { "standard": "json_handler", @@ -492,7 +497,7 @@ }, { "standard": "test_quality", - "reason": "Hooks branch does not use json_handler \u2014 has its own JSONL diagnostic logging (diagnostics.py) and stdlib json for hook protocol I/O. json_handler coverage, mock_json_handler fixture, and exception_contracts (create_default_raises, save_invalid_raises, invalid_mode_raises) are all N/A for a hook dispatch engine architecture." + "reason": "Hooks branch does not use json_handler — has its own JSONL diagnostic logging (diagnostics.py) and stdlib json for hook protocol I/O. json_handler coverage, mock_json_handler fixture, and exception_contracts (create_default_raises, save_invalid_raises, invalid_mode_raises) are all N/A for a hook dispatch engine architecture." }, { "file": "tests/conftest.py", @@ -502,12 +507,12 @@ { "file": "tests/conftest.py", "standard": "json_handler", - "reason": "Hooks branch does not use json_handler \u2014 has its own JSONL logging and stdlib json for hook protocol. mock_json_handler fixture is N/A." + "reason": "Hooks branch does not use json_handler — has its own JSONL logging and stdlib json for hook protocol. mock_json_handler fixture is N/A." }, { "file": "tests/conftest.py", "standard": "exception_contracts", - "reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns \u2014 those contracts are N/A for a hook dispatch engine." + "reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns — those contracts are N/A for a hook dispatch engine." }, { "file": "tests/test_engine.py", @@ -532,7 +537,7 @@ { "file": "tests/test_engine.py", "standard": "json_handler", - "reason": "Hooks branch does not use json_handler \u2014 has its own JSONL logging." + "reason": "Hooks branch does not use json_handler — has its own JSONL logging." }, { "file": "tests/test_engine.py", @@ -662,12 +667,12 @@ { "file": "tests/test_auto_fix.py", "standard": "commented_logger", - "reason": "Test data contains '# logger.debug(msg)' as input to pattern checker under test \u2014 not a commented-out call." + "reason": "Test data contains '# logger.debug(msg)' as input to pattern checker under test — not a commented-out call." }, { "file": "tests/test_auto_fix.py", "standard": "trigger", - "reason": "Test cleanup .unlink() removes temporary state files \u2014 not a production file deletion." + "reason": "Test cleanup .unlink() removes temporary state files — not a production file deletion." }, { "file": "tests/test_identity.py", @@ -957,47 +962,47 @@ { "file": "apps/modules/sandbox.py", "standard": "json_structure", - "reason": "Uses stdlib json.dump to write ephemeral srt config to a NamedTemporaryFile \u2014 not a tracked JSON resource needing json_handler." + "reason": "Uses stdlib json.dump to write ephemeral srt config to a NamedTemporaryFile — not a tracked JSON resource needing json_handler." }, { "file": "apps/modules/sandbox.py", "standard": "trigger", - "reason": "Path.unlink() removes an ephemeral NamedTemporaryFile (srt config) created seconds earlier in the same function \u2014 not a tracked resource or production data deletion." + "reason": "Path.unlink() removes an ephemeral NamedTemporaryFile (srt config) created seconds earlier in the same function — not a tracked resource or production data deletion." }, { "file": "artifacts/sandbox_phase1_demo.py", "standard": "architecture", - "reason": "Live demo artifact for FPLAN-0250 Phase 1 acceptance \u2014 not production code, lives in artifacts/ per the phase brief." + "reason": "Live demo artifact for FPLAN-0250 Phase 1 acceptance — not production code, lives in artifacts/ per the phase brief." }, { "file": "artifacts/sandbox_phase1_demo.py", "standard": "debug_print", - "reason": "Demo script uses print() for human-readable acceptance test output \u2014 not a module with CLI service." + "reason": "Demo script uses print() for human-readable acceptance test output — not a module with CLI service." }, { "file": "artifacts/sandbox_phase1_demo.py", "standard": "imports", - "reason": "sys.path insert needed to run standalone demo from artifacts/ \u2014 not a pip-installed module entry point." + "reason": "sys.path insert needed to run standalone demo from artifacts/ — not a pip-installed module entry point." }, { "file": "artifacts/sandbox_phase1_demo.py", "standard": "documentation", - "reason": "Demo helper function \u2014 docstrings omitted for brevity in a non-production artifact." + "reason": "Demo helper function — docstrings omitted for brevity in a non-production artifact." }, { "file": "artifacts/sandbox_phase1_demo.py", "standard": "help_text", - "reason": "Demo run instructions reference python3 as the invocation command \u2014 this is a standalone script, not a drone-routed module." + "reason": "Demo run instructions reference python3 as the invocation command — this is a standalone script, not a drone-routed module." }, { "file": "tests/test_sandbox.py", "standard": "architecture", - "reason": "Test file lives in tests/ per hooks convention \u2014 not a module or handler." + "reason": "Test file lives in tests/ per hooks convention — not a module or handler." }, { "file": "tests/test_sandbox.py", "standard": "documentation", - "reason": "Test methods use descriptive names \u2014 docstrings redundant per hooks test convention." + "reason": "Test methods use descriptive names — docstrings redundant per hooks test convention." }, { "file": "tests/test_sandbox.py", @@ -1007,7 +1012,7 @@ { "file": "apps/modules/sandbox.py", "standard": "modules", - "reason": "Read-only passport.json check in _is_devpulse() to detect branch role for policy generation \u2014 not a file-write operation, not a handler-level concern. Module reads sibling passports to determine writable/RO map." + "reason": "Read-only passport.json check in _is_devpulse() to detect branch role for policy generation — not a file-write operation, not a handler-level concern. Module reads sibling passports to determine writable/RO map." }, { "file": "apps/modules/sandbox.py", @@ -1248,10 +1253,37 @@ "file": "tests/test_wire_verify.py", "standard": "help_text", "reason": "Test fixture _BRIDGE_CMD contains 'python3' as part of a mock provider command string — not user-facing help text." + }, + { + "file": "apps/modules/context_window.py", + "standard": "json_structure", + "reason": "Reads external state (session transcript JSONL via tail, branch .claude/settings.local.json) with stdlib json — not branch data storage needing json_handler. The module IS the transcript/window reader service (DPLAN-0253), same pattern as cc_sessions.py." + }, + { + "file": "apps/handlers/lifecycle/pre_compact_prep.py", + "standard": "json_structure", + "reason": "Uses stdlib json for .trinity/local.json read+stamp (own branch's memory file, DPLAN-0253) plus AIPASS_REGISTRY.json/inbox.json/memory.config.json reads (external system + other branches' state) — not this branch's own hooks_json/ triplet storage. Matches compact.py precedent (same local.json access)." + }, + { + "file": "apps/handlers/prompt/context_gauge.py", + "standard": "json_structure", + "reason": "Delegates to context_window module for transcript/settings reads — no direct JSON file ops needing json_handler. Same pattern as session_start.py delegating to cadence." + }, + { + "file": "apps/handlers/lifecycle/pre_compact_prep.py", + "standard": "handlers", + "reason": "Line 75: lazy cross-branch import of aipass.flow.apps.handlers.plan.get_open_plans — needed for the open-plan count in the AUTO-COMPACT SNAPSHOT. No module-layer equivalent exists to switch to: flow's own modules (close_plan.py) import this same handler directly rather than re-exporting it, and list_plans.py's module is a CLI display orchestrator, not a programmatic data source. Read-only cross-branch primitive read, same authorized pattern as ai_mail's dispatch_monitor.py broker-client import. Call is wrapped in try/except and degrades to None on any failure — never corrupts the snapshot." + }, + { + "file": "apps/handlers/prompt/compass_recall.py", + "standard": "handlers", + "reason": "Lines 49, 75, 84: lazy cross-branch imports of aipass.memory.apps.modules.governance and aipass.devpulse.apps.modules.compass. Surfacing other branches' rated decisions at prompt time IS this handler's job — not orchestration bleed but the designed integration point, same authorized cross-branch primitive pattern as ai_mail's dispatch_monitor.py. Whole handler wrapped in try/except and never blocks the prompt on failure." } ], "notes": { - "removed_2026-05-19": "Stripped 4 illegitimate bypasses \u2014 hooks.py/cli, hooks.py/cli_flags, engine.py/modules, engine.py/introspection. Code fixed to meet standards instead.", - "dplan_0191_2026-05-28": "Added dead_code + unused_function bypasses for all 15 dynamically-dispatched handlers after verifying each is wired in .aipass/hooks.json AND fires in engine.jsonl. Root cause: engine._run_handler (engine.py:60-66) uses importlib.import_module + getattr on hooks.json handler strings \u2014 handlers are never statically imported. Follow-up for @seedgo: teach dead_code/unused_function about dynamic importlib dispatch patterns." + "removed_2026-05-19": "Stripped 4 illegitimate bypasses — hooks.py/cli, hooks.py/cli_flags, engine.py/modules, engine.py/introspection. Code fixed to meet standards instead.", + "dplan_0191_2026-05-28": "Added dead_code + unused_function bypasses for all 15 dynamically-dispatched handlers after verifying each is wired in .aipass/hooks.json AND fires in engine.jsonl. Root cause: engine._run_handler (engine.py:60-66) uses importlib.import_module + getattr on hooks.json handler strings — handlers are never statically imported. Follow-up for @seedgo: teach dead_code/unused_function about dynamic importlib dispatch patterns.", + "dplan_0253_2026-07-20": "Added json_structure bypasses for context_window.py, pre_compact_prep.py, context_gauge.py — same external-state-read profile as existing compact.py/cc_sessions.py/session_start.py bypasses.", + "dplan_0253_audit_catch_2026-07-21": "Seedgo's handlers checker went AST-based and caught two lazy cross-branch imports missed by the old text-matching version: pre_compact_prep.py (flow.get_open_plans) and compass_recall.py (memory.governance, devpulse.compass). Both bypassed rather than restructured — no clean module-layer alternative exists for the former, and the latter's whole purpose is surfacing cross-branch data at prompt time. Same authorized-primitive precedent as ai_mail dispatch_monitor.py." } } diff --git a/src/aipass/hooks/README.md b/src/aipass/hooks/README.md index ff7fe010..30d7c939 100644 --- a/src/aipass/hooks/README.md +++ b/src/aipass/hooks/README.md @@ -66,6 +66,7 @@ src/aipass/hooks/ │ ├── sound.py # Shared sound utilities (speak, play, mute) │ ├── modules/ │ │ ├── cadence.py # Prompt injection cadence (every-Nth-turn gating) +│ │ ├── context_window.py # Transcript usage reader + per-branch compact-window resolver │ │ ├── hook_test.py # Portable test runner (drone @hooks test) │ │ ├── cc_sessions.py # CC-native session file reader (~/.claude/sessions/.json) │ │ ├── engine.py # Core dispatch — routes events to handlers @@ -86,6 +87,8 @@ src/aipass/hooks/ │ │ │ ├── navmap.py # Injects tier1 navmap prompt (periodic) │ │ │ ├── identity.py # Injects passport identity block │ │ │ ├── feedback_pulse.py # Periodic feedback ask (~10 turns, toggleable) +│ │ │ ├── context_gauge.py # Nudges /prep before auto-compact fires (80%/95% of window) +│ │ │ ├── temporal.py # Injects weekday/date/time/tz/part-of-day, every turn │ │ │ └── persistent_alert.py # Injects advisory banners from .aipass/alerts.json │ │ ├── security/ # Enforcement hooks │ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics) @@ -98,6 +101,7 @@ src/aipass/hooks/ │ │ │ ├── auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile) │ │ │ ├── auto_watchdog.py # Watchdog arming after dispatch │ │ │ ├── compact.py # Pre-compact memory archival +│ │ │ ├── pre_compact_prep.py # Mechanical AUTO-COMPACT SNAPSHOT stamp (fill %, git, locks, plans) │ │ │ ├── rollover.py # Pre-compact memory rollover │ │ │ └── session_start.py # Cadence reset on new chat / clear (SessionStart) │ │ └── notification/ # Sound/alert hooks @@ -111,7 +115,7 @@ src/aipass/hooks/ │ └── diagnostics.py # JSONL logging for hook execution ├── logs/ │ └── engine.jsonl # JSONL diagnostics (every hook execution) -└── tests/ # 1071 tests across 29 test files +└── tests/ # 1249 tests across 43 test files ``` ## How It Works @@ -132,13 +136,13 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im | Event | Hooks | Description | |---|---|---| -| UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, 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 | | PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog | | SubagentStop | subagent_gate | Seedgo validation | | Stop | stop_sound, telegram_response, presence_release | Bell + Telegram delivery + presence release | | Notification | announce | Announcement tone | -| PreCompact | compact, rollover | Memory archival + rollover | +| PreCompact | compact, rollover, pre_compact_prep | Memory archival + rollover + mechanical snapshot stamp | ## Git Gate diff --git a/src/aipass/hooks/apps/handlers/config/diagnostics.py b/src/aipass/hooks/apps/handlers/config/diagnostics.py index 640241d4..201e898a 100644 --- a/src/aipass/hooks/apps/handlers/config/diagnostics.py +++ b/src/aipass/hooks/apps/handlers/config/diagnostics.py @@ -10,19 +10,38 @@ """JSONL diagnostic logging — appends structured entries for hook activity.""" +import sys +import tempfile from pathlib import Path from aipass.prax import append_jsonl from aipass.prax.apps.modules.logger import system_logger as logger BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent.parent -LOG_FILE = BRANCH_ROOT / "logs" / "engine.jsonl" +_PROD_LOG_FILE = BRANCH_ROOT / "logs" / "engine.jsonl" + + +def _is_pytest_session() -> bool: + """Detect pytest via sys.modules (immune to patch.dict(os.environ, clear=True)).""" + return "_pytest" in sys.modules + + +def _get_log_file() -> Path: + """Resolve log path — temp dir during pytest, prod path otherwise.""" + if _is_pytest_session(): + p = Path(tempfile.gettempdir()) / "aipass_test_logs" / "hooks" + p.mkdir(parents=True, exist_ok=True) + return p / "engine.jsonl" + return _PROD_LOG_FILE + + +LOG_FILE = _PROD_LOG_FILE def log_entry(entry: dict) -> None: """Append a JSONL log entry for detailed diagnostics.""" try: - append_jsonl(LOG_FILE, entry) + append_jsonl(_get_log_file(), entry) except OSError as exc: logger.error("[HOOKS] log write failed: %s", exc) diff --git a/src/aipass/hooks/apps/handlers/config/loader.py b/src/aipass/hooks/apps/handlers/config/loader.py index 19118990..62152f83 100644 --- a/src/aipass/hooks/apps/handlers/config/loader.py +++ b/src/aipass/hooks/apps/handlers/config/loader.py @@ -56,3 +56,37 @@ def find_project_config() -> dict | None: return None search = search.parent return None + + +def trust_break_banner() -> str | None: + """Loud, config-independent check for a stale trust enrollment. + + find_project_config() goes silent on a hash mismatch (logs one WARNING, + returns None) — the fallback config downstream then has no event_type + keys at all, so every hook including this one's own dispatch path goes + dark. This check does its own walk-up and hash comparison, never touches + hooks.json content, and does not depend on the project being trusted — + so it still works precisely when trust is broken. Returns None when + nothing is broken, or when the project was simply never enrolled (normal + first-run state, not a break). + """ + from aipass.hooks.apps.handlers.config.trust_registry import is_hash_mismatch + + search = Path.cwd() + home = Path.home() + while search != home and search.parent != search: + config_file = search / ".aipass" / "hooks.json" + if config_file.exists(): + if is_hash_mismatch(str(search)): + return ( + "# TRUST BREAK — ALL AIPASS HOOKS DISABLED\n\n" + f"{search}/.aipass/hooks.json no longer matches its enrolled hash. " + "Every hook for this project (including this warning's own delivery " + "path) is silently skipped until a human re-enrolls.\n\n" + "Fix: drone @hooks trust enroll (or: aipass init update)\n" + "This does not auto-heal — re-enrollment is a deliberate human " + "checkpoint, not a bug." + ) + return None + search = search.parent + return None diff --git a/src/aipass/hooks/apps/handlers/config/trust_registry.py b/src/aipass/hooks/apps/handlers/config/trust_registry.py index 6258d086..4e919c83 100644 --- a/src/aipass/hooks/apps/handlers/config/trust_registry.py +++ b/src/aipass/hooks/apps/handlers/config/trust_registry.py @@ -100,6 +100,25 @@ def is_trusted(project_dir: str) -> bool: return current_hash == entry.get("config_hash", "") +def is_hash_mismatch(project_dir: str) -> bool: + """True only when a project WAS enrolled but its hooks.json hash has since changed. + + Distinct from is_trusted()==False for a never-enrolled project (normal + first-run state, not a break). This flags a genuine trust break — an + existing enrollment gone stale — so callers can warn loudly instead of + treating it the same as "not set up yet". + """ + project_path = str(Path(project_dir).resolve()) + registry = read_registry() + entry = registry["projects"].get(project_path) + if entry is None: + return False + config_path = Path(project_dir).resolve() / ".aipass" / "hooks.json" + if not config_path.exists(): + return False + return _hash_file(config_path) != entry.get("config_hash", "") + + def bootstrap() -> bool: """Bootstrap the registry with ONLY the AIPass install. Returns True on success. diff --git a/src/aipass/hooks/apps/handlers/lifecycle/auto_process.py b/src/aipass/hooks/apps/handlers/lifecycle/auto_process.py index b8ca8da2..6d901631 100644 --- a/src/aipass/hooks/apps/handlers/lifecycle/auto_process.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/auto_process.py @@ -67,7 +67,7 @@ def handle(hook_data: dict) -> dict: logger.info("[HOOKS] auto_process: no-op (nothing to process)") _mark_session_ran() - return {"stdout": "", "exit_code": 0} + return {"stdout": "", "exit_code": 0, "sound": "auto process"} except Exception as exc: logger.error("[HOOKS] auto_process: error: %s", exc) diff --git a/src/aipass/hooks/apps/handlers/lifecycle/pre_compact_prep.py b/src/aipass/hooks/apps/handlers/lifecycle/pre_compact_prep.py new file mode 100644 index 00000000..ba238ba3 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/lifecycle/pre_compact_prep.py @@ -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} diff --git a/src/aipass/hooks/apps/handlers/lifecycle/session_start.py b/src/aipass/hooks/apps/handlers/lifecycle/session_start.py index d8bb21a3..dd2398c1 100644 --- a/src/aipass/hooks/apps/handlers/lifecycle/session_start.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/session_start.py @@ -37,5 +37,6 @@ def handle(hook_data: dict) -> dict: logger.info("[HOOKS] session_start: cadence reset (source=%s)", source) except Exception as exc: logger.info("[HOOKS] session_start: cadence reset failed: %s", exc) + return {"stdout": "", "exit_code": 0} - return {"stdout": "", "exit_code": 0} + return {"stdout": "", "exit_code": 0, "sound": "cadence reset"} diff --git a/src/aipass/hooks/apps/handlers/notification/telegram_response.py b/src/aipass/hooks/apps/handlers/notification/telegram_response.py index c52b40d6..8ae50ac0 100644 --- a/src/aipass/hooks/apps/handlers/notification/telegram_response.py +++ b/src/aipass/hooks/apps/handlers/notification/telegram_response.py @@ -23,6 +23,8 @@ import os import re import subprocess +import sys +import tempfile import time from pathlib import Path from urllib.error import HTTPError, URLError @@ -35,7 +37,19 @@ MIRROR_DIR = Path.home() / ".aipass" / "telegram_bots" PENDING_TTL = 3600 TELEGRAM_CHAR_LIMIT = 4096 -_DELIVERY_LOG = Path(__file__).resolve().parent.parent.parent.parent / "logs" / "telegram_delivery.jsonl" +_PROD_DELIVERY_LOG = Path(__file__).resolve().parent.parent.parent.parent / "logs" / "telegram_delivery.jsonl" + + +def _get_delivery_log() -> Path: + """Resolve delivery log path — temp dir during pytest, prod path otherwise.""" + if "_pytest" in sys.modules: + p = Path(tempfile.gettempdir()) / "aipass_test_logs" / "hooks" + p.mkdir(parents=True, exist_ok=True) + return p / "telegram_delivery.jsonl" + return _PROD_DELIVERY_LOG + + +_DELIVERY_LOG = _PROD_DELIVERY_LOG def _is_expired(data: dict) -> bool: @@ -585,7 +599,7 @@ def handle(hook_data: dict) -> dict: else: logger.error("[HOOKS] telegram: delivery failed — keeping pending for retry") - return {"stdout": "", "exit_code": 0} + return {"stdout": "", "exit_code": 0, "sound": "telegram response"} def _extract_response(hook_data: dict, transcript_path: str, pending_data: dict) -> str | None: @@ -771,6 +785,6 @@ def _write_delivery_log(intended_text: str, chunks: list[str], chunk_results: li record["culprit"] = culprit try: - append_jsonl(_DELIVERY_LOG, record) + append_jsonl(_get_delivery_log(), record) except OSError as e: logger.warning("[HOOKS] telegram: delivery log write failed: %s", e) diff --git a/src/aipass/hooks/apps/handlers/prompt/compass_recall.py b/src/aipass/hooks/apps/handlers/prompt/compass_recall.py index 2fc17eb6..84b23aa5 100644 --- a/src/aipass/hooks/apps/handlers/prompt/compass_recall.py +++ b/src/aipass/hooks/apps/handlers/prompt/compass_recall.py @@ -110,7 +110,7 @@ def handle(hook_data: dict) -> dict: mark_surfaced([c["id"] for c in approved]) json_handler.log_operation("compass_recall", {"count": len(approved)}) - return {"stdout": "\n".join(lines), "exit_code": 0} + return {"stdout": "\n".join(lines), "exit_code": 0, "sound": "compass recall"} except Exception as exc: logger.info("[HOOKS] compass_recall_unreachable: %s", exc) diff --git a/src/aipass/hooks/apps/handlers/prompt/context_gauge.py b/src/aipass/hooks/apps/handlers/prompt/context_gauge.py new file mode 100644 index 00000000..6ace33ca --- /dev/null +++ b/src/aipass/hooks/apps/handlers/prompt/context_gauge.py @@ -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} diff --git a/src/aipass/hooks/apps/handlers/prompt/feedback_pulse.py b/src/aipass/hooks/apps/handlers/prompt/feedback_pulse.py index 170f81fa..031123f1 100644 --- a/src/aipass/hooks/apps/handlers/prompt/feedback_pulse.py +++ b/src/aipass/hooks/apps/handlers/prompt/feedback_pulse.py @@ -87,6 +87,7 @@ def handle(hook_data: dict) -> dict: cwd = hook_data.get("cwd", "") if _is_disabled(cwd or None): + logger.info("[HOOKS] feedback_pulse: skipped (disabled via .aipass/feedback_off sentinel)") return {"stdout": "", "exit_code": 0} json_handler.log_operation("feedback_pulse", {"turn": turn}) diff --git a/src/aipass/hooks/apps/handlers/prompt/persistent_alert.py b/src/aipass/hooks/apps/handlers/prompt/persistent_alert.py index 98f2f81c..f5a4559c 100644 --- a/src/aipass/hooks/apps/handlers/prompt/persistent_alert.py +++ b/src/aipass/hooks/apps/handlers/prompt/persistent_alert.py @@ -11,13 +11,16 @@ """Injects advisory banners for active alerts from .aipass/alerts.json.""" import json +import os +import tempfile from datetime import datetime, timezone from pathlib import Path from aipass.hooks.apps.handlers.json import json_handler from aipass.prax.apps.modules.logger import system_logger as logger -_announced: set[str] = set() +_GUARD_DIR = Path(tempfile.gettempdir()) +_MAX_ALERTS_SHOWN = 10 def _find_aipass_dir() -> Path | None: @@ -77,10 +80,33 @@ def _load_and_clean(alerts_path: Path) -> list[dict]: return active +def _guard_path(session_id: str, alert_id: str) -> Path | None: + if not session_id: + return None + return _GUARD_DIR / f"aipass-persistent-alert-{session_id}-{alert_id}" + + +def _already_announced(session_id: str, alert_id: str) -> bool: + path = _guard_path(session_id, alert_id) + return path is not None and path.exists() + + +def _mark_announced(session_id: str, alert_id: str) -> None: + path = _guard_path(session_id, alert_id) + if path is not None: + try: + path.touch() + except OSError as exc: + logger.info("[HOOKS] persistent_alert: guard write failed: %s", exc) + + def _format_banner(alerts: list[dict]) -> str: - """Format alert banners for prompt injection.""" + """Format alert banners for prompt injection, capped at _MAX_ALERTS_SHOWN.""" + shown = alerts[:_MAX_ALERTS_SHOWN] + hidden = len(alerts) - len(shown) + lines = [] - for alert in alerts: + for alert in shown: severity = alert.get("severity", "warning").upper() title = alert.get("title", "Untitled alert") body = alert.get("body", "") @@ -89,6 +115,8 @@ def _format_banner(alerts: list[dict]) -> str: lines.append(f"[{severity}] {title} (from @{source}, id: {alert_id})") if body: lines.append(f" {body}") + if hidden > 0: + lines.append(f"...and {hidden} more (dismiss some to see the rest)") header = "# Active Alerts" dismiss_hint = "Dismiss with: drone @hooks dismiss " return "\n".join([header, ""] + lines + ["", dismiss_hint]) @@ -117,10 +145,12 @@ def handle(hook_data: dict) -> dict: banner = _format_banner(alerts) - new_ids = [a["id"] for a in alerts if a.get("id") and a["id"] not in _announced] + session_id = hook_data.get("session_id", "") or os.environ.get("CLAUDE_CODE_SESSION_ID", "") + new_ids = [a["id"] for a in alerts if a.get("id") and not _already_announced(session_id, a["id"])] sound = "" if new_ids: - _announced.update(new_ids) + for alert_id in new_ids: + _mark_announced(session_id, alert_id) count = len(alerts) plural = "s" if count != 1 else "" sound = f"alert: {count} active alert{plural}" diff --git a/src/aipass/hooks/apps/handlers/prompt/temporal.py b/src/aipass/hooks/apps/handlers/prompt/temporal.py new file mode 100644 index 00000000..d1af2328 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/prompt/temporal.py @@ -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} diff --git a/src/aipass/hooks/apps/handlers/security/presence_gate.py b/src/aipass/hooks/apps/handlers/security/presence_gate.py index d077ed62..3dfb116e 100644 --- a/src/aipass/hooks/apps/handlers/security/presence_gate.py +++ b/src/aipass/hooks/apps/handlers/security/presence_gate.py @@ -1,11 +1,11 @@ # =================== AIPass ==================== # Name: presence_gate.py -# Version: 3.0.0 +# Version: 3.0.1 # Description: Single-session gate — blocks duplicate Claude runtimes per branch # Branch: hooks # Layer: apps/handlers/security # Created: 2026-06-29 -# Modified: 2026-07-13 +# Modified: 2026-07-21 # ============================================= """Single-session gate — blocks duplicate Claude runtimes per branch. @@ -27,6 +27,12 @@ Ships in OBSERVE-ONLY mode: logs would-block decisions to engine.jsonl but never actually blocks. Flip _OBSERVE_ONLY to False after soak period confirms zero false positives. + +Occupant PID claims are identity-checked, not just liveness-checked: +cc_sessions.find_live_for_cwd() cross-verifies each session's recorded +procStart against the live process's actual start time before treating +a PID as a genuine occupant, so a PID recycled after the original +session died can never satisfy a stale claim. """ import importlib @@ -131,7 +137,7 @@ def handle(hook_data: dict) -> dict: if _OBSERVE_ONLY: logger.warning("[presence_gate] OBSERVE-ONLY would-block: %s", reason) - return _ALLOW + return {**_ALLOW, "sound": "presence gate"} logger.warning("[presence_gate] BLOCKED: %s", reason) return { diff --git a/src/aipass/hooks/apps/modules/cc_sessions.py b/src/aipass/hooks/apps/modules/cc_sessions.py index 77be13c3..47fcc144 100644 --- a/src/aipass/hooks/apps/modules/cc_sessions.py +++ b/src/aipass/hooks/apps/modules/cc_sessions.py @@ -1,11 +1,11 @@ # =================== AIPass ==================== # Name: cc_sessions.py -# Version: 3.0.0 +# Version: 3.1.0 # Description: CC-native session discovery, listing, and reclaim # Branch: hooks # Layer: apps/modules # Created: 2026-06-30 -# Modified: 2026-07-14 +# Modified: 2026-07-21 # ============================================= """Read Claude Code native session files (~/.claude/sessions/.json). @@ -68,6 +68,50 @@ def _pid_alive_windows(pid: int) -> bool: kernel32.CloseHandle(handle) +def _proc_start_ticks(pid: int) -> str | None: + """Read a live process's start time (field 22 of /proc//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: """Check if a process with the given PID exists.""" 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. 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()) live = [] @@ -157,14 +203,21 @@ def find_live_for_cwd(cwd: str) -> list[dict]: if str(Path(session_cwd).resolve()) != target: continue pid = session.get("pid") - if pid and _is_pid_alive(pid): - live.append(session) - else: + if not pid or not _is_pid_alive(pid): logger.info( "[CC_SESSIONS] Stale session file for PID %s at %s", pid, 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 diff --git a/src/aipass/hooks/apps/modules/context_window.py b/src/aipass/hooks/apps/modules/context_window.py new file mode 100644 index 00000000..b2256c84 --- /dev/null +++ b/src/aipass/hooks/apps/modules/context_window.py @@ -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/) 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 diff --git a/src/aipass/hooks/apps/modules/engine.py b/src/aipass/hooks/apps/modules/engine.py index b2583f40..379cd634 100644 --- a/src/aipass/hooks/apps/modules/engine.py +++ b/src/aipass/hooks/apps/modules/engine.py @@ -15,6 +15,7 @@ import os import subprocess import tempfile +import threading import time 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)} -def _run_handler(handler_path: str, hook_data: dict) -> dict: - """Call a handler function directly (no subprocess). Module imports handler.""" +def _run_handler(handler_path: str, hook_data: dict, timeout_s: int = 30) -> dict: + """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() try: 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) 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 return { "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 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: _log({"ts": time.time(), "event": event_type, "action": "no_hooks_configured"}) return "", 0 @@ -247,12 +291,37 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]: ) continue + hook_timeout = hook_def.get("timeout", 30) if handler: - result = _run_handler(handler, parsed) + result = _run_handler(handler, parsed, timeout_s=hook_timeout) else: - hook_timeout = hook_def.get("timeout", 30) 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( "[HOOKS] %s.%s agent=%s exit=%d out=%db %dms", event_type, diff --git a/src/aipass/hooks/tests/test_auto_process.py b/src/aipass/hooks/tests/test_auto_process.py index ddb0d8b1..09a43999 100644 --- a/src/aipass/hooks/tests/test_auto_process.py +++ b/src/aipass/hooks/tests/test_auto_process.py @@ -43,6 +43,7 @@ def test_success_returns_exit_code_0(self): assert result["exit_code"] == 0 assert result["stdout"] == "" + assert result["sound"] == "auto process" def test_calls_memory_auto_process_module(self): from aipass.hooks.apps.handlers.lifecycle.auto_process import handle @@ -168,6 +169,7 @@ def test_hook_data_dict_accepted(self): result = handle({"tool_name": "Bash", "cwd": "/tmp"}) assert result["exit_code"] == 0 + assert result["sound"] == "auto process" class TestSessionGuard: diff --git a/src/aipass/hooks/tests/test_cc_sessions.py b/src/aipass/hooks/tests/test_cc_sessions.py index 0d1c666c..a71bff4e 100644 --- a/src/aipass/hooks/tests/test_cc_sessions.py +++ b/src/aipass/hooks/tests/test_cc_sessions.py @@ -2,8 +2,11 @@ import json import os +import sys from unittest.mock import patch +import pytest + from aipass.hooks.apps.modules import cc_sessions @@ -29,6 +32,51 @@ def test_oserror_treated_as_dead(self): 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: def test_reads_pid_files(self, tmp_path): session = {"pid": 1234, "sessionId": "abc", "cwd": "/tmp/branch", "kind": "interactive"} @@ -103,6 +151,47 @@ def test_empty_cwd_skipped(self, tmp_path): result = cc_sessions.find_live_for_cwd("/tmp/hooks") 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: def test_no_occupant_when_free(self, tmp_path): @@ -135,6 +224,17 @@ def test_no_exclude_returns_any_live(self, tmp_path): result = cc_sessions.find_occupant("/tmp/hooks") 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: def test_reclaim_stops_live_sessions(self, tmp_path): diff --git a/src/aipass/hooks/tests/test_compass_recall.py b/src/aipass/hooks/tests/test_compass_recall.py index d9b4b35d..92f4a9c5 100644 --- a/src/aipass/hooks/tests/test_compass_recall.py +++ b/src/aipass/hooks/tests/test_compass_recall.py @@ -99,6 +99,7 @@ def test_surfaces_relevant_decision(self, tmp_path): assert result["exit_code"] == 0 assert "[GOOD] #56:" in result["stdout"] assert "Never hardcode config in prompts" in result["stdout"] + assert result["sound"] == "compass recall" mock_mark.assert_called_once_with([56]) def test_formats_bad_rating(self, tmp_path): diff --git a/src/aipass/hooks/tests/test_context_gauge.py b/src/aipass/hooks/tests/test_context_gauge.py new file mode 100644 index 00000000..6424673e --- /dev/null +++ b/src/aipass/hooks/tests/test_context_gauge.py @@ -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"]) diff --git a/src/aipass/hooks/tests/test_context_window.py b/src/aipass/hooks/tests/test_context_window.py new file mode 100644 index 00000000..458c2db0 --- /dev/null +++ b/src/aipass/hooks/tests/test_context_window.py @@ -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"]) diff --git a/src/aipass/hooks/tests/test_engine.py b/src/aipass/hooks/tests/test_engine.py index 0b920eeb..1d0c5a7f 100644 --- a/src/aipass/hooks/tests/test_engine.py +++ b/src/aipass/hooks/tests/test_engine.py @@ -272,6 +272,54 @@ def test_malformed_stdin_does_not_crash(self, mock_logger): assert "ok" in result[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: """Tests for find_project_config() CWD walk.""" @@ -320,7 +368,7 @@ class TestLog: def test_writes_jsonl_entry(self, temp_test_dir, mock_logger): 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"}) lines = log_file.read_text().strip().split("\n") assert len(lines) == 1 @@ -330,13 +378,13 @@ def test_writes_jsonl_entry(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" - 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"}) assert log_file.exists() def test_appends_multiple_entries(self, temp_test_dir, mock_logger): 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": "B"}) lines = log_file.read_text().strip().split("\n") @@ -435,10 +483,12 @@ def test_empty_hooks_json(self, temp_test_dir, mock_logger): config = find_project_config() 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("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE") as mock_path: - mock_path.parent.mkdir = MagicMock() + with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file): _log({"event": "Test"}) @@ -456,7 +506,7 @@ def test_config_event_values_are_dicts(self, sample_hooks_config): def test_log_entry_has_required_fields(self, temp_test_dir, mock_logger): 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"}) entry = json.loads(log_file.read_text().strip()) assert "ts" in entry @@ -505,7 +555,7 @@ class TestInitProvisioning: def test_log_auto_creates_directory(self, temp_test_dir, mock_logger): 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"}) assert log_file.parent.exists() @@ -529,7 +579,7 @@ def test_config_walks_up_from_subdirectory(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.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"}) lines = log_file.read_text().strip().split("\n") assert len(lines) == 2 @@ -664,7 +714,7 @@ def test_hook_def_has_command_key(self, sample_hooks_config): def test_data_keys_in_log_entry(self, temp_test_dir, mock_logger): 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}) entry = json.loads(log_file.read_text().strip()) assert "ts" in entry @@ -708,7 +758,7 @@ def test_config_with_nonexistent_dir(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" - 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"}) assert missing.exists() @@ -887,19 +937,212 @@ def test_command_without_source_defaults_allowed(self, mock_logger): 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: """Hooks uses JSONL logging, not json_handler. These verify the log equivalent.""" def test_log_default_factory(self, temp_test_dir, mock_logger): log_file = temp_test_dir / "factory.jsonl" 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"}) assert log_file.exists() def test_log_validate_json_output(self, temp_test_dir, mock_logger): 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": "B", "ts": 2.0}) for line in log_file.read_text().strip().split("\n"): @@ -914,13 +1157,13 @@ def test_log_get_path(self): def test_log_ensure_exists(self, temp_test_dir, mock_logger): 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}) assert log_file.parent.exists() def test_log_save_entry(self, temp_test_dir, mock_logger): 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}) entry = json.loads(log_file.read_text().strip()) assert entry["saved"] is True @@ -935,7 +1178,7 @@ def test_log_load_entry(self, temp_test_dir, mock_logger): def test_log_operation_recorded(self, temp_test_dir, mock_logger): 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}) entry = json.loads(log_file.read_text().strip()) assert entry["event"] == "PreToolUse" diff --git a/src/aipass/hooks/tests/test_feedback_pulse.py b/src/aipass/hooks/tests/test_feedback_pulse.py index 9de105cb..cfc2b4a7 100644 --- a/src/aipass/hooks/tests/test_feedback_pulse.py +++ b/src/aipass/hooks/tests/test_feedback_pulse.py @@ -107,6 +107,25 @@ def test_disabled_returns_empty(self, tmp_path): 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): result = {"stdout": "", "exit_code": 0} with ( diff --git a/src/aipass/hooks/tests/test_persistent_alert.py b/src/aipass/hooks/tests/test_persistent_alert.py index 782846f7..42db51dc 100644 --- a/src/aipass/hooks/tests/test_persistent_alert.py +++ b/src/aipass/hooks/tests/test_persistent_alert.py @@ -257,19 +257,20 @@ def test_corrupt_json_returns_empty(self, tmp_path): 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): 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-001")]) - with patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir): - result = persistent_alert.handle({}) + 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-snd-001"}) assert "sound" in result assert "1 active alert" in result["sound"] @@ -277,29 +278,59 @@ def test_sound_on_first_injection(self, tmp_path): def test_no_sound_on_repeat_injection(self, tmp_path): 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-002")]) - with patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir): - persistent_alert.handle({}) - result = persistent_alert.handle({}) + 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-snd-002"}) + result = persistent_alert.handle({"session_id": "s-snd-002"}) 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 - 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.mkdir() _write_alerts(aipass_dir, [_make_alert(alert_id="snd-003")]) - with patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir): - persistent_alert.handle({}) + 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-snd-003"}) _write_alerts( aipass_dir, @@ -309,27 +340,86 @@ def test_sound_on_new_alert_added(self, tmp_path): ], ) - with patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir): - result = persistent_alert.handle({}) + 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-snd-003"}) assert "sound" in result 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 - 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.mkdir() _write_alerts(aipass_dir, []) - with patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir): - result = persistent_alert.handle({}) + 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-none"}) 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: """drone @hooks dismiss behavior.""" diff --git a/src/aipass/hooks/tests/test_pre_compact_prep.py b/src/aipass/hooks/tests/test_pre_compact_prep.py new file mode 100644 index 00000000..67652903 --- /dev/null +++ b/src/aipass/hooks/tests/test_pre_compact_prep.py @@ -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"]) diff --git a/src/aipass/hooks/tests/test_presence_gate.py b/src/aipass/hooks/tests/test_presence_gate.py index 86d18d5a..a4b670e1 100644 --- a/src/aipass/hooks/tests/test_presence_gate.py +++ b/src/aipass/hooks/tests/test_presence_gate.py @@ -170,6 +170,7 @@ def test_observe_only_logs_but_allows(self): ): result = presence_gate.handle({}) assert result["exit_code"] == 0 + assert result["sound"] == "presence gate" def test_gate_error_allows(self): with patch.dict(os.environ, {"AIPASS_SESSION_TYPE": "interactive"}, clear=True): diff --git a/src/aipass/hooks/tests/test_session_start.py b/src/aipass/hooks/tests/test_session_start.py index 67d4b485..e4191c02 100644 --- a/src/aipass/hooks/tests/test_session_start.py +++ b/src/aipass/hooks/tests/test_session_start.py @@ -49,6 +49,7 @@ def test_startup_resets_cadence(self, tmp_path): result = handle({"source": "startup", "session_id": "test-session"}) assert result["exit_code"] == 0 + assert result["sound"] == "cadence reset" data = json.loads(state_file.read_text()) assert data["turn"] == -1 diff --git a/src/aipass/hooks/tests/test_telegram_response.py b/src/aipass/hooks/tests/test_telegram_response.py index e67e3ac0..37b35973 100644 --- a/src/aipass/hooks/tests/test_telegram_response.py +++ b/src/aipass/hooks/tests/test_telegram_response.py @@ -857,7 +857,7 @@ def test_happy_path_send_and_advance(self, tmp_path): } ) - assert result == {"stdout": "", "exit_code": 0} + assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"} assert pending_file.exists() updated = json.loads(pending_file.read_text(encoding="utf-8")) assert updated["delivered"] is True @@ -902,7 +902,7 @@ def test_send_fails_pending_kept(self, tmp_path): } ) - assert result == {"stdout": "", "exit_code": 0} + assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"} assert pending_file.exists() updated = json.loads(pending_file.read_text(encoding="utf-8")) assert "delivered" not in updated @@ -996,7 +996,7 @@ def mock_extract(tp: str, start_line: int = 0) -> str | None: } ) - assert result == {"stdout": "", "exit_code": 0} + assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"} assert extract_call_count >= 2 assert pending_file.exists() updated = json.loads(pending_file.read_text(encoding="utf-8")) @@ -1035,7 +1035,7 @@ def test_fallback_to_last_assistant_message(self, tmp_path): } ) - assert result == {"stdout": "", "exit_code": 0} + assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"} assert not pending_file.exists() def test_already_delivered_skips_fallback(self, tmp_path): @@ -1532,7 +1532,7 @@ def test_writes_jsonl_record(self, tmp_path): 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( "hello", ["hello"], @@ -1552,7 +1552,7 @@ def test_mismatch_reports_culprit(self, tmp_path): 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( "**bold text**", ["**bold text**"], @@ -1569,7 +1569,7 @@ def test_failed_chunk_culprit(self, tmp_path): 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( "hello", ["hello"], @@ -1585,7 +1585,7 @@ def test_log_write_failure_does_not_raise(self): from aipass.hooks.apps.handlers.notification.telegram_response import _write_delivery_log 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") @@ -1989,7 +1989,7 @@ def capture_send(bot_token, chat_id, text): } ) - assert result == {"stdout": "", "exit_code": 0} + assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"} assert len(sent_texts) == 1 assert "You: What is this?" in sent_texts[0] assert "This is the answer." in sent_texts[0] diff --git a/src/aipass/hooks/tests/test_temporal.py b/src/aipass/hooks/tests/test_temporal.py new file mode 100644 index 00000000..4b588e80 --- /dev/null +++ b/src/aipass/hooks/tests/test_temporal.py @@ -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"]) diff --git a/src/aipass/hooks/tests/test_trust_registry.py b/src/aipass/hooks/tests/test_trust_registry.py index 5487fd0d..ba46bb76 100644 --- a/src/aipass/hooks/tests/test_trust_registry.py +++ b/src/aipass/hooks/tests/test_trust_registry.py @@ -17,11 +17,12 @@ _hash_file, bootstrap, enroll, + is_hash_mismatch, is_trusted, read_registry, 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: @@ -151,6 +152,110 @@ def test_not_trusted_hash_mismatch(self, temp_test_dir, mock_logger): 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: """Tests for bootstrap() — enrolls ONLY AIPASS_HOME.""" diff --git a/src/aipass/memory/.seedgo/bypass.json b/src/aipass/memory/.seedgo/bypass.json index b01b259b..f4576278 100644 --- a/src/aipass/memory/.seedgo/bypass.json +++ b/src/aipass/memory/.seedgo/bypass.json @@ -759,31 +759,6 @@ "standard": "meta", "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", "standard": "architecture", diff --git a/src/aipass/prax/.seedgo/bypass.json b/src/aipass/prax/.seedgo/bypass.json index fa5fd64e..30d57725 100644 --- a/src/aipass/prax/.seedgo/bypass.json +++ b/src/aipass/prax/.seedgo/bypass.json @@ -3,7 +3,7 @@ "version": "2.0.0", "created": "2026-03-07T22:43:24.315842", "description": "Standards bypass configuration for prax branch", - "last_updated": "2026-04-26T00:06:19.911705" + "last_updated": "2026-07-21T00:00:00.000000" }, "bypass": [ { @@ -511,6 +511,12 @@ "file": "tests/test_logging.py", "standard": "log_structure", "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": { @@ -533,4 +539,4 @@ "trigger_integration": "Several handlers optionally import trigger.modules.core for event firing. All have graceful ImportError fallbacks. This is cross-branch integration, not an architectural violation." } } -} \ No newline at end of file +} diff --git a/src/aipass/prax/apps/handlers/config/load.py b/src/aipass/prax/apps/handlers/config/load.py index 18fce093..b94509fb 100755 --- a/src/aipass/prax/apps/handlers/config/load.py +++ b/src/aipass/prax/apps/handlers/config/load.py @@ -31,6 +31,7 @@ import json import logging import os +import sys import tempfile from pathlib import Path from typing import Dict, Any, Optional @@ -60,6 +61,11 @@ def _find_repo_root() -> Path: 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. # Callers should use get_system_logs_dir() for guaranteed initialization. _system_logs_dir_cache: Path | None = None @@ -76,7 +82,7 @@ def get_system_logs_dir() -> Path: p = Path(test_log_dir) / "system" p.mkdir(parents=True, exist_ok=True) 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.mkdir(parents=True, exist_ok=True) 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.mkdir(parents=True, exist_ok=True) 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.mkdir(parents=True, exist_ok=True) return p diff --git a/src/aipass/prax/apps/handlers/monitoring/commons_feed.py b/src/aipass/prax/apps/handlers/monitoring/commons_feed.py new file mode 100644 index 00000000..d46c5401 --- /dev/null +++ b/src/aipass/prax/apps/handlers/monitoring/commons_feed.py @@ -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 [,...] - 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) diff --git a/src/aipass/prax/apps/modules/monitor.py b/src/aipass/prax/apps/modules/monitor.py index d30a5b9e..a823a055 100755 --- a/src/aipass/prax/apps/modules/monitor.py +++ b/src/aipass/prax/apps/modules/monitor.py @@ -108,6 +108,14 @@ def print_help(): "drone @prax monitor run [branches]", "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", "Enable Telegram relay (mirrors feed to prax_monitor bot)" @@ -153,7 +161,7 @@ def handle_command(command: str, args: List[str]) -> bool: # Subcommand routing subcmd = args[0] if subcmd == "run": - return _run_monitor(args[1:]) + return _dispatch_run(args[1:]) # Unknown subcommand error(f"Unknown monitor subcommand: {subcmd}") @@ -161,6 +169,28 @@ def handle_command(command: str, args: List[str]) -> bool: 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]: """Load Telegram relay config from @api secrets.""" try: diff --git a/src/aipass/prax/tests/test_commons_feed.py b/src/aipass/prax/tests/test_commons_feed.py new file mode 100644 index 00000000..e4b5d3bc --- /dev/null +++ b/src/aipass/prax/tests/test_commons_feed.py @@ -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") diff --git a/src/aipass/prax/tests/test_config.py b/src/aipass/prax/tests/test_config.py index a56975c4..d19ed032 100644 --- a/src/aipass/prax/tests/test_config.py +++ b/src/aipass/prax/tests/test_config.py @@ -50,6 +50,8 @@ def _fresh_import_load(monkeypatch, tmp_path): # Clear test log redirects so tests exercise real path resolution monkeypatch.delenv("AIPASS_TEST_LOG_DIR", 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 diff --git a/src/aipass/prax/tests/test_monitor_module.py b/src/aipass/prax/tests/test_monitor_module.py index 6d8200b3..acda39ca 100644 --- a/src/aipass/prax/tests/test_monitor_module.py +++ b/src/aipass/prax/tests/test_monitor_module.py @@ -116,6 +116,78 @@ def test_unknown_subcommand_prints_error_and_help(self): 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 # --------------------------------------------------------------------------- diff --git a/src/aipass/seedgo/.seedgo/bypass.json b/src/aipass/seedgo/.seedgo/bypass.json index 1b0e515b..754a5315 100644 --- a/src/aipass/seedgo/.seedgo/bypass.json +++ b/src/aipass/seedgo/.seedgo/bypass.json @@ -195,11 +195,6 @@ "standard": "deep_nesting", "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", "standard": "deep_nesting", diff --git a/src/aipass/seedgo/README.md b/src/aipass/seedgo/README.md index d6cc947a..762d2cac 100644 --- a/src/aipass/seedgo/README.md +++ b/src/aipass/seedgo/README.md @@ -120,9 +120,9 @@ seedgo/ │ │ ├── branch_audit.py # Per-branch scoring engine │ │ ├── discovery.py # Branch discovery (CWD-first registry) │ │ └── audit_display.py # Rich result formatting -│ ├── bypass/ # Bypass system +│ ├── bypass/ # Bypass + ignore systems │ │ ├── bypass_handler.py # .seedgo/bypass.json loader -│ │ └── ignore_handler.py # .seedgo/ignore patterns +│ │ └── ignore_handler.py # Audit ignore patterns + .seedgoignore engine │ ├── config/ # Configuration handlers │ ├── diagnostics/ # Pyright integration + branch discovery │ ├── 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. +**`.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 diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_check.py index 2416e841..c3d759f6 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_check.py @@ -24,6 +24,7 @@ from aipass.prax import logger 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.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 AUDIT_SCOPE = "branch_level" @@ -42,12 +43,17 @@ # ============================================= -def _should_skip(path: Path) -> bool: - """Check whether any parent directory component is in the skip set or file is disabled.""" - return any(part in _SKIP_DIRS for part in path.parts) or is_disabled_file(path.name) +def _should_skip(path: Path, branch_root: Path, ignore_entries: list) -> bool: + """Check whether any parent directory component is in the skip set, file is + 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 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"): if py_file.name == "__init__.py": continue - if _should_skip(py_file): + if _should_skip(py_file, branch_root, ignore_entries): continue targets.append(py_file) 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. This is the corpus we search for references. """ parts: list[str] = [] for py_file in apps_dir.rglob("*.py"): - if _should_skip(py_file): + if _should_skip(py_file, branch_root, ignore_entries): continue try: 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 # 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: result = { "passed": True, @@ -287,7 +294,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: return result # 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 total_files = len(targets) diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py index 2104ad0c..bcda9dfd 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py @@ -28,6 +28,11 @@ # Audit scope: all Python 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: """Find AIPASS_REGISTRY.json by walking up from this file's location.""" diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_check.py index 9aa0c317..e11b9cd4 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_check.py @@ -13,9 +13,10 @@ Checks handler independence, auto-detection pattern, no orchestration. """ +import ast import re 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.seedgo.apps.handlers.json import json_handler @@ -24,6 +25,11 @@ # Audit scope: all Python 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: """ @@ -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"} +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: """ 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] break - in_docstring = False - for i, line in enumerate(lines, 1): - 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("#"): + for lineno, module, names in _iter_import_modules(content): + if "apps.handlers" not in module: 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 - - # Allowed: Default handlers (json_handler) - if "handlers.json import json_handler" in code_part: - continue - - # Allowed: Same package imports (relative imports like "from .decorators") - if code_part.strip().startswith("from ."): - continue + # Allowed: Default handlers (json_handler) + if module.endswith("handlers.json") and "json_handler" in names: + continue - # Allowed: Same package absolute imports - if own_package and f"handlers.{own_package}" in code_part: - continue + # Allowed: Same package absolute imports + if own_package and f"handlers.{own_package}" in module: + continue - # Forbidden: Cross-handler imports - forbidden_imports.append(f"line {i}: {stripped}") + # Forbidden: Cross-handler imports + forbidden_imports.append(f"line {lineno}: {_line_text(lines, lineno, module)}") if forbidden_imports: return { "name": "Handler independence", "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"} @@ -239,50 +246,22 @@ def check_no_orchestration(content: str, lines: List[str]) -> Optional[Dict]: """ module_imports = [] - in_docstring = False - for i, line in enumerate(lines, 1): - 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("#"): + for lineno, module, _names in _iter_import_modules(content): + if "apps.modules" not in module: 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 - - # 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: - continue + # Allowed: Service imports (prax.apps.modules.logger, cli.apps.modules) + if "prax.apps.modules.logger" in module or "cli.apps.modules" in module: + continue - # Forbidden: Module imports (orchestration) - module_imports.append(f"line {i}: {stripped}") + # Forbidden: Module imports (orchestration) + module_imports.append(f"line {lineno}: {_line_text(lines, lineno, module)}") if module_imports: return { "name": "No orchestration", "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)"} diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py index f691dc28..8acab493 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py @@ -31,6 +31,7 @@ from aipass.seedgo.apps.handlers.json import json_handler 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.bypass.ignore_handler import is_seedgo_ignored, load_ignore_entries # Audit scope: entry points only (apps/{name}.py) AUDIT_SCOPE = "entry_point" @@ -503,12 +504,16 @@ def _extract_test_counts(content: str) -> List[int]: def _count_test_functions(tests_dir: Path) -> int: """Count `def test_` functions in all test_*.py files under tests/.""" 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) 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): continue if is_disabled_file(test_file.name): continue + if is_seedgo_ignored(str(test_file), branch_root, ignore_entries): + continue try: source = test_file.read_text(encoding="utf-8") count += len(test_func_pattern.findall(source)) diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py index c627cde0..f72b0095 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py @@ -30,6 +30,7 @@ from aipass.seedgo.apps.handlers.json import json_handler 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.bypass.ignore_handler import is_seedgo_ignored, load_ignore_entries AUDIT_SCOPE = "branch_level" @@ -180,7 +181,7 @@ def _should_skip_file(name: str) -> bool: 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. 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 if any(_should_skip_dir(part) for part in py_file.relative_to(tests_dir).parts): continue + if is_seedgo_ignored(str(py_file), branch_path, ignore_entries): + continue resolved = py_file.resolve() if resolved not in seen: seen.add(resolved) @@ -208,6 +211,8 @@ def _find_test_files_broad(branch_path: Path) -> list[Path]: continue if py_file.name in ("__init__.py", "conftest.py") or _should_skip_file(py_file.name): 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"): resolved = py_file.resolve() if resolved not in seen: @@ -234,7 +239,7 @@ def _analyze_test_file_imports(source: str) -> set[str]: 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/. Returns set of module names: @@ -255,17 +260,21 @@ def _collect_testable_modules(branch_path: Path) -> set[str]: and item.suffix == ".py" and item.name != "__init__.py" and not _should_skip_file(item.name) + and not is_seedgo_ignored(str(item), branch_path, ignore_entries) ): modules.add(item.stem) handlers_dir = apps_dir / "handlers" if handlers_dir.is_dir(): 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 if item.is_dir() and item.name != "__pycache__": 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() if f.is_file() ) @@ -282,7 +291,7 @@ def _collect_testable_modules(branch_path: Path) -> set[str]: 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. 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()): if not p.is_file() or p.suffix != ".py": continue + if is_seedgo_ignored(str(p), branch_path, ignore_entries): + continue if p.name.startswith("test_") or p.name == "conftest.py": 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 - 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: 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) # 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 tested_modules: set[str] = set() 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: tested_modules = set() - all_modules = _collect_testable_modules(bp) + all_modules = _collect_testable_modules(bp, ignore_entries) total_modules = len(all_modules) # 3 module coverage items diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py index b866c6e6..ba7ed8d8 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py @@ -32,6 +32,7 @@ from aipass.prax import logger 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.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 AUDIT_SCOPE = "branch_level" @@ -60,18 +61,23 @@ # -- File collection ---------------------------------------------------------- -def _should_skip(path: Path) -> bool: - """Return True if any path component is in the skip set or file is disabled.""" - return any(part in SKIP_DIRS for part in path.parts) or is_disabled_file(path.name) +def _should_skip(path: Path, branch_root: Path, ignore_entries: list) -> bool: + """Return True if any path component is in the skip set, file is 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_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.""" files: list[Path] = [] if not branch_path.is_dir(): return files for py_file in branch_path.rglob("*.py"): - if _should_skip(py_file): + if _should_skip(py_file, branch_path, ignore_entries): continue files.append(py_file) 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 - py_files = _collect_python_files(branch) + ignore_entries = load_ignore_entries(branch) + py_files = _collect_python_files(branch, ignore_entries) if not py_files: json_handler.log_operation( "check_completed", diff --git a/src/aipass/seedgo/apps/handlers/audit/branch_audit.py b/src/aipass/seedgo/apps/handlers/audit/branch_audit.py index b8668f3f..14f5593d 100644 --- a/src/aipass/seedgo/apps/handlers/audit/branch_audit.py +++ b/src/aipass/seedgo/apps/handlers/audit/branch_audit.py @@ -42,19 +42,27 @@ def discover_checkers(pack_path: Path | None = None) -> Dict[str, Any]: return checkers -def _collect_py_files(branch_path: Path) -> List[Dict[str, str]]: - """Collect auditable .py files from apps/, respecting ignore patterns.""" +def _collect_py_files(branch_path: Path, include_init: bool = False) -> List[Dict[str, str]]: + """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" if not apps_dir.exists(): return [] ign = ignore_handler.get_audit_ignore_patterns() + ignore_entries = ignore_handler.load_ignore_entries(branch_path) return [ {"file": str(f), "name": f.name} 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_throwaway_path(str(f)) 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.""" entry_file, branch_path = branch["entry_file"], Path(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) 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 # All-files scope: scan every .py file, override score with average 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 if s: avg_score = int(sum(s) / len(s)) diff --git a/src/aipass/seedgo/apps/handlers/bypass/ignore_handler.py b/src/aipass/seedgo/apps/handlers/bypass/ignore_handler.py index 6a011e66..113b7d64 100644 --- a/src/aipass/seedgo/apps/handlers/bypass/ignore_handler.py +++ b/src/aipass/seedgo/apps/handlers/bypass/ignore_handler.py @@ -11,14 +11,23 @@ Provides ignore patterns for audit file filtering, template baseline checking, 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 # ============================================= -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 # ============================================= @@ -111,6 +120,95 @@ def get_deprecated_patterns() -> dict: 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 # ============================================= diff --git a/src/aipass/seedgo/apps/modules/checklist.py b/src/aipass/seedgo/apps/modules/checklist.py index f4997af0..479b6f52 100644 --- a/src/aipass/seedgo/apps/modules/checklist.py +++ b/src/aipass/seedgo/apps/modules/checklist.py @@ -45,6 +45,9 @@ # Throwaway / prototype detection 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 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): 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 pack_path = _resolve_pack_path(pack_name) if pack_path is None: @@ -191,20 +198,28 @@ def _resolve_pack_path(pack_name: str) -> Optional[Path]: return None -def _load_bypass_for_file(file_path: str) -> list: - """Load bypass rules for the branch containing file_path.""" +def _resolve_branch_path(file_path: str) -> Optional[Path]: + """Resolve the absolute branch root containing file_path, or None.""" branch = get_branch_from_path(file_path) if branch is None: - return [] + return None raw_path = branch.get("path", "") if not raw_path: - return [] + return None bp = Path(raw_path) if not bp.is_absolute(): from aipass.seedgo.apps.handlers.bypass.bypass_handler import _find_registry registry_path = _find_registry() 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)) diff --git a/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json b/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json index 8bdc07e3..2dda2d99 100644 --- a/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json +++ b/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json @@ -1,5 +1,21 @@ { "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": [ { @@ -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": [ { @@ -48,56 +80,85 @@ "timeout": 120 } ] + }, + { + "hooks": [ + { + "type": "command", + "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 + } + ] } ], - "PreToolUse": [ + "Notification": [ { - "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" + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Notification" } ] } ], - "PostToolUse": [ + "Stop": [ { - "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit", "hooks": [ { "type": "command", - "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse" + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Stop" } ] } ], - "SubagentStop": [ + "SessionStart": [ { "hooks": [ { "type": "command", - "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py SubagentStop" + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py SessionStart:cadence_reset", + "timeout": 30 } ] } ], - "Stop": [ + "PostToolUse": [ { + "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit", "hooks": [ { "type": "command", - "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Stop" + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse" } ] } ], - "Notification": [ + "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 Notification" + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse" + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py SubagentStop" } ] } @@ -162,14 +223,23 @@ "timeout": 120 } ] - } - ], - "SessionStart": [ + }, { + "matcher": "manual", "hooks": [ { "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 } ] diff --git a/src/aipass/seedgo/tests/test_bypass.py b/src/aipass/seedgo/tests/test_bypass.py index 0a5bf3fc..cce05bc7 100644 --- a/src/aipass/seedgo/tests/test_bypass.py +++ b/src/aipass/seedgo/tests/test_bypass.py @@ -221,6 +221,111 @@ def test_get_deprecated_patterns_returns_dict(): 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 # --------------------------------------------------------------------------- diff --git a/src/aipass/seedgo/tests/test_checkers_batch2.py b/src/aipass/seedgo/tests/test_checkers_batch2.py index c9c98e12..f29a5c45 100644 --- a/src/aipass/seedgo/tests/test_checkers_batch2.py +++ b/src/aipass/seedgo/tests/test_checkers_batch2.py @@ -158,6 +158,42 @@ def test_handlers_bypass_respected(self, tmp_path: Path) -> None: result = check_handlers(fp, bypass_rules=bypass) 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 diff --git a/src/aipass/seedgo/tests/test_checkers_batch4.py b/src/aipass/seedgo/tests/test_checkers_batch4.py index 8017b8fc..48e9c702 100644 --- a/src/aipass/seedgo/tests/test_checkers_batch4.py +++ b/src/aipass/seedgo/tests/test_checkers_batch4.py @@ -238,6 +238,21 @@ def test_dead_code_bypass_respected(self, mock_json, tmp_path: Path) -> None: assert result["score"] == 100 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) @@ -466,3 +481,16 @@ def test_unused_function_bypass_respected(self, mock_json, tmp_path: Path) -> No result = unused_function_check_branch(str(branch), bypass_rules=bypass) assert result["score"] == 100 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 diff --git a/src/aipass/seedgo/tests/test_checkers_batch8.py b/src/aipass/seedgo/tests/test_checkers_batch8.py index c2794b63..4d4f6401 100644 --- a/src/aipass/seedgo/tests/test_checkers_batch8.py +++ b/src/aipass/seedgo/tests/test_checkers_batch8.py @@ -132,6 +132,45 @@ def test_handler_independence_same_package(tmp_path): assert result["passed"] is True +def test_handler_independence_fstring_guard_text_not_flagged(tmp_path): + """An f-string that mentions apps.handlers as help text is not a real import.""" + content = ( + "def guard():\n" + " raise ImportError(\n" + ' f" from aipass.seedgo.apps.handlers.error import error_handler\\n"\n' + " )\n" + ) + handler_path = str(tmp_path / "apps" / "handlers" / "audit" / "guard.py") + + from aipass.seedgo.apps.handlers.aipass_standards.handlers_check import ( + check_handler_independence, + ) + + result = check_handler_independence(content, _lines(content), handler_path) + assert result["passed"] is True + + +def test_handler_independence_flags_real_import_alongside_guard_text(tmp_path): + """A real cross-handler import is still caught even when guard text sits nearby.""" + content = ( + "from aipass.seedgo.apps.handlers.error import error_handler\n" + "\n" + "def guard():\n" + " raise ImportError(\n" + ' f" from aipass.seedgo.apps.handlers.error import error_handler\\n"\n' + " )\n" + ) + handler_path = str(tmp_path / "apps" / "handlers" / "audit" / "mixed.py") + + from aipass.seedgo.apps.handlers.aipass_standards.handlers_check import ( + check_handler_independence, + ) + + result = check_handler_independence(content, _lines(content), handler_path) + assert result["passed"] is False + assert "line 1" in result["message"] + + # =========================================================================== # 2. handlers_check -- check_auto_detection # =========================================================================== @@ -238,6 +277,51 @@ def test_no_orchestration_in_docstring(): assert result["passed"] is True +def test_no_orchestration_fstring_guard_text_not_flagged(): + """An import-guard error message built from an f-string is not a real import. + + Regression test: apps/handlers/__init__.py raises ImportError with help text + like `f" from {MY_BRANCH}.apps.modules. import \\n"` — + the old text-matching check flagged this guard text as orchestration. + """ + content = ( + "MY_BRANCH = 'aipass.seedgo'\n" + "def guard():\n" + " raise ImportError(\n" + ' f" from {MY_BRANCH}.apps.modules. import \\n"\n' + " )\n" + ) + + from aipass.seedgo.apps.handlers.aipass_standards.handlers_check import ( + check_no_orchestration, + ) + + result = check_no_orchestration(content, _lines(content)) + assert result is not None + assert result["passed"] is True + + +def test_no_orchestration_flags_real_import_alongside_guard_text(): + """A real module import is still caught even when guard text sits nearby.""" + content = ( + "from aipass.seedgo.apps.modules.scanner import scan_all\n" + "\n" + "def guard():\n" + " raise ImportError(\n" + ' f" from aipass.seedgo.apps.modules. import \\n"\n' + " )\n" + ) + + from aipass.seedgo.apps.handlers.aipass_standards.handlers_check import ( + check_no_orchestration, + ) + + result = check_no_orchestration(content, _lines(content)) + assert result is not None + assert result["passed"] is False + assert "line 1" in result["message"] + + # =========================================================================== # 4. log_handler_check -- check_no_raw_file_handler # =========================================================================== diff --git a/src/aipass/seedgo/tests/test_checklist.py b/src/aipass/seedgo/tests/test_checklist.py index 7f655248..dce5d6e0 100644 --- a/src/aipass/seedgo/tests/test_checklist.py +++ b/src/aipass/seedgo/tests/test_checklist.py @@ -22,6 +22,11 @@ def _mock_infrastructure(monkeypatch): """Mock heavy infrastructure imports for checklist.""" import sys + from aipass.seedgo.apps.handlers.bypass.ignore_handler import ( + is_seedgo_ignored as real_is_seedgo_ignored, + load_ignore_entries as real_load_ignore_entries, + ) + mock_logger = MagicMock() mock_console = MagicMock() mock_error = MagicMock() @@ -67,6 +72,12 @@ def _mock_infrastructure(monkeypatch): bypass_mod.load_bypass_rules = MagicMock(return_value=[]) monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass.bypass_handler", bypass_mod) + ignore_mod = MagicMock() + ignore_mod.is_seedgo_ignored = real_is_seedgo_ignored + ignore_mod.load_ignore_entries = real_load_ignore_entries + bypass_pkg.ignore_handler = ignore_mod + monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass.ignore_handler", ignore_mod) + # Force re-import monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) @@ -222,6 +233,31 @@ def test_run_checklist_prototype_marker_skips(tmp_path, monkeypatch): assert "prototype" in results[0]["detail"].lower() +def test_run_checklist_seedgo_ignore_skips(tmp_path, monkeypatch): + """A file under apps/tools/ is skipped via the global .seedgoignore default.""" + import sys + + from aipass.seedgo.apps.handlers.aipass_standards import skip_dirs + + monkeypatch.setattr(skip_dirs, "_get_temp_roots", lambda: []) + + branch_mod = MagicMock() + branch_mod.get_branch_from_path = MagicMock(return_value={"path": str(tmp_path)}) + branch_mod.load_bypass_rules = MagicMock(return_value=[]) + monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass.bypass_handler", branch_mod) + + from aipass.seedgo.apps.modules.checklist import run_checklist + + tools_dir = tmp_path / "apps" / "tools" + tools_dir.mkdir(parents=True) + f = tools_dir / "scratch.py" + f.write_text("x = 1\n", encoding="utf-8") + results = run_checklist(str(f)) + assert len(results) == 1 + assert results[0]["passed"] is True + assert "seedgoignore" in results[0]["detail"].lower() + + def test_run_checklist_normal_file_still_audited(tmp_path, monkeypatch): """A normal file without markers/temp path is still fully audited.""" import sys diff --git a/src/aipass/seedgo/tests/test_coverage_audit.py b/src/aipass/seedgo/tests/test_coverage_audit.py index 5f5eb65f..ff6b3357 100644 --- a/src/aipass/seedgo/tests/test_coverage_audit.py +++ b/src/aipass/seedgo/tests/test_coverage_audit.py @@ -32,8 +32,15 @@ def _mock_infrastructure(monkeypatch): mock_console = MagicMock() mock_json_handler = MagicMock() mock_json_handler.log_operation = MagicMock(return_value=True) + from aipass.seedgo.apps.handlers.bypass.ignore_handler import ( + is_seedgo_ignored as real_is_seedgo_ignored, + load_ignore_entries as real_load_ignore_entries, + ) + mock_ignore_handler = MagicMock() mock_ignore_handler.get_audit_ignore_patterns = MagicMock(return_value=[]) + mock_ignore_handler.is_seedgo_ignored = real_is_seedgo_ignored + mock_ignore_handler.load_ignore_entries = real_load_ignore_entries mock_scan_branch = MagicMock(return_value=None) # -- prax --------------------------------------------------------------- @@ -1087,6 +1094,27 @@ def test_collects_py_files(self, tmp_path, monkeypatch): assert "handler.py" in names assert "__init__.py" not in names + def test_collects_init_files_when_requested(self, tmp_path, monkeypatch): + """include_init=True keeps __init__.py — for import checkers that must not miss them.""" + import sys + + skip_dirs = sys.modules.get("aipass.seedgo.apps.handlers.aipass_standards.skip_dirs") + if skip_dirs: + monkeypatch.setattr(skip_dirs, "_get_temp_roots", lambda: []) + + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _collect_py_files, + ) + + apps_dir = tmp_path / "apps" + apps_dir.mkdir() + (apps_dir / "__init__.py").write_text("", encoding="utf-8") + (apps_dir / "module.py").write_text("pass", encoding="utf-8") + result = _collect_py_files(tmp_path, include_init=True) + names = [f["name"] for f in result] + assert "module.py" in names + assert "__init__.py" in names + def test_respects_ignore_patterns(self, tmp_path, monkeypatch): """Files matching ignore patterns are excluded.""" import sys @@ -1134,6 +1162,29 @@ def test_excludes_disabled_files(self, tmp_path, monkeypatch): assert "module.py" in names assert "dashboard_sync(disabled).py" not in names + def test_respects_seedgo_ignore_tools_dir(self, tmp_path, monkeypatch): + """Files under apps/tools/ are excluded via the global .seedgoignore default.""" + import sys + + skip_dirs = sys.modules.get("aipass.seedgo.apps.handlers.aipass_standards.skip_dirs") + if skip_dirs: + monkeypatch.setattr(skip_dirs, "_get_temp_roots", lambda: []) + + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _collect_py_files, + ) + + apps_dir = tmp_path / "apps" + apps_dir.mkdir() + (apps_dir / "module.py").write_text("pass", encoding="utf-8") + tools_dir = apps_dir / "tools" + tools_dir.mkdir() + (tools_dir / "scratch.py").write_text("pass", encoding="utf-8") + result = _collect_py_files(tmp_path) + names = [f["name"] for f in result] + assert "module.py" in names + assert "scratch.py" not in names + class TestExtractBranchLevelViolations: """Tests for _extract_branch_level_violations.""" @@ -1462,6 +1513,7 @@ def _make_checker( checker = MagicMock() checker.AUDIT_SCOPE = scope checker.FILE_FILTER = None + checker.INCLUDE_INIT_FILES = False if has_check_module: default_mod = { @@ -1676,6 +1728,41 @@ def check_side_effect(path, bypass_rules=None): result = branch_audit.audit_branch(branch, []) assert "naming_violations" in result + def test_all_files_scope_include_init_files_opt_in(self, tmp_path, monkeypatch): + """Checker with INCLUDE_INIT_FILES=True sees __init__.py; a normal checker does not.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, branch_path = _setup_branch(tmp_path) + apps_dir = Path(branch_path) / "apps" + (apps_dir / "__init__.py").write_text("pass", encoding="utf-8") + + import_checker = _make_checker(scope="all_files") + import_checker.INCLUDE_INIT_FILES = True + normal_checker = _make_checker(scope="all_files") + + # tmp_path lives under the system temp dir, which is_throwaway_path + # filters out wholesale — neutralize that so _collect_py_files sees + # the fixture's files, same as it would for a real branch on disk. + monkeypatch.setattr(branch_audit, "is_throwaway_path", lambda path_str: False) + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {"handlers": import_checker, "naming": normal_checker}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr(branch_audit, "scan_branch", lambda p: None) + + branch_audit.audit_branch(branch, []) + + import_checked_names = {Path(c.args[0]).name for c in import_checker.check_module.call_args_list} + normal_checked_names = {Path(c.args[0]).name for c in normal_checker.check_module.call_args_list} + assert "__init__.py" in import_checked_names + assert "__init__.py" not in normal_checked_names + def test_dynamic_post_check(self, tmp_path, monkeypatch): """check_branch_post discovered and called.""" from aipass.seedgo.apps.handlers.audit import branch_audit diff --git a/src/aipass/seedgo/tests/test_readme_content_checks.py b/src/aipass/seedgo/tests/test_readme_content_checks.py index c43b5ff4..dab084b7 100644 --- a/src/aipass/seedgo/tests/test_readme_content_checks.py +++ b/src/aipass/seedgo/tests/test_readme_content_checks.py @@ -48,6 +48,10 @@ def _mock_infrastructure(monkeypatch): monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.json.json_handler", json_mod) from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed as real_is_bypassed + from aipass.seedgo.apps.handlers.bypass.ignore_handler import ( + is_seedgo_ignored as real_is_seedgo_ignored, + load_ignore_entries as real_load_ignore_entries, + ) bypass_pkg = MagicMock() bypass_utils = MagicMock() @@ -55,6 +59,8 @@ def _mock_infrastructure(monkeypatch): bypass_pkg.utils = bypass_utils bypass_ignore = MagicMock() bypass_ignore.get_template_ignore_patterns = MagicMock(return_value=[]) + bypass_ignore.is_seedgo_ignored = real_is_seedgo_ignored + bypass_ignore.load_ignore_entries = real_load_ignore_entries bypass_pkg.ignore_handler = bypass_ignore monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass", bypass_pkg) monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass.utils", bypass_utils) diff --git a/src/aipass/skills/lib/telegram/apps/handlers/base_bot.py b/src/aipass/skills/lib/telegram/apps/handlers/base_bot.py index 99cee968..5abe5cbb 100644 --- a/src/aipass/skills/lib/telegram/apps/handlers/base_bot.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/base_bot.py @@ -468,6 +468,8 @@ def poll_updates(self, offset: int) -> list: return [] if _is_network_error(e): raise _NetworkPollError(str(e)) from e + if isinstance(e, HTTPError) and e.code >= 500: + raise _NetworkPollError(str(e)) from e logger.error("Poll error: %s", e) return [] except (ConnectionError, OSError) as e: diff --git a/src/aipass/skills/lib/telegram/apps/handlers/user_message_relay.py b/src/aipass/skills/lib/telegram/apps/handlers/user_message_relay.py index 4fd271ca..4bf7b307 100644 --- a/src/aipass/skills/lib/telegram/apps/handlers/user_message_relay.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/user_message_relay.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: user_message_relay.py # Description: Relay user messages from non-TG doors to the branch TG chat -# Version: 1.2.0 +# Version: 1.3.0 # Created: 2026-07-14 -# Modified: 2026-07-14 +# Modified: 2026-07-20 # ============================================= """ @@ -187,6 +187,7 @@ def handle(hook_data: dict) -> dict: if send_user_message(bot_token, chat_id, prompt): _last_relay_hash = msg_hash logger.info("[TG] user message relayed to chat_id=%s", chat_id) + return {"stdout": "", "exit_code": 0, "sound": "user message relay"} return {"stdout": "", "exit_code": 0} except Exception as e: diff --git a/src/aipass/skills/lib/telegram/tests/test_network_backoff.py b/src/aipass/skills/lib/telegram/tests/test_network_backoff.py index cb67b082..07af0161 100644 --- a/src/aipass/skills/lib/telegram/tests/test_network_backoff.py +++ b/src/aipass/skills/lib/telegram/tests/test_network_backoff.py @@ -14,8 +14,9 @@ - _is_routine_read_timeout classification """ +from http.client import HTTPMessage from unittest.mock import patch -from urllib.error import URLError +from urllib.error import HTTPError, URLError import pytest @@ -206,6 +207,33 @@ def test_non_network_urlerror_logs_error(self, tmp_path, _patch_base_bot_deps): mock_logger.error.assert_called_once() assert "Poll error" in str(mock_logger.error.call_args) + def test_http_502_raises_network_poll_error(self, tmp_path, _patch_base_bot_deps): + """HTTP 502 Bad Gateway should trigger network backoff, not rapid-fire retry.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + exc = HTTPError("https://api.telegram.org/...", 502, "Bad Gateway", HTTPMessage(), None) + with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen", side_effect=exc): + with pytest.raises(_NetworkPollError): + bot.poll_updates(0) + + def test_http_503_raises_network_poll_error(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + exc = HTTPError("https://api.telegram.org/...", 503, "Service Unavailable", HTTPMessage(), None) + with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen", side_effect=exc): + with pytest.raises(_NetworkPollError): + bot.poll_updates(0) + + def test_http_429_not_network_error(self, tmp_path, _patch_base_bot_deps): + """Client-side HTTP errors (4xx) should NOT trigger network backoff.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + exc = HTTPError("https://api.telegram.org/...", 429, "Too Many Requests", HTTPMessage(), None) + with ( + patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen", side_effect=exc), + patch("aipass.skills.lib.telegram.apps.handlers.base_bot.logger") as mock_logger, + ): + result = bot.poll_updates(0) + assert result == [] + mock_logger.error.assert_called_once() + def test_unexpected_exception_logs_error(self, tmp_path, _patch_base_bot_deps): bot = _make_bot(tmp_path, _patch_base_bot_deps) exc = ValueError("something weird") diff --git a/src/aipass/skills/lib/telegram/tests/test_user_message_relay.py b/src/aipass/skills/lib/telegram/tests/test_user_message_relay.py index 3f4154d8..8e4fa52d 100644 --- a/src/aipass/skills/lib/telegram/tests/test_user_message_relay.py +++ b/src/aipass/skills/lib/telegram/tests/test_user_message_relay.py @@ -271,6 +271,24 @@ def test_happy_path_relays(self, bot_dirs): assert result["exit_code"] == 0 mock_send.assert_called_once_with("123:FAKETOKEN", 42, "hello world") + def test_happy_path_has_sound_key(self, bot_dirs): + with patch.object(relay_mod, "send_user_message", return_value=True): + result = handle({"prompt": "hello world", "cwd": str(bot_dirs["work"])}) + assert result["sound"] == "user message relay" + + def test_send_failure_no_sound_key(self, bot_dirs): + with patch.object(relay_mod, "send_user_message", return_value=False): + result = handle({"prompt": "hello world", "cwd": str(bot_dirs["work"])}) + assert "sound" not in result + + def test_skips_no_bot_found_no_sound_key(self, tmp_path): + with ( + patch.object(relay_mod, "MIRROR_DIR", tmp_path / "nope1"), + patch.object(relay_mod, "PENDING_DIR", tmp_path / "nope2"), + ): + result = handle({"prompt": "hello", "cwd": "/tmp/nowhere"}) + assert "sound" not in result + def test_updates_dedup_hash_on_success(self, bot_dirs): with patch.object(relay_mod, "send_user_message", return_value=True): handle({"prompt": "hello", "cwd": str(bot_dirs["work"])}) diff --git a/src/aipass/spawn/apps/handlers/delete_ops.py b/src/aipass/spawn/apps/handlers/delete_ops.py index 3c5b7787..e9c36901 100644 --- a/src/aipass/spawn/apps/handlers/delete_ops.py +++ b/src/aipass/spawn/apps/handlers/delete_ops.py @@ -19,6 +19,7 @@ from aipass.spawn.apps.handlers.registry import ( find_registry, + is_protected, load_registry, save_registry, branches_as_list, @@ -26,9 +27,6 @@ from aipass.spawn.apps.handlers.repair_ops import ARCHIVE_EXCLUDE from aipass.spawn.apps.handlers.json import json_handler -# Branches that cannot be deleted (critical infrastructure) -_PROTECTED_BRANCHES = {"spawn", "devpulse", "drone"} - # ============================================================================= # PUBLIC API @@ -120,14 +118,15 @@ def delete_branch( Returns: Dict with deletion results. """ - # Safety: check protected branches - if branch_name.lower() in _PROTECTED_BRANCHES: - msg = f"Cannot delete '{branch_name}' — protected branch ({', '.join(sorted(_PROTECTED_BRANCHES))})" + # 1. Resolve registry (needed for protection check and branch resolution) + registry_path = find_registry() + + # Safety: check protected branches (hardcoded floor + registry owner + active passport) + protected, reason = is_protected(branch_name, registry_path=registry_path) + if protected: + msg = f"Cannot delete '{branch_name}' — protected ({reason})" logger.warning(f"[delete] {msg}") return _error_result(branch_name, msg) - - # 1. Resolve branch path from registry - registry_path = find_registry() project_root = registry_path.parent registry = load_registry(registry_path) branch_entry, branch_dir = _resolve_branch_dir(branch_name, registry_path, registry) diff --git a/src/aipass/spawn/apps/handlers/registry.py b/src/aipass/spawn/apps/handlers/registry.py index 59875b24..bbf36a09 100644 --- a/src/aipass/spawn/apps/handlers/registry.py +++ b/src/aipass/spawn/apps/handlers/registry.py @@ -36,6 +36,59 @@ from aipass.spawn.apps.handlers.json import json_handler +_PROTECTED_FLOOR = frozenset({"spawn", "devpulse", "drone"}) + + +def is_protected(branch_name, branch_dir=None, registry_path=None): + """Check if a branch is protected from deletion and pollution cleanup. + + Protection layers (any one is sufficient): + 1. Hardcoded floor — spawn, devpulse, drone. + 2. Registry owner flag — entry has ``owner: true``. + 3. Active passport — ``.trinity/passport.json`` with + ``citizenship.registered == True``. + + Args: + branch_name: Branch name to check (case-insensitive). + branch_dir: Path to the branch directory (for passport check). + Auto-resolved from registry when omitted. + registry_path: Path to ``*_REGISTRY.json``. + Auto-discovered via ``find_registry()`` when omitted. + + Returns: + Tuple of (protected: bool, reason: str). + """ + name_lower = branch_name.lower() + + if name_lower in _PROTECTED_FLOOR: + return True, f"infrastructure ({', '.join(sorted(_PROTECTED_FLOOR))})" + + try: + rp = Path(registry_path) if registry_path else find_registry() + reg_data = load_registry(rp) + for entry in branches_as_list(reg_data.get("branches", [])): + if entry.get("name", "").lower() == name_lower: + if entry.get("owner") is True: + return True, "registry owner" + if branch_dir is None: + branch_dir = (rp.parent / entry.get("path", "")).resolve() + break + except (OSError, ValueError, KeyError) as exc: + logger.info("[is_protected] Registry lookup failed for %s: %s", branch_name, exc) + + if branch_dir is not None: + passport_path = Path(branch_dir) / ".trinity" / "passport.json" + if passport_path.is_file(): + try: + passport = json_handler.read_json(passport_path) + if passport and passport.get("citizenship", {}).get("registered") is True: + return True, "active citizen" + except (OSError, ValueError, KeyError) as exc: + logger.info("[is_protected] Passport read failed for %s: %s", branch_name, exc) + + return False, "" + + def branches_as_list(branches): """Normalize branches to a list regardless of storage format. diff --git a/src/aipass/spawn/apps/handlers/repair_ops.py b/src/aipass/spawn/apps/handlers/repair_ops.py index 2ee756f3..a595332e 100644 --- a/src/aipass/spawn/apps/handlers/repair_ops.py +++ b/src/aipass/spawn/apps/handlers/repair_ops.py @@ -24,6 +24,7 @@ from aipass.spawn.apps.handlers.registry import ( find_registry, + is_protected, load_registry, save_registry, branches_as_list, @@ -318,10 +319,34 @@ def move_branch(branch_name, new_path, registry_path=None, dry_run=False, reloca # ============================================================================= +def _check_nested_pollution(nested_dir, label, project_root, registry_path): + """Check if a nested duplicate directory is real pollution. + + Returns an issue dict if pollution, or None if the directory is a + protected branch (active passport, registry owner, infrastructure floor). + """ + protected, _reason = is_protected( + nested_dir.name, + branch_dir=nested_dir, + registry_path=registry_path, + ) + if protected: + return None + + rel = nested_dir.relative_to(project_root).as_posix() + return { + "type": "duplicate_nested_dir", + "path": rel, + "description": f"Duplicate nested directory: {label}/", + } + + def detect_pollution(project_root): """Detect init pollution — duplicate nested directories. Init pollution: project_name/project_name/ exists (e.g., compass/compass/). + Skips directories that are protected branches (active passport, registry + owner, or infrastructure floor). Args: project_root: Path to the project root directory @@ -333,30 +358,38 @@ def detect_pollution(project_root): issues = [] project_name = project_root.name + registry_path = None + for f in sorted(project_root.glob("*_REGISTRY.json")): + registry_path = f + break + nested = project_root / project_name if nested.is_dir(): - issues.append( - { - "type": "duplicate_nested_dir", - "path": project_name, - "description": f"Duplicate nested directory: {project_name}/{project_name}/", - } + issue = _check_nested_pollution( + nested, + f"{project_name}/{project_name}", + project_root, + registry_path, ) + if issue: + issues.append(issue) src_dir = project_root / "src" if src_dir.is_dir(): for child in sorted(src_dir.iterdir()): - if child.is_dir() and not child.name.startswith(".") and not child.name.startswith("__"): - nested_dup = child / child.name - if nested_dup.is_dir(): - rel = nested_dup.relative_to(project_root).as_posix() - issues.append( - { - "type": "duplicate_nested_dir", - "path": rel, - "description": f"Duplicate nested directory: src/{child.name}/{child.name}/", - } - ) + if not child.is_dir() or child.name.startswith(".") or child.name.startswith("__"): + continue + nested_dup = child / child.name + if not nested_dup.is_dir(): + continue + issue = _check_nested_pollution( + nested_dup, + f"src/{child.name}/{child.name}", + project_root, + registry_path, + ) + if issue: + issues.append(issue) return issues diff --git a/src/aipass/spawn/templates/aipass_framework/.spawn/.template_registry.json b/src/aipass/spawn/templates/aipass_framework/.spawn/.template_registry.json index d9227c3b..f4bf3c40 100644 --- a/src/aipass/spawn/templates/aipass_framework/.spawn/.template_registry.json +++ b/src/aipass/spawn/templates/aipass_framework/.spawn/.template_registry.json @@ -429,7 +429,7 @@ }, "metadata": { "description": "Template file tracking registry for ID-based updates", - "last_updated": "2026-07-17", + "last_updated": "2026-07-20", "version": "1.0.0" } } diff --git a/src/aipass/spawn/tests/test_repair.py b/src/aipass/spawn/tests/test_repair.py index 84e3c08d..630365c1 100644 --- a/src/aipass/spawn/tests/test_repair.py +++ b/src/aipass/spawn/tests/test_repair.py @@ -473,6 +473,223 @@ def test_no_relocation_without_flag(self, tmp_path): assert (project / ".chroma").exists() +# --------------------------------------------------------------------------- +# is_protected shared helper +# --------------------------------------------------------------------------- + + +class TestIsProtected: + """Tests for is_protected() — shared protection helper across repair and delete.""" + + def test_hardcoded_floor_spawn(self, tmp_path): + """spawn is protected by the hardcoded floor.""" + from aipass.spawn.apps.handlers.registry import is_protected + + protected, reason = is_protected("spawn") + assert protected is True + assert "infrastructure" in reason + + def test_hardcoded_floor_case_insensitive(self, tmp_path): + """Floor check is case-insensitive.""" + from aipass.spawn.apps.handlers.registry import is_protected + + protected, _reason = is_protected("DEVPULSE") + assert protected is True + + def test_registry_owner_protected(self, tmp_path): + """Branch with owner:true in registry is protected.""" + from aipass.spawn.apps.handlers.registry import is_protected + + project, reg = _make_project(tmp_path, branches=[{"name": "MYOWNER", "path": "myowner"}]) + reg_data = json.loads(reg.read_text()) + reg_data["branches"][0]["owner"] = True + reg.write_text(json.dumps(reg_data)) + + protected, reason = is_protected("myowner", registry_path=reg) + assert protected is True + assert "owner" in reason + + def test_active_passport_protected(self, tmp_path): + """Branch with citizenship.registered=True passport is protected.""" + from aipass.spawn.apps.handlers.registry import is_protected + + project, reg = _make_project(tmp_path, branches=[{"name": "CITIZEN", "path": "citizen"}]) + protected, reason = is_protected("citizen", registry_path=reg) + assert protected is True + assert "citizen" in reason + + def test_no_passport_not_protected(self, tmp_path): + """Branch without passport (no citizenship.registered) is not protected.""" + from aipass.spawn.apps.handlers.registry import is_protected + + project = tmp_path / "proj" + project.mkdir() + branch = project / "ephemeral" + branch.mkdir() + + reg = project / "TEST_REGISTRY.json" + reg.write_text( + json.dumps( + { + "metadata": {"version": "1.0.0", "last_updated": "2026-01-01", "total_branches": 1}, + "branches": [{"name": "EPHEMERAL", "path": "ephemeral", "status": "active"}], + } + ) + ) + + protected, _reason = is_protected("ephemeral", registry_path=reg) + assert protected is False + + def test_minimal_passport_not_protected(self, tmp_path): + """Passport without citizenship.registered is not protected.""" + from aipass.spawn.apps.handlers.registry import is_protected + + branch = tmp_path / "minimal" + branch.mkdir() + (branch / ".trinity").mkdir() + (branch / ".trinity" / "passport.json").write_text(json.dumps({"name": "MINIMAL", "role": "test"})) + + protected, _reason = is_protected("minimal", branch_dir=branch) + assert protected is False + + def test_unknown_branch_not_protected(self): + """Completely unknown branch is not protected.""" + from aipass.spawn.apps.handlers.registry import is_protected + + protected, _reason = is_protected("nonexistent", branch_dir=None, registry_path=None) + assert protected is False + + +# --------------------------------------------------------------------------- +# detect_pollution skips protected branches +# --------------------------------------------------------------------------- + + +class TestDetectPollutionProtection: + """Tests for detect_pollution skipping protected branches.""" + + def test_skips_branch_with_active_passport(self, tmp_path): + """src/pkg/pkg/ with active passport is NOT flagged as pollution.""" + from aipass.spawn.apps.handlers.repair_ops import detect_pollution + + project = tmp_path / "myproj" + project.mkdir() + src_pkg = project / "src" / "mypkg" / "mypkg" + src_pkg.mkdir(parents=True) + + trinity = src_pkg / ".trinity" + trinity.mkdir() + (trinity / "passport.json").write_text( + json.dumps( + { + "branch_info": {"branch_name": "mypkg"}, + "identity": {"citizen_class": "aipass_framework"}, + "citizenship": {"registered": True}, + } + ) + ) + + reg = project / "MYPROJ_REGISTRY.json" + reg.write_text( + json.dumps( + { + "metadata": {"version": "1.0.0", "last_updated": "2026-01-01", "total_branches": 1}, + "branches": [{"name": "MYPKG", "path": "src/mypkg/mypkg", "status": "active"}], + } + ) + ) + + issues = detect_pollution(project) + assert len(issues) == 0 + + def test_still_flags_real_pollution(self, tmp_path): + """src/pkg/pkg/ without passport IS flagged as pollution.""" + from aipass.spawn.apps.handlers.repair_ops import detect_pollution + + project = tmp_path / "myproj" + project.mkdir() + (project / "src" / "mypkg" / "mypkg").mkdir(parents=True) + + issues = detect_pollution(project) + assert len(issues) == 1 + assert issues[0]["type"] == "duplicate_nested_dir" + + def test_skips_owner_branch_at_root(self, tmp_path): + """project/project/ with owner flag is NOT flagged as pollution.""" + from aipass.spawn.apps.handlers.repair_ops import detect_pollution + + project = tmp_path / "compass" + project.mkdir() + nested = project / "compass" + nested.mkdir() + + reg = project / "COMPASS_REGISTRY.json" + reg.write_text( + json.dumps( + { + "metadata": {"version": "1.0.0", "last_updated": "2026-01-01", "total_branches": 1}, + "branches": [{"name": "COMPASS", "path": "compass", "status": "active", "owner": True}], + } + ) + ) + + issues = detect_pollution(project) + assert len(issues) == 0 + + +# --------------------------------------------------------------------------- +# delete_branch refuses owner branches +# --------------------------------------------------------------------------- + + +class TestDeleteOwnerProtection: + """Tests for delete_branch refusing registry-owner branches.""" + + def test_delete_owner_refused(self, tmp_path): + """Cannot delete a branch with owner:true in registry.""" + from aipass.spawn.apps.handlers.delete_ops import delete_branch + + project = tmp_path / "repo" + project.mkdir() + branch = project / "src" / "aipass" / "aipass_branch" + branch.mkdir(parents=True) + (branch / ".trinity").mkdir() + (branch / ".trinity" / "passport.json").write_text( + json.dumps( + { + "identity": {"citizen_class": "manager"}, + "citizenship": {"registered": True}, + } + ) + ) + + reg = project / "AIPASS_REGISTRY.json" + reg.write_text( + json.dumps( + { + "metadata": {"version": "1.0.0", "last_updated": "2026-01-01", "total_branches": 1}, + "branches": [ + { + "name": "AIPASS_BRANCH", + "path": "src/aipass/aipass_branch", + "status": "active", + "owner": True, + "email": "@aipass_branch", + } + ], + } + ) + ) + + with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=reg): + result = delete_branch("aipass_branch", confirm=False) + + assert result["success"] is False + assert "protected" in result.get("error", "").lower() + assert "owner" in result.get("error", "").lower() + assert branch.exists() + + # --------------------------------------------------------------------------- # ARCHIVE_EXCLUDE shared constant # --------------------------------------------------------------------------- diff --git a/src/aipass/trigger/apps/handlers/events/runaway_handler.py b/src/aipass/trigger/apps/handlers/events/runaway_handler.py index 5df7e371..f994af6b 100644 --- a/src/aipass/trigger/apps/handlers/events/runaway_handler.py +++ b/src/aipass/trigger/apps/handlers/events/runaway_handler.py @@ -3,7 +3,7 @@ # Description: Runaway log event handler with per-file cooldown gating # Version: 1.0.0 # Created: 2026-07-14 -# Modified: 2026-07-14 +# Modified: 2026-07-21 # ============================================= """ @@ -24,12 +24,16 @@ - Per-file cooldown (30min default) — independent of medic circuit breaker - Branch mute check (reuses TTL mute infrastructure from trigger_config.json) - UNKNOWN/missing branch → dispatch to @prax as fallback + +Alerts written to .aipass/alerts.json expire after 24h by default (same TTL +convention as medic_state.py's DEFAULT_MUTE_SECONDS) — pass forever=True to +skip expiry. """ import json import time import uuid -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from typing import Any, Callable, Optional @@ -71,6 +75,7 @@ def _find_repo_root() -> Path: _file_cooldowns: dict[str, float] = {} COOLDOWN_SECONDS = 1800 +DEFAULT_ALERT_TTL_SECONDS = 86400 # 24 hours — matches medic_state.py DEFAULT_MUTE_SECONDS def set_send_email_callback(callback: Callable[..., bool]) -> None: @@ -163,7 +168,9 @@ def _write_suppression_log(reason: str, file_path: str, branch: str) -> None: _log_warning(f"suppression log write failed ({reason}): {exc}") -def _write_alert(file_path: str, severity: str, branch: str, rate: float, duration: float) -> None: +def _write_alert( + file_path: str, severity: str, branch: str, rate: float, duration: float, forever: bool = False +) -> None: """Write an alert entry to .aipass/alerts.json. Args: @@ -172,8 +179,10 @@ def _write_alert(file_path: str, severity: str, branch: str, rate: float, durati branch: Responsible branch name rate: Lines per minute duration: Sustained duration in seconds + forever: If True, alert never auto-expires (default: 24h TTL) """ try: + expires_at = None if forever else (datetime.now() + timedelta(seconds=DEFAULT_ALERT_TTL_SECONDS)).isoformat() alert = { "id": str(uuid.uuid4()), "source": "prax", @@ -183,7 +192,7 @@ def _write_alert(file_path: str, severity: str, branch: str, rate: float, durati f"Log file {file_path} producing {rate:.0f} lines/min sustained {duration:.0f}s. Branch: {branch}." ), "created_at": datetime.now().isoformat(), - "expires_at": None, + "expires_at": expires_at, } ALERTS_FILE.parent.mkdir(parents=True, exist_ok=True) with json_file_lock(ALERTS_FILE): @@ -204,6 +213,7 @@ def handle_runaway_log_detected( sustained_duration_sec: float = 0, severity: str = "warning", branch: str | None = None, + forever: bool = False, **kwargs: Any, ) -> None: """Handle runaway_log_detected event — dispatch to responsible branch. @@ -217,6 +227,7 @@ def handle_runaway_log_detected( sustained_duration_sec: How long the rate has been sustained severity: "warning" or "critical" branch: Responsible branch name (None/UNKNOWN → dispatch to @prax) + forever: If True, the resulting alert never auto-expires (default: 24h TTL) **kwargs: Additional event data (ignored) """ try: @@ -276,7 +287,7 @@ def handle_runaway_log_detected( except Exception: pass # Email in inbox as fallback - _write_alert(file_path, severity, target_branch, rate_lines_per_min, sustained_duration_sec) + _write_alert(file_path, severity, target_branch, rate_lines_per_min, sustained_duration_sec, forever=forever) _record_file_dispatch(file_path) json_handler.log_operation("runaway_dispatch_sent", {"recipient": recipient, "file": file_path}) diff --git a/src/aipass/trigger/tests/test_runaway_handler.py b/src/aipass/trigger/tests/test_runaway_handler.py index f0209ceb..fe8059dd 100644 --- a/src/aipass/trigger/tests/test_runaway_handler.py +++ b/src/aipass/trigger/tests/test_runaway_handler.py @@ -2,6 +2,7 @@ import json import sys +from datetime import datetime, timedelta from pathlib import Path from unittest.mock import MagicMock, patch @@ -306,6 +307,42 @@ def test_alert_has_required_fields(self, tmp_path: Path) -> None: assert alert["body"] assert alert["created_at"] + def test_alert_defaults_to_24h_ttl(self, tmp_path: Path) -> None: + """expires_at defaults to ~24h from now, not None.""" + _setup_happy_path() + + mod.handle_runaway_log_detected( + file_path="/var/log/test.log", + branch="flow", + rate_lines_per_min=500, + sustained_duration_sec=60, + ) + + alerts_file = tmp_path / "alerts.json" + data = json.loads(alerts_file.read_text(encoding="utf-8")) + expires_at = data["alerts"][0]["expires_at"] + + assert expires_at is not None + expires_dt = datetime.fromisoformat(expires_at) + delta = expires_dt - datetime.now() + assert timedelta(hours=23) < delta <= timedelta(hours=24, minutes=1) + + def test_alert_forever_true_sets_no_expiry(self, tmp_path: Path) -> None: + """forever=True writes expires_at=None (permanent alert).""" + _setup_happy_path() + + mod.handle_runaway_log_detected( + file_path="/var/log/test.log", + branch="flow", + rate_lines_per_min=500, + sustained_duration_sec=60, + forever=True, + ) + + alerts_file = tmp_path / "alerts.json" + data = json.loads(alerts_file.read_text(encoding="utf-8")) + assert data["alerts"][0]["expires_at"] is None + # --------------------------------------------------------------------------- # 10. Alert appends — existing alerts preserved when new one appended