Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e36909e53 | ||
|
|
948c65725c |
@@ -55,17 +55,6 @@
|
|||||||
"matcher": "",
|
"matcher": "",
|
||||||
"timeout": 120
|
"timeout": 120
|
||||||
},
|
},
|
||||||
"context_gauge": {
|
|
||||||
"enabled": true,
|
|
||||||
"handler": "aipass.hooks.apps.handlers.prompt.context_gauge.handle",
|
|
||||||
"matcher": "",
|
|
||||||
"timeout": 30
|
|
||||||
},
|
|
||||||
"temporal": {
|
|
||||||
"enabled": true,
|
|
||||||
"handler": "aipass.hooks.apps.handlers.prompt.temporal.handle",
|
|
||||||
"matcher": ""
|
|
||||||
},
|
|
||||||
"user_message_relay": {
|
"user_message_relay": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"handler": "aipass.skills.lib.telegram.apps.handlers.user_message_relay.handle",
|
"handler": "aipass.skills.lib.telegram.apps.handlers.user_message_relay.handle",
|
||||||
@@ -169,12 +158,6 @@
|
|||||||
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle",
|
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle",
|
||||||
"matcher": "",
|
"matcher": "",
|
||||||
"timeout": 120
|
"timeout": 120
|
||||||
},
|
|
||||||
"pre_compact_prep": {
|
|
||||||
"enabled": true,
|
|
||||||
"handler": "aipass.hooks.apps.handlers.lifecycle.pre_compact_prep.handle",
|
|
||||||
"matcher": "",
|
|
||||||
"timeout": 30
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ Quick checks beat assumptions: `ls`/`find` for files, `git ls-files`/`grep` for
|
|||||||
|
|
||||||
- Run `drone @ai_mail inbox 2>/dev/null` — report any unread emails
|
- Run `drone @ai_mail inbox 2>/dev/null` — report any unread emails
|
||||||
- Close any that were already processed but not formally closed
|
- Close any that were already processed but not formally closed
|
||||||
- Run `drone @devpulse feedback 2>/dev/null` — report unread cross-project feedback; view/reply/clear anything NEW (S304 F46: this box sat unread for 3 months because nothing invoked it)
|
|
||||||
|
|
||||||
## 5. Compass Review (Devpulse only)
|
## 5. Compass Review (Devpulse only)
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,6 @@
|
|||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:compass_recall", "event": "UserPromptSubmit"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:compass_recall", "event": "UserPromptSubmit"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:feedback_pulse", "event": "UserPromptSubmit"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:feedback_pulse", "event": "UserPromptSubmit"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:auto_process", "event": "UserPromptSubmit"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:auto_process", "event": "UserPromptSubmit"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:context_gauge", "event": "UserPromptSubmit"},
|
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:temporal", "event": "UserPromptSubmit"},
|
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:user_message_relay", "event": "UserPromptSubmit"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:user_message_relay", "event": "UserPromptSubmit"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse", "event": "PostToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse", "event": "PostToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"},
|
||||||
@@ -25,9 +23,7 @@
|
|||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "manual", "timeout": 60},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "manual", "timeout": 60},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "auto", "timeout": 60},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "auto", "timeout": 60},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "manual", "timeout": 120},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "manual", "timeout": 120},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "auto", "timeout": 120},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "auto", "timeout": 120}
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_prep", "event": "PreCompact", "matcher": "manual", "timeout": 30},
|
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_prep", "event": "PreCompact", "matcher": "auto", "timeout": 30}
|
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"AIPASS_HOME": "{{REPO_ROOT}}",
|
"AIPASS_HOME": "{{REPO_ROOT}}",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
# Hash-pinned tool install (Scorecard: Pinned-Dependencies). Pins ruff to
|
# Hash-pinned tool install (Scorecard: Pinned-Dependencies). Pins ruff to
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- run: |
|
- run: |
|
||||||
@@ -55,7 +55,7 @@ jobs:
|
|||||||
# makes every file look born at HEAD, so every README false-fails as
|
# makes every file look born at HEAD, so every README false-fails as
|
||||||
# "stale". Full history makes CI match a local audit exactly.
|
# "stale". Full history makes CI match a local audit exactly.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
- run: |
|
- run: |
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
- run: |
|
- run: |
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
attestations: write
|
attestations: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
# Hash-pinned tool install (Scorecard: Pinned-Dependencies).
|
# Hash-pinned tool install (Scorecard: Pinned-Dependencies).
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
# Upgrade pip first: pip-audit scans the whole environment, and the
|
# Upgrade pip first: pip-audit scans the whole environment, and the
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,3 @@ README_ORIGINAL_DISABLED.md
|
|||||||
*.bak
|
*.bak
|
||||||
test/
|
test/
|
||||||
sandbox_test/
|
sandbox_test/
|
||||||
|
|
||||||
# SQLite WAL sidecars (runtime artifacts — commons.db ro-feed etc.)
|
|
||||||
*.db-wal
|
|
||||||
*.db-shm
|
|
||||||
|
|||||||
-216
@@ -9,224 +9,8 @@ PyPI version — not the changelog header.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [2026-07-21]
|
|
||||||
|
|
||||||
**feat(prax)** — Commons live social feed in the monitor (DPLAN-0257, Patrick
|
|
||||||
ask verbatim): `drone @prax monitor run commons` now streams The Commons'
|
|
||||||
chatter — posts, comments, votes, reactions — room-tagged with mood coloring,
|
|
||||||
monitor-style. ~10-event backfill on open, then 1.5s id-cursor polling.
|
|
||||||
Read-only by construction (`mode=ro` sqlite URI — write attempt refused,
|
|
||||||
verified live); commons stays the only writer, zero commons-side changes.
|
|
||||||
Branch-log tail still reachable via `monitor run commons --logs`; mixed branch
|
|
||||||
lists unchanged. `--relay` rides the existing Telegram relay path.
|
|
||||||
33 new tests, prax suite 1065 green, audit 100% (52 files). Door-tested live:
|
|
||||||
devpulse posted/replied/reacted while the feed streamed every event.
|
|
||||||
Built by @prax.
|
|
||||||
|
|
||||||
**fix(hooks)** — two DPLAN-0253 backlog hardenings (DPLAN-0256 clear):
|
|
||||||
engine handler timeout + presence_gate PID-reuse defense. `_run_handler` now
|
|
||||||
runs handler-type hooks on a daemon thread joined with the hooks.json
|
|
||||||
`timeout` field (default 30s) — a hung handler returns TIMEOUT loud
|
|
||||||
(engine.jsonl + sound) and the event moves on; daemon thread chosen over
|
|
||||||
ThreadPoolExecutor so a stuck orphan can never hang interpreter exit.
|
|
||||||
presence_gate occupancy no longer trusts `os.kill(pid, 0)` alone:
|
|
||||||
`procStart` (CC session file) is matched against `/proc/<pid>/stat` field 22
|
|
||||||
so a kernel-recycled PID can't impersonate a dead session — closes the gap
|
|
||||||
before observe-only ever flips to enforcement. Missing procStart / non-Linux
|
|
||||||
falls back to liveness-only, logged. 15 new tests, suite 1272 green,
|
|
||||||
seedgo 31/31 both files. Built by @hooks.
|
|
||||||
|
|
||||||
**fix(trigger)** — runaway-log alerts get the 24h TTL every other mute already
|
|
||||||
had (DPLAN-0256 backlog clear): `_write_alert()` hardcoded `expires_at: None`,
|
|
||||||
so alerts.json entries nagged forever while medic branch mutes self-expired.
|
|
||||||
New `DEFAULT_ALERT_TTL_SECONDS = 86400` (matches medic_state's
|
|
||||||
`DEFAULT_MUTE_SECONDS`) with a `forever` escape hatch threaded through
|
|
||||||
`handle_runaway_log_detected()`. 2 new tests, trigger suite 621 green,
|
|
||||||
audit 100%. Built by @trigger.
|
|
||||||
|
|
||||||
**feat(drone)** — joint-decision gate on `drone @git merge` (DPLAN-0256,
|
|
||||||
Patrick ruling S330: merges are always done together, never accidental).
|
|
||||||
The gate sits in `_handle_merge` before the plugin import — `merge_pr()` is
|
|
||||||
unreachable without confirmation. A real terminal gets an interactive y/N
|
|
||||||
prompt; headless callers (agent Bash) are refused unless `--confirm` is
|
|
||||||
passed explicitly. Every gate decision (confirm / tty-yes / tty-abort /
|
|
||||||
headless-refused) is logged via json_handler. 6 new tests (86 green),
|
|
||||||
live-fired refusal verified, seedgo 31/31.
|
|
||||||
|
|
||||||
**feat(skills)** — telegram user_message_relay joins the sound layer: relay
|
|
||||||
events now carry their own sound key so an inbound user message is audible
|
|
||||||
like every other hook event (59/59 + 252 green).
|
|
||||||
|
|
||||||
**fix(devpulse)** — watchdog stall threshold 120s → 300s: the 120s
|
|
||||||
no-JSONL-activity heuristic fired `[watchdog.stall]` on healthy agents doing
|
|
||||||
long tool calls; 300s matches observed real-stall behavior (verified live
|
|
||||||
S330). Branch `.claude/settings.local.json` carries the devpulse
|
|
||||||
`autoCompactWindow: 350000` dial (Patrick ruling S326 — devpulse compacts
|
|
||||||
~292k, dispatched agents stay pinned at 200k).
|
|
||||||
|
|
||||||
**fix(seedgo)** — checker accuracy arc (S330): AST-based import analysis
|
|
||||||
lands in the checkers (dead_code, encapsulation, handlers, readme,
|
|
||||||
test_quality, unused_function) — 13 false positives eliminated fleet-wide,
|
|
||||||
2 real hooks imports that legitimately bypass the pattern documented instead
|
|
||||||
of suppressed. branch_audit, checklist and ignore_handler aligned; provider
|
|
||||||
hooks snapshot fixture refreshed; stale bypass entries for deleted tools
|
|
||||||
purged across branches (devpulse, memory, seedgo, hooks). Fleet audit 100%.
|
|
||||||
|
|
||||||
**feat(hooks)** — hook sound layer + temporal grounding. Sounds now mirror
|
|
||||||
the log across the hook fleet (prompt, lifecycle, notification, security
|
|
||||||
handlers) — audible liveness for the whole layer, verified live (2465 green,
|
|
||||||
audit 100). New `prompt/temporal.py`: tiny always-on UserPromptSubmit handler
|
|
||||||
injecting one line of local date/time/weekday/part-of-day every turn — live
|
|
||||||
clock each fire, host timezone via `astimezone()` (clones see their own local
|
|
||||||
time). Wired on both wires (`.aipass/hooks.json` + provider manifest).
|
|
||||||
|
|
||||||
**feat(aipass)** — `aipass adopt` + shared scaffold refactor: adopt turns an
|
|
||||||
existing `projects/` directory into a full AIPass project (registry, resident
|
|
||||||
agent, `.aipass`/`.claude` scaffold) — every write additive, nothing existing
|
|
||||||
overwritten; unlike `aipass new` it starts from a directory with its own
|
|
||||||
content and git history. New `shared/` package (`project_home.py`,
|
|
||||||
`scaffold_content.py`) gives init/new/adopt one source of truth per helper —
|
|
||||||
`handlers/init/scaffold_content.py` moved there, no per-command copies to
|
|
||||||
drift. Proven live adopting aipass-site (doctor 31/0). Spawn template registry
|
|
||||||
synced (template bug chain me→spawn→aipass, fixed S329). 786 tests green,
|
|
||||||
audit-clean.
|
|
||||||
|
|
||||||
## [2026-07-20]
|
|
||||||
|
|
||||||
**docs(projects)** — `projects/README.md`: the projects section now ships in
|
|
||||||
the repo (the `!projects/README.md` gitignore whitelist existed since the
|
|
||||||
aipass-new design but the file was never written). Explains the project model:
|
|
||||||
**private by default** — each project is its own local git repo, fully ignored
|
|
||||||
by the AIPass repo, and publishing is an explicit opt-in step (Patrick ruling
|
|
||||||
2026-07-20). Opens the public roster with **Earmark**
|
|
||||||
([AIOSAI/earmark](https://github.com/AIOSAI/earmark)), the first public AIPass
|
|
||||||
project — a VS Code read-aloud extension with local Piper TTS and true
|
|
||||||
pause/resume, born, built, and published 2026-07-20.
|
|
||||||
|
|
||||||
**fix(hooks)** — persistent_alert dedup + loud trust-break banner (5-agent
|
|
||||||
trace round follow-ups, DPLAN-0253 tail):
|
|
||||||
|
|
||||||
- persistent_alert's once-per-session sound dedup lived in a module-global set,
|
|
||||||
but every bridge call is a fresh process — TTS would have announced on every
|
|
||||||
prompt while any alert was active. Replaced with session+alert-keyed tempdir
|
|
||||||
guard files (context_gauge idiom); banner capped at 10 alerts with an
|
|
||||||
"...and N more" note.
|
|
||||||
- Trust-registry breaks are now LOUD: any `.aipass/hooks.json` change breaks
|
|
||||||
the enrolled hash and silently disabled the entire hook layer (bit us live
|
|
||||||
for 2+ hours — tier prompts, security gates, everything dark, one log-file
|
|
||||||
WARNING as the only signal). New `is_hash_mismatch()` distinguishes a
|
|
||||||
genuine break from never-enrolled; `trust_break_banner()` does a
|
|
||||||
config-independent walk+hash check; the engine emits a full-width banner
|
|
||||||
once per prompt via the presence_gate bridge call. No auto-heal —
|
|
||||||
re-enrollment stays a deliberate human checkpoint. Live-fired: hash broken →
|
|
||||||
banner; restored → healthy. 16 new tests, suite 1206 green, seedgo 100%.
|
|
||||||
- Go-live day for the whole handler roster: 11/12 manifest entries wired into
|
|
||||||
provider settings by devpulse with Patrick accepting (user_message_relay
|
|
||||||
held: synchronous Telegram call + full prompt text off-machine — needs a
|
|
||||||
background send and an explicit call first). @hooks branch prompt corrected
|
|
||||||
and hardened: two-wires checklist + mandatory provider-wire flag in every
|
|
||||||
build reply.
|
|
||||||
|
|
||||||
**feat(hooks)** — auto-compact prep: context gauge + mechanical snapshot
|
|
||||||
(DPLAN-0253, built by @hooks, two rounds):
|
|
||||||
|
|
||||||
- `context_gauge` (UserPromptSubmit) — reads live context fill from the session
|
|
||||||
transcript every prompt (cheap 50KB tail), resolves the branch's compact
|
|
||||||
window (env > branch `settings.local.json` `autoCompactWindow` > 200k), and
|
|
||||||
injects a "run /prep NOW" nudge at 80% of the compact trigger, escalating at
|
|
||||||
95% — once per threshold per session. Memory prep happens before auto-compact
|
|
||||||
takes the choice away, on every branch including dispatched agents.
|
|
||||||
- `pre_compact_prep` (PreCompact) — stamps a mechanical AUTO-COMPACT SNAPSHOT
|
|
||||||
session entry into the compacting branch's `.trinity/local.json`: context
|
|
||||||
fill %, active dispatch locks, open plans, git state, inbox unread. Templated
|
|
||||||
from live state, defensive (malformed memory = log + skip, never raises).
|
|
||||||
- Shared `context_window` module: bounded transcript tail reader + per-branch
|
|
||||||
window resolver. 36 new tests; suite 1190 green; seedgo 100%.
|
|
||||||
- Round 2 root-cause fix: handlers wired only in `.aipass/hooks.json` never
|
|
||||||
fire on name-scoped events — UserPromptSubmit and PreCompact invoke the
|
|
||||||
bridge per-handler from provider settings. Both handlers now have
|
|
||||||
`provider_manifest.json` entries; @hooks' branch prompt corrected (it taught
|
|
||||||
the old one-entry-per-event model) with a "new handler? check the provider
|
|
||||||
wire" reminder. Go-live needs the user's `~/.claude/settings.json` synced
|
|
||||||
from the manifest + fresh sessions.
|
|
||||||
|
|
||||||
## [2026-07-19]
|
|
||||||
|
|
||||||
**feat(ai_mail)** — dispatched agents default to Sonnet 5 (Patrick ruling S326):
|
|
||||||
|
|
||||||
- wake.py model resolution passes bare aliases (`sonnet`/`opus`/`haiku`)
|
|
||||||
straight to the Claude CLI, which resolves latest-in-class — the pinned-ID
|
|
||||||
MODEL_MAP is gone and can never go stale again. Default flips opus → sonnet.
|
|
||||||
- dispatch_monitor pins `CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000` on every
|
|
||||||
spawned agent — Sonnet 5 is 1M-context native, and without the pin every
|
|
||||||
dispatched agent would silently inherit a 1M window. E2E-proven: a live
|
|
||||||
dispatched probe reported `claude-sonnet-5` + `WINDOW=200000` from inside.
|
|
||||||
- Follow-up landed same morning: the "daemon gap" was a name collision —
|
|
||||||
the unpatched `spawn_agent()` was ai_mail's own inbox-poller
|
|
||||||
(`handlers/dispatch/daemon.py`), not the @daemon branch. It now passes
|
|
||||||
`--model DEFAULT_MODEL` (imported from wake.py, single source); the 200k pin
|
|
||||||
was already covered via the shared dispatch_monitor wrapper. @daemon's
|
|
||||||
scheduled wakes import `wake_branch` directly and were covered from the start.
|
|
||||||
|
|
||||||
**fix(skills)** — Telegram poll 5xx now triggers network backoff (medic loop,
|
|
||||||
autonomous @skills fix): `HTTPError` ≥500 in `poll_updates` raises
|
|
||||||
`_NetworkPollError` instead of falling through to rapid-fire retry; 4xx still
|
|
||||||
logs and returns. Three new tests (502/503 backoff, 429 stays out).
|
|
||||||
|
|
||||||
**fix(spawn, commons, prax, hooks)** — S304 audit fix campaign, Track A
|
|
||||||
(DPLAN-0250, four owner dispatches verified + committed by devpulse):
|
|
||||||
|
|
||||||
- **spawn** — shared `is_protected()` (infrastructure floor / registry owner /
|
|
||||||
active passport) now guards both pollution repair and branch delete; repair no
|
|
||||||
longer flags the live `src/aipass/aipass` branch, and deleting a protected or
|
|
||||||
actively-passported branch is refused with the reason.
|
|
||||||
- **commons** — branch-name resolution lowercased across all five ops sites
|
|
||||||
(trade/artifact/profile/welcome/search) to match identity normalization;
|
|
||||||
gifting and trading work again (guards had never matched since mid-June).
|
|
||||||
- **prax** — pytest detection now also checks `_pytest` in `sys.modules`, so
|
|
||||||
`patch.dict(os.environ, clear=True)` in test suites can no longer strip the
|
|
||||||
guard and freeze prod-path log handlers into the setup cache.
|
|
||||||
- **hooks** — the two static-path JSONL writers (engine diagnostics, telegram
|
|
||||||
delivery log) resolve their path per-write and route to the tmp test dir
|
|
||||||
under pytest; a full 1154-test suite run now adds zero lines to prod logs
|
|
||||||
(marker-bounded proof).
|
|
||||||
|
|
||||||
Also: `/prep` now checks the cross-project feedback box every run — the S304
|
|
||||||
"unread since April" backlog (F46) is processed to zero and can't silently
|
|
||||||
rot again.
|
|
||||||
|
|
||||||
**fix(aipass)** — `aipass doctor` no longer invents errors on a healthy repo
|
|
||||||
(S304 F14-17): `.backup` and spawn `templates` dirs excluded from the agent
|
|
||||||
scan, relative registry paths anchored against the project root instead of the
|
|
||||||
caller's CWD (running doctor from inside a branch dir showed 5 fake
|
|
||||||
missing-branch errors), and info-severity findings now render as warnings
|
|
||||||
instead of pass checkmarks. Remaining findings on this repo are genuine.
|
|
||||||
|
|
||||||
**fix(flow)** — registry aggregate writes are lock+atomic (S304 F85 residual):
|
|
||||||
`save_branch_registry` and `save_central` were bare `open`+`json.dump`; both now
|
|
||||||
use the O_EXCL lockfile + temp-file + `os.replace` pattern from the earlier
|
|
||||||
`save_registry.py` fix. Proven with a 6-process concurrent-write hammer. Also
|
|
||||||
investigated (N1): FPLAN-0313/0314 closed blank because
|
|
||||||
`is_template_content()`'s line-count threshold fires before its bracket-marker
|
|
||||||
check on default templates — guard fix queued, registry annotation is the
|
|
||||||
maintainer's call.
|
|
||||||
|
|
||||||
## [2026-07-18]
|
## [2026-07-18]
|
||||||
|
|
||||||
**fix(tests)** — the immortal `MagicMock/LOG_FILE/` directory is dead: a hooks
|
|
||||||
engine test patched `diagnostics.LOG_FILE` with a bare Mock, and prax's
|
|
||||||
`append_jsonl` turned the mock's fspath into a real `mkdir` — every test run
|
|
||||||
re-minted an empty `MagicMock/LOG_FILE/` in the runner's CWD (found in repo
|
|
||||||
root, devpulse, and hooks). Test now patches a real tmp path; 100/100 green,
|
|
||||||
clean-CWD run verified to mint nothing.
|
|
||||||
|
|
||||||
**docs** — merge playbook gains a site drift-check step (DPLAN-0249 follow-on):
|
|
||||||
every merge run now asks whether install commands, onboarding flow, agent count,
|
|
||||||
or the platform/CLI story changed — if yes, aipass.ai must be updated the same
|
|
||||||
day. Codifies the S323 ruling that the site is a projection of the README, never
|
|
||||||
its own source of facts. Template edit by @flow, one checklist line in the
|
|
||||||
post-merge section.
|
|
||||||
|
|
||||||
**docs** — root README v3 restructure (DPLAN-0249): single-funnel story with
|
**docs** — root README v3 restructure (DPLAN-0249): single-funnel story with
|
||||||
zero duplicated commands (install, `aipass new`/`init run`, trees, drone
|
zero duplicated commands (install, `aipass new`/`init run`, trees, drone
|
||||||
examples each taught exactly once), hero link line to aipass.ai/PyPI/r/AIPass,
|
examples each taught exactly once), hero link line to aipass.ai/PyPI/r/AIPass,
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
[← Back to AIPass](../README.md)
|
|
||||||
|
|
||||||
# Projects
|
|
||||||
|
|
||||||
> Your projects, built with AIPass. **Private by default** — published only if and when you choose.
|
|
||||||
|
|
||||||
This folder is where your own projects live. Create one with `aipass new <name>` and build whatever you want here — a tool, an app, an experiment, something nobody else will ever see. Each project is **its own separate git repository**, and the AIPass repo ignores everything in this folder (only this README ships). Nothing you build here can leak into the AIPass repo, appear in its history, or end up in anyone's pull request.
|
|
||||||
|
|
||||||
## What a project is
|
|
||||||
|
|
||||||
Every project is born complete:
|
|
||||||
|
|
||||||
- **Own git repo** — initialized locally at creation. Local means local: no remote, no publishing, nothing leaves your machine.
|
|
||||||
- **Own registry** — a sealed `*_REGISTRY.json` seating the project's owner.
|
|
||||||
- **Resident agent** — a full AIPass citizen living at `src/<name>/<name>/`, with identity, memory, and a mailbox, ready to work the project.
|
|
||||||
- **AIPass scaffold** — the same `.aipass/` prompt structure and conventions as the main ecosystem, so any agent (or human) knows their way around immediately.
|
|
||||||
|
|
||||||
Projects use AIPass; they don't live inside its repo. The ecosystem is the workshop — what you build in it is yours.
|
|
||||||
|
|
||||||
## Going public — optional, deliberate
|
|
||||||
|
|
||||||
If a project is ready for the world, publishing is an explicit choice: create a GitHub repository for it and push. Until you do that, it exists only on your machine.
|
|
||||||
|
|
||||||
Projects from the AIPass family that chose to go public:
|
|
||||||
|
|
||||||
| Project | What it is | Repo |
|
|
||||||
|---|---|---|
|
|
||||||
| **Earmark** | Read code and docs aloud in VS Code with local Piper TTS — pause, resume, pick up where you left off. Ear + bookmark: the plan is notes anchored to where you paused. First public AIPass project. | [AIOSAI/earmark](https://github.com/AIOSAI/earmark) |
|
|
||||||
| **aipass-site** | The [aipass.ai](https://aipass.ai) website — AIPass's front door on the web. | [AIOSAI/aipass-site](https://github.com/AIOSAI/aipass-site) |
|
|
||||||
|
|
||||||
Projects in residence (private, not yet published):
|
|
||||||
|
|
||||||
| Project | What it is |
|
|
||||||
|---|---|
|
|
||||||
| **Speakeasy** | System-wide voice-to-text: press a hotkey, speak, text lands at your cursor in any app. Local Whisper (faster-whisper), VAD, zero cloud. The voice-IN half of the loop Earmark's voice-OUT completes. Repo moved from ~/Projects/Speakeasy 2026-07-21, own git history intact. |
|
|
||||||
|
|
||||||
## Creating one
|
|
||||||
|
|
||||||
```bash
|
|
||||||
aipass new <name> # empty template + resident agent
|
|
||||||
aipass new <name> --template python
|
|
||||||
aipass new <name> --no-agent
|
|
||||||
```
|
|
||||||
|
|
||||||
The project lands in `projects/<name>`, git-initialized, registry sealed, agent seated — and private until you decide otherwise.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
[← Back to AIPass](../README.md)
|
|
||||||
@@ -34,7 +34,6 @@ from aipass.ai_mail.apps.handlers.json import json_handler
|
|||||||
from aipass.ai_mail.apps.handlers.dispatch.status import log_dispatch
|
from aipass.ai_mail.apps.handlers.dispatch.status import log_dispatch
|
||||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||||
from aipass.ai_mail.apps.handlers.dispatch.test_token import scan_and_ack_test_emails
|
from aipass.ai_mail.apps.handlers.dispatch.test_token import scan_and_ack_test_emails
|
||||||
from aipass.ai_mail.apps.handlers.dispatch.wake import DEFAULT_MODEL
|
|
||||||
|
|
||||||
|
|
||||||
# Infrastructure paths
|
# Infrastructure paths
|
||||||
@@ -409,8 +408,6 @@ def spawn_agent(
|
|||||||
"-c",
|
"-c",
|
||||||
"-p",
|
"-p",
|
||||||
prompt,
|
prompt,
|
||||||
"--model",
|
|
||||||
DEFAULT_MODEL,
|
|
||||||
"--max-turns",
|
"--max-turns",
|
||||||
str(max_turns),
|
str(max_turns),
|
||||||
"--permission-mode",
|
"--permission-mode",
|
||||||
|
|||||||
@@ -449,9 +449,6 @@ def main():
|
|||||||
for key in list(spawn_env.keys()):
|
for key in list(spawn_env.keys()):
|
||||||
if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID":
|
if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID":
|
||||||
spawn_env.pop(key)
|
spawn_env.pop(key)
|
||||||
# Pin agent context window to 200k (Sonnet 5 is 1M native; without this,
|
|
||||||
# agents inherit 1M which causes cost + runaway risk).
|
|
||||||
spawn_env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = "200000"
|
|
||||||
# Strip caller identity vars to prevent dispatch context leakage.
|
# Strip caller identity vars to prevent dispatch context leakage.
|
||||||
spawn_env.pop("AIPASS_CALLER_BRANCH", None)
|
spawn_env.pop("AIPASS_CALLER_BRANCH", None)
|
||||||
spawn_env.pop("AIPASS_CALLER_CWD", None)
|
spawn_env.pop("AIPASS_CALLER_CWD", None)
|
||||||
|
|||||||
@@ -63,9 +63,13 @@ MONITOR_SCRIPT = Path(__file__).parent / "dispatch_monitor.py"
|
|||||||
# Default prompt when no custom message provided
|
# Default prompt when no custom message provided
|
||||||
DEFAULT_PROMPT = "Hi. Check inbox, process new emails, update memories when done."
|
DEFAULT_PROMPT = "Hi. Check inbox, process new emails, update memories when done."
|
||||||
|
|
||||||
# Model aliases — passed directly to claude CLI which resolves latest-in-class.
|
# Model shorthand mapping
|
||||||
KNOWN_MODEL_ALIASES: frozenset = frozenset({"sonnet", "opus", "haiku"})
|
MODEL_MAP = {
|
||||||
DEFAULT_MODEL = "sonnet"
|
"sonnet": "claude-sonnet-4-6",
|
||||||
|
"opus": "claude-opus-4-6",
|
||||||
|
"haiku": "claude-haiku-4-5-20251001",
|
||||||
|
}
|
||||||
|
DEFAULT_MODEL = "opus"
|
||||||
|
|
||||||
# Branches that cannot be woken manually by cross-branch drone commands.
|
# Branches that cannot be woken manually by cross-branch drone commands.
|
||||||
# Dispatch-send path (dispatch.py._orchestrate_dispatch_send) bypasses this check.
|
# Dispatch-send path (dispatch.py._orchestrate_dispatch_send) bypasses this check.
|
||||||
@@ -587,8 +591,8 @@ def wake_branch(
|
|||||||
config = _load_config()
|
config = _load_config()
|
||||||
max_turns = config.get("max_turns_per_wake", 100)
|
max_turns = config.get("max_turns_per_wake", 100)
|
||||||
|
|
||||||
# Pass model directly to CLI — aliases resolve latest-in-class automatically
|
# Resolve model: shorthand -> full ID, or pass through if already a full ID
|
||||||
resolved_model = model or DEFAULT_MODEL
|
resolved_model = MODEL_MAP.get(model or DEFAULT_MODEL, model or MODEL_MAP[DEFAULT_MODEL])
|
||||||
|
|
||||||
lock_file_path = str(branch_path / ".ai_mail.local" / ".dispatch.lock")
|
lock_file_path = str(branch_path / ".ai_mail.local" / ".dispatch.lock")
|
||||||
if custom_message:
|
if custom_message:
|
||||||
@@ -777,7 +781,7 @@ if __name__ == "__main__":
|
|||||||
print(" --fresh Start fresh session (claude -p) instead of resuming (claude -c -p)")
|
print(" --fresh Start fresh session (claude -p) instead of resuming (claude -c -p)")
|
||||||
print(" --auto Respect autonomous_pause (used by daemon). Manual wake ignores it.")
|
print(" --auto Respect autonomous_pause (used by daemon). Manual wake ignores it.")
|
||||||
print(" --sender @branch Set return-to-sender for bounce emails (default: @devpulse)")
|
print(" --sender @branch Set return-to-sender for bounce emails (default: @devpulse)")
|
||||||
print(" --model NAME Model to use: sonnet (default), opus, haiku, or full model ID")
|
print(" --model NAME Model to use: opus (default), sonnet, haiku, or full model ID")
|
||||||
print()
|
print()
|
||||||
print("Output: Step-by-step status of the dispatch pipeline:")
|
print("Output: Step-by-step status of the dispatch pipeline:")
|
||||||
print(" ✅ resolve → @branch found at /path/to/branch")
|
print(" ✅ resolve → @branch found at /path/to/branch")
|
||||||
|
|||||||
@@ -1317,48 +1317,6 @@ def test_spawn_agent_strips_claude_env_vars(tmp_path, monkeypatch):
|
|||||||
assert captured_env.get("AIPASS_SESSION_TYPE") == "daemon"
|
assert captured_env.get("AIPASS_SESSION_TYPE") == "daemon"
|
||||||
|
|
||||||
|
|
||||||
def test_spawn_agent_claude_cmd_includes_model_flag(tmp_path):
|
|
||||||
"""Poller-triggered spawn passes --model DEFAULT_MODEL to the claude invocation."""
|
|
||||||
branch_path = tmp_path / "branch"
|
|
||||||
branch_path.mkdir()
|
|
||||||
(branch_path / "logs").mkdir()
|
|
||||||
|
|
||||||
message = {"from": "@devpulse", "id": "msg1", "subject": "Test task"}
|
|
||||||
config = {"max_turns_per_wake": 50}
|
|
||||||
state = {"daily_counts": {}, "session_cycles": {}}
|
|
||||||
|
|
||||||
captured_args = []
|
|
||||||
|
|
||||||
def capture_popen(*args, **kwargs):
|
|
||||||
captured_args.append(args[0] if args else kwargs.get("args"))
|
|
||||||
mock_proc = MagicMock()
|
|
||||||
mock_proc.pid = 22222
|
|
||||||
return mock_proc
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen",
|
|
||||||
side_effect=capture_popen,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock",
|
|
||||||
return_value=(True, "Lock acquired"),
|
|
||||||
),
|
|
||||||
patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"),
|
|
||||||
patch(
|
|
||||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification",
|
|
||||||
create=True,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
result = spawn_agent(branch_path, "@testbranch", message, config, state)
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
monitor_cmd = captured_args[0]
|
|
||||||
assert "--model" in monitor_cmd
|
|
||||||
model_idx = monitor_cmd.index("--model")
|
|
||||||
assert monitor_cmd[model_idx + 1] == daemon_mod.DEFAULT_MODEL
|
|
||||||
|
|
||||||
|
|
||||||
def test_spawn_agent_prompt_includes_reply_id(tmp_path):
|
def test_spawn_agent_prompt_includes_reply_id(tmp_path):
|
||||||
"""Prompt includes explicit reply command with the dispatch email ID."""
|
"""Prompt includes explicit reply command with the dispatch email ID."""
|
||||||
branch_path = tmp_path / "branch"
|
branch_path = tmp_path / "branch"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from aipass.ai_mail.apps.handlers.dispatch.wake import (
|
|||||||
_find_claude_bin,
|
_find_claude_bin,
|
||||||
resolve_branch,
|
resolve_branch,
|
||||||
DispatchStatus,
|
DispatchStatus,
|
||||||
KNOWN_MODEL_ALIASES,
|
MODEL_MAP,
|
||||||
DEFAULT_MODEL,
|
DEFAULT_MODEL,
|
||||||
_acquire_lock,
|
_acquire_lock,
|
||||||
_load_config,
|
_load_config,
|
||||||
@@ -572,22 +572,24 @@ def _fake_open_factory(real_status_path, mapping):
|
|||||||
# --- Model flag tests ---------------------------------------------------
|
# --- Model flag tests ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_known_model_aliases_has_expected_entries():
|
def test_model_map_has_expected_entries():
|
||||||
"""KNOWN_MODEL_ALIASES should contain sonnet, opus, haiku."""
|
"""MODEL_MAP should contain sonnet, opus, haiku shorthand mappings."""
|
||||||
assert "sonnet" in KNOWN_MODEL_ALIASES
|
assert "sonnet" in MODEL_MAP
|
||||||
assert "opus" in KNOWN_MODEL_ALIASES
|
assert "opus" in MODEL_MAP
|
||||||
assert "haiku" in KNOWN_MODEL_ALIASES
|
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_default_model_is_sonnet():
|
def test_default_model_is_opus():
|
||||||
"""Default model should be sonnet."""
|
"""Default model should be opus."""
|
||||||
assert DEFAULT_MODEL == "sonnet"
|
assert DEFAULT_MODEL == "opus"
|
||||||
|
|
||||||
|
|
||||||
def test_known_model_aliases_are_bare_names():
|
def test_model_map_values_are_full_ids():
|
||||||
"""All KNOWN_MODEL_ALIASES should be bare alias names (no 'claude-' prefix)."""
|
"""All MODEL_MAP values should be full claude model IDs."""
|
||||||
for alias in KNOWN_MODEL_ALIASES:
|
for key, value in MODEL_MAP.items():
|
||||||
assert not alias.startswith("claude-"), f"{alias} should be a bare alias"
|
assert value.startswith("claude-"), f"{key} -> {value} doesn't start with 'claude-'"
|
||||||
|
|
||||||
|
|
||||||
# --- _find_claude_bin tests ------------------------------------------
|
# --- _find_claude_bin tests ------------------------------------------
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ aipass # Show available commands
|
|||||||
aipass doctor # Check system health
|
aipass doctor # Check system health
|
||||||
aipass help what does drone do # Search branch documentation
|
aipass help what does drone do # Search branch documentation
|
||||||
aipass new myapp --template python # Create a new project
|
aipass new myapp --template python # Create a new project
|
||||||
aipass adopt myapp --dry-run # Preview adopting an existing projects/ dir
|
|
||||||
aipass init # Guided setup (10 stages, resumable)
|
aipass init # Guided setup (10 stages, resumable)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -35,7 +34,6 @@ aipass/
|
|||||||
│ │ ├── init_flow.py # 10-stage guided setup
|
│ │ ├── init_flow.py # 10-stage guided setup
|
||||||
│ │ ├── install.py # aipass install — one-command bootstrap (clone + setup + init)
|
│ │ ├── install.py # aipass install — one-command bootstrap (clone + setup + init)
|
||||||
│ │ ├── new_project.py # aipass new — create projects inside the installation
|
│ │ ├── new_project.py # aipass new — create projects inside the installation
|
||||||
│ │ ├── adopt.py # aipass adopt — bring an existing projects/ dir into AIPass
|
|
||||||
│ │ ├── profile.py # User profile read/write
|
│ │ ├── profile.py # User profile read/write
|
||||||
│ │ ├── trust.py # Trust registry — aipass trust / aipass revoke
|
│ │ ├── trust.py # Trust registry — aipass trust / aipass revoke
|
||||||
│ │ └── feedback.py # Feedback pulse toggle — aipass feedback on/off
|
│ │ └── feedback.py # Feedback pulse toggle — aipass feedback on/off
|
||||||
@@ -44,7 +42,6 @@ aipass/
|
|||||||
│ │ ├── handoff_platform/ # Platform-specific handoff detection
|
│ │ ├── handoff_platform/ # Platform-specific handoff detection
|
||||||
│ │ ├── init/ # bootstrap.py, scaffold_content.py
|
│ │ ├── init/ # bootstrap.py, scaffold_content.py
|
||||||
│ │ ├── new_project/ # Project creation logic (registry, template, scaffold, git init)
|
│ │ ├── new_project/ # Project creation logic (registry, template, scaffold, git init)
|
||||||
│ │ │ └── adopt.py # Project adoption logic (additive scaffold onto an existing dir)
|
|
||||||
│ │ ├── json/ # JSON read/write utilities
|
│ │ ├── json/ # JSON read/write utilities
|
||||||
│ │ ├── ping_sweep/ # Branch reachability verification
|
│ │ ├── ping_sweep/ # Branch reachability verification
|
||||||
│ │ ├── provider_reconcile.py # Stale deny-rule detection + fix
|
│ │ ├── provider_reconcile.py # Stale deny-rule detection + fix
|
||||||
@@ -53,7 +50,7 @@ aipass/
|
|||||||
│ │ ├── system_detect/ # OS, shell, Python, RAM, CPU
|
│ │ ├── system_detect/ # OS, shell, Python, RAM, CPU
|
||||||
│ │ └── ui/ # Progress bars, menus, banners
|
│ │ └── ui/ # Progress bars, menus, banners
|
||||||
│ └── plugins/
|
│ └── plugins/
|
||||||
├── tests/ # 785 passing
|
├── tests/ # 756 passing
|
||||||
├── requirements.project.txt # Project-specific Python dependencies
|
├── requirements.project.txt # Project-specific Python dependencies
|
||||||
├── .trinity/ # Identity + session history + observations
|
├── .trinity/ # Identity + session history + observations
|
||||||
└── README.md
|
└── README.md
|
||||||
@@ -77,9 +74,6 @@ aipass/
|
|||||||
| `aipass new <name>` | Create a project in projects/ — own git repo, AIPass scaffold, resident agent |
|
| `aipass new <name>` | Create a project in projects/ — own git repo, AIPass scaffold, resident agent |
|
||||||
| `aipass new <name> --template python` | Create with Python template (pyproject + src/) |
|
| `aipass new <name> --template python` | Create with Python template (pyproject + src/) |
|
||||||
| `aipass new <name> --no-agent` | Create without resident agent |
|
| `aipass new <name> --no-agent` | Create without resident agent |
|
||||||
| `aipass adopt <name>` | Turn an existing `projects/<name>` directory into a full project — additive scaffold only |
|
|
||||||
| `aipass adopt <name> --no-agent` | Adopt without a resident agent |
|
|
||||||
| `aipass adopt <name> --dry-run` | Preview what adoption would do, writes nothing |
|
|
||||||
| `aipass trust [path]` | Show enrolled projects or enroll a project in the trust registry |
|
| `aipass trust [path]` | Show enrolled projects or enroll a project in the trust registry |
|
||||||
| `aipass revoke <path>` | Remove a project from the trust registry |
|
| `aipass revoke <path>` | Remove a project from the trust registry |
|
||||||
| `aipass feedback on/off` | Toggle the feedback reminder pulse (delegates to @hooks) |
|
| `aipass feedback on/off` | Toggle the feedback reminder pulse (delegates to @hooks) |
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ from aipass.prax import logger
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
_PUBLIC_COMMANDS = {
|
_PUBLIC_COMMANDS = {
|
||||||
"adopt": "Turn an existing projects/ directory into a full project",
|
|
||||||
"doctor": "System health — structure, registry, hooks, tests",
|
"doctor": "System health — structure, registry, hooks, tests",
|
||||||
"help": "README-backed Q&A — ask about any branch",
|
"help": "README-backed Q&A — ask about any branch",
|
||||||
"init": "Guided setup for new users (10 stages, resumable)",
|
"init": "Guided setup for new users (10 stages, resumable)",
|
||||||
@@ -149,10 +148,6 @@ def print_help(modules: List[Any] | None = None) -> None:
|
|||||||
" [green]install[/green] [dim]One-command bootstrap — clone + setup.sh + hooks[/dim]"
|
" [green]install[/green] [dim]One-command bootstrap — clone + setup.sh + hooks[/dim]"
|
||||||
)
|
)
|
||||||
console.print(" [green]new <name>[/green] [dim]Create a project inside AIPass[/dim]")
|
console.print(" [green]new <name>[/green] [dim]Create a project inside AIPass[/dim]")
|
||||||
console.print(
|
|
||||||
" [green]adopt <name>[/green] "
|
|
||||||
"[dim]Turn an existing projects/ directory into a full project[/dim]"
|
|
||||||
)
|
|
||||||
console.print(" [green]profile[/green] [dim]Show/edit user profile[/dim]")
|
console.print(" [green]profile[/green] [dim]Show/edit user profile[/dim]")
|
||||||
console.print(
|
console.print(
|
||||||
" [green]trust[/green] [dim][path][/dim] [dim]Trust registry — enroll/revoke projects[/dim]"
|
" [green]trust[/green] [dim][path][/dim] [dim]Trust registry — enroll/revoke projects[/dim]"
|
||||||
@@ -164,7 +159,6 @@ def print_help(modules: List[Any] | None = None) -> None:
|
|||||||
console.print(" [green]aipass doctor[/green] [dim]Check system health[/dim]")
|
console.print(" [green]aipass doctor[/green] [dim]Check system health[/dim]")
|
||||||
console.print(" [green]aipass help what does drone do[/green] [dim]Search documentation[/dim]")
|
console.print(" [green]aipass help what does drone do[/green] [dim]Search documentation[/dim]")
|
||||||
console.print(" [green]aipass new myapp --template python[/green] [dim]Create a Python project[/dim]")
|
console.print(" [green]aipass new myapp --template python[/green] [dim]Create a Python project[/dim]")
|
||||||
console.print(" [green]aipass adopt myapp --dry-run[/green] [dim]Preview adopting projects/myapp[/dim]")
|
|
||||||
console.print(" [green]aipass init[/green] [dim]Start guided setup[/dim]")
|
console.print(" [green]aipass init[/green] [dim]Start guided setup[/dim]")
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from aipass.aipass.apps.handlers.init.bootstrap import (
|
|||||||
is_projects_child,
|
is_projects_child,
|
||||||
update_project,
|
update_project,
|
||||||
)
|
)
|
||||||
from aipass.aipass.shared.scaffold_content import (
|
from aipass.aipass.apps.handlers.init.scaffold_content import (
|
||||||
global_prompt_md,
|
global_prompt_md,
|
||||||
inbox_json,
|
inbox_json,
|
||||||
prep_md,
|
prep_md,
|
||||||
|
|||||||
@@ -30,23 +30,18 @@ RULES:
|
|||||||
- No hardcoded paths
|
- No hardcoded paths
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from aipass.aipass.shared import scaffold_content as sc
|
from aipass.aipass.apps.handlers.init import scaffold_content as sc
|
||||||
from aipass.aipass.shared.project_home import (
|
|
||||||
_claude_local_settings,
|
|
||||||
_claude_settings,
|
|
||||||
_detect_aipass_home,
|
|
||||||
_enroll_project,
|
|
||||||
is_projects_child,
|
|
||||||
is_throwaway_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -55,6 +50,25 @@ _STALE_MANAGED_FILES: list[Path] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_throwaway_path(path: str | Path) -> bool:
|
||||||
|
"""True if path is under a temp dir or Claude Code scratchpad."""
|
||||||
|
resolved = str(Path(path).resolve())
|
||||||
|
tmp_roots = [tempfile.gettempdir()]
|
||||||
|
if os.name == "posix":
|
||||||
|
tmp_roots.append("/tmp")
|
||||||
|
for root in tmp_roots:
|
||||||
|
try:
|
||||||
|
r = str(Path(root).resolve())
|
||||||
|
except OSError:
|
||||||
|
logger.info("is_throwaway_path: could not resolve %s", root)
|
||||||
|
continue
|
||||||
|
if resolved == r or resolved.startswith(r + os.sep):
|
||||||
|
return True
|
||||||
|
if "scratchpad" in resolved.lower():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_name(raw: str) -> str:
|
def _sanitize_name(raw: str) -> str:
|
||||||
"""Sanitize a project name for use in filenames.
|
"""Sanitize a project name for use in filenames.
|
||||||
|
|
||||||
@@ -64,6 +78,23 @@ def _sanitize_name(raw: str) -> str:
|
|||||||
return re.sub(r"[^A-Z0-9_-]", "_", raw.upper()).strip("_")
|
return re.sub(r"[^A-Z0-9_-]", "_", raw.upper()).strip("_")
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_aipass_home() -> str | None:
|
||||||
|
"""Detect the AIPass installation root from the aipass package location.
|
||||||
|
|
||||||
|
Returns the parent of the src/ directory (the repo root).
|
||||||
|
Returns None if detection fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
spec = importlib.util.find_spec("aipass")
|
||||||
|
if spec and spec.origin:
|
||||||
|
# aipass/__init__.py lives at src/aipass/__init__.py
|
||||||
|
# parent = src/aipass/, parent.parent = src/, parent.parent.parent = AIPass root
|
||||||
|
return str(Path(spec.origin).resolve().parent.parent.parent)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.info("AIPASS_HOME detection skipped: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _hook_fingerprint(hook_entry: dict) -> str:
|
def _hook_fingerprint(hook_entry: dict) -> str:
|
||||||
"""Extract a comparable fingerprint from a hook entry."""
|
"""Extract a comparable fingerprint from a hook entry."""
|
||||||
commands = []
|
commands = []
|
||||||
@@ -102,11 +133,10 @@ def _merge_settings(existing: dict, generated: dict) -> dict:
|
|||||||
if cleaned_hooks:
|
if cleaned_hooks:
|
||||||
merged["hooks"] = cleaned_hooks
|
merged["hooks"] = cleaned_hooks
|
||||||
|
|
||||||
# env: AIPASS_HOME is machine-local — never tracked (see settings.local.json).
|
# Merge env: generated wins for AIPASS_HOME, preserve user additions
|
||||||
# Strip it from any previously-tracked settings.json; preserve other user vars.
|
existing_env = existing.get("env", {})
|
||||||
existing_env = {k: v for k, v in existing.get("env", {}).items() if k != "AIPASS_HOME"}
|
generated_env = generated.get("env", {})
|
||||||
if existing_env:
|
merged["env"] = {**existing_env, **generated_env}
|
||||||
merged["env"] = existing_env
|
|
||||||
|
|
||||||
# Merge permissions: union deny/ask lists
|
# Merge permissions: union deny/ask lists
|
||||||
existing_perms = existing.get("permissions", {})
|
existing_perms = existing.get("permissions", {})
|
||||||
@@ -180,6 +210,75 @@ def _merge_hooks_json(existing: dict, template: dict) -> dict:
|
|||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _claude_settings(aipass_home: str | None = None) -> str:
|
||||||
|
"""Generate .claude/settings.json — env and permissions only.
|
||||||
|
|
||||||
|
Hooks are NOT wired at the project level. All AIPass hooks
|
||||||
|
(prompt injection, identity, email, pre-compact, edit gates) fire
|
||||||
|
from provider settings (~/.claude/settings.json), installed by
|
||||||
|
setup.sh. Provider hooks use CWD-walking patterns that work from
|
||||||
|
any directory in any project.
|
||||||
|
|
||||||
|
Project settings only contain:
|
||||||
|
- env.AIPASS_HOME (so hooks can find the AIPass installation)
|
||||||
|
- permissions.deny (basic safety rails)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
aipass_home: Optional AIPass installation root to add as env.AIPASS_HOME.
|
||||||
|
"""
|
||||||
|
data: dict = {}
|
||||||
|
|
||||||
|
data["permissions"] = {
|
||||||
|
"deny": [
|
||||||
|
"Bash(git push --force*)",
|
||||||
|
"Bash(git reset --hard*)",
|
||||||
|
"EnterPlanMode",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if aipass_home and not is_throwaway_path(aipass_home):
|
||||||
|
data["env"] = {"AIPASS_HOME": aipass_home}
|
||||||
|
elif aipass_home:
|
||||||
|
logger.warning(
|
||||||
|
"AIPASS_HOME '%s' is a throwaway path — not writing to settings",
|
||||||
|
aipass_home,
|
||||||
|
)
|
||||||
|
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _enroll_project(target: Path) -> None:
|
||||||
|
"""Enroll a project in the trusted-project registry (DPLAN-0244).
|
||||||
|
|
||||||
|
Lazy import to keep bootstrap.py free of prax/module-level deps.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from aipass.hooks.apps.handlers.config.trust_registry import enroll
|
||||||
|
|
||||||
|
if enroll(str(target)):
|
||||||
|
logger.info("Enrolled project in trust registry: %s", target)
|
||||||
|
else:
|
||||||
|
logger.warning("Trust enrollment failed for %s", target)
|
||||||
|
except ImportError as exc:
|
||||||
|
logger.info("Trust registry unavailable, skipping enrollment: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def is_projects_child(target: Path) -> bool:
|
||||||
|
"""True if *target* is ``<host>/projects/<name>`` — a valid nested project path.
|
||||||
|
|
||||||
|
The host is identified by having a ``*_REGISTRY.json`` in the grandparent
|
||||||
|
of target (i.e. target's parent is named ``projects``).
|
||||||
|
"""
|
||||||
|
resolved = target.resolve()
|
||||||
|
if resolved.parent.name != "projects":
|
||||||
|
return False
|
||||||
|
host = resolved.parent.parent
|
||||||
|
try:
|
||||||
|
return any(f.is_file() and f.name.endswith("_REGISTRY.json") for f in host.iterdir())
|
||||||
|
except OSError as exc:
|
||||||
|
logger.info("is_projects_child: could not read host dir %s: %s", host, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _guard_init(target: Path, *, allow_projects_child: bool = False) -> None:
|
def _guard_init(target: Path, *, allow_projects_child: bool = False) -> None:
|
||||||
"""Block init if target is inside an agent branch or existing project.
|
"""Block init if target is inside an agent branch or existing project.
|
||||||
|
|
||||||
@@ -348,23 +447,16 @@ def init_project(
|
|||||||
gitignore_path.write_text(sc.gitignore(), encoding="utf-8")
|
gitignore_path.write_text(sc.gitignore(), encoding="utf-8")
|
||||||
created.append(str(gitignore_path))
|
created.append(str(gitignore_path))
|
||||||
|
|
||||||
# 9. .claude/settings.json — tracked, permissions only (no machine-local paths)
|
# 9. .claude/settings.json
|
||||||
claude_dir = target / ".claude"
|
claude_dir = target / ".claude"
|
||||||
claude_dir.mkdir(exist_ok=True)
|
claude_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
settings_path = claude_dir / "settings.json"
|
settings_path = claude_dir / "settings.json"
|
||||||
if not settings_path.exists():
|
if not settings_path.exists():
|
||||||
settings_path.write_text(_claude_settings(), encoding="utf-8")
|
settings_path.write_text(_claude_settings(aipass_home), encoding="utf-8")
|
||||||
created.append(str(settings_path))
|
created.append(str(settings_path))
|
||||||
|
|
||||||
# 9b. .claude/settings.local.json — machine-local AIPASS_HOME (gitignored)
|
# 9b. .claude/commands/prep.md — /prep session wrap-up slash command
|
||||||
if aipass_home and not is_throwaway_path(aipass_home):
|
|
||||||
local_settings_path = claude_dir / "settings.local.json"
|
|
||||||
if not local_settings_path.exists():
|
|
||||||
local_settings_path.write_text(_claude_local_settings(aipass_home), encoding="utf-8")
|
|
||||||
created.append(str(local_settings_path))
|
|
||||||
|
|
||||||
# 9c. .claude/commands/prep.md — /prep session wrap-up slash command
|
|
||||||
# Only prep.md here — memo.md belongs at provider level (~/.claude/commands/)
|
# Only prep.md here — memo.md belongs at provider level (~/.claude/commands/)
|
||||||
commands_dir = claude_dir / "commands"
|
commands_dir = claude_dir / "commands"
|
||||||
commands_dir.mkdir(exist_ok=True)
|
commands_dir.mkdir(exist_ok=True)
|
||||||
@@ -488,11 +580,11 @@ def update_project(target: Path) -> dict:
|
|||||||
elif tier_dest.exists():
|
elif tier_dest.exists():
|
||||||
already_current.append(str(tier_dest))
|
already_current.append(str(tier_dest))
|
||||||
|
|
||||||
# settings.json — smart merge: preserve user hooks, update AIPass permissions.
|
# settings.json — smart merge: preserve user hooks + env, update AIPass hooks
|
||||||
# AIPASS_HOME never lives here — machine-local paths go in settings.local.json.
|
|
||||||
settings_path = claude_dir / "settings.json"
|
settings_path = claude_dir / "settings.json"
|
||||||
if not settings_path.exists():
|
if not settings_path.exists():
|
||||||
settings_path.write_text(_claude_settings(), encoding="utf-8")
|
aipass_home = _detect_aipass_home()
|
||||||
|
settings_path.write_text(_claude_settings(aipass_home), encoding="utf-8")
|
||||||
updated.append(str(settings_path))
|
updated.append(str(settings_path))
|
||||||
else:
|
else:
|
||||||
existing_content = settings_path.read_text(encoding="utf-8")
|
existing_content = settings_path.read_text(encoding="utf-8")
|
||||||
@@ -501,7 +593,9 @@ def update_project(target: Path) -> dict:
|
|||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
logger.info("settings.json parse failed, rebuilding: %s", exc)
|
logger.info("settings.json parse failed, rebuilding: %s", exc)
|
||||||
existing = {}
|
existing = {}
|
||||||
generated = json.loads(_claude_settings())
|
existing_env = existing.get("env", {})
|
||||||
|
aipass_home = existing_env.get("AIPASS_HOME") or _detect_aipass_home()
|
||||||
|
generated = json.loads(_claude_settings(aipass_home))
|
||||||
merged = _merge_settings(existing, generated)
|
merged = _merge_settings(existing, generated)
|
||||||
merged_content = json.dumps(merged, indent=2, ensure_ascii=False) + "\n"
|
merged_content = json.dumps(merged, indent=2, ensure_ascii=False) + "\n"
|
||||||
if existing != merged:
|
if existing != merged:
|
||||||
@@ -510,15 +604,6 @@ def update_project(target: Path) -> dict:
|
|||||||
else:
|
else:
|
||||||
already_current.append(str(settings_path))
|
already_current.append(str(settings_path))
|
||||||
|
|
||||||
# settings.local.json — machine-local AIPASS_HOME (gitignored, create if missing)
|
|
||||||
if aipass_home and not is_throwaway_path(aipass_home):
|
|
||||||
local_settings_path = claude_dir / "settings.local.json"
|
|
||||||
if not local_settings_path.exists():
|
|
||||||
local_settings_path.write_text(_claude_local_settings(aipass_home), encoding="utf-8")
|
|
||||||
updated.append(str(local_settings_path))
|
|
||||||
else:
|
|
||||||
already_current.append(str(local_settings_path))
|
|
||||||
|
|
||||||
# hooks.json — union-merge: preserve user enabled, add new hooks from template
|
# hooks.json — union-merge: preserve user enabled, add new hooks from template
|
||||||
hooks_json_path = aipass_dir / "hooks.json"
|
hooks_json_path = aipass_dir / "hooks.json"
|
||||||
hook_home = aipass_home or _detect_aipass_home()
|
hook_home = aipass_home or _detect_aipass_home()
|
||||||
|
|||||||
+6
-12
@@ -1,20 +1,17 @@
|
|||||||
# =================== AIPass ====================
|
# =================== AIPass ====================
|
||||||
# Name: scaffold_content.py
|
# Name: scaffold_content.py
|
||||||
# Description: Shared template content generators for project scaffolding
|
# Description: Template content generators for aipass init scaffold
|
||||||
# Version: 1.0.0
|
# Version: 1.0.0
|
||||||
# Created: 2026-04-22
|
# Created: 2026-04-22
|
||||||
# Modified: 2026-07-21
|
# Modified: 2026-04-22
|
||||||
# =============================================
|
# =============================================
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Scaffold Content — template generators for `aipass init`, `aipass new`, `aipass adopt`
|
Scaffold Content — template generators for `aipass init`
|
||||||
|
|
||||||
Pure string-returning functions that produce the content for each scaffold
|
Pure string-returning functions that produce the content for each scaffold
|
||||||
file (CLAUDE.md, AGENTS.md, .gitignore, etc.). Shared across every command
|
file (CLAUDE.md, AGENTS.md, etc.). Extracted from bootstrap.py to keep
|
||||||
that mints AIPass project files, so there is exactly one source of truth
|
the handler under 700 lines.
|
||||||
per template — no per-command copies to drift apart.
|
|
||||||
|
|
||||||
Dependency-free: uses only stdlib. Importable before drone/prax exist.
|
|
||||||
|
|
||||||
RULES:
|
RULES:
|
||||||
- Pure Python only (no module/prax/cli imports)
|
- Pure Python only (no module/prax/cli imports)
|
||||||
@@ -254,7 +251,7 @@ def gitignore() -> str:
|
|||||||
"*.egg-info/\n"
|
"*.egg-info/\n"
|
||||||
"dist/\n"
|
"dist/\n"
|
||||||
"build/\n"
|
"build/\n"
|
||||||
".venv\n"
|
".venv/\n"
|
||||||
"venv/\n"
|
"venv/\n"
|
||||||
"\n"
|
"\n"
|
||||||
"# IDE\n"
|
"# IDE\n"
|
||||||
@@ -270,9 +267,6 @@ def gitignore() -> str:
|
|||||||
"\n"
|
"\n"
|
||||||
"# Disabled files\n"
|
"# Disabled files\n"
|
||||||
"*(disabled)*\n"
|
"*(disabled)*\n"
|
||||||
"\n"
|
|
||||||
"# Registry lock\n"
|
|
||||||
".*_REGISTRY.lock\n"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -29,7 +29,6 @@ import uuid
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from aipass.aipass.shared import scaffold_content as sc
|
|
||||||
from aipass.prax import logger
|
from aipass.prax import logger
|
||||||
from aipass.spawn import spawn_agent
|
from aipass.spawn import spawn_agent
|
||||||
|
|
||||||
@@ -118,7 +117,10 @@ def _write_template(target: Path, name: str, template: str) -> list[str]:
|
|||||||
)
|
)
|
||||||
created.append("README.md")
|
created.append("README.md")
|
||||||
|
|
||||||
(target / ".gitignore").write_text(sc.gitignore(), encoding="utf-8")
|
(target / ".gitignore").write_text(
|
||||||
|
"__pycache__/\n*.pyc\n.venv\n.trinity/\n.ai_mail.local/\n*.local.json\n*.local/\nlogs/\n.*_REGISTRY.lock\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
created.append(".gitignore")
|
created.append(".gitignore")
|
||||||
|
|
||||||
if template == "python":
|
if template == "python":
|
||||||
@@ -156,13 +158,12 @@ def _write_template(target: Path, name: str, template: str) -> list[str]:
|
|||||||
|
|
||||||
def _scaffold_aipass(target: Path, name: str) -> list[str]:
|
def _scaffold_aipass(target: Path, name: str) -> list[str]:
|
||||||
"""Write AIPass scaffold files (tiers, hooks, CLAUDE.md, settings, .venv)."""
|
"""Write AIPass scaffold files (tiers, hooks, CLAUDE.md, settings, .venv)."""
|
||||||
from aipass.aipass.shared.project_home import (
|
from aipass.aipass.apps.handlers.init.bootstrap import (
|
||||||
_claude_local_settings,
|
|
||||||
_claude_settings,
|
_claude_settings,
|
||||||
_detect_aipass_home,
|
_detect_aipass_home,
|
||||||
_enroll_project,
|
_enroll_project,
|
||||||
is_throwaway_path,
|
|
||||||
)
|
)
|
||||||
|
from aipass.aipass.apps.handlers.init import scaffold_content as sc
|
||||||
|
|
||||||
created: list[str] = []
|
created: list[str] = []
|
||||||
aipass_home = _detect_aipass_home()
|
aipass_home = _detect_aipass_home()
|
||||||
@@ -205,23 +206,15 @@ def _scaffold_aipass(target: Path, name: str) -> list[str]:
|
|||||||
dest.write_text(sc.agents_md(reg), encoding="utf-8")
|
dest.write_text(sc.agents_md(reg), encoding="utf-8")
|
||||||
created.append(md_name)
|
created.append(md_name)
|
||||||
|
|
||||||
# .claude/settings.json — tracked, permissions only (no machine-local paths)
|
# .claude/settings.json
|
||||||
claude_dir = target / ".claude"
|
claude_dir = target / ".claude"
|
||||||
claude_dir.mkdir(exist_ok=True)
|
claude_dir.mkdir(exist_ok=True)
|
||||||
(claude_dir / "settings.json").write_text(
|
(claude_dir / "settings.json").write_text(
|
||||||
_claude_settings(),
|
_claude_settings(aipass_home),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
created.append(".claude/settings.json")
|
created.append(".claude/settings.json")
|
||||||
|
|
||||||
# .claude/settings.local.json — machine-local AIPASS_HOME (gitignored)
|
|
||||||
if aipass_home and not is_throwaway_path(aipass_home):
|
|
||||||
(claude_dir / "settings.local.json").write_text(
|
|
||||||
_claude_local_settings(aipass_home),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
created.append(".claude/settings.local.json")
|
|
||||||
|
|
||||||
# .claude/commands/prep.md
|
# .claude/commands/prep.md
|
||||||
commands_dir = claude_dir / "commands"
|
commands_dir = claude_dir / "commands"
|
||||||
commands_dir.mkdir(exist_ok=True)
|
commands_dir.mkdir(exist_ok=True)
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
# =================== AIPass ====================
|
|
||||||
# Name: adopt.py
|
|
||||||
# Description: aipass adopt — bring an existing projects/ directory into AIPass
|
|
||||||
# Version: 1.0.0
|
|
||||||
# Created: 2026-07-20
|
|
||||||
# Modified: 2026-07-20
|
|
||||||
# =============================================
|
|
||||||
|
|
||||||
"""
|
|
||||||
Adopt Handler — PRIVATE implementation
|
|
||||||
|
|
||||||
Business logic for `aipass adopt`. Turns an EXISTING directory at
|
|
||||||
<host>/projects/<name> into a full AIPass project: sealed registry,
|
|
||||||
resident agent, .aipass/.claude scaffold.
|
|
||||||
|
|
||||||
Unlike `aipass new` (which births a brand-new directory), adopt starts
|
|
||||||
from a directory that already has its own content — possibly its own
|
|
||||||
git repo, possibly public. Every write is existence-guarded; nothing
|
|
||||||
already present is ever overwritten. .gitignore is the one file that's
|
|
||||||
patched rather than skipped, since AIPass local state (.trinity/,
|
|
||||||
mailbox, *.local.json) must never leak into a tracked commit.
|
|
||||||
|
|
||||||
RULES:
|
|
||||||
- Additive only — never overwrite a file that already exists
|
|
||||||
- Never touch the target's git history (no git init/add/commit)
|
|
||||||
- gitignore safety runs BEFORE any other AIPass file is written
|
|
||||||
- Registry-first: mint the project registry before spawning the agent
|
|
||||||
- dry_run performs zero filesystem writes and never calls spawn_agent
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from aipass.aipass.apps.handlers.json import json_handler
|
|
||||||
from aipass.aipass.apps.handlers.new_project import (
|
|
||||||
_agent_home,
|
|
||||||
_registry_name,
|
|
||||||
_spawn_project_agent,
|
|
||||||
_write_registry,
|
|
||||||
)
|
|
||||||
from aipass.aipass.shared import scaffold_content as sc
|
|
||||||
from aipass.aipass.shared.project_home import (
|
|
||||||
_claude_local_settings,
|
|
||||||
_claude_settings,
|
|
||||||
_detect_aipass_home,
|
|
||||||
_enroll_project,
|
|
||||||
is_projects_child,
|
|
||||||
is_throwaway_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
GITIGNORE_MARKER = "# AIPass local state"
|
|
||||||
|
|
||||||
|
|
||||||
def _gitignore_safety(target: Path, *, dry_run: bool) -> str:
|
|
||||||
"""Ensure AIPass-managed paths are gitignored. Returns 'created', 'appended', or 'already-safe'."""
|
|
||||||
gitignore_path = target / ".gitignore"
|
|
||||||
if gitignore_path.exists():
|
|
||||||
existing = gitignore_path.read_text(encoding="utf-8")
|
|
||||||
if GITIGNORE_MARKER in existing:
|
|
||||||
return "already-safe"
|
|
||||||
if not dry_run:
|
|
||||||
separator = "" if existing.endswith("\n") else "\n"
|
|
||||||
gitignore_path.write_text(existing + separator + "\n" + sc.gitignore(), encoding="utf-8")
|
|
||||||
return "appended"
|
|
||||||
if not dry_run:
|
|
||||||
gitignore_path.write_text(sc.gitignore(), encoding="utf-8")
|
|
||||||
return "created"
|
|
||||||
|
|
||||||
|
|
||||||
def _write_if_missing(path: Path, content: str, *, dry_run: bool, planned: list[str]) -> None:
|
|
||||||
"""Record and (unless dry_run) write *content* to *path*, but only if it doesn't already exist."""
|
|
||||||
if path.exists():
|
|
||||||
return
|
|
||||||
if not dry_run:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(content, encoding="utf-8")
|
|
||||||
planned.append(str(path))
|
|
||||||
|
|
||||||
|
|
||||||
def adopt_project(target: Path, *, no_agent: bool = False, dry_run: bool = False) -> dict:
|
|
||||||
"""Adopt an existing directory at <host>/projects/<name> as a full AIPass project.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
target: Existing directory to adopt — must be <host>/projects/<name>.
|
|
||||||
no_agent: Skip resident-agent creation.
|
|
||||||
dry_run: Report what WOULD happen; performs zero filesystem writes and
|
|
||||||
never calls spawn_agent.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict with name, target, host, dry_run, registry_id, registry_file,
|
|
||||||
files, gitignore_action, agent_created, agent_home, spawn_result.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RuntimeError: target missing, not a projects/ child, already adopted,
|
|
||||||
or a resident-agent home path collision.
|
|
||||||
"""
|
|
||||||
target = target.resolve()
|
|
||||||
if not target.is_dir():
|
|
||||||
raise RuntimeError(f"'{target}' does not exist. `aipass adopt` brings in an EXISTING directory.")
|
|
||||||
|
|
||||||
if not is_projects_child(target):
|
|
||||||
raise RuntimeError(
|
|
||||||
f"'{target}' is not <host>/projects/<name>. `aipass adopt` only works inside projects/ "
|
|
||||||
"— use `aipass new` to create a fresh project instead."
|
|
||||||
)
|
|
||||||
|
|
||||||
existing_registry = [f for f in target.iterdir() if f.is_file() and f.name.endswith("_REGISTRY.json")]
|
|
||||||
if existing_registry:
|
|
||||||
raise RuntimeError(f"'{target}' is already adopted (has {existing_registry[0].name}).")
|
|
||||||
|
|
||||||
host = target.parent.parent
|
|
||||||
name = target.name
|
|
||||||
reg = _registry_name(name)
|
|
||||||
|
|
||||||
agent_home = _agent_home(target, name)
|
|
||||||
if not no_agent and agent_home.exists() and any(agent_home.iterdir()):
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Name collision: '{agent_home}' already exists and is non-empty — "
|
|
||||||
"cannot seat a resident agent there. Retry with --no-agent."
|
|
||||||
)
|
|
||||||
|
|
||||||
aipass_home = _detect_aipass_home()
|
|
||||||
files: list[str] = []
|
|
||||||
|
|
||||||
# gitignore safety FIRST — nothing AIPass-managed gets written before the
|
|
||||||
# target is confirmed to ignore it (target may be a public repo).
|
|
||||||
gitignore_action = _gitignore_safety(target, dry_run=dry_run)
|
|
||||||
|
|
||||||
# Registry — sealed, minted BEFORE spawn (registry-first rule)
|
|
||||||
registry_id = None
|
|
||||||
registry_filename = f"{reg}_REGISTRY.json"
|
|
||||||
if not dry_run:
|
|
||||||
registry_id, registry_filename = _write_registry(target, name)
|
|
||||||
files.append(registry_filename)
|
|
||||||
|
|
||||||
# .aipass/ — tier files, hooks.json, CLAUDE.md/AGENTS.md
|
|
||||||
aipass_dir = target / ".aipass"
|
|
||||||
if aipass_home:
|
|
||||||
for tier_file in ("tier0_kernel.md", "tier1_navmap.md"):
|
|
||||||
src_path = Path(aipass_home) / ".aipass" / tier_file
|
|
||||||
if src_path.is_file():
|
|
||||||
_write_if_missing(
|
|
||||||
aipass_dir / tier_file,
|
|
||||||
src_path.read_text(encoding="utf-8"),
|
|
||||||
dry_run=dry_run,
|
|
||||||
planned=files,
|
|
||||||
)
|
|
||||||
|
|
||||||
hooks_template = Path(aipass_home) / ".aipass" / "project_hooks.json"
|
|
||||||
if hooks_template.is_file():
|
|
||||||
hooks_dest = aipass_dir / "hooks.json"
|
|
||||||
already_had_hooks = hooks_dest.exists()
|
|
||||||
_write_if_missing(hooks_dest, hooks_template.read_text(encoding="utf-8"), dry_run=dry_run, planned=files)
|
|
||||||
if not already_had_hooks and not dry_run:
|
|
||||||
_enroll_project(target)
|
|
||||||
|
|
||||||
for md_name in ("CLAUDE.md", "AGENTS.md"):
|
|
||||||
dest = target / md_name
|
|
||||||
if dest.exists():
|
|
||||||
continue
|
|
||||||
if aipass_home:
|
|
||||||
tmpl = Path(aipass_home) / ".aipass" / f"project_{md_name}"
|
|
||||||
if tmpl.is_file():
|
|
||||||
content = tmpl.read_text(encoding="utf-8").replace("{name}", reg)
|
|
||||||
_write_if_missing(dest, content, dry_run=dry_run, planned=files)
|
|
||||||
continue
|
|
||||||
if md_name == "AGENTS.md":
|
|
||||||
_write_if_missing(dest, sc.agents_md(reg), dry_run=dry_run, planned=files)
|
|
||||||
|
|
||||||
# .claude/settings.json — tracked, permissions only
|
|
||||||
claude_dir = target / ".claude"
|
|
||||||
_write_if_missing(claude_dir / "settings.json", _claude_settings(), dry_run=dry_run, planned=files)
|
|
||||||
|
|
||||||
# .claude/settings.local.json — machine-local AIPASS_HOME (gitignored)
|
|
||||||
if aipass_home and not is_throwaway_path(aipass_home):
|
|
||||||
_write_if_missing(
|
|
||||||
claude_dir / "settings.local.json",
|
|
||||||
_claude_local_settings(aipass_home),
|
|
||||||
dry_run=dry_run,
|
|
||||||
planned=files,
|
|
||||||
)
|
|
||||||
|
|
||||||
# .claude/commands/prep.md
|
|
||||||
_write_if_missing(claude_dir / "commands" / "prep.md", sc.prep_md(), dry_run=dry_run, planned=files)
|
|
||||||
|
|
||||||
# .venv symlink → AIPass shared runtime
|
|
||||||
if aipass_home:
|
|
||||||
venv = Path(aipass_home) / ".venv"
|
|
||||||
venv_link = target / ".venv"
|
|
||||||
if venv.is_dir() and not venv_link.exists():
|
|
||||||
if not dry_run:
|
|
||||||
venv_link.symlink_to(venv)
|
|
||||||
files.append(str(venv_link))
|
|
||||||
|
|
||||||
# Resident agent — registry MUST exist first (dry_run never spawns)
|
|
||||||
spawn_result = None
|
|
||||||
agent_created = False
|
|
||||||
will_have_agent = not no_agent
|
|
||||||
if will_have_agent and not dry_run:
|
|
||||||
spawn_result = _spawn_project_agent(target, name)
|
|
||||||
agent_created = True
|
|
||||||
elif will_have_agent and dry_run:
|
|
||||||
files.append(f"{agent_home} (resident agent — planned)")
|
|
||||||
|
|
||||||
json_handler.log_operation(
|
|
||||||
"adopt_project",
|
|
||||||
{"name": name, "target": str(target), "dry_run": dry_run},
|
|
||||||
"adopt",
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"name": name,
|
|
||||||
"target": str(target),
|
|
||||||
"host": str(host),
|
|
||||||
"dry_run": dry_run,
|
|
||||||
"registry_id": registry_id,
|
|
||||||
"registry_file": registry_filename,
|
|
||||||
"files": files,
|
|
||||||
"gitignore_action": gitignore_action,
|
|
||||||
"agent_created": agent_created,
|
|
||||||
"agent_home": str(agent_home) if will_have_agent else None,
|
|
||||||
"spawn_result": spawn_result,
|
|
||||||
}
|
|
||||||
@@ -93,7 +93,7 @@ def find_project_root(start: Path) -> Optional[Path]:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
_SCAN_SKIP_DIRS = {".archive", ".backup", ".venv", ".git", "__pycache__", "node_modules", ".chroma", "templates"}
|
_SCAN_SKIP_DIRS = {".archive", ".venv", ".git", "__pycache__", "node_modules", ".chroma"}
|
||||||
|
|
||||||
|
|
||||||
def scan_agents(project_root: Path) -> List[AgentInfo]:
|
def scan_agents(project_root: Path) -> List[AgentInfo]:
|
||||||
@@ -286,15 +286,12 @@ def detect_pollution(agents: List[AgentInfo]) -> List[PollutionHit]:
|
|||||||
def check_registry_consistency(
|
def check_registry_consistency(
|
||||||
registry_path: Path,
|
registry_path: Path,
|
||||||
agents: List[AgentInfo],
|
agents: List[AgentInfo],
|
||||||
project_root: Optional[Path] = None,
|
|
||||||
) -> List[RegistryIssue]:
|
) -> List[RegistryIssue]:
|
||||||
"""Validate that registry branches[].path entries match actual filesystem.
|
"""Validate that registry branches[].path entries match actual filesystem.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of RegistryIssue for each problem found.
|
List of RegistryIssue for each problem found.
|
||||||
"""
|
"""
|
||||||
anchor = (project_root or registry_path.parent).resolve()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||||
except (json.JSONDecodeError, OSError) as exc:
|
except (json.JSONDecodeError, OSError) as exc:
|
||||||
@@ -312,8 +309,7 @@ def check_registry_consistency(
|
|||||||
issues.append(RegistryIssue(name, "", "missing"))
|
issues.append(RegistryIssue(name, "", "missing"))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
p = Path(path_str)
|
reg_path = Path(path_str).resolve()
|
||||||
reg_path = p.resolve() if p.is_absolute() else (anchor / p).resolve()
|
|
||||||
if not reg_path.exists():
|
if not reg_path.exists():
|
||||||
issues.append(RegistryIssue(name, path_str, "missing"))
|
issues.append(RegistryIssue(name, path_str, "missing"))
|
||||||
elif str(reg_path) not in agent_paths:
|
elif str(reg_path) not in agent_paths:
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
# =================== AIPass ====================
|
|
||||||
# Name: adopt.py
|
|
||||||
# Description: aipass adopt — bring an existing projects/ directory into AIPass
|
|
||||||
# Version: 1.0.0
|
|
||||||
# Created: 2026-07-20
|
|
||||||
# Modified: 2026-07-20
|
|
||||||
# =============================================
|
|
||||||
|
|
||||||
"""
|
|
||||||
aipass adopt — turn an existing directory under projects/ into a full
|
|
||||||
AIPass project (registry, resident agent, .aipass/.claude scaffold).
|
|
||||||
|
|
||||||
Unlike `aipass new`, adopt starts from a directory that already has its
|
|
||||||
own content and git history — every write is additive, nothing existing
|
|
||||||
is ever overwritten.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from aipass.aipass.apps.handlers.json import json_handler
|
|
||||||
from aipass.cli.apps.modules import console, error, success
|
|
||||||
from aipass.prax import logger
|
|
||||||
|
|
||||||
COMMAND = "adopt"
|
|
||||||
|
|
||||||
|
|
||||||
def print_introspection() -> None:
|
|
||||||
"""Bare invocation — usage pointer."""
|
|
||||||
console.print()
|
|
||||||
console.print("[bold cyan]aipass adopt[/bold cyan] — bring an existing directory into projects/")
|
|
||||||
console.print()
|
|
||||||
console.print("[dim]Usage: aipass adopt <name-or-path> [--no-agent] [--dry-run][/dim]")
|
|
||||||
console.print()
|
|
||||||
|
|
||||||
|
|
||||||
def print_help() -> None:
|
|
||||||
"""Print usage help for the adopt command."""
|
|
||||||
console.print()
|
|
||||||
console.print("[bold cyan]aipass adopt[/bold cyan] — adopt an existing directory as an AIPass project")
|
|
||||||
console.print()
|
|
||||||
console.print("[yellow]USAGE:[/yellow]")
|
|
||||||
console.print(" [green]aipass adopt <name>[/green] [dim]# Adopt <host>/projects/<name>[/dim]")
|
|
||||||
console.print(" [green]aipass adopt <path>[/green] [dim]# Adopt by relative/absolute path[/dim]")
|
|
||||||
console.print(" [green]aipass adopt <name> --no-agent[/green] [dim]# Skip resident agent[/dim]")
|
|
||||||
console.print(" [green]aipass adopt <name> --dry-run[/green] [dim]# Preview, write nothing[/dim]")
|
|
||||||
console.print()
|
|
||||||
console.print("[yellow]WHAT IT DOES:[/yellow]")
|
|
||||||
console.print(" Seats a sealed registry, scaffolds .aipass/.claude, and (unless")
|
|
||||||
console.print(" --no-agent) creates a resident agent — all ADDITIVE. Never touches")
|
|
||||||
console.print(" the target's existing git history or tracked files. If the target")
|
|
||||||
console.print(" has no .gitignore covering AIPass local state, one is created or")
|
|
||||||
console.print(" appended to first — the target may be a public repo.")
|
|
||||||
console.print()
|
|
||||||
console.print("[yellow]REQUIRES:[/yellow]")
|
|
||||||
console.print(" Target must be an existing directory at <host>/projects/<name>.")
|
|
||||||
console.print(" Use 'aipass new' instead to create a brand-new project.")
|
|
||||||
console.print()
|
|
||||||
|
|
||||||
|
|
||||||
def handle_command(command: str, args: list[str]) -> bool:
|
|
||||||
"""Route the 'adopt' command. Returns True if handled."""
|
|
||||||
if command != COMMAND:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not args:
|
|
||||||
json_handler.log_operation("adopt_usage", {"command": command})
|
|
||||||
print_introspection()
|
|
||||||
return True
|
|
||||||
if args[0] in ("--help", "-h", "help"):
|
|
||||||
json_handler.log_operation("adopt_help", {"command": command})
|
|
||||||
print_help()
|
|
||||||
return True
|
|
||||||
|
|
||||||
name_or_path = args[0]
|
|
||||||
known = {"--no-agent", "--dry-run"}
|
|
||||||
unknown = [a for a in args[1:] if a not in known]
|
|
||||||
if unknown:
|
|
||||||
error(f"Unknown option: {unknown[0]}")
|
|
||||||
print_help()
|
|
||||||
return True
|
|
||||||
no_agent = "--no-agent" in args[1:]
|
|
||||||
dry_run = "--dry-run" in args[1:]
|
|
||||||
|
|
||||||
from aipass.aipass.apps.handlers.new_project import find_host_root
|
|
||||||
from aipass.aipass.apps.handlers.new_project.adopt import adopt_project
|
|
||||||
|
|
||||||
target_path = Path(name_or_path)
|
|
||||||
if not target_path.exists():
|
|
||||||
host = find_host_root(Path.cwd())
|
|
||||||
if host is None:
|
|
||||||
error("Not inside an AIPass installation (no *_REGISTRY.json found).")
|
|
||||||
sys.exit(1)
|
|
||||||
target_path = host / "projects" / name_or_path
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = adopt_project(target_path, no_agent=no_agent, dry_run=dry_run)
|
|
||||||
except RuntimeError as e:
|
|
||||||
logger.warning("[AIPASS] adopt failed: %s", e)
|
|
||||||
error(str(e))
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
console.print()
|
|
||||||
verb = "Would adopt" if dry_run else "Adopted"
|
|
||||||
success(f"{verb} '{result['name']}' at {result['target']}")
|
|
||||||
console.print()
|
|
||||||
console.print(f" [dim]Registry:[/dim] {result['registry_file']}")
|
|
||||||
console.print(f" [dim]Gitignore:[/dim] {result['gitignore_action']}")
|
|
||||||
if result["agent_home"]:
|
|
||||||
state = "created" if result["agent_created"] else "planned"
|
|
||||||
console.print(f" [dim]Agent:[/dim] {state} ({result['agent_home']})")
|
|
||||||
else:
|
|
||||||
console.print(" [dim]Agent:[/dim] skipped (--no-agent)")
|
|
||||||
console.print()
|
|
||||||
console.print("[dim]Files:[/dim]")
|
|
||||||
for f in result["files"]:
|
|
||||||
console.print(f" [dim]{f}[/dim]")
|
|
||||||
console.print()
|
|
||||||
|
|
||||||
json_handler.log_operation(
|
|
||||||
"adopt_project",
|
|
||||||
{"name": result["name"], "target": result["target"], "dry_run": dry_run},
|
|
||||||
)
|
|
||||||
logger.info("[AIPASS] adopt: %s at %s (dry_run=%s)", result["name"], result["target"], dry_run)
|
|
||||||
return True
|
|
||||||
@@ -670,7 +670,7 @@ def _check_structure() -> List[CheckResult]:
|
|||||||
# Registry consistency
|
# Registry consistency
|
||||||
reg_path = _discover_registry(start_path=project_root)
|
reg_path = _discover_registry(start_path=project_root)
|
||||||
if reg_path and reg_path.exists():
|
if reg_path and reg_path.exists():
|
||||||
reg_issues = check_registry_consistency(reg_path, agents, project_root=project_root)
|
reg_issues = check_registry_consistency(reg_path, agents)
|
||||||
if reg_issues:
|
if reg_issues:
|
||||||
for issue in reg_issues:
|
for issue in reg_issues:
|
||||||
glyph = GLYPH_FAIL if issue.problem == "missing" else GLYPH_WARN
|
glyph = GLYPH_FAIL if issue.problem == "missing" else GLYPH_WARN
|
||||||
@@ -686,7 +686,7 @@ def _check_structure() -> List[CheckResult]:
|
|||||||
root_hits = check_root_artifacts(project_root)
|
root_hits = check_root_artifacts(project_root)
|
||||||
if root_hits:
|
if root_hits:
|
||||||
for hit in root_hits:
|
for hit in root_hits:
|
||||||
glyph = GLYPH_PASS if hit.severity == "pass" else GLYPH_WARN
|
glyph = GLYPH_WARN if hit.severity == "warn" else GLYPH_PASS
|
||||||
results.append(CheckResult(f"root: {hit.name}", glyph, hit.description, ""))
|
results.append(CheckResult(f"root: {hit.name}", glyph, hit.description, ""))
|
||||||
else:
|
else:
|
||||||
results.append(CheckResult("root artifacts", GLYPH_PASS, "none misplaced", ""))
|
results.append(CheckResult("root artifacts", GLYPH_PASS, "none misplaced", ""))
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
# =================== AIPass ====================
|
|
||||||
# Name: project_home.py
|
|
||||||
# Description: Shared AIPass-home detection, project-path validation, and settings content
|
|
||||||
# Version: 1.0.0
|
|
||||||
# Created: 2026-07-21
|
|
||||||
# Modified: 2026-07-21
|
|
||||||
# =============================================
|
|
||||||
|
|
||||||
"""Project home — AIPASS_HOME detection, path validation, settings content.
|
|
||||||
|
|
||||||
Shared across every command that scaffolds or inspects an AIPass project
|
|
||||||
(`aipass init`, `aipass new`, `aipass adopt`), so there is exactly one
|
|
||||||
source of truth per helper — no per-command copies to drift apart.
|
|
||||||
|
|
||||||
Dependency-free: uses only stdlib. Importable before drone/prax exist.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def is_throwaway_path(path: str | Path) -> bool:
|
|
||||||
"""True if path is under a temp dir or Claude Code scratchpad."""
|
|
||||||
resolved = str(Path(path).resolve())
|
|
||||||
tmp_roots = [tempfile.gettempdir()]
|
|
||||||
if os.name == "posix":
|
|
||||||
tmp_roots.append("/tmp")
|
|
||||||
for root in tmp_roots:
|
|
||||||
try:
|
|
||||||
r = str(Path(root).resolve())
|
|
||||||
except OSError:
|
|
||||||
logger.info("is_throwaway_path: could not resolve %s", root)
|
|
||||||
continue
|
|
||||||
if resolved == r or resolved.startswith(r + os.sep):
|
|
||||||
return True
|
|
||||||
if "scratchpad" in resolved.lower():
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_aipass_home() -> str | None:
|
|
||||||
"""Detect the AIPass installation root from the aipass package location.
|
|
||||||
|
|
||||||
Returns the parent of the src/ directory (the repo root).
|
|
||||||
Returns None if detection fails.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
spec = importlib.util.find_spec("aipass")
|
|
||||||
if spec and spec.origin:
|
|
||||||
# aipass/__init__.py lives at src/aipass/__init__.py
|
|
||||||
# parent = src/aipass/, parent.parent = src/, parent.parent.parent = AIPass root
|
|
||||||
return str(Path(spec.origin).resolve().parent.parent.parent)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.info("AIPASS_HOME detection skipped: %s", exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _claude_settings() -> str:
|
|
||||||
"""Generate .claude/settings.json — permissions only, no machine-local paths.
|
|
||||||
|
|
||||||
Hooks are NOT wired at the project level. All AIPass hooks
|
|
||||||
(prompt injection, identity, email, pre-compact, edit gates) fire
|
|
||||||
from provider settings (~/.claude/settings.json), installed by
|
|
||||||
setup.sh. Provider hooks use CWD-walking patterns that work from
|
|
||||||
any directory in any project.
|
|
||||||
|
|
||||||
AIPASS_HOME is a machine-local absolute path — it never belongs in this
|
|
||||||
tracked file. It goes in .claude/settings.local.json instead, which is
|
|
||||||
gitignored and merged over tracked settings by Claude Code natively.
|
|
||||||
See _claude_local_settings().
|
|
||||||
"""
|
|
||||||
data: dict = {
|
|
||||||
"permissions": {
|
|
||||||
"deny": [
|
|
||||||
"Bash(git push --force*)",
|
|
||||||
"Bash(git reset --hard*)",
|
|
||||||
"EnterPlanMode",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _claude_local_settings(aipass_home: str) -> str:
|
|
||||||
"""Generate .claude/settings.local.json — machine-local env (gitignored)."""
|
|
||||||
return json.dumps({"env": {"AIPASS_HOME": aipass_home}}, indent=2, ensure_ascii=False) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _enroll_project(target: Path) -> None:
|
|
||||||
"""Enroll a project in the trusted-project registry (DPLAN-0244).
|
|
||||||
|
|
||||||
Lazy import to keep this module free of prax/module-level deps.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from aipass.hooks.apps.handlers.config.trust_registry import enroll
|
|
||||||
|
|
||||||
if enroll(str(target)):
|
|
||||||
logger.info("Enrolled project in trust registry: %s", target)
|
|
||||||
else:
|
|
||||||
logger.warning("Trust enrollment failed for %s", target)
|
|
||||||
except ImportError as exc:
|
|
||||||
logger.info("Trust registry unavailable, skipping enrollment: %s", exc)
|
|
||||||
|
|
||||||
|
|
||||||
def is_projects_child(target: Path) -> bool:
|
|
||||||
"""True if *target* is ``<host>/projects/<name>`` — a valid nested project path.
|
|
||||||
|
|
||||||
The host is identified by having a ``*_REGISTRY.json`` in the grandparent
|
|
||||||
of target (i.e. target's parent is named ``projects``).
|
|
||||||
"""
|
|
||||||
resolved = target.resolve()
|
|
||||||
if resolved.parent.name != "projects":
|
|
||||||
return False
|
|
||||||
host = resolved.parent.parent
|
|
||||||
try:
|
|
||||||
return any(f.is_file() and f.name.endswith("_REGISTRY.json") for f in host.iterdir())
|
|
||||||
except OSError as exc:
|
|
||||||
logger.info("is_projects_child: could not read host dir %s: %s", host, exc)
|
|
||||||
return False
|
|
||||||
@@ -1,421 +0,0 @@
|
|||||||
# =================== AIPass ====================
|
|
||||||
# Name: test_adopt.py
|
|
||||||
# Description: Tests for aipass adopt — project adoption handler
|
|
||||||
# Version: 1.0.0
|
|
||||||
# Created: 2026-07-20
|
|
||||||
# Modified: 2026-07-20
|
|
||||||
# =============================================
|
|
||||||
|
|
||||||
"""Tests for the adopt handler and module.
|
|
||||||
|
|
||||||
All file operations use tmp_path to stay fully isolated from the live
|
|
||||||
filesystem. dry_run tests assert zero filesystem mutation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest # pyright: ignore[reportMissingImports]
|
|
||||||
|
|
||||||
from aipass.aipass.apps.handlers.new_project.adopt import (
|
|
||||||
GITIGNORE_MARKER,
|
|
||||||
adopt_project,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def host_env(tmp_path):
|
|
||||||
"""Minimal AIPass host installation with an existing (pre-populated) project dir."""
|
|
||||||
(tmp_path / "AIPASS_REGISTRY.json").write_text(json.dumps({"metadata": {"id": "host-id"}, "branches": []}))
|
|
||||||
projects = tmp_path / "projects"
|
|
||||||
projects.mkdir()
|
|
||||||
target = projects / "existing-site"
|
|
||||||
target.mkdir()
|
|
||||||
(target / "index.html").write_text("<html></html>\n", encoding="utf-8")
|
|
||||||
(target / "README.md").write_text("# existing-site\n\nReal content.\n", encoding="utf-8")
|
|
||||||
return tmp_path, target
|
|
||||||
|
|
||||||
|
|
||||||
def _no_home():
|
|
||||||
return patch(
|
|
||||||
"aipass.aipass.apps.handlers.new_project.adopt._detect_aipass_home",
|
|
||||||
return_value=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# adopt_project — guards / refusals
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_adopt_rejects_missing_target(tmp_path):
|
|
||||||
with pytest.raises(RuntimeError, match="does not exist"):
|
|
||||||
adopt_project(tmp_path / "nope")
|
|
||||||
|
|
||||||
|
|
||||||
def test_adopt_rejects_non_projects_child(tmp_path):
|
|
||||||
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
|
||||||
outside = tmp_path / "elsewhere" / "myapp"
|
|
||||||
outside.mkdir(parents=True)
|
|
||||||
with pytest.raises(RuntimeError, match="not <host>/projects/<name>"):
|
|
||||||
adopt_project(outside)
|
|
||||||
|
|
||||||
|
|
||||||
def test_adopt_rejects_already_adopted(host_env):
|
|
||||||
_, target = host_env
|
|
||||||
(target / "EXISTING_SITE_REGISTRY.json").write_text("{}")
|
|
||||||
with pytest.raises(RuntimeError, match="already adopted"):
|
|
||||||
adopt_project(target, no_agent=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_adopt_rejects_agent_home_collision(host_env):
|
|
||||||
_, target = host_env
|
|
||||||
home = target / "src" / "existing_site" / "existing_site"
|
|
||||||
home.mkdir(parents=True)
|
|
||||||
(home / "stray.txt").write_text("junk\n", encoding="utf-8")
|
|
||||||
with _no_home():
|
|
||||||
with pytest.raises(RuntimeError, match="Name collision"):
|
|
||||||
adopt_project(target, no_agent=False)
|
|
||||||
|
|
||||||
|
|
||||||
def test_adopt_allows_collision_with_no_agent(host_env):
|
|
||||||
"""An occupied agent-home path is fine when --no-agent skips the agent entirely."""
|
|
||||||
_, target = host_env
|
|
||||||
home = target / "src" / "existing_site" / "existing_site"
|
|
||||||
home.mkdir(parents=True)
|
|
||||||
(home / "stray.txt").write_text("junk\n", encoding="utf-8")
|
|
||||||
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
|
||||||
result = adopt_project(target, no_agent=True)
|
|
||||||
assert result["agent_created"] is False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# adopt_project — gitignore safety
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_adopt_creates_gitignore_when_absent(host_env):
|
|
||||||
_, target = host_env
|
|
||||||
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
|
||||||
result = adopt_project(target, no_agent=True)
|
|
||||||
assert result["gitignore_action"] == "created"
|
|
||||||
content = (target / ".gitignore").read_text(encoding="utf-8")
|
|
||||||
assert GITIGNORE_MARKER in content
|
|
||||||
assert ".trinity/" in content
|
|
||||||
|
|
||||||
|
|
||||||
def test_adopt_appends_gitignore_when_marker_absent(host_env):
|
|
||||||
_, target = host_env
|
|
||||||
(target / ".gitignore").write_text("node_modules/\ndist/\n", encoding="utf-8")
|
|
||||||
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
|
||||||
result = adopt_project(target, no_agent=True)
|
|
||||||
assert result["gitignore_action"] == "appended"
|
|
||||||
content = (target / ".gitignore").read_text(encoding="utf-8")
|
|
||||||
assert "node_modules/" in content
|
|
||||||
assert GITIGNORE_MARKER in content
|
|
||||||
assert ".trinity/" in content
|
|
||||||
|
|
||||||
|
|
||||||
def test_adopt_skips_gitignore_when_marker_present(host_env):
|
|
||||||
_, target = host_env
|
|
||||||
(target / ".gitignore").write_text(f"{GITIGNORE_MARKER}\n.trinity/\n", encoding="utf-8")
|
|
||||||
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
|
||||||
result = adopt_project(target, no_agent=True)
|
|
||||||
assert result["gitignore_action"] == "already-safe"
|
|
||||||
content = (target / ".gitignore").read_text(encoding="utf-8")
|
|
||||||
assert content.count(GITIGNORE_MARKER) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_adopt_gitignore_covers_symlinked_venv_and_registry_lock(host_env, tmp_path):
|
|
||||||
"""Live-verification regression: a symlinked .venv (not a real dir) and a
|
|
||||||
registry lock file must both be actually ignored by real git, not just
|
|
||||||
present as a string in the .gitignore content."""
|
|
||||||
host, target = host_env
|
|
||||||
aipass_home = tmp_path / "fake_aipass_home"
|
|
||||||
(aipass_home / ".venv").mkdir(parents=True)
|
|
||||||
subprocess.run(["git", "init"], cwd=target, capture_output=True, text=True, check=True)
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"aipass.aipass.apps.handlers.new_project.adopt._detect_aipass_home",
|
|
||||||
return_value=str(aipass_home),
|
|
||||||
),
|
|
||||||
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
|
|
||||||
):
|
|
||||||
adopt_project(target, no_agent=True)
|
|
||||||
|
|
||||||
venv_link = target / ".venv"
|
|
||||||
assert venv_link.is_symlink()
|
|
||||||
lock_file = target / ".EXISTING-SITE_REGISTRY.lock"
|
|
||||||
lock_file.write_text("", encoding="utf-8")
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "check-ignore", ".venv", ".EXISTING-SITE_REGISTRY.lock"],
|
|
||||||
cwd=target,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
assert result.returncode == 0
|
|
||||||
assert ".venv" in result.stdout.split()
|
|
||||||
assert ".EXISTING-SITE_REGISTRY.lock" in result.stdout.split()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# adopt_project — additive scaffold, existing files untouched
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_adopt_never_overwrites_existing_readme(host_env):
|
|
||||||
_, target = host_env
|
|
||||||
original = (target / "README.md").read_text(encoding="utf-8")
|
|
||||||
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
|
||||||
adopt_project(target, no_agent=True)
|
|
||||||
assert (target / "README.md").read_text(encoding="utf-8") == original
|
|
||||||
|
|
||||||
|
|
||||||
def test_adopt_never_touches_existing_tracked_files(host_env):
|
|
||||||
_, target = host_env
|
|
||||||
original = (target / "index.html").read_text(encoding="utf-8")
|
|
||||||
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
|
||||||
adopt_project(target, no_agent=True)
|
|
||||||
assert (target / "index.html").read_text(encoding="utf-8") == original
|
|
||||||
|
|
||||||
|
|
||||||
def test_adopt_writes_registry_and_settings(host_env):
|
|
||||||
_, target = host_env
|
|
||||||
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
|
||||||
result = adopt_project(target, no_agent=True)
|
|
||||||
assert result["registry_file"] == "EXISTING-SITE_REGISTRY.json"
|
|
||||||
assert (target / "EXISTING-SITE_REGISTRY.json").exists()
|
|
||||||
reg = json.loads((target / "EXISTING-SITE_REGISTRY.json").read_text())
|
|
||||||
assert reg["metadata"]["name"] == "EXISTING-SITE"
|
|
||||||
assert (target / ".claude" / "settings.json").exists()
|
|
||||||
settings = json.loads((target / ".claude" / "settings.json").read_text())
|
|
||||||
assert "env" not in settings
|
|
||||||
|
|
||||||
|
|
||||||
def test_adopt_no_agent_skips_spawn(host_env):
|
|
||||||
_, target = host_env
|
|
||||||
with (
|
|
||||||
_no_home(),
|
|
||||||
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
|
|
||||||
patch("aipass.aipass.apps.handlers.new_project.spawn_agent") as mock_spawn,
|
|
||||||
):
|
|
||||||
result = adopt_project(target, no_agent=True)
|
|
||||||
mock_spawn.assert_not_called()
|
|
||||||
assert result["agent_created"] is False
|
|
||||||
assert result["agent_home"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_adopt_with_agent_spawns(host_env):
|
|
||||||
_, target = host_env
|
|
||||||
spawn_ok = {
|
|
||||||
"success": True,
|
|
||||||
"branch_name": "EXISTING_SITE",
|
|
||||||
"path": str(target / "src" / "existing_site" / "existing_site"),
|
|
||||||
"files_copied": 12,
|
|
||||||
"registry_updated": True,
|
|
||||||
"validation_issues": [],
|
|
||||||
}
|
|
||||||
with (
|
|
||||||
_no_home(),
|
|
||||||
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
|
|
||||||
patch(
|
|
||||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
|
||||||
return_value=spawn_ok,
|
|
||||||
) as mock_spawn,
|
|
||||||
):
|
|
||||||
result = adopt_project(target, no_agent=False)
|
|
||||||
mock_spawn.assert_called_once()
|
|
||||||
assert result["agent_created"] is True
|
|
||||||
assert result["agent_home"] == str(target / "src" / "existing_site" / "existing_site")
|
|
||||||
|
|
||||||
|
|
||||||
def test_adopt_registry_minted_before_spawn(host_env):
|
|
||||||
"""Registry-first invariant: registry file exists on disk before spawn_agent is called."""
|
|
||||||
_, target = host_env
|
|
||||||
seen = {}
|
|
||||||
|
|
||||||
def _check_registry_exists(**kwargs):
|
|
||||||
seen["registry_present"] = (target / "EXISTING-SITE_REGISTRY.json").exists()
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"branch_name": "EXISTING_SITE",
|
|
||||||
"path": kwargs["target_path"],
|
|
||||||
"files_copied": 1,
|
|
||||||
"registry_updated": True,
|
|
||||||
"validation_issues": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
with (
|
|
||||||
_no_home(),
|
|
||||||
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
|
|
||||||
patch(
|
|
||||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
|
||||||
side_effect=_check_registry_exists,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
adopt_project(target, no_agent=False)
|
|
||||||
assert seen["registry_present"] is True
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# adopt_project — dry_run performs zero writes
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_adopt_dry_run_writes_nothing(host_env):
|
|
||||||
_, target = host_env
|
|
||||||
before = sorted(p.relative_to(target) for p in target.rglob("*"))
|
|
||||||
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.spawn_agent") as mock_spawn:
|
|
||||||
result = adopt_project(target, no_agent=False, dry_run=True)
|
|
||||||
after = sorted(p.relative_to(target) for p in target.rglob("*"))
|
|
||||||
assert before == after
|
|
||||||
mock_spawn.assert_not_called()
|
|
||||||
assert result["dry_run"] is True
|
|
||||||
assert result["registry_id"] is None
|
|
||||||
assert result["agent_created"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_adopt_dry_run_reports_planned_registry_and_agent(host_env):
|
|
||||||
_, target = host_env
|
|
||||||
with _no_home():
|
|
||||||
result = adopt_project(target, no_agent=False, dry_run=True)
|
|
||||||
assert result["registry_file"] == "EXISTING-SITE_REGISTRY.json"
|
|
||||||
assert "EXISTING-SITE_REGISTRY.json" in result["files"]
|
|
||||||
assert result["agent_home"] == str(target / "src" / "existing_site" / "existing_site")
|
|
||||||
assert any("resident agent" in f for f in result["files"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_adopt_dry_run_no_agent_reports_no_home(host_env):
|
|
||||||
_, target = host_env
|
|
||||||
with _no_home():
|
|
||||||
result = adopt_project(target, no_agent=True, dry_run=True)
|
|
||||||
assert result["agent_home"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_adopt_dry_run_still_reports_gitignore_action(host_env):
|
|
||||||
_, target = host_env
|
|
||||||
with _no_home():
|
|
||||||
result = adopt_project(target, no_agent=True, dry_run=True)
|
|
||||||
assert result["gitignore_action"] == "created"
|
|
||||||
assert not (target / ".gitignore").exists()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Module handle_command
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_module_handles_not_mine():
|
|
||||||
from aipass.aipass.apps.modules.adopt import handle_command
|
|
||||||
|
|
||||||
assert handle_command("notmine", []) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_module_handles_help():
|
|
||||||
from aipass.aipass.apps.modules.adopt import handle_command
|
|
||||||
|
|
||||||
assert handle_command("adopt", ["--help"]) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_module_handles_no_args():
|
|
||||||
from aipass.aipass.apps.modules.adopt import handle_command
|
|
||||||
|
|
||||||
assert handle_command("adopt", []) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_module_rejects_unknown_option(host_env):
|
|
||||||
from aipass.aipass.apps.modules.adopt import handle_command
|
|
||||||
|
|
||||||
_, target = host_env
|
|
||||||
with patch("aipass.aipass.apps.modules.adopt.error") as mock_error:
|
|
||||||
handle_command("adopt", [str(target), "--bogus"])
|
|
||||||
mock_error.assert_called_once()
|
|
||||||
assert "Unknown option" in mock_error.call_args[0][0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_module_adopt_by_absolute_path(host_env, monkeypatch):
|
|
||||||
from aipass.aipass.apps.modules.adopt import handle_command
|
|
||||||
|
|
||||||
host, target = host_env
|
|
||||||
monkeypatch.chdir(host)
|
|
||||||
with (
|
|
||||||
_no_home(),
|
|
||||||
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
|
|
||||||
patch("aipass.aipass.apps.modules.adopt.console") as mock_con,
|
|
||||||
):
|
|
||||||
handle_command("adopt", [str(target), "--no-agent"])
|
|
||||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
|
||||||
assert "Registry:" in printed
|
|
||||||
assert "EXISTING-SITE_REGISTRY.json" in printed
|
|
||||||
|
|
||||||
|
|
||||||
def test_module_adopt_by_bare_name(host_env, monkeypatch):
|
|
||||||
from aipass.aipass.apps.modules.adopt import handle_command
|
|
||||||
|
|
||||||
host, target = host_env
|
|
||||||
monkeypatch.chdir(host)
|
|
||||||
with (
|
|
||||||
_no_home(),
|
|
||||||
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
|
|
||||||
patch("aipass.aipass.apps.modules.adopt.console") as mock_con,
|
|
||||||
):
|
|
||||||
handle_command("adopt", ["existing-site", "--no-agent"])
|
|
||||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
|
||||||
assert "Registry:" in printed
|
|
||||||
assert "EXISTING-SITE_REGISTRY.json" in printed
|
|
||||||
|
|
||||||
|
|
||||||
def test_module_adopt_dry_run_reports_would_adopt(host_env, monkeypatch, capsys):
|
|
||||||
from aipass.aipass.apps.modules.adopt import handle_command
|
|
||||||
|
|
||||||
host, target = host_env
|
|
||||||
monkeypatch.chdir(host)
|
|
||||||
with _no_home():
|
|
||||||
handle_command("adopt", ["existing-site", "--no-agent", "--dry-run"])
|
|
||||||
out = capsys.readouterr().out
|
|
||||||
assert "Would adopt" in out
|
|
||||||
assert not (target / "EXISTING-SITE_REGISTRY.json").exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_module_adopt_missing_target_no_host(tmp_path, monkeypatch):
|
|
||||||
from aipass.aipass.apps.modules.adopt import handle_command
|
|
||||||
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
with (
|
|
||||||
patch("aipass.aipass.apps.modules.adopt.error") as mock_error,
|
|
||||||
pytest.raises(SystemExit) as exc_info,
|
|
||||||
):
|
|
||||||
handle_command("adopt", ["nope"])
|
|
||||||
mock_error.assert_called_once()
|
|
||||||
assert "Not inside an AIPass installation" in mock_error.call_args[0][0]
|
|
||||||
assert exc_info.value.code == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_module_adopt_reports_refusal(host_env, monkeypatch):
|
|
||||||
from aipass.aipass.apps.modules.adopt import handle_command
|
|
||||||
|
|
||||||
host, target = host_env
|
|
||||||
monkeypatch.chdir(host)
|
|
||||||
(target / "EXISTING-SITE_REGISTRY.json").write_text("{}")
|
|
||||||
with (
|
|
||||||
patch("aipass.aipass.apps.modules.adopt.error") as mock_error,
|
|
||||||
pytest.raises(SystemExit) as exc_info,
|
|
||||||
):
|
|
||||||
handle_command("adopt", ["existing-site", "--no-agent"])
|
|
||||||
mock_error.assert_called_once()
|
|
||||||
assert "already adopted" in mock_error.call_args[0][0]
|
|
||||||
assert exc_info.value.code == 1
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# aipass.py wiring
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_aipass_public_commands_includes_adopt():
|
|
||||||
from aipass.aipass.apps.aipass import _PUBLIC_COMMANDS
|
|
||||||
|
|
||||||
assert "adopt" in _PUBLIC_COMMANDS
|
|
||||||
@@ -20,6 +20,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest # pyright: ignore[reportMissingImports]
|
import pytest # pyright: ignore[reportMissingImports]
|
||||||
|
|
||||||
|
from aipass.aipass.apps.handlers.init import scaffold_content as sc
|
||||||
from aipass.aipass.apps.handlers.init.bootstrap import (
|
from aipass.aipass.apps.handlers.init.bootstrap import (
|
||||||
_merge_hooks_json,
|
_merge_hooks_json,
|
||||||
_sanitize_name,
|
_sanitize_name,
|
||||||
@@ -27,7 +28,6 @@ from aipass.aipass.apps.handlers.init.bootstrap import (
|
|||||||
init_project,
|
init_project,
|
||||||
update_project,
|
update_project,
|
||||||
)
|
)
|
||||||
from aipass.aipass.shared import scaffold_content as sc
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -293,7 +293,7 @@ def test_init_project_settings_no_hooks(tmp_path, monkeypatch):
|
|||||||
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
assert "hooks" not in data, "Project settings should not contain hooks"
|
assert "hooks" not in data, "Project settings should not contain hooks"
|
||||||
assert "env" not in data, "AIPASS_HOME is machine-local — belongs in settings.local.json"
|
assert "env" in data
|
||||||
assert "permissions" in data
|
assert "permissions" in data
|
||||||
|
|
||||||
|
|
||||||
@@ -547,10 +547,9 @@ def test_update_project_creates_missing_managed_dirs(tmp_path):
|
|||||||
result = update_project(target)
|
result = update_project(target)
|
||||||
|
|
||||||
assert (target / ".claude" / "settings.json").exists()
|
assert (target / ".claude" / "settings.json").exists()
|
||||||
# Managed files in deleted dirs re-written (tier0_kernel, tier1_navmap,
|
# Managed files in deleted dirs re-written (tier0_kernel, tier1_navmap, hooks.json, settings, prep)
|
||||||
# hooks.json, settings.json, settings.local.json, prep)
|
|
||||||
if result["aipass_home"]:
|
if result["aipass_home"]:
|
||||||
assert len(result["updated_files"]) == 6
|
assert len(result["updated_files"]) == 5
|
||||||
else:
|
else:
|
||||||
assert len(result["updated_files"]) == 2
|
assert len(result["updated_files"]) == 2
|
||||||
assert len(result["already_current"]) >= 2
|
assert len(result["already_current"]) >= 2
|
||||||
@@ -585,7 +584,7 @@ def test_init_project_returns_aipass_home(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_init_project_settings_has_aipass_home_when_detected(tmp_path, monkeypatch):
|
def test_init_project_settings_has_aipass_home_when_detected(tmp_path, monkeypatch):
|
||||||
"""When AIPASS_HOME is detected, settings.local.json (not settings.json) includes env.AIPASS_HOME."""
|
"""When AIPASS_HOME is detected, settings.json includes env.AIPASS_HOME."""
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||||
lambda _: False,
|
lambda _: False,
|
||||||
@@ -599,10 +598,8 @@ def test_init_project_settings_has_aipass_home_when_detected(tmp_path, monkeypat
|
|||||||
pytest.skip("AIPASS_HOME not detectable in this environment")
|
pytest.skip("AIPASS_HOME not detectable in this environment")
|
||||||
|
|
||||||
settings = json.loads((target / ".claude" / "settings.json").read_text(encoding="utf-8"))
|
settings = json.loads((target / ".claude" / "settings.json").read_text(encoding="utf-8"))
|
||||||
assert "env" not in settings
|
assert "env" in settings
|
||||||
|
assert settings["env"]["AIPASS_HOME"] == result["aipass_home"]
|
||||||
local_settings = json.loads((target / ".claude" / "settings.local.json").read_text(encoding="utf-8"))
|
|
||||||
assert local_settings["env"]["AIPASS_HOME"] == result["aipass_home"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_project_returns_aipass_home(tmp_path):
|
def test_update_project_returns_aipass_home(tmp_path):
|
||||||
@@ -618,7 +615,7 @@ def test_update_project_returns_aipass_home(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_update_project_adds_aipass_home_if_missing(tmp_path, monkeypatch):
|
def test_update_project_adds_aipass_home_if_missing(tmp_path, monkeypatch):
|
||||||
"""update_project recreates settings.local.json with AIPASS_HOME if missing."""
|
"""update_project injects AIPASS_HOME into settings.json if env section is absent."""
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||||
lambda _: False,
|
lambda _: False,
|
||||||
@@ -627,15 +624,17 @@ def test_update_project_adds_aipass_home_if_missing(tmp_path, monkeypatch):
|
|||||||
target.mkdir()
|
target.mkdir()
|
||||||
init_project(target, project_name="addenv")
|
init_project(target, project_name="addenv")
|
||||||
|
|
||||||
local_settings_path = target / ".claude" / "settings.local.json"
|
settings_path = target / ".claude" / "settings.json"
|
||||||
local_settings_path.unlink(missing_ok=True)
|
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")
|
||||||
|
|
||||||
result = update_project(target)
|
result = update_project(target)
|
||||||
|
|
||||||
if result["aipass_home"] is not None:
|
if result["aipass_home"] is not None:
|
||||||
new_data = json.loads(local_settings_path.read_text(encoding="utf-8"))
|
new_data = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||||
assert new_data.get("env", {}).get("AIPASS_HOME") == result["aipass_home"]
|
assert new_data.get("env", {}).get("AIPASS_HOME") == result["aipass_home"]
|
||||||
assert str(local_settings_path) in result["updated_files"]
|
assert str(settings_path) in result["updated_files"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1257,49 +1256,10 @@ def test_throwaway_path_allows_project():
|
|||||||
assert not is_throwaway_path(str(Path.home() / "Projects" / "myapp"))
|
assert not is_throwaway_path(str(Path.home() / "Projects" / "myapp"))
|
||||||
|
|
||||||
|
|
||||||
def test_settings_omits_throwaway_aipass_home(tmp_path, monkeypatch):
|
def test_settings_omits_throwaway_aipass_home(tmp_path):
|
||||||
"""init_project skips settings.local.json when detected AIPASS_HOME is a throwaway path."""
|
"""_claude_settings refuses to write AIPASS_HOME when it's a throwaway path."""
|
||||||
from aipass.aipass.apps.handlers.init import bootstrap
|
from aipass.aipass.apps.handlers.init.bootstrap import _claude_settings
|
||||||
|
|
||||||
monkeypatch.setattr(bootstrap, "_detect_aipass_home", lambda: str(tmp_path))
|
content = _claude_settings(str(tmp_path))
|
||||||
|
data = json.loads(content)
|
||||||
target = tmp_path / "proj"
|
assert "AIPASS_HOME" not in data.get("env", {})
|
||||||
target.mkdir()
|
|
||||||
bootstrap.init_project(target, project_name="alpha")
|
|
||||||
|
|
||||||
assert not (target / ".claude" / "settings.local.json").exists()
|
|
||||||
|
|
||||||
|
|
||||||
# Directory/file-name fragments that .gitignore excludes from git tracking —
|
|
||||||
# mirrors scaffold_content.gitignore(). Tracked-file scans must skip these.
|
|
||||||
_GITIGNORED_PARTS = (".trinity", ".ai_mail.local", "logs", ".venv", "venv", ".git")
|
|
||||||
|
|
||||||
|
|
||||||
def test_minted_tracked_files_have_no_absolute_paths(tmp_path, monkeypatch):
|
|
||||||
"""No file init_project writes into a TRACKED path may contain the machine-local AIPASS_HOME.
|
|
||||||
|
|
||||||
Regression guard for the settings.json bug: AIPASS_HOME is an absolute,
|
|
||||||
machine-local path and must only ever land in gitignored *.local.json
|
|
||||||
files. This walks every minted file that would actually be committed
|
|
||||||
and greps it for the (fake) AIPASS_HOME value.
|
|
||||||
"""
|
|
||||||
from aipass.aipass.apps.handlers.init import bootstrap
|
|
||||||
|
|
||||||
fake_home = str(tmp_path / f"fake_aipass_home_{uuid.uuid4().hex}")
|
|
||||||
monkeypatch.setattr(bootstrap, "_detect_aipass_home", lambda: fake_home)
|
|
||||||
monkeypatch.setattr(bootstrap, "is_throwaway_path", lambda _: False)
|
|
||||||
|
|
||||||
target = tmp_path / "proj"
|
|
||||||
target.mkdir()
|
|
||||||
bootstrap.init_project(target, project_name="pathcheck")
|
|
||||||
|
|
||||||
for path in target.rglob("*"):
|
|
||||||
if not path.is_file():
|
|
||||||
continue
|
|
||||||
rel = path.relative_to(target)
|
|
||||||
if any(part in _GITIGNORED_PARTS for part in rel.parts):
|
|
||||||
continue
|
|
||||||
if ".local." in rel.name:
|
|
||||||
continue
|
|
||||||
content = path.read_text(encoding="utf-8")
|
|
||||||
assert fake_home not in content, f"{rel} leaks machine-local AIPASS_HOME"
|
|
||||||
|
|||||||
@@ -183,11 +183,11 @@ def test_create_project_empty_template(host_env, monkeypatch):
|
|||||||
with (
|
with (
|
||||||
patch("subprocess.run", side_effect=_mock_git_run),
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
patch(
|
patch(
|
||||||
"aipass.aipass.shared.project_home._detect_aipass_home",
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"aipass.aipass.shared.project_home._enroll_project",
|
"aipass.aipass.apps.handlers.init.bootstrap._enroll_project",
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
result = create_project("testproj", template="empty", no_agent=True)
|
result = create_project("testproj", template="empty", no_agent=True)
|
||||||
@@ -208,11 +208,11 @@ def test_create_project_python_template(host_env, monkeypatch):
|
|||||||
with (
|
with (
|
||||||
patch("subprocess.run", side_effect=_mock_git_run),
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
patch(
|
patch(
|
||||||
"aipass.aipass.shared.project_home._detect_aipass_home",
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"aipass.aipass.shared.project_home._enroll_project",
|
"aipass.aipass.apps.handlers.init.bootstrap._enroll_project",
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
result = create_project("pyapp", template="python", no_agent=True)
|
result = create_project("pyapp", template="python", no_agent=True)
|
||||||
@@ -261,10 +261,10 @@ def test_create_project_cleans_up_on_failure(host_env, monkeypatch):
|
|||||||
with (
|
with (
|
||||||
patch("subprocess.run", side_effect=_fail_git),
|
patch("subprocess.run", side_effect=_fail_git),
|
||||||
patch(
|
patch(
|
||||||
"aipass.aipass.shared.project_home._detect_aipass_home",
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
),
|
),
|
||||||
patch("aipass.aipass.shared.project_home._enroll_project"),
|
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||||
pytest.raises(RuntimeError, match="simulated failure"),
|
pytest.raises(RuntimeError, match="simulated failure"),
|
||||||
):
|
):
|
||||||
create_project("failproj", no_agent=True)
|
create_project("failproj", no_agent=True)
|
||||||
@@ -299,10 +299,10 @@ def test_create_project_registry_before_scaffold(host_env, monkeypatch):
|
|||||||
side_effect=track_template,
|
side_effect=track_template,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"aipass.aipass.shared.project_home._detect_aipass_home",
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
),
|
),
|
||||||
patch("aipass.aipass.shared.project_home._enroll_project"),
|
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||||
):
|
):
|
||||||
create_project("ordertest", no_agent=True)
|
create_project("ordertest", no_agent=True)
|
||||||
|
|
||||||
@@ -399,10 +399,10 @@ def test_create_project_with_agent(host_env, monkeypatch):
|
|||||||
with (
|
with (
|
||||||
patch("subprocess.run", side_effect=_mock_git_run),
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
patch(
|
patch(
|
||||||
"aipass.aipass.shared.project_home._detect_aipass_home",
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
),
|
),
|
||||||
patch("aipass.aipass.shared.project_home._enroll_project"),
|
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||||
patch(
|
patch(
|
||||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
return_value=spawn_ok,
|
return_value=spawn_ok,
|
||||||
@@ -426,10 +426,10 @@ def test_create_project_spawn_failure_cleans_up(host_env, monkeypatch):
|
|||||||
with (
|
with (
|
||||||
patch("subprocess.run", side_effect=_mock_git_run),
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
patch(
|
patch(
|
||||||
"aipass.aipass.shared.project_home._detect_aipass_home",
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
),
|
),
|
||||||
patch("aipass.aipass.shared.project_home._enroll_project"),
|
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||||
patch(
|
patch(
|
||||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
return_value={"success": False, "error": "template missing"},
|
return_value={"success": False, "error": "template missing"},
|
||||||
@@ -449,10 +449,10 @@ def test_create_project_no_agent_next_steps(host_env, monkeypatch):
|
|||||||
with (
|
with (
|
||||||
patch("subprocess.run", side_effect=_mock_git_run),
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
patch(
|
patch(
|
||||||
"aipass.aipass.shared.project_home._detect_aipass_home",
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
),
|
),
|
||||||
patch("aipass.aipass.shared.project_home._enroll_project"),
|
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||||
patch("aipass.aipass.apps.modules.new_project.console") as mock_con,
|
patch("aipass.aipass.apps.modules.new_project.console") as mock_con,
|
||||||
):
|
):
|
||||||
handle_command("new", ["cosmtest", "--template", "empty", "--no-agent"])
|
handle_command("new", ["cosmtest", "--template", "empty", "--no-agent"])
|
||||||
@@ -466,10 +466,10 @@ def test_create_project_no_agent_flag(host_env, monkeypatch):
|
|||||||
with (
|
with (
|
||||||
patch("subprocess.run", side_effect=_mock_git_run),
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
patch(
|
patch(
|
||||||
"aipass.aipass.shared.project_home._detect_aipass_home",
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
),
|
),
|
||||||
patch("aipass.aipass.shared.project_home._enroll_project"),
|
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||||
):
|
):
|
||||||
result = create_project("noagent", template="empty", no_agent=True)
|
result = create_project("noagent", template="empty", no_agent=True)
|
||||||
|
|
||||||
@@ -645,10 +645,10 @@ def test_tty_auto_launches_agent(host_env, monkeypatch):
|
|||||||
patch("subprocess.run", side_effect=_mock_git_run),
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
patch("builtins.input", return_value=""),
|
patch("builtins.input", return_value=""),
|
||||||
patch(
|
patch(
|
||||||
"aipass.aipass.shared.project_home._detect_aipass_home",
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
),
|
),
|
||||||
patch("aipass.aipass.shared.project_home._enroll_project"),
|
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||||
patch(
|
patch(
|
||||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
return_value=spawn_ok,
|
return_value=spawn_ok,
|
||||||
@@ -680,10 +680,10 @@ def test_no_tty_skips_auto_launch(host_env, monkeypatch):
|
|||||||
patch("subprocess.run", side_effect=_mock_git_run),
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
patch("builtins.input", return_value=""),
|
patch("builtins.input", return_value=""),
|
||||||
patch(
|
patch(
|
||||||
"aipass.aipass.shared.project_home._detect_aipass_home",
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
),
|
),
|
||||||
patch("aipass.aipass.shared.project_home._enroll_project"),
|
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||||
patch(
|
patch(
|
||||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
return_value=spawn_ok,
|
return_value=spawn_ok,
|
||||||
@@ -708,10 +708,10 @@ def test_no_agent_skips_auto_launch(host_env, monkeypatch):
|
|||||||
with (
|
with (
|
||||||
patch("subprocess.run", side_effect=_mock_git_run),
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
patch(
|
patch(
|
||||||
"aipass.aipass.shared.project_home._detect_aipass_home",
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
),
|
),
|
||||||
patch("aipass.aipass.shared.project_home._enroll_project"),
|
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||||
patch("aipass.aipass.apps.modules.new_project.console"),
|
patch("aipass.aipass.apps.modules.new_project.console"),
|
||||||
patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys,
|
patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys,
|
||||||
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||||
|
|||||||
@@ -24,9 +24,7 @@ import sys
|
|||||||
|
|
||||||
import aipass.aipass.shared.json_handler
|
import aipass.aipass.shared.json_handler
|
||||||
import aipass.aipass.shared.json_ops
|
import aipass.aipass.shared.json_ops
|
||||||
import aipass.aipass.shared.project_home
|
|
||||||
import aipass.aipass.shared.registry_discovery
|
import aipass.aipass.shared.registry_discovery
|
||||||
import aipass.aipass.shared.scaffold_content
|
|
||||||
|
|
||||||
bad = []
|
bad = []
|
||||||
for name in sorted(sys.modules):
|
for name in sorted(sys.modules):
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ def _validate_metadata(metadata_str: str) -> Optional[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_branch_name(mention: str) -> Optional[str]:
|
def _resolve_branch_name(mention: str) -> Optional[str]:
|
||||||
"""Resolve a @mention to a branch name (lowercase-normalized)."""
|
"""Resolve a @mention to a branch name."""
|
||||||
name = mention.lstrip("@").lower()
|
name = mention.lstrip("@").upper()
|
||||||
|
|
||||||
if not os.path.exists(BRANCH_REGISTRY_PATH):
|
if not os.path.exists(BRANCH_REGISTRY_PATH):
|
||||||
return None
|
return None
|
||||||
@@ -85,7 +85,7 @@ def _resolve_branch_name(mention: str) -> Optional[str]:
|
|||||||
with open(BRANCH_REGISTRY_PATH, encoding="utf-8") as f:
|
with open(BRANCH_REGISTRY_PATH, encoding="utf-8") as f:
|
||||||
registry = json.load(f)
|
registry = json.load(f)
|
||||||
for branch in registry.get("branches", []):
|
for branch in registry.get("branches", []):
|
||||||
if branch.get("name", "").lower() == name:
|
if branch.get("name") == name:
|
||||||
return name
|
return name
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ RARITY_COLORS = {
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_branch_name(mention: str) -> Optional[str]:
|
def _resolve_branch_name(mention: str) -> Optional[str]:
|
||||||
"""Resolve a @mention to a branch name (lowercase-normalized)."""
|
"""Resolve a @mention to a branch name."""
|
||||||
name = mention.lstrip("@").lower()
|
name = mention.lstrip("@").upper()
|
||||||
|
|
||||||
if not os.path.exists(BRANCH_REGISTRY_PATH):
|
if not os.path.exists(BRANCH_REGISTRY_PATH):
|
||||||
return None
|
return None
|
||||||
@@ -64,7 +64,7 @@ def _resolve_branch_name(mention: str) -> Optional[str]:
|
|||||||
with open(BRANCH_REGISTRY_PATH, encoding="utf-8") as f:
|
with open(BRANCH_REGISTRY_PATH, encoding="utf-8") as f:
|
||||||
registry = json.load(f)
|
registry = json.load(f)
|
||||||
for branch in registry.get("branches", []):
|
for branch in registry.get("branches", []):
|
||||||
if branch.get("name", "").lower() == name:
|
if branch.get("name") == name:
|
||||||
return name
|
return name
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ def show_profile(args: List[str]) -> dict:
|
|||||||
|
|
||||||
# Determine which branch to show
|
# Determine which branch to show
|
||||||
if args:
|
if args:
|
||||||
target_branch = args[0].lower()
|
target_branch = args[0].upper()
|
||||||
else:
|
else:
|
||||||
caller = get_caller_branch()
|
caller = get_caller_branch()
|
||||||
if not caller:
|
if not caller:
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ def _parse_search_args(args: List[str]) -> dict:
|
|||||||
result["room"] = remaining[i + 1].lower()
|
result["room"] = remaining[i + 1].lower()
|
||||||
i += 2
|
i += 2
|
||||||
elif flag == "--author" and i + 1 < len(remaining):
|
elif flag == "--author" and i + 1 < len(remaining):
|
||||||
result["author"] = remaining[i + 1].lower()
|
result["author"] = remaining[i + 1].upper()
|
||||||
i += 2
|
i += 2
|
||||||
elif flag == "--type" and i + 1 < len(remaining):
|
elif flag == "--type" and i + 1 < len(remaining):
|
||||||
search_type = remaining[i + 1].lower()
|
search_type = remaining[i + 1].lower()
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ def run_welcome(args: List[str]) -> dict:
|
|||||||
conn = get_db()
|
conn = get_db()
|
||||||
|
|
||||||
if filtered_args:
|
if filtered_args:
|
||||||
branch_name = filtered_args[0].lower()
|
branch_name = filtered_args[0].upper()
|
||||||
if dry_run:
|
if dry_run:
|
||||||
already = has_been_welcomed(conn, branch_name)
|
already = has_been_welcomed(conn, branch_name)
|
||||||
close_db(conn)
|
close_db(conn)
|
||||||
@@ -114,7 +114,7 @@ def _welcome_specific(conn, branch_name: str) -> dict:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with success and welcome result
|
Dict with success and welcome result
|
||||||
"""
|
"""
|
||||||
agent = conn.execute("SELECT branch_name FROM agents WHERE LOWER(branch_name) = ?", (branch_name,)).fetchone()
|
agent = conn.execute("SELECT branch_name FROM agents WHERE branch_name = ?", (branch_name,)).fetchone()
|
||||||
|
|
||||||
if not agent:
|
if not agent:
|
||||||
return {"success": False, "error": f"Branch '{branch_name}' not found in The Commons."}
|
return {"success": False, "error": f"Branch '{branch_name}' not found in The Commons."}
|
||||||
|
|||||||
@@ -993,7 +993,7 @@ def test_run_welcome_dry_run_specific_branch(
|
|||||||
"""run_welcome <branch> --dry-run reports whether the branch would be welcomed."""
|
"""run_welcome <branch> --dry-run reports whether the branch would be welcomed."""
|
||||||
from aipass.commons.apps.handlers.welcome.welcome_ops import run_welcome
|
from aipass.commons.apps.handlers.welcome.welcome_ops import run_welcome
|
||||||
|
|
||||||
_seed_agent(initialized_db, "gamma")
|
_seed_agent(initialized_db, "GAMMA")
|
||||||
|
|
||||||
mock_get_db.return_value = initialized_db
|
mock_get_db.return_value = initialized_db
|
||||||
mock_close.side_effect = lambda c: None
|
mock_close.side_effect = lambda c: None
|
||||||
@@ -1001,7 +1001,7 @@ def test_run_welcome_dry_run_specific_branch(
|
|||||||
result = run_welcome(["gamma", "--dry-run"])
|
result = run_welcome(["gamma", "--dry-run"])
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert result["dry_run"] is True
|
assert result["dry_run"] is True
|
||||||
assert result["branch"] == "gamma"
|
assert result["branch"] == "GAMMA"
|
||||||
assert result["would_welcome"] is True
|
assert result["would_welcome"] is True
|
||||||
|
|
||||||
|
|
||||||
@@ -1076,7 +1076,7 @@ def test_run_welcome_specific_branch_success(
|
|||||||
"""run_welcome <branch> creates a welcome post for that branch."""
|
"""run_welcome <branch> creates a welcome post for that branch."""
|
||||||
from aipass.commons.apps.handlers.welcome.welcome_ops import run_welcome
|
from aipass.commons.apps.handlers.welcome.welcome_ops import run_welcome
|
||||||
|
|
||||||
_seed_agent(initialized_db, "epsilon")
|
_seed_agent(initialized_db, "EPSILON")
|
||||||
|
|
||||||
mock_get_db.return_value = initialized_db
|
mock_get_db.return_value = initialized_db
|
||||||
mock_close.side_effect = lambda c: None
|
mock_close.side_effect = lambda c: None
|
||||||
@@ -1085,7 +1085,7 @@ def test_run_welcome_specific_branch_success(
|
|||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert result["action"] == "specific"
|
assert result["action"] == "specific"
|
||||||
assert result["already_welcomed"] is False
|
assert result["already_welcomed"] is False
|
||||||
assert result["branch"] == "epsilon"
|
assert result["branch"] == "EPSILON"
|
||||||
assert result["post_id"] is not None
|
assert result["post_id"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -58,10 +58,10 @@ def test_parse_search_args_room_flag():
|
|||||||
|
|
||||||
|
|
||||||
def test_parse_search_args_author_flag():
|
def test_parse_search_args_author_flag():
|
||||||
"""The --author flag should set the author filter and lowercase it."""
|
"""The --author flag should set the author filter and uppercase it."""
|
||||||
result = _parse_search_args(["test", "--author", "drone"])
|
result = _parse_search_args(["test", "--author", "drone"])
|
||||||
assert result["query"] == "test"
|
assert result["query"] == "test"
|
||||||
assert result["author"] == "drone"
|
assert result["author"] == "DRONE"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_search_args_type_flag_valid():
|
def test_parse_search_args_type_flag_valid():
|
||||||
@@ -94,7 +94,7 @@ def test_parse_search_args_all_flags():
|
|||||||
)
|
)
|
||||||
assert result["query"] == "registry"
|
assert result["query"] == "registry"
|
||||||
assert result["room"] == "dev"
|
assert result["room"] == "dev"
|
||||||
assert result["author"] == "flow"
|
assert result["author"] == "FLOW"
|
||||||
assert result["search_type"] == "posts"
|
assert result["search_type"] == "posts"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"autoCompactWindow": 350000,
|
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [],
|
"allow": [],
|
||||||
"deny": [
|
"deny": [
|
||||||
|
|||||||
@@ -70,6 +70,71 @@
|
|||||||
"file": "tests/test_owner_guard.py",
|
"file": "tests/test_owner_guard.py",
|
||||||
"reason": "Unit test imports the owner guard handler (its SUT) and patches spawn.registry's get_owner/is_owner — the guard is a shared handler primitive with no apps/modules/ command entry point. Same direct-SUT pattern as test_compass_store.py. #681."
|
"reason": "Unit test imports the owner guard handler (its SUT) and patches spawn.registry's get_owner/is_owner — the guard is a shared handler primitive with no apps/modules/ command entry point. Same direct-SUT pattern as test_compass_store.py. #681."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"standard": "encapsulation",
|
||||||
|
"file": "tools/spot_check.py",
|
||||||
|
"reason": "Standalone diagnostic tool script, not a handler."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"standard": "debug_print",
|
||||||
|
"file": "tools/spot_check.py",
|
||||||
|
"reason": "Standalone CLI tool — print() is the intended output method."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"standard": "silent_catch",
|
||||||
|
"file": "tools/hook_engine_poc/engine.py",
|
||||||
|
"reason": "POC hook engine — stdlib only, no prax logger. Uses sys.stderr for error reporting."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"standard": "error_handling",
|
||||||
|
"file": "tools/hook_engine_poc/engine.py",
|
||||||
|
"reason": "POC hook engine — exception handlers write to stderr, not prax logger."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"standard": "silent_catch",
|
||||||
|
"file": "tools/hook_engine_poc/test_engine.py",
|
||||||
|
"reason": "POC test harness — catches test exceptions to report pass/fail, writes to stderr."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"standard": "imports",
|
||||||
|
"file": "tools/hook_engine_poc/test_engine.py",
|
||||||
|
"reason": "POC test harness — sys.path needed to import engine.py from same directory."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"standard": "trigger",
|
||||||
|
"file": "tools/hook_engine_poc/test_engine.py",
|
||||||
|
"reason": "POC test harness — .unlink() clears ephemeral JSONL log between tests, not a tracked file."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"standard": "help_text",
|
||||||
|
"file": "tools/hook_engine_poc/test_engine.py",
|
||||||
|
"reason": "POC test harness — usage example in docstring."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"standard": "debug_print",
|
||||||
|
"file": "tools/rm_shim/redteam_suite.py",
|
||||||
|
"reason": "Standalone red-team diagnostic runner (FPLAN-0250 Phase 6) — print() IS the report output, same as broker_acceptance_test.py."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"standard": "encapsulation",
|
||||||
|
"file": "tools/rm_shim/redteam_suite.py",
|
||||||
|
"reason": "Red-team tool imports the real broker daemon/client + sandbox module directly to exercise them under live conditions — that is the point of an integration probe, not a handler."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"standard": "imports",
|
||||||
|
"file": "tools/rm_shim/redteam_suite.py",
|
||||||
|
"reason": "Standalone script run via 'python tools/...' — sys.path insert lets it import the production modules it red-teams without being pip-installed."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"standard": "help_text",
|
||||||
|
"file": "tools/rm_shim/redteam_suite.py",
|
||||||
|
"reason": "Diagnostic script — docstring shows the 'python tools/...' invocation; it is not a drone-routed module."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"standard": "documentation",
|
||||||
|
"file": "tools/rm_shim/redteam_suite.py",
|
||||||
|
"reason": "Result.ok/bad are 2-line internal report helpers in a diagnostic script — self-evident, docstrings redundant."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"standard": "encapsulation",
|
"standard": "encapsulation",
|
||||||
"file": "tests/test_compass_store.py",
|
"file": "tests/test_compass_store.py",
|
||||||
|
|||||||
@@ -423,12 +423,7 @@ class StallTracker:
|
|||||||
(#634 part 2). The verbose trail stays on ``_stderr`` + logger.
|
(#634 part 2). The verbose trail stays on ``_stderr`` + logger.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Quiet spans with zero JSONL output are routine, not stalls: a model composing
|
STALL_THRESHOLD = 120.0
|
||||||
# a long response writes nothing until the turn completes, and compaction is one
|
|
||||||
# multi-minute silent API call. 120s false-fired on every stall of S329 (8/8);
|
|
||||||
# 300s clears normal turns and most compactions while a wedged agent still
|
|
||||||
# surfaces well inside a long watch.
|
|
||||||
STALL_THRESHOLD = 300.0
|
|
||||||
# A single tool call held in-flight this long is surfaced as a soft advisory
|
# A single tool call held in-flight this long is surfaced as a soft advisory
|
||||||
# (heavy op or a hung tool). Below the 600s default timeout so long watches
|
# (heavy op or a hung tool). Below the 600s default timeout so long watches
|
||||||
# get a mid-flight heads-up instead of waiting on the timeout.
|
# get a mid-flight heads-up instead of waiting on the timeout.
|
||||||
@@ -521,7 +516,7 @@ def watch_agent(
|
|||||||
poll_interval: Seconds between checks. Default 5.0 — the per-tick work (lock
|
poll_interval: Seconds between checks. Default 5.0 — the per-tick work (lock
|
||||||
stat, PID liveness, one-dir JSONL size scan) is cheap, so a tight cadence
|
stat, PID liveness, one-dir JSONL size scan) is cheap, so a tight cadence
|
||||||
just burns CPU. 5s keeps completion latency invisible on multi-minute
|
just burns CPU. 5s keeps completion latency invisible on multi-minute
|
||||||
dispatches while the 300s stall threshold has ample resolution.
|
dispatches while the 120s stall threshold has ample resolution.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with keys: woke, reason, elapsed, agent_state, exit_code, agent_id.
|
dict with keys: woke, reason, elapsed, agent_state, exit_code, agent_id.
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
<title>S324 Advisory Scan — AIPass</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--paper: #f4f1ea; --surface: #fbf9f4; --surface-2: #eeeae0; --ink: #1a1e27;
|
|
||||||
--ink-soft: #454b58; --ink-faint: #6b7280; --hair: #ddd6c8;
|
|
||||||
--accent: #b57e1f; --accent-soft: #e9d9b4;
|
|
||||||
--crit: #c92a30; --high: #c0741f; --med: #4964a8; --low: #7b8494; --good: #2f8f5b;
|
|
||||||
--crit-bg: #f6dcdc; --high-bg: #f4e6d0; --med-bg: #dde3f2; --low-bg: #e6e8ec; --good-bg: #d8ecdf;
|
|
||||||
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
|
||||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
--maxw: 940px;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--paper: #10131a; --surface: #161a23; --surface-2: #1e2430; --ink: #e7e9ee;
|
|
||||||
--ink-soft: #aab2c0; --ink-faint: #79828f; --hair: #2a3140; --accent: #d9a441;
|
|
||||||
--accent-soft: #4a3d1c; --crit: #f0565b; --high: #e8973f; --med: #7d97d8; --low: #8a93a3; --good: #4cc082;
|
|
||||||
--crit-bg: #3a1c1e; --high-bg: #382a17; --med-bg: #1f2740; --low-bg: #242a35; --good-bg: #16301f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
:root[data-theme="light"] {
|
|
||||||
--paper: #f4f1ea; --surface: #fbf9f4; --surface-2: #eeeae0; --ink: #1a1e27;
|
|
||||||
--ink-soft: #454b58; --ink-faint: #6b7280; --hair: #ddd6c8; --accent: #b57e1f;
|
|
||||||
--accent-soft: #e9d9b4; --crit: #c92a30; --high: #c0741f; --med: #4964a8; --low: #7b8494; --good: #2f8f5b;
|
|
||||||
--crit-bg: #f6dcdc; --high-bg: #f4e6d0; --med-bg: #dde3f2; --low-bg: #e6e8ec; --good-bg: #d8ecdf;
|
|
||||||
}
|
|
||||||
:root[data-theme="dark"] {
|
|
||||||
--paper: #10131a; --surface: #161a23; --surface-2: #1e2430; --ink: #e7e9ee;
|
|
||||||
--ink-soft: #aab2c0; --ink-faint: #79828f; --hair: #2a3140; --accent: #d9a441;
|
|
||||||
--accent-soft: #4a3d1c; --crit: #f0565b; --high: #e8973f; --med: #7d97d8; --low: #8a93a3; --good: #4cc082;
|
|
||||||
--crit-bg: #3a1c1e; --high-bg: #382a17; --med-bg: #1f2740; --low-bg: #242a35; --good-bg: #16301f;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
|
||||||
.wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 22px; }
|
|
||||||
|
|
||||||
header { padding: 54px 0 30px; border-bottom: 1px solid var(--hair); }
|
|
||||||
.eyebrow { font-family: var(--mono); font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: var(--accent); margin: 0 0 16px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
|
||||||
.eyebrow .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--good); box-shadow: 0 0 0 3px var(--good-bg); }
|
|
||||||
h1 { font-family: var(--mono); font-weight: 600; font-size: clamp(28px, 5vw, 44px); line-height: 1.06; letter-spacing: -0.02em; margin: 0 0 14px; text-wrap: balance; }
|
|
||||||
h1 .melt { color: var(--accent); }
|
|
||||||
.lede { font-size: 18px; color: var(--ink-soft); max-width: 62ch; margin: 0; }
|
|
||||||
.meta { font-family: var(--mono); font-size: 12.5px; color: var(--ink-faint); margin-top: 22px; display: flex; gap: 8px 20px; flex-wrap: wrap; }
|
|
||||||
.meta b { color: var(--ink-soft); font-weight: 600; }
|
|
||||||
|
|
||||||
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--hair); border: 1px solid var(--hair); border-radius: 10px; overflow: hidden; margin: 30px 0 8px; }
|
|
||||||
.stat { background: var(--surface); padding: 18px 18px 16px; }
|
|
||||||
.stat .n { font-family: var(--mono); font-size: 30px; font-weight: 600; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; line-height: 1; }
|
|
||||||
.stat .k { font-size: 12.5px; color: var(--ink-faint); margin-top: 7px; }
|
|
||||||
.stat.good .n { color: var(--good); }
|
|
||||||
.stat.med .n { color: var(--med); }
|
|
||||||
@media (max-width: 620px) { .stats { grid-template-columns: repeat(2, 1fr); } }
|
|
||||||
|
|
||||||
section { padding: 40px 0; border-bottom: 1px solid var(--hair); }
|
|
||||||
.sec-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 22px; }
|
|
||||||
.sec-head h2 { font-family: var(--mono); font-size: 14px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink); margin: 0; font-weight: 600; }
|
|
||||||
.sec-head .rule { flex: 1; height: 1px; background: var(--hair); }
|
|
||||||
.sec-head .count { font-family: var(--mono); font-size: 12px; color: var(--ink-faint); }
|
|
||||||
|
|
||||||
.findings { display: flex; flex-direction: column; gap: 12px; }
|
|
||||||
.f { background: var(--surface); border: 1px solid var(--hair); border-radius: 9px; border-left-width: 4px; padding: 16px 18px; }
|
|
||||||
.f.high { border-left-color: var(--high); }
|
|
||||||
.f.med { border-left-color: var(--med); }
|
|
||||||
.f.low { border-left-color: var(--low); }
|
|
||||||
.f.good { border-left-color: var(--good); }
|
|
||||||
.f-top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 7px; }
|
|
||||||
.id { font-family: var(--mono); font-size: 12px; font-weight: 600; color: var(--ink-faint); }
|
|
||||||
.pill { font-family: var(--mono); font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase; font-weight: 600; padding: 2px 8px; border-radius: 20px; }
|
|
||||||
.pill.high { color: var(--high); background: var(--high-bg); }
|
|
||||||
.pill.med { color: var(--med); background: var(--med-bg); }
|
|
||||||
.pill.low { color: var(--low); background: var(--low-bg); }
|
|
||||||
.pill.good { color: var(--good); background: var(--good-bg); }
|
|
||||||
.f-title { font-weight: 600; font-size: 15.5px; color: var(--ink); flex: 1 1 100%; margin-top: 2px; }
|
|
||||||
.f-body { font-size: 14px; color: var(--ink-soft); margin: 0; }
|
|
||||||
.f-body code { font-family: var(--mono); font-size: 12.5px; background: var(--surface-2); padding: 1px 5px; border-radius: 4px; color: var(--ink); }
|
|
||||||
|
|
||||||
table.melt { width: 100%; border-collapse: collapse; font-size: 14px; margin-top: 4px; }
|
|
||||||
table.melt th { font-family: var(--mono); font-size: 11.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-faint); text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--hair); }
|
|
||||||
table.melt td { padding: 9px 12px; border-bottom: 1px solid var(--hair); color: var(--ink-soft); }
|
|
||||||
table.melt td.n { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--ink); font-weight: 600; white-space: nowrap; }
|
|
||||||
table.melt td.verdict-good { color: var(--good); font-weight: 600; }
|
|
||||||
table.melt td.verdict-open { color: var(--med); font-weight: 600; }
|
|
||||||
.tblwrap { overflow-x: auto; }
|
|
||||||
|
|
||||||
.callout { background: var(--surface-2); border: 1px solid var(--hair); border-radius: 9px; padding: 18px 20px; margin-top: 18px; }
|
|
||||||
.callout h3 { font-family: var(--mono); font-size: 13px; letter-spacing: .06em; text-transform: uppercase; margin: 0 0 8px; color: var(--accent); }
|
|
||||||
.callout p { margin: 0; font-size: 14px; color: var(--ink-soft); }
|
|
||||||
|
|
||||||
footer { padding: 30px 0 60px; font-family: var(--mono); font-size: 12px; color: var(--ink-faint); }
|
|
||||||
footer .disc { color: var(--good); }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="wrap">
|
|
||||||
<header>
|
|
||||||
<p class="eyebrow"><span class="dot"></span> Session S324 · Advisory Scan · Read-only</p>
|
|
||||||
<h1>The scanners CI never runs said 1,419 problems. Verification <span class="melt">melted it to a handful.</span></h1>
|
|
||||||
<p class="lede">Patrick's ask: "look in ur tools folder run some random scan see what surfaces." Ran the April-era advisory scanners — the ones deliberately NOT wired into seedgo's audit — across all 17 branches, then read every scary number in context before believing it. The tree is clean where it counts; most of the raw count is archive noise and scanner age.</p>
|
|
||||||
<p class="meta"><span><b>Date</b> 2026-07-18</span><span><b>Agent</b> @devpulse</span><span><b>Scanners</b> fallback · stale-terms · readme-freshness</span><span><b>Branches</b> 17/17</span><span><b>Files changed</b> 0</span></p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="stats" role="list">
|
|
||||||
<div class="stat" role="listitem"><div class="n">1,419</div><div class="k">raw findings, all scanners</div></div>
|
|
||||||
<div class="stat med" role="listitem"><div class="n">16</div><div class="k">fallbacks actually in live code</div></div>
|
|
||||||
<div class="stat good" role="listitem"><div class="n">2/2</div><div class="k">sampled live hits = defensible on read</div></div>
|
|
||||||
<div class="stat" role="listitem"><div class="n">1</div><div class="k">genuine surprise (MagicMock leak)</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- THE MELT -->
|
|
||||||
<section>
|
|
||||||
<div class="sec-head"><h2>How the numbers melt</h2><span class="rule"></span><span class="count">raw → verified</span></div>
|
|
||||||
<div class="tblwrap"><table class="melt">
|
|
||||||
<tr><th>Scanner</th><th>Raw</th><th>After context</th><th>Verdict</th></tr>
|
|
||||||
<tr><td>Silent fallbacks</td><td class="n">61</td><td class="n">16 live · 45 = devpulse <code>dropbox/</code> quarantine copies</td><td class="verdict-good">mostly archive noise</td></tr>
|
|
||||||
<tr><td> ↳ of the 16 live</td><td class="n">16</td><td class="n">@trigger 11 · @cli 2 · @skills 2 (tests) · @spawn 1 (template)</td><td class="verdict-open">@trigger needs owner triage</td></tr>
|
|
||||||
<tr><td>Stale terminology</td><td class="n">1,358</td><td class="n">~600 genuinely dead names · rest = valid <code>.ai_mail.local/</code> refs</td><td class="verdict-open">needs live-code filter</td></tr>
|
|
||||||
<tr><td>README freshness</td><td class="n">15/17 stale</td><td class="n">dates lag the front-door code week</td><td class="verdict-good">informational</td></tr>
|
|
||||||
</table></div>
|
|
||||||
<div class="callout">
|
|
||||||
<h3>Why sampling flipped the verdict</h3>
|
|
||||||
<p>Both @trigger hits I read in full are <b>recursion-safe meta-logging guards</b> — "if logging this warning fails, don't crash the error pipeline" — one even carries a <code># seedgo:bypass meta-logging</code> marker. The @spawn template hit is a two-prefix import <em>probe</em> that surfaces real failures later, not a swallow. The scanner predates the bypass convention, so it re-alarms on code the standards system already acquitted.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- FINDINGS -->
|
|
||||||
<section>
|
|
||||||
<div class="sec-head"><h2>What actually surfaced</h2><span class="rule"></span><span class="count">5 findings</span></div>
|
|
||||||
<div class="findings">
|
|
||||||
|
|
||||||
<div class="f med">
|
|
||||||
<div class="f-top"><span class="id">S324-1</span><span class="pill med">Medium · real · unowned</span>
|
|
||||||
<span class="f-title">A test leaked a Mock onto disk: <code>MagicMock/LOG_FILE/</code> sits in devpulse's branch root</span></div>
|
|
||||||
<p class="f-body">Empty, untracked, born <b>Jul 11 03:31</b> (a night-shift test run). Something called mkdir on a path built from an unpatched <code>Mock().LOG_FILE</code>. Four candidate offenders mock a <code>LOG_FILE</code> attribute: ai_mail <code>test_daemon.py</code> / <code>test_dispatch_status.py</code>, hooks <code>test_engine.py</code>, seedgo <code>test_checkers_batch3.py</code>. Cheap hunt: run each suite from a scratch CWD and watch which one mints the dir. Until then it's clutter that will regrow after cleanup.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="f med">
|
|
||||||
<div class="f-top"><span class="id">S324-2</span><span class="pill med">Medium · owner call</span>
|
|
||||||
<span class="f-title">@trigger: 11 bare <code>except Exception: pass</code> in live event handlers — 9 still unread</span></div>
|
|
||||||
<p class="f-body">The error-detection branch swallowing exceptions is worth eyes on principle. The 2 I sampled are legitimate meta-logging guards; the other 9 (in <code>error_detected</code>, <code>runaway_handler</code>, <code>plan_file</code>, <code>pr_status_sync</code>, <code>memory_pool</code>, <code>registry</code>, <code>startup</code>) deserve the same context read by their owner. Concern, not prescription — @trigger designs the fix if any is real.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="f low">
|
|
||||||
<div class="f-top"><span class="id">S324-3</span><span class="pill low">Low · tooling debt</span>
|
|
||||||
<span class="f-title">The advisory scanners are aging out of the conventions they audit</span></div>
|
|
||||||
<p class="f-body">April-era tools: the fallback scanner doesn't honor <code>seedgo:bypass</code> markers (false alarms on acquitted code), the stale scanner counts valid <code>.ai_mail.local/</code> usage in its headline, and none of them exclude <code>dropbox/</code> / <code>.archive/</code> quarantine dirs — which is where 45 of 61 "fallbacks" came from. A bypass-aware, archive-excluding refresh would make raw counts trustworthy again.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="f low">
|
|
||||||
<div class="f-top"><span class="id">S324-4</span><span class="pill low">Low · unfiltered</span>
|
|
||||||
<span class="f-title">~600 dead-name references still in the tree: dev central 243 · branch_registry 211 · ai_central 131 · cortex 13</span></div>
|
|
||||||
<p class="f-body">Not yet split between live code (worth fixing) and historical plans/archives (should keep their old names — never rewrite history). A filtered live-code pass is the prerequisite before this number means anything. Public-repo angle: strangers reading live code shouldn't meet four generations of naming.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="f good">
|
|
||||||
<div class="f-top"><span class="id">S324-5</span><span class="pill good">Verified good</span>
|
|
||||||
<span class="f-title">Where it counts, the tree is clean</span></div>
|
|
||||||
<p class="f-body">Devpulse live code: <b>zero</b> silent fallbacks — all 45 flagged were quarantined pollution captures in <code>dropbox/</code>, doing exactly what quarantine is for. The 10 hard checkers born from this same tools folder already run inside <code>drone @seedgo audit</code>, where all 17 branches sit at 100%. The advisory layer found no criticals, no highs, nothing live-broken.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- CONTEXT -->
|
|
||||||
<section>
|
|
||||||
<div class="sec-head"><h2>Report lineage</h2><span class="rule"></span><span class="count">where this fits</span></div>
|
|
||||||
<p class="f-body" style="max-width:70ch">Sibling of <code>docs/discovery/S304_report.html</code> (Jul 12 — the 124-finding autonomous discovery walk; two criticals from it, medic-off and the rollover off-by-one, were since fixed and Patrick-monitored respectively). The other big HTML artifact of the week is <code>artifacts/ux_flow_graph.html</code> (3.5 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 <code>artifacts/gif_recording_plan.md</code>.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<span class="disc">read-only scan</span> · zero files changed · scanners: fallback_scanner_v1, stale_scanner_v1, readme_freshness_scanner_v1 · @devpulse S324 · 2026-07-18
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
@@ -312,7 +312,7 @@ def test_stalltracker_inflight_tool_prevents_stall(monkeypatch, capsys):
|
|||||||
monkeypatch.setattr(agent_handler, "_last_entry_is_inflight_tool", lambda *a, **kw: True)
|
monkeypatch.setattr(agent_handler, "_last_entry_is_inflight_tool", lambda *a, **kw: True)
|
||||||
t = agent_handler.StallTracker("@x", Path("/nope"), {}, now=0.0, pid=123)
|
t = agent_handler.StallTracker("@x", Path("/nope"), {}, now=0.0, pid=123)
|
||||||
|
|
||||||
for now in (120.0, 240.0, 360.0, 480.0):
|
for now in (60.0, 120.0, 180.0, 240.0):
|
||||||
t.observe(now=now)
|
t.observe(now=now)
|
||||||
|
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
@@ -391,8 +391,7 @@ def test_watch_agent_surfaces_stall_to_stdout(monkeypatch, tmp_path, capsys):
|
|||||||
monkeypatch.setattr(agent_handler, "_pid_alive", lambda pid: True)
|
monkeypatch.setattr(agent_handler, "_pid_alive", lambda pid: True)
|
||||||
monkeypatch.setattr(agent_handler, "_has_jsonl_activity", lambda *a, **kw: False)
|
monkeypatch.setattr(agent_handler, "_has_jsonl_activity", lambda *a, **kw: False)
|
||||||
monkeypatch.setattr(agent_handler, "_last_entry_is_inflight_tool", lambda *a, **kw: False)
|
monkeypatch.setattr(agent_handler, "_last_entry_is_inflight_tool", lambda *a, **kw: False)
|
||||||
# Lock must outlive STALL_THRESHOLD (300s) or the watch completes stall-free.
|
_fake_clock_sleep(agent_handler, monkeypatch, lock_file)
|
||||||
_fake_clock_sleep(agent_handler, monkeypatch, lock_file, unlink_at=400.0)
|
|
||||||
|
|
||||||
result = agent_handler.watch_agent("@fakebranch", timeout_seconds=100000, poll_interval=0.01)
|
result = agent_handler.watch_agent("@fakebranch", timeout_seconds=100000, poll_interval=0.01)
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from aipass.prax import logger
|
from aipass.prax import logger
|
||||||
@@ -303,47 +302,10 @@ def _handle_close_pr(args: list[str]) -> dict:
|
|||||||
return {"stdout": "", "stderr": result["message"], "exit_code": 1}
|
return {"stdout": "", "stderr": result["message"], "exit_code": 1}
|
||||||
|
|
||||||
|
|
||||||
def _confirm_merge(pr_number: str, caller: str, confirmed: bool) -> dict | None:
|
|
||||||
"""Joint-decision gate: merges must never happen accidentally (DPLAN-0256).
|
|
||||||
|
|
||||||
Returns None when the merge may proceed, or a refusal/abort result dict.
|
|
||||||
Order matters: an explicit --confirm always passes; otherwise a real
|
|
||||||
terminal gets an interactive y/N prompt; headless callers are refused.
|
|
||||||
"""
|
|
||||||
if confirmed:
|
|
||||||
json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "--confirm"})
|
|
||||||
return None
|
|
||||||
|
|
||||||
if sys.stdin.isatty():
|
|
||||||
answer = input(f"Merge PR #{pr_number}? Merges are a joint decision. [y/N] ")
|
|
||||||
if answer.strip().lower() in ("y", "yes"):
|
|
||||||
json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "tty-yes"})
|
|
||||||
return None
|
|
||||||
json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "tty-abort"})
|
|
||||||
return {"stdout": "", "stderr": f"Merge of PR #{pr_number} aborted at prompt.", "exit_code": 1}
|
|
||||||
|
|
||||||
json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "headless-refused"})
|
|
||||||
logger.info("merge gate: refused headless merge of PR #%s by %s (no --confirm)", pr_number, caller)
|
|
||||||
return {
|
|
||||||
"stdout": "",
|
|
||||||
"stderr": (
|
|
||||||
f"Merge of PR #{pr_number} requires explicit confirmation — merges are a joint decision.\n"
|
|
||||||
f"Re-run once agreed: drone @git merge {pr_number} --confirm"
|
|
||||||
),
|
|
||||||
"exit_code": 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_merge(args: list[str], caller: str) -> dict:
|
def _handle_merge(args: list[str], caller: str) -> dict:
|
||||||
"""Handle the merge subcommand (owner-tier, auth pre-checked)."""
|
"""Handle the merge subcommand (owner-tier, auth pre-checked)."""
|
||||||
confirmed = "--confirm" in args
|
if not args:
|
||||||
pr_args = [a for a in args if not a.startswith("--")]
|
return {"stdout": "", "stderr": "Usage: drone @git merge <PR#>", "exit_code": 1}
|
||||||
if not pr_args:
|
|
||||||
return {"stdout": "", "stderr": "Usage: drone @git merge <PR#> [--confirm]", "exit_code": 1}
|
|
||||||
|
|
||||||
refusal = _confirm_merge(pr_args[0], caller, confirmed)
|
|
||||||
if refusal is not None:
|
|
||||||
return refusal
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr
|
from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr
|
||||||
@@ -351,7 +313,7 @@ def _handle_merge(args: list[str], caller: str) -> dict:
|
|||||||
logger.error("Failed to import devpulse_ops merge plugin: %s", exc)
|
logger.error("Failed to import devpulse_ops merge plugin: %s", exc)
|
||||||
return {"stdout": "", "stderr": f"devpulse_ops plugin not available: {exc}", "exit_code": 1}
|
return {"stdout": "", "stderr": f"devpulse_ops plugin not available: {exc}", "exit_code": 1}
|
||||||
|
|
||||||
result = merge_pr(pr_args[0], caller)
|
result = merge_pr(args[0], caller)
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
return {"stdout": result["message"], "stderr": "", "exit_code": 0}
|
return {"stdout": result["message"], "stderr": "", "exit_code": 0}
|
||||||
return {"stdout": "", "stderr": result["message"], "exit_code": 1}
|
return {"stdout": "", "stderr": result["message"], "exit_code": 1}
|
||||||
@@ -675,10 +637,8 @@ def get_help(command: str | None = None) -> str:
|
|||||||
return "git unlock --force — Force-release the PR lock [owner]\n"
|
return "git unlock --force — Force-release the PR lock [owner]\n"
|
||||||
if command == "merge":
|
if command == "merge":
|
||||||
return (
|
return (
|
||||||
"git merge <PR#> [--confirm] — Merge a PR and sync local main [owner]\n"
|
"git merge <PR#> — Merge a PR and sync local main [owner]\n"
|
||||||
" Runs gh pr merge --merge --delete-branch, then git pull --rebase.\n"
|
" Runs gh pr merge --merge --delete-branch, then git pull --rebase.\n"
|
||||||
" Joint-decision gate: terminal sessions get a y/N prompt; headless\n"
|
|
||||||
" callers must pass --confirm or the merge is refused.\n"
|
|
||||||
)
|
)
|
||||||
if command == "smart-sync":
|
if command == "smart-sync":
|
||||||
return "git smart-sync — Fetch origin and rebase if behind [owner]\n"
|
return "git smart-sync — Fetch origin and rebase if behind [owner]\n"
|
||||||
@@ -726,7 +686,7 @@ def get_help(command: str | None = None) -> str:
|
|||||||
" dev-pr <desc> Push dev and create PR to main\n"
|
" dev-pr <desc> Push dev and create PR to main\n"
|
||||||
" delete-branch <name> Delete a remote branch\n"
|
" delete-branch <name> Delete a remote branch\n"
|
||||||
" close-pr <number> Close a PR\n"
|
" close-pr <number> Close a PR\n"
|
||||||
" merge <PR#> [--confirm] Merge a PR (gated: y/N prompt or --confirm)\n"
|
" merge <PR#> Merge a PR\n"
|
||||||
" sync [--autostash] Sync with origin/main (FF on dev)\n"
|
" sync [--autostash] Sync with origin/main (FF on dev)\n"
|
||||||
" smart-sync Fetch + rebase if behind\n"
|
" smart-sync Fetch + rebase if behind\n"
|
||||||
" unlock --force Force-release the PR lock\n"
|
" unlock --force Force-release the PR lock\n"
|
||||||
|
|||||||
@@ -552,9 +552,7 @@ class TestGitModuleRouting:
|
|||||||
proc.stdout = "ok\n"
|
proc.stdout = "ok\n"
|
||||||
mock_run.return_value = proc
|
mock_run.return_value = proc
|
||||||
|
|
||||||
# --confirm satisfies the joint-decision gate (DPLAN-0256); headless
|
result = handle_command("merge", ["42"])
|
||||||
# merge without it is refused before routing reaches merge_pr.
|
|
||||||
result = handle_command("merge", ["42", "--confirm"])
|
|
||||||
assert result["exit_code"] == 0
|
assert result["exit_code"] == 0
|
||||||
|
|
||||||
@patch(
|
@patch(
|
||||||
|
|||||||
@@ -1471,91 +1471,3 @@ class TestScopeFooter:
|
|||||||
result = handle_command("diff", ["--all"])
|
result = handle_command("diff", ["--all"])
|
||||||
|
|
||||||
assert "showing drone scope" not in result["stdout"]
|
assert "showing drone scope" not in result["stdout"]
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
|
||||||
# Merge joint-decision gate (DPLAN-0256)
|
|
||||||
# ===========================================================================
|
|
||||||
|
|
||||||
_MERGE_PR = "aipass.drone.apps.plugins.devpulse_ops.merge_plugin.merge_pr"
|
|
||||||
_MERGE_OK = {
|
|
||||||
"success": True,
|
|
||||||
"pr_number": "123",
|
|
||||||
"title": "t",
|
|
||||||
"merge_commit": "abc123",
|
|
||||||
"message": "PR #123 merged: t (abc123)",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestMergeGate:
|
|
||||||
"""merge must never run without explicit confirmation."""
|
|
||||||
|
|
||||||
@patch(_AUTH, return_value="devpulse")
|
|
||||||
def test_headless_without_confirm_refused(self, _mock_auth: MagicMock) -> None:
|
|
||||||
"""Non-TTY caller without --confirm is refused before the plugin loads."""
|
|
||||||
with patch(_MERGE_PR) as mock_merge:
|
|
||||||
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
|
|
||||||
mock_stdin.isatty.return_value = False
|
|
||||||
result = handle_command("merge", ["123"])
|
|
||||||
|
|
||||||
assert result["exit_code"] == 1
|
|
||||||
assert "requires explicit confirmation" in result["stderr"]
|
|
||||||
assert "--confirm" in result["stderr"]
|
|
||||||
mock_merge.assert_not_called()
|
|
||||||
|
|
||||||
@patch(_AUTH, return_value="devpulse")
|
|
||||||
def test_confirm_flag_proceeds(self, _mock_auth: MagicMock) -> None:
|
|
||||||
"""--confirm merges without prompting, even headless."""
|
|
||||||
with patch(_MERGE_PR, return_value=dict(_MERGE_OK)) as mock_merge:
|
|
||||||
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
|
|
||||||
mock_stdin.isatty.return_value = False
|
|
||||||
result = handle_command("merge", ["123", "--confirm"])
|
|
||||||
|
|
||||||
assert result["exit_code"] == 0
|
|
||||||
mock_merge.assert_called_once_with("123", "devpulse")
|
|
||||||
|
|
||||||
@patch(_AUTH, return_value="devpulse")
|
|
||||||
def test_confirm_flag_position_agnostic(self, _mock_auth: MagicMock) -> None:
|
|
||||||
"""--confirm before the PR number still resolves the right PR."""
|
|
||||||
with patch(_MERGE_PR, return_value=dict(_MERGE_OK)) as mock_merge:
|
|
||||||
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
|
|
||||||
mock_stdin.isatty.return_value = False
|
|
||||||
result = handle_command("merge", ["--confirm", "123"])
|
|
||||||
|
|
||||||
assert result["exit_code"] == 0
|
|
||||||
mock_merge.assert_called_once_with("123", "devpulse")
|
|
||||||
|
|
||||||
@patch(_AUTH, return_value="devpulse")
|
|
||||||
def test_tty_yes_proceeds(self, _mock_auth: MagicMock) -> None:
|
|
||||||
"""Interactive terminal answering y merges."""
|
|
||||||
with patch(_MERGE_PR, return_value=dict(_MERGE_OK)) as mock_merge:
|
|
||||||
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
|
|
||||||
mock_stdin.isatty.return_value = True
|
|
||||||
with patch("builtins.input", return_value="y"):
|
|
||||||
result = handle_command("merge", ["123"])
|
|
||||||
|
|
||||||
assert result["exit_code"] == 0
|
|
||||||
mock_merge.assert_called_once_with("123", "devpulse")
|
|
||||||
|
|
||||||
@patch(_AUTH, return_value="devpulse")
|
|
||||||
def test_tty_default_aborts(self, _mock_auth: MagicMock) -> None:
|
|
||||||
"""Interactive terminal hitting enter (default N) aborts."""
|
|
||||||
with patch(_MERGE_PR) as mock_merge:
|
|
||||||
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
|
|
||||||
mock_stdin.isatty.return_value = True
|
|
||||||
with patch("builtins.input", return_value=""):
|
|
||||||
result = handle_command("merge", ["123"])
|
|
||||||
|
|
||||||
assert result["exit_code"] == 1
|
|
||||||
assert "aborted" in result["stderr"]
|
|
||||||
mock_merge.assert_not_called()
|
|
||||||
|
|
||||||
@patch(_AUTH, return_value="devpulse")
|
|
||||||
def test_confirm_without_pr_number_is_usage_error(self, _mock_auth: MagicMock) -> None:
|
|
||||||
"""--confirm alone (no PR number) is a usage error, not a merge."""
|
|
||||||
with patch(_MERGE_PR) as mock_merge:
|
|
||||||
result = handle_command("merge", ["--confirm"])
|
|
||||||
|
|
||||||
assert result["exit_code"] == 1
|
|
||||||
assert "Usage" in result["stderr"]
|
|
||||||
mock_merge.assert_not_called()
|
|
||||||
|
|||||||
@@ -184,15 +184,11 @@ def get_closed_plans() -> List[Dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
def is_template_content(content: str) -> bool:
|
def is_template_content(content: str) -> bool:
|
||||||
"""Check if plan content is still unedited template (v5.0)
|
"""Check if plan content is still unedited template (v4.0)
|
||||||
|
|
||||||
Priority order:
|
Uses bracket placeholders only (not section headers) to detect untouched
|
||||||
1. User-content signals (checked checkboxes) — strongest evidence of
|
templates. Also checks for user-added content in key sections — if any
|
||||||
real work, overrides everything.
|
real work was added, the plan is NOT a template even if placeholders remain.
|
||||||
2. Real content in Notes section — overrides bracket markers.
|
|
||||||
3. Non-boilerplate Execution Log entries — user-written entries
|
|
||||||
override bracket markers; template boilerplate is ignored.
|
|
||||||
4. Bracket-marker check — 3+ template placeholders = template.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
content: Plan file content
|
content: Plan file content
|
||||||
@@ -200,38 +196,37 @@ def is_template_content(content: str) -> bool:
|
|||||||
Returns:
|
Returns:
|
||||||
True if plan is essentially an untouched template
|
True if plan is essentially an untouched template
|
||||||
"""
|
"""
|
||||||
import re
|
# User content indicators — if ANY of these are found, plan has real work
|
||||||
|
|
||||||
# --- 1. User content signals override everything ---
|
|
||||||
user_content_signals = [
|
user_content_signals = [
|
||||||
"- [x] Agent deployed",
|
"- [x] Agent deployed", # Checked execution log item
|
||||||
"- [x] Agent completed",
|
"- [x] Agent completed", # Checked execution log item
|
||||||
"- [x] Seedgo checklist",
|
"- [x] Seedgo checklist", # Checked completion item
|
||||||
"- [x] All goals achieved",
|
"- [x] All goals achieved", # Checked completion item
|
||||||
]
|
]
|
||||||
for signal in user_content_signals:
|
for signal in user_content_signals:
|
||||||
if signal in content:
|
if signal in content:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# --- 2. Real Notes content → not template ---
|
# Check Notes section for user content (not just the placeholder)
|
||||||
|
import re
|
||||||
|
|
||||||
notes_match = re.search(r"## Notes\s*\n(.*?)(?=\n---|\n## |\Z)", content, re.DOTALL)
|
notes_match = re.search(r"## Notes\s*\n(.*?)(?=\n---|\n## |\Z)", content, re.DOTALL)
|
||||||
if notes_match:
|
if notes_match:
|
||||||
notes_content = notes_match.group(1).strip()
|
notes_content = notes_match.group(1).strip()
|
||||||
|
# If notes has content beyond the template placeholder, it's real work
|
||||||
if notes_content and notes_content != "[Working notes, issues encountered, decisions made]":
|
if notes_content and notes_content != "[Working notes, issues encountered, decisions made]":
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# --- 3. Non-boilerplate Exec Log entries → not template ---
|
# Check Execution Log for user-added entries beyond template
|
||||||
_exec_boilerplate = ["**Log Pattern:**", "[task]", "[outcome]", "[file]"]
|
|
||||||
exec_match = re.search(r"## Execution Log\s*\n(.*?)(?=\n---|\n## |\Z)", content, re.DOTALL)
|
exec_match = re.search(r"## Execution Log\s*\n(.*?)(?=\n---|\n## |\Z)", content, re.DOTALL)
|
||||||
if exec_match:
|
if exec_match:
|
||||||
exec_content = exec_match.group(1).strip()
|
exec_content = exec_match.group(1).strip()
|
||||||
is_boilerplate = any(m in exec_content for m in _exec_boilerplate)
|
|
||||||
if not is_boilerplate:
|
|
||||||
lines = [line.strip() for line in exec_content.split("\n") if line.strip()]
|
lines = [line.strip() for line in exec_content.split("\n") if line.strip()]
|
||||||
|
# Template has ~6 lines (date header + checkbox items). More = user added content.
|
||||||
if len(lines) > 8:
|
if len(lines) > 8:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# --- 4. Bracket markers: 3+ from any type = template ---
|
# Bracket placeholders only (no section headers — those persist in real plans)
|
||||||
default_markers = [
|
default_markers = [
|
||||||
"[What do you want to achieve? Specific end state.]",
|
"[What do you want to achieve? Specific end state.]",
|
||||||
"[How will agents tackle this? What instructions will they need?]",
|
"[How will agents tackle this? What instructions will they need?]",
|
||||||
@@ -255,6 +250,7 @@ def is_template_content(content: str) -> bool:
|
|||||||
"[Any other branches, services, or approvals needed?]",
|
"[Any other branches, services, or approvals needed?]",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# 3+ bracket placeholders from any type = template
|
||||||
for markers in [default_markers, master_markers, proposal_markers]:
|
for markers in [default_markers, master_markers, proposal_markers]:
|
||||||
found = sum(1 for m in markers if m in content)
|
found = sum(1 for m in markers if m in content)
|
||||||
if found >= 3:
|
if found >= 3:
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ Usage:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, Any, List, Tuple, Optional
|
from typing import Dict, Any, List, Tuple, Optional
|
||||||
@@ -34,34 +32,6 @@ from aipass.flow.apps.handlers.json import json_handler
|
|||||||
|
|
||||||
MODULE_NAME = "aggregate_central"
|
MODULE_NAME = "aggregate_central"
|
||||||
|
|
||||||
_LOCK_RETRIES = 10
|
|
||||||
_LOCK_BACKOFF_BASE = 0.05
|
|
||||||
|
|
||||||
|
|
||||||
def _acquire_lock(lock_path: Path) -> bool:
|
|
||||||
"""Atomically acquire a lockfile via O_CREAT|O_EXCL with retry+backoff."""
|
|
||||||
for attempt in range(_LOCK_RETRIES):
|
|
||||||
try:
|
|
||||||
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
||||||
os.write(fd, str(os.getpid()).encode())
|
|
||||||
os.close(fd)
|
|
||||||
return True
|
|
||||||
except FileExistsError:
|
|
||||||
logger.info("[%s] Lock contention on %s, retry %d", MODULE_NAME, lock_path, attempt + 1)
|
|
||||||
time.sleep(_LOCK_BACKOFF_BASE * (2**attempt))
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning("[%s] Lock creation failed for %s: %s", MODULE_NAME, lock_path, exc)
|
|
||||||
return False
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _release_lock(lock_path: Path) -> None:
|
|
||||||
"""Remove lockfile, tolerating already-removed."""
|
|
||||||
try:
|
|
||||||
lock_path.unlink(missing_ok=True)
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning("[%s] Could not release lock %s: %s", MODULE_NAME, lock_path, exc)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================
|
# =============================================
|
||||||
# HELPER FUNCTIONS
|
# HELPER FUNCTIONS
|
||||||
@@ -131,36 +101,13 @@ def save_branch_registry(registry_path: Path, registry: Dict[str, Any]) -> bool:
|
|||||||
Returns:
|
Returns:
|
||||||
True on success, False on failure
|
True on success, False on failure
|
||||||
"""
|
"""
|
||||||
lock_path = registry_path.with_suffix(".lock")
|
|
||||||
try:
|
|
||||||
registry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
if not _acquire_lock(lock_path):
|
|
||||||
logger.error(
|
|
||||||
"[%s] Could not acquire lock for %s after %d retries",
|
|
||||||
MODULE_NAME,
|
|
||||||
registry_path,
|
|
||||||
_LOCK_RETRIES,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
|
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||||
tmp_path = registry_path.with_suffix(".tmp")
|
with open(registry_path, "w", encoding="utf-8") as f:
|
||||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(registry, f, indent=2, ensure_ascii=False)
|
json.dump(registry, f, indent=2, ensure_ascii=False)
|
||||||
os.replace(str(tmp_path), str(registry_path))
|
|
||||||
finally:
|
|
||||||
_release_lock(lock_path)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(f"[{MODULE_NAME}] Failed to save registry {registry_path}: {e}")
|
||||||
"[%s] Failed to save registry %s: %s",
|
|
||||||
MODULE_NAME,
|
|
||||||
registry_path,
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -318,27 +265,10 @@ def save_central(central_file: Path, central_dir: Path, central_data: Dict[str,
|
|||||||
Returns:
|
Returns:
|
||||||
True on success, False on failure
|
True on success, False on failure
|
||||||
"""
|
"""
|
||||||
lock_path = central_file.with_suffix(".lock")
|
|
||||||
try:
|
try:
|
||||||
central_dir.mkdir(parents=True, exist_ok=True)
|
central_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(central_file, "w", encoding="utf-8") as f:
|
||||||
if not _acquire_lock(lock_path):
|
|
||||||
logger.error(
|
|
||||||
"[%s] Could not acquire lock for %s after %d retries",
|
|
||||||
MODULE_NAME,
|
|
||||||
central_file,
|
|
||||||
_LOCK_RETRIES,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
tmp_path = central_file.with_suffix(".tmp")
|
|
||||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(central_data, f, indent=2, ensure_ascii=False)
|
json.dump(central_data, f, indent=2, ensure_ascii=False)
|
||||||
os.replace(str(tmp_path), str(central_file))
|
|
||||||
finally:
|
|
||||||
_release_lock(lock_path)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{MODULE_NAME}] Failed to save {central_file}: {e}")
|
logger.error(f"[{MODULE_NAME}] Failed to save {central_file}: {e}")
|
||||||
@@ -462,10 +392,7 @@ def aggregate_central_impl(
|
|||||||
# Save central file
|
# Save central file
|
||||||
if save_central(central_file, central_dir, central_data):
|
if save_central(central_file, central_dir, central_data):
|
||||||
logger.info(
|
logger.info(
|
||||||
"[%s] SUCCESS: Aggregation complete: %d active, %d recently closed",
|
f"[{MODULE_NAME}] SUCCESS: Aggregation complete: {len(all_active)} active, {len(recently_closed)} recently closed"
|
||||||
MODULE_NAME,
|
|
||||||
len(all_active),
|
|
||||||
len(recently_closed),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fire trigger event
|
# Fire trigger event
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ The PR gate (verified against `.github/workflows/`):
|
|||||||
- [ ] **Stay on `dev`. Do not check out `main`.** Local main being behind is fine — `drone @git sync` from dev covers it.
|
- [ ] **Stay on `dev`. Do not check out `main`.** Local main being behind is fine — `drone @git sync` from dev covers it.
|
||||||
- [ ] Never rebase, never reset, never checkout main.
|
- [ ] Never rebase, never reset, never checkout main.
|
||||||
- [ ] Dependabot / other PRs targeting main: they go green once main has the fix + bots rebase — check after the push
|
- [ ] Dependabot / other PRs targeting main: they go green once main has the fix + bots rebase — check after the push
|
||||||
- [ ] **Site drift check (S323):** does this merge change install commands, onboarding flow, agent count, or the platform/CLI story? If YES → note it here and flag that aipass.ai must be updated same-day (devpulse handles the site edit; the site is a projection of the README, never its own source of facts).
|
|
||||||
|
|
||||||
## 7. Release tag
|
## 7. Release tag
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ HOOKS -- hook infrastructure owner. Single engine dispatches all hooks across pl
|
|||||||
## What I Do
|
## What I Do
|
||||||
|
|
||||||
- Own the hook engine -- receives events from platform bridges, routes to handlers, logs everything
|
- Own the hook engine -- receives events from platform bridges, routes to handlers, logs everything
|
||||||
- Maintain 27 native handlers across 4 categories (prompt, security, lifecycle, notification)
|
- Maintain 14 native handlers across 4 categories (prompt, security, lifecycle, notification)
|
||||||
- Bridge platforms -- thin normalization layer per provider (Claude today, Codex planned)
|
- Bridge platforms -- thin normalization layer per provider (Claude today, Codex planned)
|
||||||
- Per-project config -- `.aipass/hooks.json` controls what fires per project
|
- Per-project config -- `.aipass/hooks.json` controls what fires per project
|
||||||
- Log everything -- prax integration + JSONL diagnostics for every hook execution
|
- Log everything -- prax integration + JSONL diagnostics for every hook execution
|
||||||
|
|
||||||
## What I Don't Do
|
## What I Don't Do
|
||||||
|
|
||||||
- Touch `~/.claude/settings.json` -- personal file, doctor/init syncs it. The manifest (`.claude/provider_manifest.json`) IS mine to maintain
|
- Touch provider settings directly -- setup.sh/doctor handles platform config installation
|
||||||
- Manage other branches -- I'm a builder, not an orchestrator
|
- Manage other branches -- I'm a builder, not an orchestrator
|
||||||
- Own handler business logic -- handlers are self-contained, engine just dispatches
|
- Own handler business logic -- handlers are self-contained, engine just dispatches
|
||||||
|
|
||||||
@@ -40,59 +40,45 @@ apps/
|
|||||||
handlers/
|
handlers/
|
||||||
bridges/
|
bridges/
|
||||||
claude.py # Claude Code bridge (provider settings entry point)
|
claude.py # Claude Code bridge (provider settings entry point)
|
||||||
codex.py # Codex bridge (planned)
|
prompt/ # Prompt injection hooks
|
||||||
prompt/ # Prompt injection hooks (UserPromptSubmit)
|
|
||||||
branch_loader.py # Injects aipass_local_prompt.md
|
branch_loader.py # Injects aipass_local_prompt.md
|
||||||
tier0_kernel.py # Injects tier0 kernel prompt (every turn)
|
tier0_kernel.py # Injects tier0 kernel prompt (every turn)
|
||||||
navmap.py # Injects tier1 navmap prompt (periodic)
|
navmap.py # Injects tier1 navmap prompt (periodic)
|
||||||
identity.py # Injects passport identity block
|
identity.py # Injects passport identity block
|
||||||
compass_recall.py # Governance recall injection
|
|
||||||
feedback_pulse.py # 10-turn cadence feedback nudge (disabled default)
|
|
||||||
context_gauge.py # Live transcript-fill nudge toward /prep
|
|
||||||
temporal.py # Weekday/date/time/tz/part-of-day, every turn
|
|
||||||
persistent_alert.py # Advisory banners for .aipass/alerts.json
|
|
||||||
security/ # Enforcement hooks
|
security/ # Enforcement hooks
|
||||||
presence_gate.py # Session presence gate (UserPromptSubmit + Stop release)
|
|
||||||
edit_gate.py # Blocks edits while type errors exist
|
edit_gate.py # Blocks edits while type errors exist
|
||||||
git_gate.py # Enforces git access tiers
|
git_gate.py # Enforces git access tiers
|
||||||
rm_gate.py # Guards destructive rm commands
|
|
||||||
registry_gate.py # Guards registry-modifying commands
|
|
||||||
subagent_gate.py # Blocks sub-agent stop until clean
|
subagent_gate.py # Blocks sub-agent stop until clean
|
||||||
lifecycle/ # Session management hooks
|
lifecycle/ # Session management hooks
|
||||||
auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile)
|
auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile)
|
||||||
auto_watchdog.py # Watchdog arming after dispatch
|
auto_watchdog.py # Watchdog arming after dispatch
|
||||||
auto_process.py # Scheduled inbox/task processing
|
|
||||||
compact.py # Pre-compact memory archival
|
compact.py # Pre-compact memory archival
|
||||||
rollover.py # Pre-compact memory rollover
|
rollover.py # Pre-compact memory rollover
|
||||||
pre_compact_prep.py # Pre-compact snapshot stamp (context/dispatch/plans)
|
|
||||||
session_start.py # SessionStart cadence reset
|
|
||||||
notification/ # Alert hooks
|
notification/ # Alert hooks
|
||||||
announce.py # Inbox banner on prompt
|
announce.py # Inbox banner on prompt
|
||||||
email.py # Email notification
|
email.py # Email notification
|
||||||
stop_sound.py # Sound on session stop
|
stop_sound.py # Sound on session stop
|
||||||
tool_sound.py # Sound on tool use
|
tool_sound.py # Sound on tool use
|
||||||
telegram_response.py # Telegram reply delivery on Stop
|
|
||||||
config/
|
config/
|
||||||
loader.py # hooks.json discovery + validation, config-independent trust checks
|
loader.py # hooks.json discovery + validation
|
||||||
trust_registry.py # Trusted-project registry (enroll/revoke/hash checks)
|
diagnostics.py # Diagnostics config
|
||||||
diagnostics.py # JSONL diagnostics config
|
|
||||||
logs/
|
logs/
|
||||||
engine.jsonl # JSONL diagnostics (every hook execution)
|
engine.jsonl # JSONL diagnostics (every hook execution)
|
||||||
tests/ # 43 test files, 1249 tests
|
tests/ # 15 test files, 244 tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## Handler Categories
|
## Handler Categories
|
||||||
|
|
||||||
| Category | Count | Handlers |
|
| Category | Count | Handlers |
|
||||||
|----------|-------|----------|
|
|----------|-------|----------|
|
||||||
| prompt | 9 | branch_loader, tier0_kernel, navmap, identity, compass_recall, feedback_pulse, context_gauge, temporal, persistent_alert |
|
| prompt | 4 | branch_loader, tier0_kernel, navmap, identity |
|
||||||
| security | 6 | presence_gate, edit_gate, git_gate, rm_gate, registry_gate, subagent_gate |
|
| security | 3 | edit_gate, git_gate, subagent_gate |
|
||||||
| lifecycle | 7 | auto_fix, auto_watchdog, auto_process, compact, rollover, pre_compact_prep, session_start |
|
| lifecycle | 4 | auto_fix, auto_watchdog, compact, rollover |
|
||||||
| notification | 5 | announce, email, stop_sound, tool_sound, telegram_response |
|
| notification | 4 | announce, email, stop_sound, tool_sound |
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
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)
|
1. Provider settings point ONE bridge entry per event type (e.g., `claude.py UserPromptSubmit`)
|
||||||
2. Bridge calls `engine.dispatch(event_type, stdin_data, config)`
|
2. Bridge calls `engine.dispatch(event_type, stdin_data, config)`
|
||||||
3. Engine reads `.aipass/hooks.json` (walks up from CWD)
|
3. Engine reads `.aipass/hooks.json` (walks up from CWD)
|
||||||
4. Engine runs matching hooks sequentially, logs each to JSONL
|
4. Engine runs matching hooks sequentially, logs each to JSONL
|
||||||
@@ -100,12 +86,6 @@ tests/ # 43 test files, 1249 tests
|
|||||||
6. Exit code 2 without JSON = crash (log error, continue to next hook)
|
6. Exit code 2 without JSON = crash (log error, continue to next hook)
|
||||||
7. All hook stdout concatenated and returned to platform
|
7. All hook stdout concatenated and returned to platform
|
||||||
|
|
||||||
## New handler? Check the provider wire
|
|
||||||
|
|
||||||
hooks.json alone is not live: UserPromptSubmit + PreCompact are invoked per-handler (`claude.py Event:name`) -- handlers on those events ALSO need a command entry in `.claude/provider_manifest.json` (PreCompact: manual + auto pair). Verify with firing evidence in engine.jsonl, not just the suite.
|
|
||||||
|
|
||||||
EVERY reply that adds/renames/moves a handler MUST state either "provider settings update needed: <exact entries>" or "no provider wire needed" -- never silent. Devpulse + Patrick apply live-settings changes; flag it every time, even if the manifest is already updated.
|
|
||||||
|
|
||||||
## Integration
|
## Integration
|
||||||
|
|
||||||
- **Depends on:** @prax for logging (system_logger for prax monitor visibility)
|
- **Depends on:** @prax for logging (system_logger for prax monitor visibility)
|
||||||
@@ -125,4 +105,4 @@ EVERY reply that adds/renames/moves a handler MUST state either "provider settin
|
|||||||
|
|
||||||
- Exit code 2 has dual meaning: intentional block (with JSON) vs crash (without JSON). Engine distinguishes by checking stdout.
|
- Exit code 2 has dual meaning: intentional block (with JSON) vs crash (without JSON). Engine distinguishes by checking stdout.
|
||||||
- JSONL log lives at `logs/engine.jsonl` -- not in prax. Prax gets a copy via system_logger, but JSONL is the source of truth for hook diagnostics.
|
- JSONL log lives at `logs/engine.jsonl` -- not in prax. Prax gets a copy via system_logger, but JSONL is the source of truth for hook diagnostics.
|
||||||
- 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.
|
- Bridge must be the ONLY entry in provider settings per event type. Multiple entries per event = platform calls them all independently, bypassing engine sequencing.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"created": "2026-05-18",
|
"created": "2026-05-18",
|
||||||
"updated": "2026-05-28",
|
"updated": "2026-05-28",
|
||||||
"description": "Standards bypass configuration for this branch",
|
"description": "Standards bypass configuration for this branch",
|
||||||
"audit_context": "DPLAN-0191: Full ownership hardening. All handler wiring verified against .aipass/hooks.json + engine.jsonl firing evidence. Dynamic dispatch via engine._run_handler (importlib.import_module + getattr) means handlers are never statically imported — 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 \u2014 seedgo's dead_code/unused_function checks are false positives for this architecture."
|
||||||
},
|
},
|
||||||
"bypass": [
|
"bypass": [
|
||||||
{
|
{
|
||||||
@@ -14,17 +14,17 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/handlers/bridges/claude.py",
|
"file": "apps/handlers/bridges/claude.py",
|
||||||
"standard": "dead_code",
|
"standard": "dead_code",
|
||||||
"reason": "Bridge called externally by provider settings subprocess — no internal import. Verified wired in ~/.claude/settings.json hook entries."
|
"reason": "Bridge called externally by provider settings subprocess \u2014 no internal import. Verified wired in ~/.claude/settings.json hook entries."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/bridges/claude.py",
|
"file": "apps/handlers/bridges/claude.py",
|
||||||
"standard": "unused_function",
|
"standard": "unused_function",
|
||||||
"reason": "main() called as subprocess entry point from provider settings — never statically imported."
|
"reason": "main() called as subprocess entry point from provider settings \u2014 never statically imported."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/bridges/claude.py",
|
"file": "apps/handlers/bridges/claude.py",
|
||||||
"standard": "handlers",
|
"standard": "handlers",
|
||||||
"reason": "Bridges import engine module by design — that is their entire purpose."
|
"reason": "Bridges import engine module by design \u2014 that is their entire purpose."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/bridges/claude.py",
|
"file": "apps/handlers/bridges/claude.py",
|
||||||
@@ -39,27 +39,27 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/handlers/bridges/claude.py",
|
"file": "apps/handlers/bridges/claude.py",
|
||||||
"standard": "imports",
|
"standard": "imports",
|
||||||
"reason": "Bridge imports engine module by design — sole purpose."
|
"reason": "Bridge imports engine module by design \u2014 sole purpose."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/bridges/codex.py",
|
"file": "apps/handlers/bridges/codex.py",
|
||||||
"standard": "dead_code",
|
"standard": "dead_code",
|
||||||
"reason": "Bridge called externally by Codex hook settings subprocess — no internal import. Wired in .codex/hooks.json."
|
"reason": "Bridge called externally by Codex hook settings subprocess \u2014 no internal import. Wired in .codex/hooks.json."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/bridges/codex.py",
|
"file": "apps/handlers/bridges/codex.py",
|
||||||
"standard": "unused_function",
|
"standard": "unused_function",
|
||||||
"reason": "main() called as subprocess entry point from Codex hook settings — never statically imported."
|
"reason": "main() called as subprocess entry point from Codex hook settings \u2014 never statically imported."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/bridges/codex.py",
|
"file": "apps/handlers/bridges/codex.py",
|
||||||
"standard": "handlers",
|
"standard": "handlers",
|
||||||
"reason": "Bridges import engine module by design — that is their entire purpose."
|
"reason": "Bridges import engine module by design \u2014 that is their entire purpose."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/bridges/codex.py",
|
"file": "apps/handlers/bridges/codex.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Thin entry point using stdlib json for Codex protocol envelope — no JSON file ops needing json_handler."
|
"reason": "Thin entry point using stdlib json for Codex protocol envelope \u2014 no JSON file ops needing json_handler."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/bridges/codex.py",
|
"file": "apps/handlers/bridges/codex.py",
|
||||||
@@ -69,12 +69,12 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/handlers/bridges/codex.py",
|
"file": "apps/handlers/bridges/codex.py",
|
||||||
"standard": "imports",
|
"standard": "imports",
|
||||||
"reason": "Bridge imports engine module by design — sole purpose."
|
"reason": "Bridge imports engine module by design \u2014 sole purpose."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/prompt/identity.py",
|
"file": "apps/handlers/prompt/identity.py",
|
||||||
"standard": "dead_code",
|
"standard": "dead_code",
|
||||||
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.identity.handle' — 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' \u2014 not statically imported by design. Verified wired in UserPromptSubmit.identity_injector + fires in engine.jsonl."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/prompt/identity.py",
|
"file": "apps/handlers/prompt/identity.py",
|
||||||
@@ -84,12 +84,12 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/handlers/prompt/identity.py",
|
"file": "apps/handlers/prompt/identity.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Uses stdlib json.loads to read passport.json — no JSON file ops needing json_handler."
|
"reason": "Uses stdlib json.loads to read passport.json \u2014 no JSON file ops needing json_handler."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/prompt/branch_loader.py",
|
"file": "apps/handlers/prompt/branch_loader.py",
|
||||||
"standard": "dead_code",
|
"standard": "dead_code",
|
||||||
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.branch_loader.handle' — 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' \u2014 not statically imported by design. Verified wired in UserPromptSubmit.branch_prompt + fires in engine.jsonl."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/prompt/branch_loader.py",
|
"file": "apps/handlers/prompt/branch_loader.py",
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/handlers/prompt/branch_loader.py",
|
"file": "apps/handlers/prompt/branch_loader.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "No JSON operations — reads markdown files and outputs text."
|
"reason": "No JSON operations \u2014 reads markdown files and outputs text."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/prompt/tier0_kernel.py",
|
"file": "apps/handlers/prompt/tier0_kernel.py",
|
||||||
@@ -131,15 +131,10 @@
|
|||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "No JSON operations - reads markdown file (.aipass/tier1_navmap.md) and outputs text."
|
"reason": "No JSON operations - reads markdown file (.aipass/tier1_navmap.md) and outputs text."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"file": "apps/handlers/prompt/temporal.py",
|
|
||||||
"standard": "json_structure",
|
|
||||||
"reason": "No JSON operations - reads the system clock and outputs text. Wired in UserPromptSubmit.temporal (FPLAN-0348)."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/security/edit_gate.py",
|
"file": "apps/handlers/security/edit_gate.py",
|
||||||
"standard": "dead_code",
|
"standard": "dead_code",
|
||||||
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.edit_gate.handle' — 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' \u2014 not statically imported by design. Verified wired in PreToolUse.pre_edit_gate + fires in engine.jsonl."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/security/edit_gate.py",
|
"file": "apps/handlers/security/edit_gate.py",
|
||||||
@@ -149,12 +144,12 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/handlers/security/edit_gate.py",
|
"file": "apps/handlers/security/edit_gate.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
|
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/security/git_gate.py",
|
"file": "apps/handlers/security/git_gate.py",
|
||||||
"standard": "dead_code",
|
"standard": "dead_code",
|
||||||
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.git_gate.handle' — 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' \u2014 not statically imported by design. Verified wired in PreToolUse.git_gate + fires in engine.jsonl."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/security/git_gate.py",
|
"file": "apps/handlers/security/git_gate.py",
|
||||||
@@ -164,12 +159,12 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/handlers/security/git_gate.py",
|
"file": "apps/handlers/security/git_gate.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
|
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/security/rm_gate.py",
|
"file": "apps/handlers/security/rm_gate.py",
|
||||||
"standard": "dead_code",
|
"standard": "dead_code",
|
||||||
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.rm_gate.handle' — 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' \u2014 not statically imported by design. Wired in PreToolUse.rm_gate."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/security/rm_gate.py",
|
"file": "apps/handlers/security/rm_gate.py",
|
||||||
@@ -179,12 +174,12 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/handlers/security/rm_gate.py",
|
"file": "apps/handlers/security/rm_gate.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
|
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/security/subagent_gate.py",
|
"file": "apps/handlers/security/subagent_gate.py",
|
||||||
"standard": "dead_code",
|
"standard": "dead_code",
|
||||||
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.subagent_gate.handle' — 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' \u2014 not statically imported by design. Verified wired in SubagentStop.subagent_stop_gate + fires in engine.jsonl."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/security/subagent_gate.py",
|
"file": "apps/handlers/security/subagent_gate.py",
|
||||||
@@ -194,12 +189,12 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/handlers/security/subagent_gate.py",
|
"file": "apps/handlers/security/subagent_gate.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
|
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/security/subagent_gate.py",
|
"file": "apps/handlers/security/subagent_gate.py",
|
||||||
"standard": "open_encoding",
|
"standard": "open_encoding",
|
||||||
"reason": "NamedTemporaryFile creates binary wav for Piper TTS — encoding not applicable to binary audio."
|
"reason": "NamedTemporaryFile creates binary wav for Piper TTS \u2014 encoding not applicable to binary audio."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/security/registry_gate.py",
|
"file": "apps/handlers/security/registry_gate.py",
|
||||||
@@ -249,7 +244,7 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/handlers/lifecycle/auto_fix.py",
|
"file": "apps/handlers/lifecycle/auto_fix.py",
|
||||||
"standard": "dead_code",
|
"standard": "dead_code",
|
||||||
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_fix.handle' — 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' \u2014 not statically imported by design. Verified wired in PostToolUse.auto_fix_diagnostics + fires in engine.jsonl."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/lifecycle/auto_fix.py",
|
"file": "apps/handlers/lifecycle/auto_fix.py",
|
||||||
@@ -259,12 +254,12 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/handlers/lifecycle/auto_fix.py",
|
"file": "apps/handlers/lifecycle/auto_fix.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Diagnostics handler uses stdlib json for hook protocol responses and state file — no JSON file ops needing json_handler."
|
"reason": "Diagnostics handler uses stdlib json for hook protocol responses and state file \u2014 no JSON file ops needing json_handler."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/lifecycle/auto_watchdog.py",
|
"file": "apps/handlers/lifecycle/auto_watchdog.py",
|
||||||
"standard": "dead_code",
|
"standard": "dead_code",
|
||||||
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_watchdog.handle' — 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' \u2014 not statically imported by design. Verified wired in PostToolUse.auto_watchdog + fires in engine.jsonl."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/lifecycle/auto_watchdog.py",
|
"file": "apps/handlers/lifecycle/auto_watchdog.py",
|
||||||
@@ -274,42 +269,42 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/handlers/lifecycle/auto_watchdog.py",
|
"file": "apps/handlers/lifecycle/auto_watchdog.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Uses stdlib json.dumps to produce additionalContext output — no JSON file ops needing json_handler."
|
"reason": "Uses stdlib json.dumps to produce additionalContext output \u2014 no JSON file ops needing json_handler."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/lifecycle/compact.py",
|
"file": "apps/handlers/lifecycle/compact.py",
|
||||||
"standard": "dead_code",
|
"standard": "dead_code",
|
||||||
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.compact.handle' — not statically imported by design. Verified wired in PreCompact.pre_compact (PreCompact events are rare — fires only during context compaction)."
|
"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)."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/lifecycle/compact.py",
|
"file": "apps/handlers/lifecycle/compact.py",
|
||||||
"standard": "unused_function",
|
"standard": "unused_function",
|
||||||
"reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact — 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 \u2014 fires during compaction events."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/lifecycle/compact.py",
|
"file": "apps/handlers/lifecycle/compact.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Uses stdlib json.loads for local.json reading — no JSON file ops needing json_handler."
|
"reason": "Uses stdlib json.loads for local.json reading \u2014 no JSON file ops needing json_handler."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/lifecycle/rollover.py",
|
"file": "apps/handlers/lifecycle/rollover.py",
|
||||||
"standard": "dead_code",
|
"standard": "dead_code",
|
||||||
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.rollover.handle' — not statically imported by design. Verified wired in PreCompact.pre_compact_rollover (PreCompact events are rare — fires only during context compaction)."
|
"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)."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/lifecycle/rollover.py",
|
"file": "apps/handlers/lifecycle/rollover.py",
|
||||||
"standard": "unused_function",
|
"standard": "unused_function",
|
||||||
"reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact_rollover — 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 \u2014 fires during compaction events."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/lifecycle/rollover.py",
|
"file": "apps/handlers/lifecycle/rollover.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Uses stdlib json.loads for registry and memory file checks — no JSON file ops needing json_handler."
|
"reason": "Uses stdlib json.loads for registry and memory file checks \u2014 no JSON file ops needing json_handler."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/lifecycle/auto_process.py",
|
"file": "apps/handlers/lifecycle/auto_process.py",
|
||||||
"standard": "dead_code",
|
"standard": "dead_code",
|
||||||
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_process.handle' — 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' \u2014 not statically imported by design. Wired in UserPromptSubmit.auto_process + PreCompact.auto_process."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/lifecycle/auto_process.py",
|
"file": "apps/handlers/lifecycle/auto_process.py",
|
||||||
@@ -319,12 +314,12 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/handlers/lifecycle/auto_process.py",
|
"file": "apps/handlers/lifecycle/auto_process.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Delegates to @memory's auto_process() via importlib — no direct JSON file ops needing json_handler."
|
"reason": "Delegates to @memory's auto_process() via importlib \u2014 no direct JSON file ops needing json_handler."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/lifecycle/session_start.py",
|
"file": "apps/handlers/lifecycle/session_start.py",
|
||||||
"standard": "dead_code",
|
"standard": "dead_code",
|
||||||
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.session_start.handle' — 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' \u2014 not statically imported by design. Wired in SessionStart.cadence_reset."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/lifecycle/session_start.py",
|
"file": "apps/handlers/lifecycle/session_start.py",
|
||||||
@@ -334,12 +329,12 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/handlers/lifecycle/session_start.py",
|
"file": "apps/handlers/lifecycle/session_start.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Delegates to cadence.reset_counter() via importlib — no direct JSON file ops needing json_handler."
|
"reason": "Delegates to cadence.reset_counter() via importlib \u2014 no direct JSON file ops needing json_handler."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/cadence.py",
|
"file": "apps/modules/cadence.py",
|
||||||
"standard": "dead_code",
|
"standard": "dead_code",
|
||||||
"reason": "Cadence module — 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 \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."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/cadence.py",
|
"file": "apps/modules/cadence.py",
|
||||||
@@ -349,12 +344,12 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/modules/cadence.py",
|
"file": "apps/modules/cadence.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Uses stdlib json for /tmp state file (turn counter) and hooks_json/custom_config/cadence_config.json config loading — 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 \u2014 lightweight ephemeral state, not branch data storage."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/notification/announce.py",
|
"file": "apps/handlers/notification/announce.py",
|
||||||
"standard": "dead_code",
|
"standard": "dead_code",
|
||||||
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.announce.handle' — 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' \u2014 not statically imported by design. Verified wired in Notification.notification_sound + fires in engine.jsonl."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/notification/announce.py",
|
"file": "apps/handlers/notification/announce.py",
|
||||||
@@ -364,12 +359,12 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/handlers/notification/announce.py",
|
"file": "apps/handlers/notification/announce.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Sound handler — no JSON operations, plays WAV files."
|
"reason": "Sound handler \u2014 no JSON operations, plays WAV files."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/notification/email.py",
|
"file": "apps/handlers/notification/email.py",
|
||||||
"standard": "dead_code",
|
"standard": "dead_code",
|
||||||
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.email.handle' — 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' \u2014 not statically imported by design. Verified wired in UserPromptSubmit.email_notification + fires in engine.jsonl."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/notification/email.py",
|
"file": "apps/handlers/notification/email.py",
|
||||||
@@ -379,12 +374,12 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/handlers/notification/email.py",
|
"file": "apps/handlers/notification/email.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Uses stdlib json.loads for inbox parsing — no JSON file ops needing json_handler."
|
"reason": "Uses stdlib json.loads for inbox parsing \u2014 no JSON file ops needing json_handler."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/notification/stop_sound.py",
|
"file": "apps/handlers/notification/stop_sound.py",
|
||||||
"standard": "dead_code",
|
"standard": "dead_code",
|
||||||
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.stop_sound.handle' — 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' \u2014 not statically imported by design. Verified wired in Stop.stop_sound + fires in engine.jsonl."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/notification/stop_sound.py",
|
"file": "apps/handlers/notification/stop_sound.py",
|
||||||
@@ -394,12 +389,12 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/handlers/notification/stop_sound.py",
|
"file": "apps/handlers/notification/stop_sound.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Sound handler — no JSON operations, plays WAV files."
|
"reason": "Sound handler \u2014 no JSON operations, plays WAV files."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/notification/tool_sound.py",
|
"file": "apps/handlers/notification/tool_sound.py",
|
||||||
"standard": "dead_code",
|
"standard": "dead_code",
|
||||||
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.tool_sound.handle' — 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' \u2014 not statically imported by design. Verified wired in PreToolUse.tool_use_sound + fires in engine.jsonl."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/notification/tool_sound.py",
|
"file": "apps/handlers/notification/tool_sound.py",
|
||||||
@@ -409,7 +404,7 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/handlers/notification/tool_sound.py",
|
"file": "apps/handlers/notification/tool_sound.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Sound handler — no JSON operations, plays WAV files."
|
"reason": "Sound handler \u2014 no JSON operations, plays WAV files."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/config/trust_registry.py",
|
"file": "apps/handlers/config/trust_registry.py",
|
||||||
@@ -419,22 +414,22 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/handlers/config/loader.py",
|
"file": "apps/handlers/config/loader.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Config loader does $AIPASS_HOME variable expansion before JSON parse — json_handler does not support this."
|
"reason": "Config loader does $AIPASS_HOME variable expansion before JSON parse \u2014 json_handler does not support this."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/config/diagnostics.py",
|
"file": "apps/handlers/config/diagnostics.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "JSONL append-only diagnostic log — different pattern from branch json_handler storage."
|
"reason": "JSONL append-only diagnostic log \u2014 different pattern from branch json_handler storage."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/engine.py",
|
"file": "apps/modules/engine.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Engine uses JSONL diagnostic logging, not branch json_handler — different purpose."
|
"reason": "Engine uses JSONL diagnostic logging, not branch json_handler \u2014 different purpose."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/engine.py",
|
"file": "apps/modules/engine.py",
|
||||||
"standard": "modules",
|
"standard": "modules",
|
||||||
"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."
|
"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."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/hook_test.py",
|
"file": "apps/modules/hook_test.py",
|
||||||
@@ -444,32 +439,32 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/modules/hooksound.py",
|
"file": "apps/modules/hooksound.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Sound mute toggle — touches /tmp/aipass-hooks-muted flag file only, no JSON operations or json_handler storage."
|
"reason": "Sound mute toggle \u2014 touches /tmp/aipass-hooks-muted flag file only, no JSON operations or json_handler storage."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/hooksound.py",
|
"file": "apps/modules/hooksound.py",
|
||||||
"standard": "trigger",
|
"standard": "trigger",
|
||||||
"reason": "MUTE_FLAG.unlink() removes a /tmp mute flag file for sound toggle — 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 \u2014 not a tracked resource or production data deletion. Deliberate user action via 'drone @hooks hooksound on'."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/feedback.py",
|
"file": "apps/modules/feedback.py",
|
||||||
"standard": "trigger",
|
"standard": "trigger",
|
||||||
"reason": "sentinel.unlink() removes a .aipass/feedback_off toggle file — not a tracked resource. Deliberate user action via 'drone @hooks feedback on'."
|
"reason": "sentinel.unlink() removes a .aipass/feedback_off toggle file \u2014 not a tracked resource. Deliberate user action via 'drone @hooks feedback on'."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/hookstatus.py",
|
"file": "apps/modules/hookstatus.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Read-only config viewer — delegates JSON loading to config/loader.py, no direct JSON file ops."
|
"reason": "Read-only config viewer \u2014 delegates JSON loading to config/loader.py, no direct JSON file ops."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/wire_verify.py",
|
"file": "apps/modules/wire_verify.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Reads ~/.claude/settings.json (external provider settings) with stdlib json — not branch data storage needing json_handler."
|
"reason": "Reads ~/.claude/settings.json (external provider settings) with stdlib json \u2014 not branch data storage needing json_handler."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/cadence.py",
|
"file": "apps/modules/cadence.py",
|
||||||
"standard": "modules",
|
"standard": "modules",
|
||||||
"reason": "Cadence module reads /tmp state file and hooks_json/custom_config/cadence_config.json with stdlib json — 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 \u2014 lightweight ephemeral state (turn counter) + tunable config. json_handler is for persistent branch data, not /tmp session state or config knobs."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/hooks.py",
|
"file": "apps/hooks.py",
|
||||||
@@ -479,17 +474,17 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/sound.py",
|
"file": "apps/sound.py",
|
||||||
"standard": "unused_function",
|
"standard": "unused_function",
|
||||||
"reason": "play() called by handler files (stop_sound.py, announce.py) that are dynamically dispatched via importlib — static analysis cannot trace the call chain from hooks.json → engine → handler → sound.play()."
|
"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()."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/sound.py",
|
"file": "apps/sound.py",
|
||||||
"standard": "open_encoding",
|
"standard": "open_encoding",
|
||||||
"reason": "NamedTemporaryFile creates binary WAV for Piper TTS — encoding not applicable to binary audio."
|
"reason": "NamedTemporaryFile creates binary WAV for Piper TTS \u2014 encoding not applicable to binary audio."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/sound.py",
|
"file": "apps/sound.py",
|
||||||
"standard": "cli_flags",
|
"standard": "cli_flags",
|
||||||
"reason": "Shared utility module imported by handlers — 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 \u2014 not a CLI entry point. Has print_introspection() for drone discovery but no handle_command() or user-facing CLI."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"standard": "json_handler",
|
"standard": "json_handler",
|
||||||
@@ -497,7 +492,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"standard": "test_quality",
|
"standard": "test_quality",
|
||||||
"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."
|
"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."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "tests/conftest.py",
|
"file": "tests/conftest.py",
|
||||||
@@ -507,12 +502,12 @@
|
|||||||
{
|
{
|
||||||
"file": "tests/conftest.py",
|
"file": "tests/conftest.py",
|
||||||
"standard": "json_handler",
|
"standard": "json_handler",
|
||||||
"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."
|
"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."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "tests/conftest.py",
|
"file": "tests/conftest.py",
|
||||||
"standard": "exception_contracts",
|
"standard": "exception_contracts",
|
||||||
"reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns — those contracts are N/A for a hook dispatch engine."
|
"reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns \u2014 those contracts are N/A for a hook dispatch engine."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "tests/test_engine.py",
|
"file": "tests/test_engine.py",
|
||||||
@@ -537,7 +532,7 @@
|
|||||||
{
|
{
|
||||||
"file": "tests/test_engine.py",
|
"file": "tests/test_engine.py",
|
||||||
"standard": "json_handler",
|
"standard": "json_handler",
|
||||||
"reason": "Hooks branch does not use json_handler — has its own JSONL logging."
|
"reason": "Hooks branch does not use json_handler \u2014 has its own JSONL logging."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "tests/test_engine.py",
|
"file": "tests/test_engine.py",
|
||||||
@@ -667,12 +662,12 @@
|
|||||||
{
|
{
|
||||||
"file": "tests/test_auto_fix.py",
|
"file": "tests/test_auto_fix.py",
|
||||||
"standard": "commented_logger",
|
"standard": "commented_logger",
|
||||||
"reason": "Test data contains '# logger.debug(msg)' as input to pattern checker under test — not a commented-out call."
|
"reason": "Test data contains '# logger.debug(msg)' as input to pattern checker under test \u2014 not a commented-out call."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "tests/test_auto_fix.py",
|
"file": "tests/test_auto_fix.py",
|
||||||
"standard": "trigger",
|
"standard": "trigger",
|
||||||
"reason": "Test cleanup .unlink() removes temporary state files — not a production file deletion."
|
"reason": "Test cleanup .unlink() removes temporary state files \u2014 not a production file deletion."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "tests/test_identity.py",
|
"file": "tests/test_identity.py",
|
||||||
@@ -962,47 +957,47 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/modules/sandbox.py",
|
"file": "apps/modules/sandbox.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Uses stdlib json.dump to write ephemeral srt config to a NamedTemporaryFile — not a tracked JSON resource needing json_handler."
|
"reason": "Uses stdlib json.dump to write ephemeral srt config to a NamedTemporaryFile \u2014 not a tracked JSON resource needing json_handler."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/sandbox.py",
|
"file": "apps/modules/sandbox.py",
|
||||||
"standard": "trigger",
|
"standard": "trigger",
|
||||||
"reason": "Path.unlink() removes an ephemeral NamedTemporaryFile (srt config) created seconds earlier in the same function — not a tracked resource or production data deletion."
|
"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."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "artifacts/sandbox_phase1_demo.py",
|
"file": "artifacts/sandbox_phase1_demo.py",
|
||||||
"standard": "architecture",
|
"standard": "architecture",
|
||||||
"reason": "Live demo artifact for FPLAN-0250 Phase 1 acceptance — not production code, lives in artifacts/ per the phase brief."
|
"reason": "Live demo artifact for FPLAN-0250 Phase 1 acceptance \u2014 not production code, lives in artifacts/ per the phase brief."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "artifacts/sandbox_phase1_demo.py",
|
"file": "artifacts/sandbox_phase1_demo.py",
|
||||||
"standard": "debug_print",
|
"standard": "debug_print",
|
||||||
"reason": "Demo script uses print() for human-readable acceptance test output — not a module with CLI service."
|
"reason": "Demo script uses print() for human-readable acceptance test output \u2014 not a module with CLI service."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "artifacts/sandbox_phase1_demo.py",
|
"file": "artifacts/sandbox_phase1_demo.py",
|
||||||
"standard": "imports",
|
"standard": "imports",
|
||||||
"reason": "sys.path insert needed to run standalone demo from artifacts/ — not a pip-installed module entry point."
|
"reason": "sys.path insert needed to run standalone demo from artifacts/ \u2014 not a pip-installed module entry point."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "artifacts/sandbox_phase1_demo.py",
|
"file": "artifacts/sandbox_phase1_demo.py",
|
||||||
"standard": "documentation",
|
"standard": "documentation",
|
||||||
"reason": "Demo helper function — docstrings omitted for brevity in a non-production artifact."
|
"reason": "Demo helper function \u2014 docstrings omitted for brevity in a non-production artifact."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "artifacts/sandbox_phase1_demo.py",
|
"file": "artifacts/sandbox_phase1_demo.py",
|
||||||
"standard": "help_text",
|
"standard": "help_text",
|
||||||
"reason": "Demo run instructions reference python3 as the invocation command — this is a standalone script, not a drone-routed module."
|
"reason": "Demo run instructions reference python3 as the invocation command \u2014 this is a standalone script, not a drone-routed module."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "tests/test_sandbox.py",
|
"file": "tests/test_sandbox.py",
|
||||||
"standard": "architecture",
|
"standard": "architecture",
|
||||||
"reason": "Test file lives in tests/ per hooks convention — not a module or handler."
|
"reason": "Test file lives in tests/ per hooks convention \u2014 not a module or handler."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "tests/test_sandbox.py",
|
"file": "tests/test_sandbox.py",
|
||||||
"standard": "documentation",
|
"standard": "documentation",
|
||||||
"reason": "Test methods use descriptive names — docstrings redundant per hooks test convention."
|
"reason": "Test methods use descriptive names \u2014 docstrings redundant per hooks test convention."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "tests/test_sandbox.py",
|
"file": "tests/test_sandbox.py",
|
||||||
@@ -1012,7 +1007,7 @@
|
|||||||
{
|
{
|
||||||
"file": "apps/modules/sandbox.py",
|
"file": "apps/modules/sandbox.py",
|
||||||
"standard": "modules",
|
"standard": "modules",
|
||||||
"reason": "Read-only passport.json check in _is_devpulse() to detect branch role for policy generation — 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 \u2014 not a file-write operation, not a handler-level concern. Module reads sibling passports to determine writable/RO map."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/sandbox.py",
|
"file": "apps/modules/sandbox.py",
|
||||||
@@ -1253,37 +1248,10 @@
|
|||||||
"file": "tests/test_wire_verify.py",
|
"file": "tests/test_wire_verify.py",
|
||||||
"standard": "help_text",
|
"standard": "help_text",
|
||||||
"reason": "Test fixture _BRIDGE_CMD contains 'python3' as part of a mock provider command string — not user-facing help text."
|
"reason": "Test fixture _BRIDGE_CMD contains 'python3' as part of a mock provider command string — not user-facing help text."
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "apps/modules/context_window.py",
|
|
||||||
"standard": "json_structure",
|
|
||||||
"reason": "Reads external state (session transcript JSONL via tail, branch .claude/settings.local.json) with stdlib json — not branch data storage needing json_handler. The module IS the transcript/window reader service (DPLAN-0253), same pattern as cc_sessions.py."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "apps/handlers/lifecycle/pre_compact_prep.py",
|
|
||||||
"standard": "json_structure",
|
|
||||||
"reason": "Uses stdlib json for .trinity/local.json read+stamp (own branch's memory file, DPLAN-0253) plus AIPASS_REGISTRY.json/inbox.json/memory.config.json reads (external system + other branches' state) — not this branch's own hooks_json/ triplet storage. Matches compact.py precedent (same local.json access)."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "apps/handlers/prompt/context_gauge.py",
|
|
||||||
"standard": "json_structure",
|
|
||||||
"reason": "Delegates to context_window module for transcript/settings reads — no direct JSON file ops needing json_handler. Same pattern as session_start.py delegating to cadence."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "apps/handlers/lifecycle/pre_compact_prep.py",
|
|
||||||
"standard": "handlers",
|
|
||||||
"reason": "Line 75: lazy cross-branch import of aipass.flow.apps.handlers.plan.get_open_plans — needed for the open-plan count in the AUTO-COMPACT SNAPSHOT. No module-layer equivalent exists to switch to: flow's own modules (close_plan.py) import this same handler directly rather than re-exporting it, and list_plans.py's module is a CLI display orchestrator, not a programmatic data source. Read-only cross-branch primitive read, same authorized pattern as ai_mail's dispatch_monitor.py broker-client import. Call is wrapped in try/except and degrades to None on any failure — never corrupts the snapshot."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "apps/handlers/prompt/compass_recall.py",
|
|
||||||
"standard": "handlers",
|
|
||||||
"reason": "Lines 49, 75, 84: lazy cross-branch imports of aipass.memory.apps.modules.governance and aipass.devpulse.apps.modules.compass. Surfacing other branches' rated decisions at prompt time IS this handler's job — not orchestration bleed but the designed integration point, same authorized cross-branch primitive pattern as ai_mail's dispatch_monitor.py. Whole handler wrapped in try/except and never blocks the prompt on failure."
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"notes": {
|
"notes": {
|
||||||
"removed_2026-05-19": "Stripped 4 illegitimate bypasses — hooks.py/cli, hooks.py/cli_flags, engine.py/modules, engine.py/introspection. Code fixed to meet standards instead.",
|
"removed_2026-05-19": "Stripped 4 illegitimate bypasses \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 — handlers are never statically imported. Follow-up for @seedgo: teach dead_code/unused_function about dynamic importlib dispatch patterns.",
|
"dplan_0191_2026-05-28": "Added dead_code + unused_function bypasses for all 15 dynamically-dispatched handlers after verifying each is wired in .aipass/hooks.json AND fires in engine.jsonl. Root cause: engine._run_handler (engine.py:60-66) uses importlib.import_module + getattr on hooks.json handler strings \u2014 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."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ src/aipass/hooks/
|
|||||||
│ ├── sound.py # Shared sound utilities (speak, play, mute)
|
│ ├── sound.py # Shared sound utilities (speak, play, mute)
|
||||||
│ ├── modules/
|
│ ├── modules/
|
||||||
│ │ ├── cadence.py # Prompt injection cadence (every-Nth-turn gating)
|
│ │ ├── cadence.py # Prompt injection cadence (every-Nth-turn gating)
|
||||||
│ │ ├── context_window.py # Transcript usage reader + per-branch compact-window resolver
|
|
||||||
│ │ ├── hook_test.py # Portable test runner (drone @hooks test)
|
│ │ ├── hook_test.py # Portable test runner (drone @hooks test)
|
||||||
│ │ ├── cc_sessions.py # CC-native session file reader (~/.claude/sessions/<pid>.json)
|
│ │ ├── cc_sessions.py # CC-native session file reader (~/.claude/sessions/<pid>.json)
|
||||||
│ │ ├── engine.py # Core dispatch — routes events to handlers
|
│ │ ├── engine.py # Core dispatch — routes events to handlers
|
||||||
@@ -87,8 +86,6 @@ src/aipass/hooks/
|
|||||||
│ │ │ ├── navmap.py # Injects tier1 navmap prompt (periodic)
|
│ │ │ ├── navmap.py # Injects tier1 navmap prompt (periodic)
|
||||||
│ │ │ ├── identity.py # Injects passport identity block
|
│ │ │ ├── identity.py # Injects passport identity block
|
||||||
│ │ │ ├── feedback_pulse.py # Periodic feedback ask (~10 turns, toggleable)
|
│ │ │ ├── feedback_pulse.py # Periodic feedback ask (~10 turns, toggleable)
|
||||||
│ │ │ ├── context_gauge.py # Nudges /prep before auto-compact fires (80%/95% of window)
|
|
||||||
│ │ │ ├── temporal.py # Injects weekday/date/time/tz/part-of-day, every turn
|
|
||||||
│ │ │ └── persistent_alert.py # Injects advisory banners from .aipass/alerts.json
|
│ │ │ └── persistent_alert.py # Injects advisory banners from .aipass/alerts.json
|
||||||
│ │ ├── security/ # Enforcement hooks
|
│ │ ├── security/ # Enforcement hooks
|
||||||
│ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics)
|
│ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics)
|
||||||
@@ -101,7 +98,6 @@ src/aipass/hooks/
|
|||||||
│ │ │ ├── auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile)
|
│ │ │ ├── auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile)
|
||||||
│ │ │ ├── auto_watchdog.py # Watchdog arming after dispatch
|
│ │ │ ├── auto_watchdog.py # Watchdog arming after dispatch
|
||||||
│ │ │ ├── compact.py # Pre-compact memory archival
|
│ │ │ ├── compact.py # Pre-compact memory archival
|
||||||
│ │ │ ├── pre_compact_prep.py # Mechanical AUTO-COMPACT SNAPSHOT stamp (fill %, git, locks, plans)
|
|
||||||
│ │ │ ├── rollover.py # Pre-compact memory rollover
|
│ │ │ ├── rollover.py # Pre-compact memory rollover
|
||||||
│ │ │ └── session_start.py # Cadence reset on new chat / clear (SessionStart)
|
│ │ │ └── session_start.py # Cadence reset on new chat / clear (SessionStart)
|
||||||
│ │ └── notification/ # Sound/alert hooks
|
│ │ └── notification/ # Sound/alert hooks
|
||||||
@@ -115,7 +111,7 @@ src/aipass/hooks/
|
|||||||
│ └── diagnostics.py # JSONL logging for hook execution
|
│ └── diagnostics.py # JSONL logging for hook execution
|
||||||
├── logs/
|
├── logs/
|
||||||
│ └── engine.jsonl # JSONL diagnostics (every hook execution)
|
│ └── engine.jsonl # JSONL diagnostics (every hook execution)
|
||||||
└── tests/ # 1249 tests across 43 test files
|
└── tests/ # 1071 tests across 29 test files
|
||||||
```
|
```
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
@@ -136,13 +132,13 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im
|
|||||||
|
|
||||||
| Event | Hooks | Description |
|
| Event | Hooks | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, feedback_pulse, context_gauge, temporal, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + feedback + context gauge + temporal + auto-process + TG mirror |
|
| UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, feedback_pulse, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + feedback + auto-process + TG mirror |
|
||||||
| PreToolUse | tool_sound, edit_gate, git_gate, rm_gate, registry_gate | Security gates + guardrails + sound |
|
| PreToolUse | tool_sound, edit_gate, git_gate, rm_gate, registry_gate | Security gates + guardrails + sound |
|
||||||
| PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog |
|
| PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog |
|
||||||
| SubagentStop | subagent_gate | Seedgo validation |
|
| SubagentStop | subagent_gate | Seedgo validation |
|
||||||
| Stop | stop_sound, telegram_response, presence_release | Bell + Telegram delivery + presence release |
|
| Stop | stop_sound, telegram_response, presence_release | Bell + Telegram delivery + presence release |
|
||||||
| Notification | announce | Announcement tone |
|
| Notification | announce | Announcement tone |
|
||||||
| PreCompact | compact, rollover, pre_compact_prep | Memory archival + rollover + mechanical snapshot stamp |
|
| PreCompact | compact, rollover | Memory archival + rollover |
|
||||||
|
|
||||||
## Git Gate
|
## Git Gate
|
||||||
|
|
||||||
|
|||||||
@@ -10,38 +10,19 @@
|
|||||||
|
|
||||||
"""JSONL diagnostic logging — appends structured entries for hook activity."""
|
"""JSONL diagnostic logging — appends structured entries for hook activity."""
|
||||||
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from aipass.prax import append_jsonl
|
from aipass.prax import append_jsonl
|
||||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||||
|
|
||||||
BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
_PROD_LOG_FILE = BRANCH_ROOT / "logs" / "engine.jsonl"
|
LOG_FILE = BRANCH_ROOT / "logs" / "engine.jsonl"
|
||||||
|
|
||||||
|
|
||||||
def _is_pytest_session() -> bool:
|
|
||||||
"""Detect pytest via sys.modules (immune to patch.dict(os.environ, clear=True))."""
|
|
||||||
return "_pytest" in sys.modules
|
|
||||||
|
|
||||||
|
|
||||||
def _get_log_file() -> Path:
|
|
||||||
"""Resolve log path — temp dir during pytest, prod path otherwise."""
|
|
||||||
if _is_pytest_session():
|
|
||||||
p = Path(tempfile.gettempdir()) / "aipass_test_logs" / "hooks"
|
|
||||||
p.mkdir(parents=True, exist_ok=True)
|
|
||||||
return p / "engine.jsonl"
|
|
||||||
return _PROD_LOG_FILE
|
|
||||||
|
|
||||||
|
|
||||||
LOG_FILE = _PROD_LOG_FILE
|
|
||||||
|
|
||||||
|
|
||||||
def log_entry(entry: dict) -> None:
|
def log_entry(entry: dict) -> None:
|
||||||
"""Append a JSONL log entry for detailed diagnostics."""
|
"""Append a JSONL log entry for detailed diagnostics."""
|
||||||
try:
|
try:
|
||||||
append_jsonl(_get_log_file(), entry)
|
append_jsonl(LOG_FILE, entry)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logger.error("[HOOKS] log write failed: %s", exc)
|
logger.error("[HOOKS] log write failed: %s", exc)
|
||||||
|
|
||||||
|
|||||||
@@ -56,37 +56,3 @@ def find_project_config() -> dict | None:
|
|||||||
return None
|
return None
|
||||||
search = search.parent
|
search = search.parent
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def trust_break_banner() -> str | None:
|
|
||||||
"""Loud, config-independent check for a stale trust enrollment.
|
|
||||||
|
|
||||||
find_project_config() goes silent on a hash mismatch (logs one WARNING,
|
|
||||||
returns None) — the fallback config downstream then has no event_type
|
|
||||||
keys at all, so every hook including this one's own dispatch path goes
|
|
||||||
dark. This check does its own walk-up and hash comparison, never touches
|
|
||||||
hooks.json content, and does not depend on the project being trusted —
|
|
||||||
so it still works precisely when trust is broken. Returns None when
|
|
||||||
nothing is broken, or when the project was simply never enrolled (normal
|
|
||||||
first-run state, not a break).
|
|
||||||
"""
|
|
||||||
from aipass.hooks.apps.handlers.config.trust_registry import is_hash_mismatch
|
|
||||||
|
|
||||||
search = Path.cwd()
|
|
||||||
home = Path.home()
|
|
||||||
while search != home and search.parent != search:
|
|
||||||
config_file = search / ".aipass" / "hooks.json"
|
|
||||||
if config_file.exists():
|
|
||||||
if is_hash_mismatch(str(search)):
|
|
||||||
return (
|
|
||||||
"# TRUST BREAK — ALL AIPASS HOOKS DISABLED\n\n"
|
|
||||||
f"{search}/.aipass/hooks.json no longer matches its enrolled hash. "
|
|
||||||
"Every hook for this project (including this warning's own delivery "
|
|
||||||
"path) is silently skipped until a human re-enrolls.\n\n"
|
|
||||||
"Fix: drone @hooks trust enroll (or: aipass init update)\n"
|
|
||||||
"This does not auto-heal — re-enrollment is a deliberate human "
|
|
||||||
"checkpoint, not a bug."
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
search = search.parent
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -100,25 +100,6 @@ def is_trusted(project_dir: str) -> bool:
|
|||||||
return current_hash == entry.get("config_hash", "")
|
return current_hash == entry.get("config_hash", "")
|
||||||
|
|
||||||
|
|
||||||
def is_hash_mismatch(project_dir: str) -> bool:
|
|
||||||
"""True only when a project WAS enrolled but its hooks.json hash has since changed.
|
|
||||||
|
|
||||||
Distinct from is_trusted()==False for a never-enrolled project (normal
|
|
||||||
first-run state, not a break). This flags a genuine trust break — an
|
|
||||||
existing enrollment gone stale — so callers can warn loudly instead of
|
|
||||||
treating it the same as "not set up yet".
|
|
||||||
"""
|
|
||||||
project_path = str(Path(project_dir).resolve())
|
|
||||||
registry = read_registry()
|
|
||||||
entry = registry["projects"].get(project_path)
|
|
||||||
if entry is None:
|
|
||||||
return False
|
|
||||||
config_path = Path(project_dir).resolve() / ".aipass" / "hooks.json"
|
|
||||||
if not config_path.exists():
|
|
||||||
return False
|
|
||||||
return _hash_file(config_path) != entry.get("config_hash", "")
|
|
||||||
|
|
||||||
|
|
||||||
def bootstrap() -> bool:
|
def bootstrap() -> bool:
|
||||||
"""Bootstrap the registry with ONLY the AIPass install. Returns True on success.
|
"""Bootstrap the registry with ONLY the AIPass install. Returns True on success.
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ def handle(hook_data: dict) -> dict:
|
|||||||
logger.info("[HOOKS] auto_process: no-op (nothing to process)")
|
logger.info("[HOOKS] auto_process: no-op (nothing to process)")
|
||||||
|
|
||||||
_mark_session_ran()
|
_mark_session_ran()
|
||||||
return {"stdout": "", "exit_code": 0, "sound": "auto process"}
|
return {"stdout": "", "exit_code": 0}
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("[HOOKS] auto_process: error: %s", exc)
|
logger.error("[HOOKS] auto_process: error: %s", exc)
|
||||||
|
|||||||
@@ -1,246 +0,0 @@
|
|||||||
# =================== AIPass ====================
|
|
||||||
# Name: pre_compact_prep.py
|
|
||||||
# Version: 1.0.0
|
|
||||||
# Description: Stamps a mechanical AUTO-COMPACT SNAPSHOT into the compacting branch's memory (PreCompact)
|
|
||||||
# Branch: hooks
|
|
||||||
# Layer: apps/handlers/lifecycle
|
|
||||||
# Created: 2026-07-20
|
|
||||||
# Modified: 2026-07-20
|
|
||||||
# =============================================
|
|
||||||
|
|
||||||
"""Mechanical /prep AT compact time (DPLAN-0253).
|
|
||||||
|
|
||||||
Resolves the compacting branch from cwd, then prepends a session entry to its
|
|
||||||
.trinity/local.json — context fill, in-flight dispatch locks across the
|
|
||||||
system, this branch's open plan count, git state, and inbox unread count.
|
|
||||||
Templated from live state, no model turn needed. The judgment layer (todo
|
|
||||||
reconcile, thoughtful summary) is handled separately by the context gauge
|
|
||||||
nudge, which fires early enough for a live model turn to run /prep.
|
|
||||||
|
|
||||||
Defensive: a missing or malformed .trinity/local.json is logged and skipped —
|
|
||||||
this handler must never corrupt memory or raise out of PreCompact."""
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
from datetime import date
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
|
||||||
|
|
||||||
_DEFAULT_SUMMARY_CAP = 300
|
|
||||||
|
|
||||||
|
|
||||||
def _find_repo_root(start: Path) -> Path | None:
|
|
||||||
for parent in [start, *start.parents]:
|
|
||||||
if (parent / "AIPASS_REGISTRY.json").exists():
|
|
||||||
return parent
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _context_fill_pct(hook_data: dict, cwd: str) -> str | None:
|
|
||||||
context_window = importlib.import_module("aipass.hooks.apps.modules.context_window")
|
|
||||||
usage = context_window.read_latest_usage(hook_data.get("transcript_path", ""))
|
|
||||||
if usage is None:
|
|
||||||
return None
|
|
||||||
fill = context_window.context_fill_tokens(usage)
|
|
||||||
window = context_window.resolve_compact_window(cwd)
|
|
||||||
if window <= 0:
|
|
||||||
return None
|
|
||||||
pct = round(fill / window * 100)
|
|
||||||
return f"~{fill // 1000}k/{window // 1000}k ({pct}%)"
|
|
||||||
|
|
||||||
|
|
||||||
def _count_active_dispatch_locks(repo_root: Path | None) -> int | None:
|
|
||||||
if repo_root is None:
|
|
||||||
return None
|
|
||||||
registry_path = repo_root / "AIPASS_REGISTRY.json"
|
|
||||||
try:
|
|
||||||
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
|
||||||
logger.info("[HOOKS] pre_compact_prep: registry read failed: %s", exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
count = 0
|
|
||||||
for branch in data.get("branches", []):
|
|
||||||
branch_path = repo_root / branch.get("path", "")
|
|
||||||
lock_path = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
|
||||||
if lock_path.is_file():
|
|
||||||
count += 1
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
def _count_open_plans(branch_dir: Path) -> int | None:
|
|
||||||
try:
|
|
||||||
from aipass.flow.apps.handlers.plan.get_open_plans import get_open_plans
|
|
||||||
|
|
||||||
target = str(branch_dir.resolve())
|
|
||||||
return sum(1 for _num, info in get_open_plans() if info.get("location") == target)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.info("[HOOKS] pre_compact_prep: open plans read failed: %s", exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _git_snapshot(branch_dir: Path) -> str | None:
|
|
||||||
try:
|
|
||||||
branch = subprocess.run(
|
|
||||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5,
|
|
||||||
cwd=str(branch_dir),
|
|
||||||
)
|
|
||||||
last_commit = subprocess.run(
|
|
||||||
["git", "log", "-1", "--format=%h %s"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5,
|
|
||||||
cwd=str(branch_dir),
|
|
||||||
)
|
|
||||||
dirty = subprocess.run(
|
|
||||||
["git", "status", "--porcelain"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5,
|
|
||||||
cwd=str(branch_dir),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.info("[HOOKS] pre_compact_prep: git snapshot failed: %s", exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
bits: list[str] = []
|
|
||||||
if branch.returncode == 0 and branch.stdout.strip():
|
|
||||||
bits.append(branch.stdout.strip())
|
|
||||||
if last_commit.returncode == 0 and last_commit.stdout.strip():
|
|
||||||
bits.append(last_commit.stdout.strip())
|
|
||||||
if dirty.returncode == 0:
|
|
||||||
dirty_lines = [ln for ln in dirty.stdout.strip().split("\n") if ln]
|
|
||||||
bits.append(f"{len(dirty_lines)} dirty")
|
|
||||||
return " / ".join(bits) if bits else None
|
|
||||||
|
|
||||||
|
|
||||||
def _inbox_unread(branch_dir: Path) -> int | None:
|
|
||||||
inbox_path = branch_dir / ".ai_mail.local" / "inbox.json"
|
|
||||||
if not inbox_path.is_file():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
data = json.loads(inbox_path.read_text(encoding="utf-8"))
|
|
||||||
unread = data.get("unread_count")
|
|
||||||
return unread if isinstance(unread, int) else None
|
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
|
||||||
logger.info("[HOOKS] pre_compact_prep: inbox read failed: %s", exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _summary_cap(repo_root: Path | None) -> int:
|
|
||||||
if repo_root is None:
|
|
||||||
return _DEFAULT_SUMMARY_CAP
|
|
||||||
config_path = repo_root / "src" / "aipass" / "memory" / "memory_json" / "custom_config" / "memory.config.json"
|
|
||||||
try:
|
|
||||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
||||||
cap = data["entry_limits"]["entry_types"]["sessions"]["max_chars"]
|
|
||||||
if isinstance(cap, int) and cap > 0:
|
|
||||||
return cap
|
|
||||||
except (OSError, json.JSONDecodeError, KeyError, TypeError) as exc:
|
|
||||||
logger.info("[HOOKS] pre_compact_prep: entry_limits read failed, using default: %s", exc)
|
|
||||||
return _DEFAULT_SUMMARY_CAP
|
|
||||||
|
|
||||||
|
|
||||||
def _build_snapshot(hook_data: dict, branch_dir: Path, repo_root: Path | None) -> str:
|
|
||||||
cwd = hook_data.get("cwd", "") or str(branch_dir)
|
|
||||||
parts: list[str] = []
|
|
||||||
|
|
||||||
fill = _context_fill_pct(hook_data, cwd)
|
|
||||||
if fill:
|
|
||||||
parts.append(f"context {fill}")
|
|
||||||
|
|
||||||
locks = _count_active_dispatch_locks(repo_root)
|
|
||||||
if locks is not None:
|
|
||||||
parts.append(f"{locks} active dispatch(es)")
|
|
||||||
|
|
||||||
open_plans = _count_open_plans(branch_dir)
|
|
||||||
if open_plans is not None:
|
|
||||||
parts.append(f"{open_plans} open plan(s)")
|
|
||||||
|
|
||||||
git_info = _git_snapshot(branch_dir)
|
|
||||||
if git_info:
|
|
||||||
parts.append(f"git: {git_info}")
|
|
||||||
|
|
||||||
unread = _inbox_unread(branch_dir)
|
|
||||||
if unread is not None:
|
|
||||||
parts.append(f"{unread} unread")
|
|
||||||
|
|
||||||
body = ", ".join(parts) if parts else "no live state available"
|
|
||||||
return f"AUTO-COMPACT SNAPSHOT: {body}"
|
|
||||||
|
|
||||||
|
|
||||||
def _stamp_session_entry(branch_dir: Path, summary: str, cap: int) -> bool:
|
|
||||||
local_path = branch_dir / ".trinity" / "local.json"
|
|
||||||
if not local_path.is_file():
|
|
||||||
logger.info("[HOOKS] pre_compact_prep: no local.json at %s — skipping stamp", local_path)
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(local_path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
|
||||||
logger.warning("[HOOKS] pre_compact_prep: local.json unreadable, skipping stamp: %s", exc)
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
logger.warning("[HOOKS] pre_compact_prep: local.json malformed (not a dict), skipping stamp")
|
|
||||||
return False
|
|
||||||
|
|
||||||
sessions = data.get("sessions")
|
|
||||||
if not isinstance(sessions, list):
|
|
||||||
logger.warning("[HOOKS] pre_compact_prep: sessions container malformed, skipping stamp")
|
|
||||||
return False
|
|
||||||
|
|
||||||
truncated = summary if len(summary) <= cap else summary[: cap - 1].rstrip() + "…"
|
|
||||||
|
|
||||||
existing_numbers = [
|
|
||||||
entry.get("number", 0) for entry in sessions if isinstance(entry, dict) and isinstance(entry.get("number"), int)
|
|
||||||
]
|
|
||||||
next_number = max(existing_numbers, default=0) + 1
|
|
||||||
|
|
||||||
sessions.insert(
|
|
||||||
0,
|
|
||||||
{
|
|
||||||
"date": date.today().isoformat(),
|
|
||||||
"summary": truncated,
|
|
||||||
"status": "auto-compact",
|
|
||||||
"number": next_number,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
data["sessions"] = sessions
|
|
||||||
|
|
||||||
try:
|
|
||||||
local_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning("[HOOKS] pre_compact_prep: local.json write failed: %s", exc)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def handle(hook_data: dict) -> dict:
|
|
||||||
"""Stamp a mechanical AUTO-COMPACT SNAPSHOT into the compacting branch's memory."""
|
|
||||||
try:
|
|
||||||
cwd = hook_data.get("cwd", "") or str(Path.cwd())
|
|
||||||
context_window = importlib.import_module("aipass.hooks.apps.modules.context_window")
|
|
||||||
branch_dir = context_window.find_branch_dir(cwd)
|
|
||||||
if branch_dir is None:
|
|
||||||
logger.info("[HOOKS] pre_compact_prep: no branch dir resolved from cwd=%s", cwd)
|
|
||||||
return {"stdout": "", "exit_code": 0}
|
|
||||||
|
|
||||||
repo_root = _find_repo_root(branch_dir)
|
|
||||||
cap = _summary_cap(repo_root)
|
|
||||||
snapshot = _build_snapshot(hook_data, branch_dir, repo_root)
|
|
||||||
|
|
||||||
stamped = _stamp_session_entry(branch_dir, snapshot, cap)
|
|
||||||
logger.info("[HOOKS] pre_compact_prep: snapshot stamped=%s branch=%s", stamped, branch_dir.name)
|
|
||||||
|
|
||||||
return {"stdout": snapshot, "exit_code": 0, "sound": "pre compact prep"}
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
logger.info("[HOOKS] pre_compact_prep: unexpected error: %s", exc)
|
|
||||||
return {"stdout": "", "exit_code": 0}
|
|
||||||
@@ -37,6 +37,5 @@ def handle(hook_data: dict) -> dict:
|
|||||||
logger.info("[HOOKS] session_start: cadence reset (source=%s)", source)
|
logger.info("[HOOKS] session_start: cadence reset (source=%s)", source)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.info("[HOOKS] session_start: cadence reset failed: %s", exc)
|
logger.info("[HOOKS] session_start: cadence reset failed: %s", exc)
|
||||||
return {"stdout": "", "exit_code": 0}
|
|
||||||
|
|
||||||
return {"stdout": "", "exit_code": 0, "sound": "cadence reset"}
|
return {"stdout": "", "exit_code": 0}
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
@@ -37,19 +35,7 @@ PENDING_DIR = Path.home() / ".aipass" / "telegram_pending"
|
|||||||
MIRROR_DIR = Path.home() / ".aipass" / "telegram_bots"
|
MIRROR_DIR = Path.home() / ".aipass" / "telegram_bots"
|
||||||
PENDING_TTL = 3600
|
PENDING_TTL = 3600
|
||||||
TELEGRAM_CHAR_LIMIT = 4096
|
TELEGRAM_CHAR_LIMIT = 4096
|
||||||
_PROD_DELIVERY_LOG = Path(__file__).resolve().parent.parent.parent.parent / "logs" / "telegram_delivery.jsonl"
|
_DELIVERY_LOG = Path(__file__).resolve().parent.parent.parent.parent / "logs" / "telegram_delivery.jsonl"
|
||||||
|
|
||||||
|
|
||||||
def _get_delivery_log() -> Path:
|
|
||||||
"""Resolve delivery log path — temp dir during pytest, prod path otherwise."""
|
|
||||||
if "_pytest" in sys.modules:
|
|
||||||
p = Path(tempfile.gettempdir()) / "aipass_test_logs" / "hooks"
|
|
||||||
p.mkdir(parents=True, exist_ok=True)
|
|
||||||
return p / "telegram_delivery.jsonl"
|
|
||||||
return _PROD_DELIVERY_LOG
|
|
||||||
|
|
||||||
|
|
||||||
_DELIVERY_LOG = _PROD_DELIVERY_LOG
|
|
||||||
|
|
||||||
|
|
||||||
def _is_expired(data: dict) -> bool:
|
def _is_expired(data: dict) -> bool:
|
||||||
@@ -599,7 +585,7 @@ def handle(hook_data: dict) -> dict:
|
|||||||
else:
|
else:
|
||||||
logger.error("[HOOKS] telegram: delivery failed — keeping pending for retry")
|
logger.error("[HOOKS] telegram: delivery failed — keeping pending for retry")
|
||||||
|
|
||||||
return {"stdout": "", "exit_code": 0, "sound": "telegram response"}
|
return {"stdout": "", "exit_code": 0}
|
||||||
|
|
||||||
|
|
||||||
def _extract_response(hook_data: dict, transcript_path: str, pending_data: dict) -> str | None:
|
def _extract_response(hook_data: dict, transcript_path: str, pending_data: dict) -> str | None:
|
||||||
@@ -785,6 +771,6 @@ def _write_delivery_log(intended_text: str, chunks: list[str], chunk_results: li
|
|||||||
record["culprit"] = culprit
|
record["culprit"] = culprit
|
||||||
|
|
||||||
try:
|
try:
|
||||||
append_jsonl(_get_delivery_log(), record)
|
append_jsonl(_DELIVERY_LOG, record)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning("[HOOKS] telegram: delivery log write failed: %s", e)
|
logger.warning("[HOOKS] telegram: delivery log write failed: %s", e)
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ def handle(hook_data: dict) -> dict:
|
|||||||
mark_surfaced([c["id"] for c in approved])
|
mark_surfaced([c["id"] for c in approved])
|
||||||
json_handler.log_operation("compass_recall", {"count": len(approved)})
|
json_handler.log_operation("compass_recall", {"count": len(approved)})
|
||||||
|
|
||||||
return {"stdout": "\n".join(lines), "exit_code": 0, "sound": "compass recall"}
|
return {"stdout": "\n".join(lines), "exit_code": 0}
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.info("[HOOKS] compass_recall_unreachable: %s", exc)
|
logger.info("[HOOKS] compass_recall_unreachable: %s", exc)
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
# =================== AIPass ====================
|
|
||||||
# Name: context_gauge.py
|
|
||||||
# Version: 1.0.0
|
|
||||||
# Description: Nudges the model to run /prep before auto-compact fires (UserPromptSubmit, DPLAN-0253)
|
|
||||||
# Branch: hooks
|
|
||||||
# Layer: apps/handlers/prompt
|
|
||||||
# Created: 2026-07-20
|
|
||||||
# Modified: 2026-07-20
|
|
||||||
# =============================================
|
|
||||||
|
|
||||||
"""Early-warning nudge so the model runs /prep before the compact ceiling —
|
|
||||||
memory should never be at the mercy of an auto-compact firing mid-work.
|
|
||||||
|
|
||||||
Reads live transcript usage every prompt (cheap tail read), resolves the
|
|
||||||
branch's compact trigger (window * 0.9), and injects a hard line once fill
|
|
||||||
crosses 80%/95% of that trigger. Independent of the cadence system — its own
|
|
||||||
per-session, per-threshold guard file in tempdir, same idiom as
|
|
||||||
feedback_pulse.py / auto_process.py, so it isn't gated by turn count."""
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
|
||||||
|
|
||||||
_GUARD_DIR = Path(tempfile.gettempdir())
|
|
||||||
_TRIGGER_RATIO = 0.9
|
|
||||||
_NUDGE_THRESHOLD_PCT = 80
|
|
||||||
_ESCALATE_THRESHOLD_PCT = 95
|
|
||||||
|
|
||||||
|
|
||||||
def _guard_path(session_id: str, threshold: str) -> Path | None:
|
|
||||||
if not session_id:
|
|
||||||
return None
|
|
||||||
return _GUARD_DIR / f"aipass-context-gauge-{session_id}-{threshold}"
|
|
||||||
|
|
||||||
|
|
||||||
def _already_fired(session_id: str, threshold: str) -> bool:
|
|
||||||
path = _guard_path(session_id, threshold)
|
|
||||||
return path is not None and path.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def _mark_fired(session_id: str, threshold: str) -> None:
|
|
||||||
path = _guard_path(session_id, threshold)
|
|
||||||
if path is not None:
|
|
||||||
try:
|
|
||||||
path.touch()
|
|
||||||
except OSError as exc:
|
|
||||||
logger.info("[HOOKS] context_gauge: guard write failed: %s", exc)
|
|
||||||
|
|
||||||
|
|
||||||
def handle(hook_data: dict) -> dict:
|
|
||||||
"""Inject a context-fill nudge once per threshold per session."""
|
|
||||||
try:
|
|
||||||
session_id = hook_data.get("session_id", "") or os.environ.get("CLAUDE_CODE_SESSION_ID", "")
|
|
||||||
transcript_path = hook_data.get("transcript_path", "")
|
|
||||||
if not transcript_path:
|
|
||||||
return {"stdout": "", "exit_code": 0}
|
|
||||||
|
|
||||||
context_window = importlib.import_module("aipass.hooks.apps.modules.context_window")
|
|
||||||
usage = context_window.read_latest_usage(transcript_path)
|
|
||||||
if usage is None:
|
|
||||||
return {"stdout": "", "exit_code": 0}
|
|
||||||
|
|
||||||
fill = context_window.context_fill_tokens(usage)
|
|
||||||
cwd = hook_data.get("cwd", "") or str(Path.cwd())
|
|
||||||
window = context_window.resolve_compact_window(cwd)
|
|
||||||
trigger = window * _TRIGGER_RATIO
|
|
||||||
if trigger <= 0:
|
|
||||||
return {"stdout": "", "exit_code": 0}
|
|
||||||
|
|
||||||
pct = fill / trigger * 100
|
|
||||||
fill_k = fill // 1000
|
|
||||||
trigger_k = int(trigger) // 1000
|
|
||||||
|
|
||||||
if pct >= _ESCALATE_THRESHOLD_PCT and not _already_fired(session_id, "95"):
|
|
||||||
_mark_fired(session_id, "95")
|
|
||||||
_mark_fired(session_id, "80")
|
|
||||||
logger.info("[HOOKS] context_gauge: escalate fired at %.0f%% session=%s", pct, session_id[:8])
|
|
||||||
return {
|
|
||||||
"stdout": (
|
|
||||||
f"CONTEXT GAUGE: ~{fill_k}k/{trigger_k}k ({pct:.0f}%) — run /prep NOW "
|
|
||||||
"AND wrap up the current work item. Auto-compact is imminent."
|
|
||||||
),
|
|
||||||
"exit_code": 0,
|
|
||||||
"sound": "context gauge",
|
|
||||||
}
|
|
||||||
|
|
||||||
if pct >= _NUDGE_THRESHOLD_PCT and not _already_fired(session_id, "80"):
|
|
||||||
_mark_fired(session_id, "80")
|
|
||||||
logger.info("[HOOKS] context_gauge: nudge fired at %.0f%% session=%s", pct, session_id[:8])
|
|
||||||
return {
|
|
||||||
"stdout": (
|
|
||||||
f"CONTEXT GAUGE: ~{fill_k}k/{trigger_k}k ({pct:.0f}%) — run /prep NOW, "
|
|
||||||
"before auto-compact takes the choice away."
|
|
||||||
),
|
|
||||||
"exit_code": 0,
|
|
||||||
"sound": "context gauge",
|
|
||||||
}
|
|
||||||
|
|
||||||
return {"stdout": "", "exit_code": 0}
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
logger.info("[HOOKS] context_gauge: unexpected error: %s", exc)
|
|
||||||
return {"stdout": "", "exit_code": 0}
|
|
||||||
@@ -87,7 +87,6 @@ def handle(hook_data: dict) -> dict:
|
|||||||
|
|
||||||
cwd = hook_data.get("cwd", "")
|
cwd = hook_data.get("cwd", "")
|
||||||
if _is_disabled(cwd or None):
|
if _is_disabled(cwd or None):
|
||||||
logger.info("[HOOKS] feedback_pulse: skipped (disabled via .aipass/feedback_off sentinel)")
|
|
||||||
return {"stdout": "", "exit_code": 0}
|
return {"stdout": "", "exit_code": 0}
|
||||||
|
|
||||||
json_handler.log_operation("feedback_pulse", {"turn": turn})
|
json_handler.log_operation("feedback_pulse", {"turn": turn})
|
||||||
|
|||||||
@@ -11,16 +11,13 @@
|
|||||||
"""Injects advisory banners for active alerts from .aipass/alerts.json."""
|
"""Injects advisory banners for active alerts from .aipass/alerts.json."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from aipass.hooks.apps.handlers.json import json_handler
|
from aipass.hooks.apps.handlers.json import json_handler
|
||||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||||
|
|
||||||
_GUARD_DIR = Path(tempfile.gettempdir())
|
_announced: set[str] = set()
|
||||||
_MAX_ALERTS_SHOWN = 10
|
|
||||||
|
|
||||||
|
|
||||||
def _find_aipass_dir() -> Path | None:
|
def _find_aipass_dir() -> Path | None:
|
||||||
@@ -80,33 +77,10 @@ def _load_and_clean(alerts_path: Path) -> list[dict]:
|
|||||||
return active
|
return active
|
||||||
|
|
||||||
|
|
||||||
def _guard_path(session_id: str, alert_id: str) -> Path | None:
|
|
||||||
if not session_id:
|
|
||||||
return None
|
|
||||||
return _GUARD_DIR / f"aipass-persistent-alert-{session_id}-{alert_id}"
|
|
||||||
|
|
||||||
|
|
||||||
def _already_announced(session_id: str, alert_id: str) -> bool:
|
|
||||||
path = _guard_path(session_id, alert_id)
|
|
||||||
return path is not None and path.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def _mark_announced(session_id: str, alert_id: str) -> None:
|
|
||||||
path = _guard_path(session_id, alert_id)
|
|
||||||
if path is not None:
|
|
||||||
try:
|
|
||||||
path.touch()
|
|
||||||
except OSError as exc:
|
|
||||||
logger.info("[HOOKS] persistent_alert: guard write failed: %s", exc)
|
|
||||||
|
|
||||||
|
|
||||||
def _format_banner(alerts: list[dict]) -> str:
|
def _format_banner(alerts: list[dict]) -> str:
|
||||||
"""Format alert banners for prompt injection, capped at _MAX_ALERTS_SHOWN."""
|
"""Format alert banners for prompt injection."""
|
||||||
shown = alerts[:_MAX_ALERTS_SHOWN]
|
|
||||||
hidden = len(alerts) - len(shown)
|
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
for alert in shown:
|
for alert in alerts:
|
||||||
severity = alert.get("severity", "warning").upper()
|
severity = alert.get("severity", "warning").upper()
|
||||||
title = alert.get("title", "Untitled alert")
|
title = alert.get("title", "Untitled alert")
|
||||||
body = alert.get("body", "")
|
body = alert.get("body", "")
|
||||||
@@ -115,8 +89,6 @@ def _format_banner(alerts: list[dict]) -> str:
|
|||||||
lines.append(f"[{severity}] {title} (from @{source}, id: {alert_id})")
|
lines.append(f"[{severity}] {title} (from @{source}, id: {alert_id})")
|
||||||
if body:
|
if body:
|
||||||
lines.append(f" {body}")
|
lines.append(f" {body}")
|
||||||
if hidden > 0:
|
|
||||||
lines.append(f"...and {hidden} more (dismiss some to see the rest)")
|
|
||||||
header = "# Active Alerts"
|
header = "# Active Alerts"
|
||||||
dismiss_hint = "Dismiss with: drone @hooks dismiss <alert-id>"
|
dismiss_hint = "Dismiss with: drone @hooks dismiss <alert-id>"
|
||||||
return "\n".join([header, ""] + lines + ["", dismiss_hint])
|
return "\n".join([header, ""] + lines + ["", dismiss_hint])
|
||||||
@@ -145,12 +117,10 @@ def handle(hook_data: dict) -> dict:
|
|||||||
|
|
||||||
banner = _format_banner(alerts)
|
banner = _format_banner(alerts)
|
||||||
|
|
||||||
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 a["id"] not in _announced]
|
||||||
new_ids = [a["id"] for a in alerts if a.get("id") and not _already_announced(session_id, a["id"])]
|
|
||||||
sound = ""
|
sound = ""
|
||||||
if new_ids:
|
if new_ids:
|
||||||
for alert_id in new_ids:
|
_announced.update(new_ids)
|
||||||
_mark_announced(session_id, alert_id)
|
|
||||||
count = len(alerts)
|
count = len(alerts)
|
||||||
plural = "s" if count != 1 else ""
|
plural = "s" if count != 1 else ""
|
||||||
sound = f"alert: {count} active alert{plural}"
|
sound = f"alert: {count} active alert{plural}"
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
# =================== AIPass ====================
|
|
||||||
# Name: temporal.py
|
|
||||||
# Version: 1.0.0
|
|
||||||
# Description: Every-turn local date/time/weekday/part-of-day injection (UserPromptSubmit)
|
|
||||||
# Branch: hooks
|
|
||||||
# Layer: apps/handlers/prompt
|
|
||||||
# Created: 2026-07-21
|
|
||||||
# Modified: 2026-07-21
|
|
||||||
# =============================================
|
|
||||||
|
|
||||||
"""Tiny always-on temporal grounding line — no cadence gating, fires every
|
|
||||||
turn. Reads the live clock each fire (no caching) and injects one short line:
|
|
||||||
weekday, date, 24h time, tz abbreviation, part of day. Timezone comes from
|
|
||||||
the host system (astimezone()) — never hardcoded, so clones running outside
|
|
||||||
Vancouver still show their own local time. Keep it to one line — every-turn
|
|
||||||
cost matters."""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
|
||||||
|
|
||||||
|
|
||||||
def _part_of_day(hour: int) -> str:
|
|
||||||
"""Bucket an hour (0-23) into morning/afternoon/evening/night."""
|
|
||||||
if 5 <= hour < 12:
|
|
||||||
return "morning"
|
|
||||||
if 12 <= hour < 17:
|
|
||||||
return "afternoon"
|
|
||||||
if 17 <= hour < 22:
|
|
||||||
return "evening"
|
|
||||||
return "night"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_line(now: datetime) -> str:
|
|
||||||
weekday = now.strftime("%a")
|
|
||||||
date = now.strftime("%Y-%m-%d")
|
|
||||||
time_str = now.strftime("%H:%M")
|
|
||||||
tz = now.strftime("%Z")
|
|
||||||
part = _part_of_day(now.hour)
|
|
||||||
clock = f"{weekday} {date} {time_str} {tz}" if tz else f"{weekday} {date} {time_str}"
|
|
||||||
return f"Temporal: {clock} ({part})"
|
|
||||||
|
|
||||||
|
|
||||||
def handle(hook_data: dict) -> dict:
|
|
||||||
"""Inject the current local temporal line — every turn, no cadence."""
|
|
||||||
try:
|
|
||||||
line = _format_line(datetime.now().astimezone())
|
|
||||||
return {"stdout": line, "exit_code": 0, "sound": "temporal"}
|
|
||||||
except Exception as exc:
|
|
||||||
logger.info("[HOOKS] temporal: unexpected error: %s", exc)
|
|
||||||
return {"stdout": "", "exit_code": 0}
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
# =================== AIPass ====================
|
# =================== AIPass ====================
|
||||||
# Name: presence_gate.py
|
# Name: presence_gate.py
|
||||||
# Version: 3.0.1
|
# Version: 3.0.0
|
||||||
# Description: Single-session gate — blocks duplicate Claude runtimes per branch
|
# Description: Single-session gate — blocks duplicate Claude runtimes per branch
|
||||||
# Branch: hooks
|
# Branch: hooks
|
||||||
# Layer: apps/handlers/security
|
# Layer: apps/handlers/security
|
||||||
# Created: 2026-06-29
|
# Created: 2026-06-29
|
||||||
# Modified: 2026-07-21
|
# Modified: 2026-07-13
|
||||||
# =============================================
|
# =============================================
|
||||||
|
|
||||||
"""Single-session gate — blocks duplicate Claude runtimes per branch.
|
"""Single-session gate — blocks duplicate Claude runtimes per branch.
|
||||||
@@ -27,12 +27,6 @@ including background sessions with agent_type "claude".
|
|||||||
Ships in OBSERVE-ONLY mode: logs would-block decisions to engine.jsonl
|
Ships in OBSERVE-ONLY mode: logs would-block decisions to engine.jsonl
|
||||||
but never actually blocks. Flip _OBSERVE_ONLY to False after soak period
|
but never actually blocks. Flip _OBSERVE_ONLY to False after soak period
|
||||||
confirms zero false positives.
|
confirms zero false positives.
|
||||||
|
|
||||||
Occupant PID claims are identity-checked, not just liveness-checked:
|
|
||||||
cc_sessions.find_live_for_cwd() cross-verifies each session's recorded
|
|
||||||
procStart against the live process's actual start time before treating
|
|
||||||
a PID as a genuine occupant, so a PID recycled after the original
|
|
||||||
session died can never satisfy a stale claim.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
@@ -137,7 +131,7 @@ def handle(hook_data: dict) -> dict:
|
|||||||
|
|
||||||
if _OBSERVE_ONLY:
|
if _OBSERVE_ONLY:
|
||||||
logger.warning("[presence_gate] OBSERVE-ONLY would-block: %s", reason)
|
logger.warning("[presence_gate] OBSERVE-ONLY would-block: %s", reason)
|
||||||
return {**_ALLOW, "sound": "presence gate"}
|
return _ALLOW
|
||||||
|
|
||||||
logger.warning("[presence_gate] BLOCKED: %s", reason)
|
logger.warning("[presence_gate] BLOCKED: %s", reason)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# =================== AIPass ====================
|
# =================== AIPass ====================
|
||||||
# Name: cc_sessions.py
|
# Name: cc_sessions.py
|
||||||
# Version: 3.1.0
|
# Version: 3.0.0
|
||||||
# Description: CC-native session discovery, listing, and reclaim
|
# Description: CC-native session discovery, listing, and reclaim
|
||||||
# Branch: hooks
|
# Branch: hooks
|
||||||
# Layer: apps/modules
|
# Layer: apps/modules
|
||||||
# Created: 2026-06-30
|
# Created: 2026-06-30
|
||||||
# Modified: 2026-07-21
|
# Modified: 2026-07-14
|
||||||
# =============================================
|
# =============================================
|
||||||
|
|
||||||
"""Read Claude Code native session files (~/.claude/sessions/<pid>.json).
|
"""Read Claude Code native session files (~/.claude/sessions/<pid>.json).
|
||||||
@@ -68,50 +68,6 @@ def _pid_alive_windows(pid: int) -> bool:
|
|||||||
kernel32.CloseHandle(handle)
|
kernel32.CloseHandle(handle)
|
||||||
|
|
||||||
|
|
||||||
def _proc_start_ticks(pid: int) -> str | None:
|
|
||||||
"""Read a live process's start time (field 22 of /proc/<pid>/stat), in
|
|
||||||
clock ticks since boot. Linux only — returns None elsewhere or on any
|
|
||||||
read failure, so callers can fall back to liveness-only checking.
|
|
||||||
|
|
||||||
This is the identity half of the occupant check: os.kill(pid, 0) proves
|
|
||||||
*a* process holds this PID right now, not that it's the *same* process
|
|
||||||
that wrote the session file. A PID recycled by the kernel after the
|
|
||||||
original session died would otherwise pass silently.
|
|
||||||
"""
|
|
||||||
if sys.platform != "linux":
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
raw = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
|
|
||||||
# comm (field 2) is parenthesized and may itself contain ')' or
|
|
||||||
# whitespace — split on the LAST ')' to get past it safely.
|
|
||||||
fields_after_comm = raw.rsplit(")", 1)[1].split()
|
|
||||||
return fields_after_comm[19] # field 22 overall, starttime
|
|
||||||
except (OSError, IndexError) as exc:
|
|
||||||
logger.info("[CC_SESSIONS] Cannot read /proc/%d/stat: %s", pid, exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _session_pid_matches(session: dict) -> bool:
|
|
||||||
"""Verify the live PID is still the same process that wrote this session
|
|
||||||
file — not an unrelated process that later reused the same PID number.
|
|
||||||
|
|
||||||
Compares the session's recorded procStart (written by CC at session
|
|
||||||
start) against the live process's current start time. Sessions without
|
|
||||||
a recorded procStart (older/malformed files) or non-Linux hosts can't be
|
|
||||||
checked — falls back to liveness-only, the pre-hardening behavior.
|
|
||||||
"""
|
|
||||||
recorded = session.get("procStart")
|
|
||||||
if not recorded:
|
|
||||||
return True
|
|
||||||
pid = session.get("pid")
|
|
||||||
if not isinstance(pid, int):
|
|
||||||
return True
|
|
||||||
live_start = _proc_start_ticks(pid)
|
|
||||||
if live_start is None:
|
|
||||||
return True
|
|
||||||
return str(live_start) == str(recorded)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_pid_alive(pid: int) -> bool:
|
def _is_pid_alive(pid: int) -> bool:
|
||||||
"""Check if a process with the given PID exists."""
|
"""Check if a process with the given PID exists."""
|
||||||
if pid <= 1:
|
if pid <= 1:
|
||||||
@@ -190,9 +146,7 @@ def find_live_for_cwd(cwd: str) -> list[dict]:
|
|||||||
"""Find all live CC sessions whose cwd matches the given directory.
|
"""Find all live CC sessions whose cwd matches the given directory.
|
||||||
|
|
||||||
Compares resolved paths for robustness (symlinks, trailing slashes).
|
Compares resolved paths for robustness (symlinks, trailing slashes).
|
||||||
Only returns sessions whose PID is both alive and identity-checked
|
Only returns sessions whose PID is still alive.
|
||||||
against procStart, so a PID recycled after the original session died
|
|
||||||
is never mistaken for a live occupant.
|
|
||||||
"""
|
"""
|
||||||
target = str(Path(cwd).resolve())
|
target = str(Path(cwd).resolve())
|
||||||
live = []
|
live = []
|
||||||
@@ -203,21 +157,14 @@ def find_live_for_cwd(cwd: str) -> list[dict]:
|
|||||||
if str(Path(session_cwd).resolve()) != target:
|
if str(Path(session_cwd).resolve()) != target:
|
||||||
continue
|
continue
|
||||||
pid = session.get("pid")
|
pid = session.get("pid")
|
||||||
if not pid or not _is_pid_alive(pid):
|
if pid and _is_pid_alive(pid):
|
||||||
|
live.append(session)
|
||||||
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
"[CC_SESSIONS] Stale session file for PID %s at %s",
|
"[CC_SESSIONS] Stale session file for PID %s at %s",
|
||||||
pid,
|
pid,
|
||||||
session_cwd,
|
session_cwd,
|
||||||
)
|
)
|
||||||
continue
|
|
||||||
if not _session_pid_matches(session):
|
|
||||||
logger.warning(
|
|
||||||
"[CC_SESSIONS] PID %s reused (procStart mismatch) — treating stale session as dead at %s",
|
|
||||||
pid,
|
|
||||||
session_cwd,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
live.append(session)
|
|
||||||
return live
|
return live
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
# =================== AIPass ====================
|
|
||||||
# Name: context_window.py
|
|
||||||
# Version: 1.0.0
|
|
||||||
# Description: Shared transcript-usage reader + per-branch compact-window resolver (DPLAN-0253)
|
|
||||||
# Branch: hooks
|
|
||||||
# Layer: apps/modules
|
|
||||||
# Created: 2026-07-20
|
|
||||||
# Modified: 2026-07-20
|
|
||||||
# =============================================
|
|
||||||
|
|
||||||
"""Reads live context fill from a session transcript and resolves the branch's
|
|
||||||
auto-compact window — shared by pre_compact_prep.py and context_gauge.py.
|
|
||||||
|
|
||||||
Live context size ≈ input_tokens + cache_read_input_tokens + cache_creation_input_tokens
|
|
||||||
of the most recent assistant turn (proven readable S326)."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from aipass.cli.apps.modules import err_console
|
|
||||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
|
||||||
|
|
||||||
CONSOLE = err_console
|
|
||||||
|
|
||||||
_DEFAULT_WINDOW = 200_000
|
|
||||||
_TAIL_BYTES = 50_000
|
|
||||||
|
|
||||||
|
|
||||||
def find_branch_dir(cwd: str) -> Path | None:
|
|
||||||
"""Resolve the branch root (src/aipass/<name>) from a cwd. Same walk as compact.py."""
|
|
||||||
parts = Path(cwd).parts
|
|
||||||
for i, part in enumerate(parts):
|
|
||||||
if part == "aipass" and i > 0 and parts[i - 1] == "src":
|
|
||||||
branch_dir = Path(*parts[: i + 2])
|
|
||||||
if branch_dir.is_dir():
|
|
||||||
return branch_dir
|
|
||||||
if (Path(cwd) / ".trinity").is_dir():
|
|
||||||
return Path(cwd)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def read_latest_usage(transcript_path: str, tail_bytes: int = _TAIL_BYTES) -> dict | None:
|
|
||||||
"""Tail a transcript JSONL and return the most recent assistant message's usage dict."""
|
|
||||||
if not transcript_path:
|
|
||||||
return None
|
|
||||||
|
|
||||||
path = Path(transcript_path)
|
|
||||||
try:
|
|
||||||
size = path.stat().st_size
|
|
||||||
with path.open("rb") as fh:
|
|
||||||
if size > tail_bytes:
|
|
||||||
fh.seek(size - tail_bytes)
|
|
||||||
chunk = fh.read()
|
|
||||||
except OSError as exc:
|
|
||||||
logger.info("[HOOKS] context_window: transcript read failed: %s", exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
lines = [ln for ln in chunk.decode("utf-8", errors="replace").splitlines() if ln.strip()]
|
|
||||||
for line in reversed(lines):
|
|
||||||
try:
|
|
||||||
entry = json.loads(line)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
logger.info("[HOOKS] context_window: skipping unparsable transcript line: %s", exc)
|
|
||||||
continue
|
|
||||||
if entry.get("type") != "assistant":
|
|
||||||
continue
|
|
||||||
usage = entry.get("message", {}).get("usage")
|
|
||||||
if isinstance(usage, dict):
|
|
||||||
return usage
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def context_fill_tokens(usage: dict) -> int:
|
|
||||||
"""Live context size ≈ input + cache_read + cache_creation of one turn."""
|
|
||||||
return (
|
|
||||||
usage.get("input_tokens", 0)
|
|
||||||
+ usage.get("cache_read_input_tokens", 0)
|
|
||||||
+ usage.get("cache_creation_input_tokens", 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_compact_window(cwd: str) -> int:
|
|
||||||
"""Resolve the auto-compact window: env var > branch settings.local.json > default.
|
|
||||||
|
|
||||||
Mirrors CC's own precedence (env beats settings)."""
|
|
||||||
env_window = os.environ.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "")
|
|
||||||
if env_window:
|
|
||||||
try:
|
|
||||||
return int(env_window)
|
|
||||||
except ValueError:
|
|
||||||
logger.info("[HOOKS] context_window: bad env window value %r", env_window)
|
|
||||||
|
|
||||||
branch_dir = find_branch_dir(cwd)
|
|
||||||
if branch_dir is not None:
|
|
||||||
settings_path = branch_dir / ".claude" / "settings.local.json"
|
|
||||||
if settings_path.is_file():
|
|
||||||
try:
|
|
||||||
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
||||||
window = data.get("autoCompactWindow")
|
|
||||||
if isinstance(window, int) and window > 0:
|
|
||||||
return window
|
|
||||||
except (json.JSONDecodeError, OSError) as exc:
|
|
||||||
logger.info("[HOOKS] context_window: settings read failed: %s", exc)
|
|
||||||
|
|
||||||
return _DEFAULT_WINDOW
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# MODULE INTERFACE (drone @hooks routing)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def print_introspection() -> None:
|
|
||||||
"""Print context_window config and the resolved window for CWD."""
|
|
||||||
CONSOLE.print("[bold cyan]context_window[/bold cyan] Module")
|
|
||||||
CONSOLE.print(f" Default window: {_DEFAULT_WINDOW:,}")
|
|
||||||
CONSOLE.print(f" Transcript tail read: {_TAIL_BYTES:,} bytes")
|
|
||||||
window = resolve_compact_window(str(Path.cwd()))
|
|
||||||
CONSOLE.print(f" Resolved window (CWD): {window:,}")
|
|
||||||
|
|
||||||
|
|
||||||
def handle_command(command: str, args: list) -> bool:
|
|
||||||
"""Route context_window commands from drone @hooks."""
|
|
||||||
if command in ("--help", "-h", "help"):
|
|
||||||
CONSOLE.print("[bold cyan]context_window[/bold cyan] — Transcript usage + compact-window resolver")
|
|
||||||
CONSOLE.print()
|
|
||||||
CONSOLE.print(" drone @hooks context_window Show default window, tail size, resolved window for CWD")
|
|
||||||
return True
|
|
||||||
|
|
||||||
if command == "context_window":
|
|
||||||
if not args:
|
|
||||||
print_introspection()
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
@@ -15,7 +15,6 @@ import json
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -62,14 +61,8 @@ def _run_hook(hook_cmd: str, stdin_data: str, timeout_s: int = 30) -> dict:
|
|||||||
return {"exit_code": -1, "stdout": "", "stderr": str(exc), "elapsed_ms": round(elapsed_ms, 1)}
|
return {"exit_code": -1, "stdout": "", "stderr": str(exc), "elapsed_ms": round(elapsed_ms, 1)}
|
||||||
|
|
||||||
|
|
||||||
def _run_handler(handler_path: str, hook_data: dict, timeout_s: int = 30) -> dict:
|
def _run_handler(handler_path: str, hook_data: dict) -> dict:
|
||||||
"""Call a handler function directly (no subprocess). Module imports handler.
|
"""Call a handler function directly (no subprocess). Module imports handler."""
|
||||||
|
|
||||||
Runs the handler on a daemon thread and joins with a timeout so a hung
|
|
||||||
handler can never stall the event — the calling thread returns control
|
|
||||||
on expiry instead of blocking forever. The orphaned thread (if any) is
|
|
||||||
left to die with the process; it never blocks interpreter exit.
|
|
||||||
"""
|
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
try:
|
try:
|
||||||
module_path, func_name = handler_path.rsplit(".", 1)
|
module_path, func_name = handler_path.rsplit(".", 1)
|
||||||
@@ -87,34 +80,7 @@ def _run_handler(handler_path: str, hook_data: dict, timeout_s: int = 30) -> dic
|
|||||||
}
|
}
|
||||||
module = importlib.import_module(module_path)
|
module = importlib.import_module(module_path)
|
||||||
handler_func = getattr(module, func_name)
|
handler_func = getattr(module, func_name)
|
||||||
|
result = handler_func(hook_data)
|
||||||
outcome = {}
|
|
||||||
|
|
||||||
def _call():
|
|
||||||
try:
|
|
||||||
outcome["result"] = handler_func(hook_data)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.info("[HOOKS] handler %s raised on worker thread: %s", handler_path, exc)
|
|
||||||
outcome["error"] = exc # re-raised on the calling thread below
|
|
||||||
|
|
||||||
worker = threading.Thread(target=_call, daemon=True)
|
|
||||||
worker.start()
|
|
||||||
worker.join(timeout_s)
|
|
||||||
|
|
||||||
if worker.is_alive():
|
|
||||||
elapsed_ms = (time.monotonic() - start) * 1000
|
|
||||||
logger.error("[HOOKS] handler timeout after %ds: %s", timeout_s, handler_path)
|
|
||||||
return {
|
|
||||||
"exit_code": -1,
|
|
||||||
"stdout": "",
|
|
||||||
"stderr": "TIMEOUT",
|
|
||||||
"elapsed_ms": round(elapsed_ms, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
if "error" in outcome:
|
|
||||||
raise outcome["error"]
|
|
||||||
|
|
||||||
result = outcome["result"]
|
|
||||||
elapsed_ms = (time.monotonic() - start) * 1000
|
elapsed_ms = (time.monotonic() - start) * 1000
|
||||||
return {
|
return {
|
||||||
"exit_code": result.get("exit_code", 0),
|
"exit_code": result.get("exit_code", 0),
|
||||||
@@ -201,16 +167,6 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]:
|
|||||||
return "", 0
|
return "", 0
|
||||||
|
|
||||||
event_hooks = config.get(event_type, {})
|
event_hooks = config.get(event_type, {})
|
||||||
|
|
||||||
if event_type == "UserPromptSubmit" and "presence_gate" in event_hooks:
|
|
||||||
from aipass.hooks.apps.handlers.config.loader import trust_break_banner
|
|
||||||
|
|
||||||
banner = trust_break_banner()
|
|
||||||
if banner:
|
|
||||||
logger.error("[HOOKS] trust break detected — emitting loud banner")
|
|
||||||
_log({"ts": time.time(), "event": event_type, "action": "trust_break_banner"})
|
|
||||||
return banner, 0
|
|
||||||
|
|
||||||
if not event_hooks:
|
if not event_hooks:
|
||||||
_log({"ts": time.time(), "event": event_type, "action": "no_hooks_configured"})
|
_log({"ts": time.time(), "event": event_type, "action": "no_hooks_configured"})
|
||||||
return "", 0
|
return "", 0
|
||||||
@@ -291,37 +247,12 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
hook_timeout = hook_def.get("timeout", 30)
|
|
||||||
if handler:
|
if handler:
|
||||||
result = _run_handler(handler, parsed, timeout_s=hook_timeout)
|
result = _run_handler(handler, parsed)
|
||||||
else:
|
else:
|
||||||
|
hook_timeout = hook_def.get("timeout", 30)
|
||||||
result = _run_hook(command, stdin_data, timeout_s=hook_timeout)
|
result = _run_hook(command, stdin_data, timeout_s=hook_timeout)
|
||||||
|
|
||||||
if result.get("stderr") == "TIMEOUT":
|
|
||||||
logger.error(
|
|
||||||
"[HOOKS] %s.%s TIMED OUT after %ds — never silently swallowed",
|
|
||||||
event_type,
|
|
||||||
hook_name,
|
|
||||||
hook_timeout,
|
|
||||||
)
|
|
||||||
_log(
|
|
||||||
{
|
|
||||||
"ts": time.time(),
|
|
||||||
"event": event_type,
|
|
||||||
"hook": hook_name,
|
|
||||||
"action": "timeout",
|
|
||||||
"timeout_s": hook_timeout,
|
|
||||||
"elapsed_ms": result["elapsed_ms"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
from aipass.hooks.apps.sound import speak
|
|
||||||
|
|
||||||
speak(f"{hook_name.replace('_', ' ')} timed out")
|
|
||||||
except Exception as exc:
|
|
||||||
logger.info("[HOOKS] sound playback failed for timeout %s.%s: %s", event_type, hook_name, exc)
|
|
||||||
continue
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"[HOOKS] %s.%s agent=%s exit=%d out=%db %dms",
|
"[HOOKS] %s.%s agent=%s exit=%d out=%db %dms",
|
||||||
event_type,
|
event_type,
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ class TestAutoProcessHandler:
|
|||||||
|
|
||||||
assert result["exit_code"] == 0
|
assert result["exit_code"] == 0
|
||||||
assert result["stdout"] == ""
|
assert result["stdout"] == ""
|
||||||
assert result["sound"] == "auto process"
|
|
||||||
|
|
||||||
def test_calls_memory_auto_process_module(self):
|
def test_calls_memory_auto_process_module(self):
|
||||||
from aipass.hooks.apps.handlers.lifecycle.auto_process import handle
|
from aipass.hooks.apps.handlers.lifecycle.auto_process import handle
|
||||||
@@ -169,7 +168,6 @@ class TestAutoProcessHandler:
|
|||||||
result = handle({"tool_name": "Bash", "cwd": "/tmp"})
|
result = handle({"tool_name": "Bash", "cwd": "/tmp"})
|
||||||
|
|
||||||
assert result["exit_code"] == 0
|
assert result["exit_code"] == 0
|
||||||
assert result["sound"] == "auto process"
|
|
||||||
|
|
||||||
|
|
||||||
class TestSessionGuard:
|
class TestSessionGuard:
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from aipass.hooks.apps.modules import cc_sessions
|
from aipass.hooks.apps.modules import cc_sessions
|
||||||
|
|
||||||
|
|
||||||
@@ -32,51 +29,6 @@ class TestIsPidAlive:
|
|||||||
assert cc_sessions._is_pid_alive(42) is False
|
assert cc_sessions._is_pid_alive(42) is False
|
||||||
|
|
||||||
|
|
||||||
class TestProcStartTicks:
|
|
||||||
@pytest.mark.skipif(sys.platform != "linux", reason="reads the real /proc filesystem")
|
|
||||||
def test_current_process_returns_value_on_linux(self):
|
|
||||||
result = cc_sessions._proc_start_ticks(os.getpid())
|
|
||||||
assert result is not None
|
|
||||||
assert result.isdigit()
|
|
||||||
|
|
||||||
@pytest.mark.skipif(sys.platform != "linux", reason="reads the real /proc filesystem")
|
|
||||||
def test_matches_raw_proc_stat_field(self):
|
|
||||||
result = cc_sessions._proc_start_ticks(os.getpid())
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
raw = Path(f"/proc/{os.getpid()}/stat").read_text(encoding="utf-8")
|
|
||||||
expected = raw.rsplit(")", 1)[1].split()[19]
|
|
||||||
assert result == expected
|
|
||||||
|
|
||||||
def test_non_linux_returns_none(self):
|
|
||||||
with patch("sys.platform", "win32"):
|
|
||||||
assert cc_sessions._proc_start_ticks(os.getpid()) is None
|
|
||||||
|
|
||||||
def test_missing_pid_returns_none(self):
|
|
||||||
with patch("sys.platform", "linux"):
|
|
||||||
assert cc_sessions._proc_start_ticks(999999999) is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestSessionPidMatches:
|
|
||||||
def test_no_procstart_recorded_passes(self):
|
|
||||||
assert cc_sessions._session_pid_matches({"pid": os.getpid()}) is True
|
|
||||||
|
|
||||||
def test_non_int_pid_passes(self):
|
|
||||||
assert cc_sessions._session_pid_matches({"pid": "not-an-int", "procStart": "123"}) is True
|
|
||||||
|
|
||||||
def test_matching_procstart_passes(self):
|
|
||||||
with patch.object(cc_sessions, "_proc_start_ticks", return_value="11277752"):
|
|
||||||
assert cc_sessions._session_pid_matches({"pid": 123, "procStart": "11277752"}) is True
|
|
||||||
|
|
||||||
def test_mismatched_procstart_fails(self):
|
|
||||||
with patch.object(cc_sessions, "_proc_start_ticks", return_value="99999999"):
|
|
||||||
assert cc_sessions._session_pid_matches({"pid": 123, "procStart": "11277752"}) is False
|
|
||||||
|
|
||||||
def test_unreadable_live_start_falls_back_to_pass(self):
|
|
||||||
with patch.object(cc_sessions, "_proc_start_ticks", return_value=None):
|
|
||||||
assert cc_sessions._session_pid_matches({"pid": 123, "procStart": "11277752"}) is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestReadAllSessions:
|
class TestReadAllSessions:
|
||||||
def test_reads_pid_files(self, tmp_path):
|
def test_reads_pid_files(self, tmp_path):
|
||||||
session = {"pid": 1234, "sessionId": "abc", "cwd": "/tmp/branch", "kind": "interactive"}
|
session = {"pid": 1234, "sessionId": "abc", "cwd": "/tmp/branch", "kind": "interactive"}
|
||||||
@@ -151,47 +103,6 @@ class TestFindLiveForCwd:
|
|||||||
result = cc_sessions.find_live_for_cwd("/tmp/hooks")
|
result = cc_sessions.find_live_for_cwd("/tmp/hooks")
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
def test_excludes_reused_pid_with_mismatched_procstart(self, tmp_path):
|
|
||||||
s = {
|
|
||||||
"pid": os.getpid(),
|
|
||||||
"sessionId": "reused",
|
|
||||||
"cwd": "/tmp/hooks",
|
|
||||||
"kind": "interactive",
|
|
||||||
"procStart": "1",
|
|
||||||
}
|
|
||||||
(tmp_path / f"{os.getpid()}.json").write_text(json.dumps(s))
|
|
||||||
with (
|
|
||||||
patch.object(cc_sessions, "CC_SESSIONS_DIR", tmp_path),
|
|
||||||
patch.object(cc_sessions, "_proc_start_ticks", return_value="999999999"),
|
|
||||||
):
|
|
||||||
result = cc_sessions.find_live_for_cwd("/tmp/hooks")
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_includes_session_with_matching_procstart(self, tmp_path):
|
|
||||||
s = {
|
|
||||||
"pid": os.getpid(),
|
|
||||||
"sessionId": "genuine",
|
|
||||||
"cwd": "/tmp/hooks",
|
|
||||||
"kind": "interactive",
|
|
||||||
"procStart": "42",
|
|
||||||
}
|
|
||||||
(tmp_path / f"{os.getpid()}.json").write_text(json.dumps(s))
|
|
||||||
with (
|
|
||||||
patch.object(cc_sessions, "CC_SESSIONS_DIR", tmp_path),
|
|
||||||
patch.object(cc_sessions, "_proc_start_ticks", return_value="42"),
|
|
||||||
):
|
|
||||||
result = cc_sessions.find_live_for_cwd("/tmp/hooks")
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["sessionId"] == "genuine"
|
|
||||||
|
|
||||||
def test_includes_session_without_procstart_field(self, tmp_path):
|
|
||||||
s = {"pid": os.getpid(), "sessionId": "no-procstart", "cwd": "/tmp/hooks", "kind": "interactive"}
|
|
||||||
(tmp_path / f"{os.getpid()}.json").write_text(json.dumps(s))
|
|
||||||
with patch.object(cc_sessions, "CC_SESSIONS_DIR", tmp_path):
|
|
||||||
result = cc_sessions.find_live_for_cwd("/tmp/hooks")
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["sessionId"] == "no-procstart"
|
|
||||||
|
|
||||||
|
|
||||||
class TestFindOccupant:
|
class TestFindOccupant:
|
||||||
def test_no_occupant_when_free(self, tmp_path):
|
def test_no_occupant_when_free(self, tmp_path):
|
||||||
@@ -224,17 +135,6 @@ class TestFindOccupant:
|
|||||||
result = cc_sessions.find_occupant("/tmp/hooks")
|
result = cc_sessions.find_occupant("/tmp/hooks")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
|
|
||||||
def test_reused_pid_never_reported_as_occupant(self, tmp_path):
|
|
||||||
my_pid = os.getpid()
|
|
||||||
s = {"pid": my_pid, "sessionId": "stale-claim", "cwd": "/tmp/hooks", "kind": "interactive", "procStart": "1"}
|
|
||||||
(tmp_path / f"{my_pid}.json").write_text(json.dumps(s))
|
|
||||||
with (
|
|
||||||
patch.object(cc_sessions, "CC_SESSIONS_DIR", tmp_path),
|
|
||||||
patch.object(cc_sessions, "_proc_start_ticks", return_value="999999999"),
|
|
||||||
):
|
|
||||||
result = cc_sessions.find_occupant("/tmp/hooks", exclude_pid=99999)
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestReclaim:
|
class TestReclaim:
|
||||||
def test_reclaim_stops_live_sessions(self, tmp_path):
|
def test_reclaim_stops_live_sessions(self, tmp_path):
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ class TestCompassRecallHandler:
|
|||||||
assert result["exit_code"] == 0
|
assert result["exit_code"] == 0
|
||||||
assert "[GOOD] #56:" in result["stdout"]
|
assert "[GOOD] #56:" in result["stdout"]
|
||||||
assert "Never hardcode config in prompts" in result["stdout"]
|
assert "Never hardcode config in prompts" in result["stdout"]
|
||||||
assert result["sound"] == "compass recall"
|
|
||||||
mock_mark.assert_called_once_with([56])
|
mock_mark.assert_called_once_with([56])
|
||||||
|
|
||||||
def test_formats_bad_rating(self, tmp_path):
|
def test_formats_bad_rating(self, tmp_path):
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
# =================== 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"])
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
# =================== 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"])
|
|
||||||
@@ -272,54 +272,6 @@ class TestDispatch:
|
|||||||
assert "ok" in result[0]
|
assert "ok" in result[0]
|
||||||
assert result[1] == 0
|
assert result[1] == 0
|
||||||
|
|
||||||
def test_trust_break_banner_short_circuits_presence_gate_dispatch(self, mock_logger):
|
|
||||||
"""A hash-mismatch banner must reach the user even though presence_gate's own hook_def is empty."""
|
|
||||||
config = {"hooks_enabled": True, "UserPromptSubmit": {"presence_gate": {}}}
|
|
||||||
with (
|
|
||||||
patch("aipass.hooks.apps.modules.engine._log"),
|
|
||||||
patch(
|
|
||||||
"aipass.hooks.apps.handlers.config.loader.trust_break_banner",
|
|
||||||
return_value="# TRUST BREAK — ALL AIPASS HOOKS DISABLED",
|
|
||||||
),
|
|
||||||
patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run,
|
|
||||||
):
|
|
||||||
result = dispatch("UserPromptSubmit", "{}", config)
|
|
||||||
mock_run.assert_not_called()
|
|
||||||
assert result == ("# TRUST BREAK — ALL AIPASS HOOKS DISABLED", 0)
|
|
||||||
|
|
||||||
def test_no_trust_break_falls_through_to_normal_dispatch(self, mock_logger):
|
|
||||||
config = {"hooks_enabled": True, "UserPromptSubmit": {"presence_gate": {}}}
|
|
||||||
with (
|
|
||||||
patch("aipass.hooks.apps.modules.engine._log"),
|
|
||||||
patch("aipass.hooks.apps.handlers.config.loader.trust_break_banner", return_value=None),
|
|
||||||
):
|
|
||||||
result = dispatch("UserPromptSubmit", "{}", config)
|
|
||||||
assert result == ("", 0)
|
|
||||||
|
|
||||||
def test_trust_break_check_skipped_when_presence_gate_not_dispatched(self, mock_logger):
|
|
||||||
"""The check is scoped to the presence_gate-filtered bridge call, not every UserPromptSubmit dispatch."""
|
|
||||||
config = {
|
|
||||||
"hooks_enabled": True,
|
|
||||||
"UserPromptSubmit": {"other_hook": {"enabled": True, "command": "echo hi", "matcher": ""}},
|
|
||||||
}
|
|
||||||
with (
|
|
||||||
patch("aipass.hooks.apps.modules.engine._log"),
|
|
||||||
patch("aipass.hooks.apps.handlers.config.loader.trust_break_banner") as mock_banner,
|
|
||||||
patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run,
|
|
||||||
):
|
|
||||||
mock_run.return_value = {"exit_code": 0, "stdout": "hi", "stderr": "", "elapsed_ms": 5}
|
|
||||||
dispatch("UserPromptSubmit", "{}", config)
|
|
||||||
mock_banner.assert_not_called()
|
|
||||||
|
|
||||||
def test_trust_break_check_skipped_for_non_prompt_events(self, mock_logger):
|
|
||||||
config = {"hooks_enabled": True, "PreToolUse": {"presence_gate": {}}}
|
|
||||||
with (
|
|
||||||
patch("aipass.hooks.apps.modules.engine._log"),
|
|
||||||
patch("aipass.hooks.apps.handlers.config.loader.trust_break_banner") as mock_banner,
|
|
||||||
):
|
|
||||||
dispatch("PreToolUse", "{}", config)
|
|
||||||
mock_banner.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestFindProjectConfig:
|
class TestFindProjectConfig:
|
||||||
"""Tests for find_project_config() CWD walk."""
|
"""Tests for find_project_config() CWD walk."""
|
||||||
@@ -368,7 +320,7 @@ class TestLog:
|
|||||||
|
|
||||||
def test_writes_jsonl_entry(self, temp_test_dir, mock_logger):
|
def test_writes_jsonl_entry(self, temp_test_dir, mock_logger):
|
||||||
log_file = temp_test_dir / "test.jsonl"
|
log_file = temp_test_dir / "test.jsonl"
|
||||||
with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
|
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file):
|
||||||
_log({"event": "Test", "action": "test_write"})
|
_log({"event": "Test", "action": "test_write"})
|
||||||
lines = log_file.read_text().strip().split("\n")
|
lines = log_file.read_text().strip().split("\n")
|
||||||
assert len(lines) == 1
|
assert len(lines) == 1
|
||||||
@@ -378,13 +330,13 @@ class TestLog:
|
|||||||
|
|
||||||
def test_creates_parent_directory(self, temp_test_dir, mock_logger):
|
def test_creates_parent_directory(self, temp_test_dir, mock_logger):
|
||||||
log_file = temp_test_dir / "subdir" / "test.jsonl"
|
log_file = temp_test_dir / "subdir" / "test.jsonl"
|
||||||
with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
|
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file):
|
||||||
_log({"event": "Test"})
|
_log({"event": "Test"})
|
||||||
assert log_file.exists()
|
assert log_file.exists()
|
||||||
|
|
||||||
def test_appends_multiple_entries(self, temp_test_dir, mock_logger):
|
def test_appends_multiple_entries(self, temp_test_dir, mock_logger):
|
||||||
log_file = temp_test_dir / "test.jsonl"
|
log_file = temp_test_dir / "test.jsonl"
|
||||||
with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
|
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file):
|
||||||
_log({"event": "A"})
|
_log({"event": "A"})
|
||||||
_log({"event": "B"})
|
_log({"event": "B"})
|
||||||
lines = log_file.read_text().strip().split("\n")
|
lines = log_file.read_text().strip().split("\n")
|
||||||
@@ -483,12 +435,10 @@ class TestErrorResilience:
|
|||||||
config = find_project_config()
|
config = find_project_config()
|
||||||
assert config is None
|
assert config is None
|
||||||
|
|
||||||
def test_log_write_failure_does_not_crash(self, tmp_path, mock_logger):
|
def test_log_write_failure_does_not_crash(self, mock_logger):
|
||||||
# A bare-Mock LOG_FILE leaks a real MagicMock/LOG_FILE/ dir into the CWD
|
|
||||||
# via append_jsonl's Path(...).parent.mkdir — use a real tmp path.
|
|
||||||
log_file = tmp_path / "engine.jsonl"
|
|
||||||
with patch("builtins.open", side_effect=OSError("disk full")):
|
with patch("builtins.open", side_effect=OSError("disk full")):
|
||||||
with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
|
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE") as mock_path:
|
||||||
|
mock_path.parent.mkdir = MagicMock()
|
||||||
_log({"event": "Test"})
|
_log({"event": "Test"})
|
||||||
|
|
||||||
|
|
||||||
@@ -506,7 +456,7 @@ class TestDataStructureContracts:
|
|||||||
|
|
||||||
def test_log_entry_has_required_fields(self, temp_test_dir, mock_logger):
|
def test_log_entry_has_required_fields(self, temp_test_dir, mock_logger):
|
||||||
log_file = temp_test_dir / "test.jsonl"
|
log_file = temp_test_dir / "test.jsonl"
|
||||||
with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
|
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file):
|
||||||
_log({"ts": 123.0, "event": "Test", "action": "check"})
|
_log({"ts": 123.0, "event": "Test", "action": "check"})
|
||||||
entry = json.loads(log_file.read_text().strip())
|
entry = json.loads(log_file.read_text().strip())
|
||||||
assert "ts" in entry
|
assert "ts" in entry
|
||||||
@@ -555,7 +505,7 @@ class TestInitProvisioning:
|
|||||||
|
|
||||||
def test_log_auto_creates_directory(self, temp_test_dir, mock_logger):
|
def test_log_auto_creates_directory(self, temp_test_dir, mock_logger):
|
||||||
log_file = temp_test_dir / "new_dir" / "engine.jsonl"
|
log_file = temp_test_dir / "new_dir" / "engine.jsonl"
|
||||||
with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
|
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file):
|
||||||
_log({"event": "init_test"})
|
_log({"event": "init_test"})
|
||||||
assert log_file.parent.exists()
|
assert log_file.parent.exists()
|
||||||
|
|
||||||
@@ -579,7 +529,7 @@ class TestInitProvisioning:
|
|||||||
def test_log_no_overwrite_on_append(self, temp_test_dir, mock_logger):
|
def test_log_no_overwrite_on_append(self, temp_test_dir, mock_logger):
|
||||||
log_file = temp_test_dir / "test.jsonl"
|
log_file = temp_test_dir / "test.jsonl"
|
||||||
log_file.write_text('{"existing": true}\n')
|
log_file.write_text('{"existing": true}\n')
|
||||||
with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
|
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file):
|
||||||
_log({"event": "new"})
|
_log({"event": "new"})
|
||||||
lines = log_file.read_text().strip().split("\n")
|
lines = log_file.read_text().strip().split("\n")
|
||||||
assert len(lines) == 2
|
assert len(lines) == 2
|
||||||
@@ -714,7 +664,7 @@ class TestConfigDataContracts:
|
|||||||
|
|
||||||
def test_data_keys_in_log_entry(self, temp_test_dir, mock_logger):
|
def test_data_keys_in_log_entry(self, temp_test_dir, mock_logger):
|
||||||
log_file = temp_test_dir / "test.jsonl"
|
log_file = temp_test_dir / "test.jsonl"
|
||||||
with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
|
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file):
|
||||||
_log({"ts": 1.0, "event": "Test", "hook": "test_hook", "exit_code": 0})
|
_log({"ts": 1.0, "event": "Test", "hook": "test_hook", "exit_code": 0})
|
||||||
entry = json.loads(log_file.read_text().strip())
|
entry = json.loads(log_file.read_text().strip())
|
||||||
assert "ts" in entry
|
assert "ts" in entry
|
||||||
@@ -758,7 +708,7 @@ class TestErrorResilienceExtended:
|
|||||||
|
|
||||||
def test_missing_file_in_log_path(self, temp_test_dir, mock_logger):
|
def test_missing_file_in_log_path(self, temp_test_dir, mock_logger):
|
||||||
missing = temp_test_dir / "missing" / "nonexistent.jsonl"
|
missing = temp_test_dir / "missing" / "nonexistent.jsonl"
|
||||||
with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=missing):
|
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", missing):
|
||||||
_log({"event": "test_missing"})
|
_log({"event": "test_missing"})
|
||||||
assert missing.exists()
|
assert missing.exists()
|
||||||
|
|
||||||
@@ -937,212 +887,19 @@ class TestLayerATrustEnforcement:
|
|||||||
assert "ok" in result[0]
|
assert "ok" in result[0]
|
||||||
|
|
||||||
|
|
||||||
class TestRunHandlerTimeout:
|
|
||||||
"""DPLAN-0256 backlog #1: handler-type hooks had no engine-side timeout — a hung
|
|
||||||
handler could stall the whole event. _run_handler now runs the handler on a
|
|
||||||
daemon thread and joins with a timeout instead of calling it inline."""
|
|
||||||
|
|
||||||
def test_handler_completes_within_timeout(self, mock_logger):
|
|
||||||
from aipass.hooks.apps.modules.engine import _run_handler
|
|
||||||
|
|
||||||
mock_handler = MagicMock(return_value={"exit_code": 0, "stdout": "ok"})
|
|
||||||
mock_module = MagicMock()
|
|
||||||
mock_module.handle = mock_handler
|
|
||||||
with patch("importlib.import_module", return_value=mock_module):
|
|
||||||
result = _run_handler("aipass.hooks.apps.handlers.notification.stop_sound.handle", {}, timeout_s=1)
|
|
||||||
assert result["exit_code"] == 0
|
|
||||||
assert result["stdout"] == "ok"
|
|
||||||
|
|
||||||
def test_handler_exceeding_timeout_returns_timeout_marker(self, mock_logger):
|
|
||||||
import time as time_module
|
|
||||||
|
|
||||||
from aipass.hooks.apps.modules.engine import _run_handler
|
|
||||||
|
|
||||||
def _slow_handler(_data):
|
|
||||||
time_module.sleep(1.3)
|
|
||||||
return {"exit_code": 0, "stdout": "too late"}
|
|
||||||
|
|
||||||
mock_module = MagicMock()
|
|
||||||
mock_module.handle = _slow_handler
|
|
||||||
with patch("importlib.import_module", return_value=mock_module):
|
|
||||||
result = _run_handler("aipass.hooks.apps.handlers.fake.handle", {}, timeout_s=1)
|
|
||||||
assert result["exit_code"] == -1
|
|
||||||
assert result["stderr"] == "TIMEOUT"
|
|
||||||
|
|
||||||
def test_handler_timeout_does_not_block_caller(self, mock_logger):
|
|
||||||
"""Regression: the caller must return promptly even if the handler thread never finishes."""
|
|
||||||
import time as time_module
|
|
||||||
|
|
||||||
from aipass.hooks.apps.modules.engine import _run_handler
|
|
||||||
|
|
||||||
def _hangs_much_longer_than_timeout(_data):
|
|
||||||
time_module.sleep(3)
|
|
||||||
return {"exit_code": 0, "stdout": "should never see this"}
|
|
||||||
|
|
||||||
mock_module = MagicMock()
|
|
||||||
mock_module.handle = _hangs_much_longer_than_timeout
|
|
||||||
with patch("importlib.import_module", return_value=mock_module):
|
|
||||||
start = time_module.monotonic()
|
|
||||||
result = _run_handler("aipass.hooks.apps.handlers.fake.handle", {}, timeout_s=1)
|
|
||||||
elapsed = time_module.monotonic() - start
|
|
||||||
assert elapsed < 2.0
|
|
||||||
assert result["stderr"] == "TIMEOUT"
|
|
||||||
|
|
||||||
def test_default_handler_timeout_is_30(self):
|
|
||||||
import inspect
|
|
||||||
|
|
||||||
from aipass.hooks.apps.modules.engine import _run_handler
|
|
||||||
|
|
||||||
sig = inspect.signature(_run_handler)
|
|
||||||
assert sig.parameters["timeout_s"].default == 30
|
|
||||||
|
|
||||||
def test_handler_exception_still_surfaces_as_error_not_timeout(self, mock_logger):
|
|
||||||
from aipass.hooks.apps.modules.engine import _run_handler
|
|
||||||
|
|
||||||
def _boom(_data):
|
|
||||||
raise RuntimeError("handler blew up")
|
|
||||||
|
|
||||||
mock_module = MagicMock()
|
|
||||||
mock_module.handle = _boom
|
|
||||||
with patch("importlib.import_module", return_value=mock_module):
|
|
||||||
result = _run_handler("aipass.hooks.apps.handlers.fake.handle", {}, timeout_s=1)
|
|
||||||
assert result["exit_code"] == -1
|
|
||||||
assert "handler blew up" in result["stderr"]
|
|
||||||
assert result["stderr"] != "TIMEOUT"
|
|
||||||
|
|
||||||
|
|
||||||
class TestDispatchHandlerTimeout:
|
|
||||||
"""Dispatch-level wiring for DPLAN-0256 backlog #1 — hooks.json timeout fields
|
|
||||||
are honored for handler-type hooks, a sane default applies when absent, and a
|
|
||||||
timeout fails loud (log + sound) without ever silently swallowing or stalling
|
|
||||||
the rest of the event."""
|
|
||||||
|
|
||||||
def test_handler_timeout_propagates_hook_def_value(self, mock_logger):
|
|
||||||
config = {
|
|
||||||
"hooks_enabled": True,
|
|
||||||
"UserPromptSubmit": {
|
|
||||||
"slow_handler": {
|
|
||||||
"enabled": True,
|
|
||||||
"handler": "aipass.hooks.apps.handlers.fake.handle",
|
|
||||||
"matcher": "",
|
|
||||||
"timeout": 5,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
with (
|
|
||||||
patch("aipass.hooks.apps.modules.engine._log"),
|
|
||||||
patch("aipass.hooks.apps.modules.engine._run_handler") as mock_run,
|
|
||||||
):
|
|
||||||
mock_run.return_value = {"exit_code": 0, "stdout": "ok", "stderr": "", "elapsed_ms": 5}
|
|
||||||
dispatch("UserPromptSubmit", "{}", config)
|
|
||||||
mock_run.assert_called_once_with("aipass.hooks.apps.handlers.fake.handle", {}, timeout_s=5)
|
|
||||||
|
|
||||||
def test_handler_default_timeout_is_30_when_unset(self, mock_logger):
|
|
||||||
config = {
|
|
||||||
"hooks_enabled": True,
|
|
||||||
"UserPromptSubmit": {
|
|
||||||
"no_timeout_handler": {
|
|
||||||
"enabled": True,
|
|
||||||
"handler": "aipass.hooks.apps.handlers.fake.handle",
|
|
||||||
"matcher": "",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
with (
|
|
||||||
patch("aipass.hooks.apps.modules.engine._log"),
|
|
||||||
patch("aipass.hooks.apps.modules.engine._run_handler") as mock_run,
|
|
||||||
):
|
|
||||||
mock_run.return_value = {"exit_code": 0, "stdout": "ok", "stderr": "", "elapsed_ms": 5}
|
|
||||||
dispatch("UserPromptSubmit", "{}", config)
|
|
||||||
mock_run.assert_called_once_with("aipass.hooks.apps.handlers.fake.handle", {}, timeout_s=30)
|
|
||||||
|
|
||||||
def test_timeout_logs_speaks_and_lets_dispatch_continue(self, mock_logger):
|
|
||||||
config = {
|
|
||||||
"hooks_enabled": True,
|
|
||||||
"UserPromptSubmit": {
|
|
||||||
"hung_handler": {
|
|
||||||
"enabled": True,
|
|
||||||
"handler": "aipass.hooks.apps.handlers.fake.handle",
|
|
||||||
"matcher": "",
|
|
||||||
},
|
|
||||||
"next_handler": {
|
|
||||||
"enabled": True,
|
|
||||||
"handler": "aipass.hooks.apps.handlers.fake2.handle",
|
|
||||||
"matcher": "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
with (
|
|
||||||
patch("aipass.hooks.apps.modules.engine._log") as mock_log,
|
|
||||||
patch("aipass.hooks.apps.modules.engine._run_handler") as mock_run,
|
|
||||||
patch("aipass.hooks.apps.sound.speak") as mock_speak,
|
|
||||||
):
|
|
||||||
mock_run.side_effect = [
|
|
||||||
{"exit_code": -1, "stdout": "", "stderr": "TIMEOUT", "elapsed_ms": 30000},
|
|
||||||
{"exit_code": 0, "stdout": "survived", "stderr": "", "elapsed_ms": 5},
|
|
||||||
]
|
|
||||||
result = dispatch("UserPromptSubmit", "{}", config)
|
|
||||||
assert "survived" in result[0]
|
|
||||||
assert result[1] == 0
|
|
||||||
mock_speak.assert_called_once()
|
|
||||||
log_calls = [c[0][0] for c in mock_log.call_args_list]
|
|
||||||
assert any(e.get("action") == "timeout" for e in log_calls if isinstance(e, dict))
|
|
||||||
|
|
||||||
def test_timed_out_handler_produces_no_output(self, mock_logger):
|
|
||||||
config = {
|
|
||||||
"hooks_enabled": True,
|
|
||||||
"UserPromptSubmit": {
|
|
||||||
"hung_handler": {
|
|
||||||
"enabled": True,
|
|
||||||
"handler": "aipass.hooks.apps.handlers.fake.handle",
|
|
||||||
"matcher": "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
with (
|
|
||||||
patch("aipass.hooks.apps.modules.engine._log"),
|
|
||||||
patch("aipass.hooks.apps.modules.engine._run_handler") as mock_run,
|
|
||||||
patch("aipass.hooks.apps.sound.speak"),
|
|
||||||
):
|
|
||||||
mock_run.return_value = {"exit_code": -1, "stdout": "", "stderr": "TIMEOUT", "elapsed_ms": 30000}
|
|
||||||
result = dispatch("UserPromptSubmit", "{}", config)
|
|
||||||
assert result == ("", 0)
|
|
||||||
|
|
||||||
def test_command_type_timeout_also_fails_loud(self, mock_logger):
|
|
||||||
"""The fail-loud path is shared by both hook types — command-type timeouts already
|
|
||||||
existed (subprocess timeout=), but were silent past the generic per-hook log line."""
|
|
||||||
config = {
|
|
||||||
"hooks_enabled": True,
|
|
||||||
"PreToolUse": {
|
|
||||||
"slow_cmd": {"enabled": True, "command": "sleep 999", "matcher": ""},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
with (
|
|
||||||
patch("aipass.hooks.apps.modules.engine._log") as mock_log,
|
|
||||||
patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run,
|
|
||||||
patch("aipass.hooks.apps.sound.speak") as mock_speak,
|
|
||||||
):
|
|
||||||
mock_run.return_value = {"exit_code": -1, "stdout": "", "stderr": "TIMEOUT", "elapsed_ms": 30000}
|
|
||||||
result = dispatch("PreToolUse", '{"tool_name":"Edit"}', config)
|
|
||||||
assert result == ("", 0)
|
|
||||||
mock_speak.assert_called_once()
|
|
||||||
log_calls = [c[0][0] for c in mock_log.call_args_list]
|
|
||||||
assert any(e.get("action") == "timeout" for e in log_calls if isinstance(e, dict))
|
|
||||||
|
|
||||||
|
|
||||||
class TestJsonHandlerNotApplicable:
|
class TestJsonHandlerNotApplicable:
|
||||||
"""Hooks uses JSONL logging, not json_handler. These verify the log equivalent."""
|
"""Hooks uses JSONL logging, not json_handler. These verify the log equivalent."""
|
||||||
|
|
||||||
def test_log_default_factory(self, temp_test_dir, mock_logger):
|
def test_log_default_factory(self, temp_test_dir, mock_logger):
|
||||||
log_file = temp_test_dir / "factory.jsonl"
|
log_file = temp_test_dir / "factory.jsonl"
|
||||||
assert not log_file.exists()
|
assert not log_file.exists()
|
||||||
with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
|
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file):
|
||||||
_log({"event": "factory_test"})
|
_log({"event": "factory_test"})
|
||||||
assert log_file.exists()
|
assert log_file.exists()
|
||||||
|
|
||||||
def test_log_validate_json_output(self, temp_test_dir, mock_logger):
|
def test_log_validate_json_output(self, temp_test_dir, mock_logger):
|
||||||
log_file = temp_test_dir / "validate.jsonl"
|
log_file = temp_test_dir / "validate.jsonl"
|
||||||
with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
|
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file):
|
||||||
_log({"event": "A", "ts": 1.0})
|
_log({"event": "A", "ts": 1.0})
|
||||||
_log({"event": "B", "ts": 2.0})
|
_log({"event": "B", "ts": 2.0})
|
||||||
for line in log_file.read_text().strip().split("\n"):
|
for line in log_file.read_text().strip().split("\n"):
|
||||||
@@ -1157,13 +914,13 @@ class TestJsonHandlerNotApplicable:
|
|||||||
|
|
||||||
def test_log_ensure_exists(self, temp_test_dir, mock_logger):
|
def test_log_ensure_exists(self, temp_test_dir, mock_logger):
|
||||||
log_file = temp_test_dir / "new_dir" / "ensure.jsonl"
|
log_file = temp_test_dir / "new_dir" / "ensure.jsonl"
|
||||||
with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
|
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file):
|
||||||
_log({"ensure": True})
|
_log({"ensure": True})
|
||||||
assert log_file.parent.exists()
|
assert log_file.parent.exists()
|
||||||
|
|
||||||
def test_log_save_entry(self, temp_test_dir, mock_logger):
|
def test_log_save_entry(self, temp_test_dir, mock_logger):
|
||||||
log_file = temp_test_dir / "save.jsonl"
|
log_file = temp_test_dir / "save.jsonl"
|
||||||
with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
|
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file):
|
||||||
_log({"saved": True, "value": 42})
|
_log({"saved": True, "value": 42})
|
||||||
entry = json.loads(log_file.read_text().strip())
|
entry = json.loads(log_file.read_text().strip())
|
||||||
assert entry["saved"] is True
|
assert entry["saved"] is True
|
||||||
@@ -1178,7 +935,7 @@ class TestJsonHandlerNotApplicable:
|
|||||||
|
|
||||||
def test_log_operation_recorded(self, temp_test_dir, mock_logger):
|
def test_log_operation_recorded(self, temp_test_dir, mock_logger):
|
||||||
log_file = temp_test_dir / "ops.jsonl"
|
log_file = temp_test_dir / "ops.jsonl"
|
||||||
with patch("aipass.hooks.apps.handlers.config.diagnostics._get_log_file", return_value=log_file):
|
with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file):
|
||||||
_log({"event": "PreToolUse", "hook": "test", "exit_code": 0})
|
_log({"event": "PreToolUse", "hook": "test", "exit_code": 0})
|
||||||
entry = json.loads(log_file.read_text().strip())
|
entry = json.loads(log_file.read_text().strip())
|
||||||
assert entry["event"] == "PreToolUse"
|
assert entry["event"] == "PreToolUse"
|
||||||
|
|||||||
@@ -107,25 +107,6 @@ class TestFeedbackPulseHandler:
|
|||||||
|
|
||||||
assert result["stdout"] == ""
|
assert result["stdout"] == ""
|
||||||
|
|
||||||
def test_disabled_logs_distinguishing_line(self, tmp_path, caplog):
|
|
||||||
import logging
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
|
||||||
tmp_path,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled",
|
|
||||||
return_value=True,
|
|
||||||
),
|
|
||||||
caplog.at_level(logging.INFO),
|
|
||||||
):
|
|
||||||
for i in range(11):
|
|
||||||
self._handler()({"session_id": "test-disabled-log"})
|
|
||||||
|
|
||||||
assert "skipped (disabled via .aipass/feedback_off sentinel)" in caplog.text
|
|
||||||
|
|
||||||
def test_output_is_one_line(self, tmp_path):
|
def test_output_is_one_line(self, tmp_path):
|
||||||
result = {"stdout": "", "exit_code": 0}
|
result = {"stdout": "", "exit_code": 0}
|
||||||
with (
|
with (
|
||||||
|
|||||||
@@ -257,20 +257,19 @@ class TestExpiredAlertCleanup:
|
|||||||
|
|
||||||
|
|
||||||
class TestAlertSound:
|
class TestAlertSound:
|
||||||
"""Sound fires once per alert per session (session-keyed tempdir guard)."""
|
"""Sound fires on first injection only."""
|
||||||
|
|
||||||
def test_sound_on_first_injection(self, tmp_path):
|
def test_sound_on_first_injection(self, tmp_path):
|
||||||
from aipass.hooks.apps.handlers.prompt import persistent_alert
|
from aipass.hooks.apps.handlers.prompt import persistent_alert
|
||||||
|
|
||||||
|
persistent_alert._announced.clear()
|
||||||
|
|
||||||
aipass_dir = tmp_path / ".aipass"
|
aipass_dir = tmp_path / ".aipass"
|
||||||
aipass_dir.mkdir()
|
aipass_dir.mkdir()
|
||||||
_write_alerts(aipass_dir, [_make_alert(alert_id="snd-001")])
|
_write_alerts(aipass_dir, [_make_alert(alert_id="snd-001")])
|
||||||
|
|
||||||
with (
|
with patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir):
|
||||||
patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir),
|
result = persistent_alert.handle({})
|
||||||
patch.object(persistent_alert, "_GUARD_DIR", tmp_path),
|
|
||||||
):
|
|
||||||
result = persistent_alert.handle({"session_id": "s-snd-001"})
|
|
||||||
|
|
||||||
assert "sound" in result
|
assert "sound" in result
|
||||||
assert "1 active alert" in result["sound"]
|
assert "1 active alert" in result["sound"]
|
||||||
@@ -278,59 +277,29 @@ class TestAlertSound:
|
|||||||
def test_no_sound_on_repeat_injection(self, tmp_path):
|
def test_no_sound_on_repeat_injection(self, tmp_path):
|
||||||
from aipass.hooks.apps.handlers.prompt import persistent_alert
|
from aipass.hooks.apps.handlers.prompt import persistent_alert
|
||||||
|
|
||||||
|
persistent_alert._announced.clear()
|
||||||
|
|
||||||
aipass_dir = tmp_path / ".aipass"
|
aipass_dir = tmp_path / ".aipass"
|
||||||
aipass_dir.mkdir()
|
aipass_dir.mkdir()
|
||||||
_write_alerts(aipass_dir, [_make_alert(alert_id="snd-002")])
|
_write_alerts(aipass_dir, [_make_alert(alert_id="snd-002")])
|
||||||
|
|
||||||
with (
|
with patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir):
|
||||||
patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir),
|
persistent_alert.handle({})
|
||||||
patch.object(persistent_alert, "_GUARD_DIR", tmp_path),
|
result = persistent_alert.handle({})
|
||||||
):
|
|
||||||
persistent_alert.handle({"session_id": "s-snd-002"})
|
|
||||||
result = persistent_alert.handle({"session_id": "s-snd-002"})
|
|
||||||
|
|
||||||
assert "sound" not in result
|
assert "sound" not in result
|
||||||
|
|
||||||
def test_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
|
|
||||||
|
|
||||||
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):
|
def test_sound_on_new_alert_added(self, tmp_path):
|
||||||
from aipass.hooks.apps.handlers.prompt import persistent_alert
|
from aipass.hooks.apps.handlers.prompt import persistent_alert
|
||||||
|
|
||||||
|
persistent_alert._announced.clear()
|
||||||
|
|
||||||
aipass_dir = tmp_path / ".aipass"
|
aipass_dir = tmp_path / ".aipass"
|
||||||
aipass_dir.mkdir()
|
aipass_dir.mkdir()
|
||||||
_write_alerts(aipass_dir, [_make_alert(alert_id="snd-003")])
|
_write_alerts(aipass_dir, [_make_alert(alert_id="snd-003")])
|
||||||
|
|
||||||
with (
|
with patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir):
|
||||||
patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir),
|
persistent_alert.handle({})
|
||||||
patch.object(persistent_alert, "_GUARD_DIR", tmp_path),
|
|
||||||
):
|
|
||||||
persistent_alert.handle({"session_id": "s-snd-003"})
|
|
||||||
|
|
||||||
_write_alerts(
|
_write_alerts(
|
||||||
aipass_dir,
|
aipass_dir,
|
||||||
@@ -340,86 +309,27 @@ class TestAlertSound:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
with (
|
with patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir):
|
||||||
patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir),
|
result = persistent_alert.handle({})
|
||||||
patch.object(persistent_alert, "_GUARD_DIR", tmp_path),
|
|
||||||
):
|
|
||||||
result = persistent_alert.handle({"session_id": "s-snd-003"})
|
|
||||||
|
|
||||||
assert "sound" in result
|
assert "sound" in result
|
||||||
assert "2 active alerts" in result["sound"]
|
assert "2 active alerts" in result["sound"]
|
||||||
|
|
||||||
def test_different_sessions_both_hear_sound(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-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):
|
def test_no_sound_when_no_alerts(self, tmp_path):
|
||||||
from aipass.hooks.apps.handlers.prompt import persistent_alert
|
from aipass.hooks.apps.handlers.prompt import persistent_alert
|
||||||
|
|
||||||
|
persistent_alert._announced.clear()
|
||||||
|
|
||||||
aipass_dir = tmp_path / ".aipass"
|
aipass_dir = tmp_path / ".aipass"
|
||||||
aipass_dir.mkdir()
|
aipass_dir.mkdir()
|
||||||
_write_alerts(aipass_dir, [])
|
_write_alerts(aipass_dir, [])
|
||||||
|
|
||||||
with (
|
with patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir):
|
||||||
patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir),
|
result = persistent_alert.handle({})
|
||||||
patch.object(persistent_alert, "_GUARD_DIR", tmp_path),
|
|
||||||
):
|
|
||||||
result = persistent_alert.handle({"session_id": "s-none"})
|
|
||||||
|
|
||||||
assert "sound" not in result
|
assert "sound" not in result
|
||||||
|
|
||||||
|
|
||||||
class TestAlertBannerCap:
|
|
||||||
"""Banner truncates at _MAX_ALERTS_SHOWN with a hidden-count note."""
|
|
||||||
|
|
||||||
def test_cap_truncates_and_notes_hidden_count(self, tmp_path):
|
|
||||||
from aipass.hooks.apps.handlers.prompt import persistent_alert
|
|
||||||
|
|
||||||
aipass_dir = tmp_path / ".aipass"
|
|
||||||
aipass_dir.mkdir()
|
|
||||||
alerts = [_make_alert(alert_id=f"cap-{i}", title=f"Alert {i}") for i in range(13)]
|
|
||||||
_write_alerts(aipass_dir, alerts)
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir),
|
|
||||||
patch.object(persistent_alert, "_GUARD_DIR", tmp_path),
|
|
||||||
):
|
|
||||||
result = persistent_alert.handle({"session_id": "s-cap"})
|
|
||||||
|
|
||||||
assert "Alert 0" in result["stdout"]
|
|
||||||
assert "Alert 9" in result["stdout"]
|
|
||||||
assert "Alert 10" not in result["stdout"]
|
|
||||||
assert "...and 3 more" in result["stdout"]
|
|
||||||
|
|
||||||
def test_under_cap_no_hidden_note(self, tmp_path):
|
|
||||||
from aipass.hooks.apps.handlers.prompt import persistent_alert
|
|
||||||
|
|
||||||
aipass_dir = tmp_path / ".aipass"
|
|
||||||
aipass_dir.mkdir()
|
|
||||||
_write_alerts(aipass_dir, [_make_alert(alert_id="under-cap")])
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(persistent_alert, "_find_aipass_dir", return_value=aipass_dir),
|
|
||||||
patch.object(persistent_alert, "_GUARD_DIR", tmp_path),
|
|
||||||
):
|
|
||||||
result = persistent_alert.handle({"session_id": "s-under-cap"})
|
|
||||||
|
|
||||||
assert "more (dismiss some" not in result["stdout"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestAlertDismiss:
|
class TestAlertDismiss:
|
||||||
"""drone @hooks dismiss behavior."""
|
"""drone @hooks dismiss behavior."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
# =================== AIPass ====================
|
|
||||||
# Name: test_pre_compact_prep.py
|
|
||||||
# Version: 1.0.0
|
|
||||||
# Description: Tests for pre_compact_prep lifecycle handler
|
|
||||||
# Branch: hooks
|
|
||||||
# Created: 2026-07-20
|
|
||||||
# Modified: 2026-07-20
|
|
||||||
# =============================================
|
|
||||||
|
|
||||||
"""Tests for handlers/lifecycle/pre_compact_prep.py."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
MODULE = "aipass.hooks.apps.handlers.lifecycle.pre_compact_prep"
|
|
||||||
|
|
||||||
|
|
||||||
def _make_branch(tmp_path, with_local=True, sessions=None):
|
|
||||||
branch_dir = tmp_path / "src" / "aipass" / "widget"
|
|
||||||
branch_dir.mkdir(parents=True)
|
|
||||||
if with_local:
|
|
||||||
trinity = branch_dir / ".trinity"
|
|
||||||
trinity.mkdir()
|
|
||||||
(trinity / "local.json").write_text(
|
|
||||||
json.dumps({"sessions": sessions if sessions is not None else []}), encoding="utf-8"
|
|
||||||
)
|
|
||||||
return branch_dir
|
|
||||||
|
|
||||||
|
|
||||||
class TestHandle:
|
|
||||||
def test_stamps_session_entry_and_returns_stdout(self, tmp_path):
|
|
||||||
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
|
|
||||||
|
|
||||||
branch_dir = _make_branch(tmp_path, sessions=[{"number": 3, "date": "2026-01-01", "summary": "old"}])
|
|
||||||
|
|
||||||
with patch(f"{MODULE}._find_repo_root", return_value=None):
|
|
||||||
result = handle({"cwd": str(branch_dir)})
|
|
||||||
|
|
||||||
assert result["exit_code"] == 0
|
|
||||||
assert "AUTO-COMPACT SNAPSHOT" in result["stdout"]
|
|
||||||
assert result["sound"] == "pre compact prep"
|
|
||||||
|
|
||||||
data = json.loads((branch_dir / ".trinity" / "local.json").read_text(encoding="utf-8"))
|
|
||||||
assert len(data["sessions"]) == 2
|
|
||||||
newest = data["sessions"][0]
|
|
||||||
assert newest["number"] == 4
|
|
||||||
assert newest["status"] == "auto-compact"
|
|
||||||
assert "AUTO-COMPACT SNAPSHOT" in newest["summary"]
|
|
||||||
|
|
||||||
def test_prepends_number_as_max_plus_one(self, tmp_path):
|
|
||||||
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
|
|
||||||
|
|
||||||
branch_dir = _make_branch(
|
|
||||||
tmp_path,
|
|
||||||
sessions=[
|
|
||||||
{"number": 10, "date": "2026-01-01", "summary": "a"},
|
|
||||||
{"number": 7, "date": "2026-01-01", "summary": "b"},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(f"{MODULE}._find_repo_root", return_value=None):
|
|
||||||
handle({"cwd": str(branch_dir)})
|
|
||||||
|
|
||||||
data = json.loads((branch_dir / ".trinity" / "local.json").read_text(encoding="utf-8"))
|
|
||||||
assert data["sessions"][0]["number"] == 11
|
|
||||||
|
|
||||||
def test_no_branch_dir_resolved_is_a_noop(self, tmp_path):
|
|
||||||
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
|
|
||||||
|
|
||||||
result = handle({"cwd": str(tmp_path)})
|
|
||||||
assert result == {"stdout": "", "exit_code": 0}
|
|
||||||
|
|
||||||
def test_missing_local_json_skips_write_but_still_returns_snapshot(self, tmp_path):
|
|
||||||
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
|
|
||||||
|
|
||||||
branch_dir = _make_branch(tmp_path, with_local=False)
|
|
||||||
|
|
||||||
with patch(f"{MODULE}._find_repo_root", return_value=None):
|
|
||||||
result = handle({"cwd": str(branch_dir)})
|
|
||||||
|
|
||||||
assert result["exit_code"] == 0
|
|
||||||
assert "AUTO-COMPACT SNAPSHOT" in result["stdout"]
|
|
||||||
|
|
||||||
def test_malformed_local_json_never_raises_and_is_untouched(self, tmp_path):
|
|
||||||
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
|
|
||||||
|
|
||||||
branch_dir = tmp_path / "src" / "aipass" / "widget"
|
|
||||||
trinity = branch_dir / ".trinity"
|
|
||||||
trinity.mkdir(parents=True)
|
|
||||||
local_path = trinity / "local.json"
|
|
||||||
local_path.write_text("{not valid json", encoding="utf-8")
|
|
||||||
|
|
||||||
with patch(f"{MODULE}._find_repo_root", return_value=None):
|
|
||||||
result = handle({"cwd": str(branch_dir)})
|
|
||||||
|
|
||||||
assert result["exit_code"] == 0
|
|
||||||
assert local_path.read_text(encoding="utf-8") == "{not valid json"
|
|
||||||
|
|
||||||
def test_sessions_not_a_list_skips_write(self, tmp_path):
|
|
||||||
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
|
|
||||||
|
|
||||||
branch_dir = tmp_path / "src" / "aipass" / "widget"
|
|
||||||
trinity = branch_dir / ".trinity"
|
|
||||||
trinity.mkdir(parents=True)
|
|
||||||
local_path = trinity / "local.json"
|
|
||||||
local_path.write_text(json.dumps({"sessions": "not-a-list"}), encoding="utf-8")
|
|
||||||
|
|
||||||
with patch(f"{MODULE}._find_repo_root", return_value=None):
|
|
||||||
result = handle({"cwd": str(branch_dir)})
|
|
||||||
|
|
||||||
assert result["exit_code"] == 0
|
|
||||||
data = json.loads(local_path.read_text(encoding="utf-8"))
|
|
||||||
assert data["sessions"] == "not-a-list"
|
|
||||||
|
|
||||||
def test_summary_truncated_to_cap(self, tmp_path):
|
|
||||||
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
|
|
||||||
|
|
||||||
branch_dir = _make_branch(tmp_path, sessions=[])
|
|
||||||
|
|
||||||
with patch(f"{MODULE}._find_repo_root", return_value=None):
|
|
||||||
with patch(f"{MODULE}._build_snapshot", return_value="X" * 500):
|
|
||||||
handle({"cwd": str(branch_dir)})
|
|
||||||
|
|
||||||
data = json.loads((branch_dir / ".trinity" / "local.json").read_text(encoding="utf-8"))
|
|
||||||
assert len(data["sessions"][0]["summary"]) == 300
|
|
||||||
|
|
||||||
def test_never_raises_on_unexpected_error(self):
|
|
||||||
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
|
|
||||||
|
|
||||||
with patch("aipass.hooks.apps.modules.context_window.find_branch_dir", side_effect=RuntimeError("boom")):
|
|
||||||
result = handle({"cwd": "/tmp"})
|
|
||||||
|
|
||||||
assert result == {"stdout": "", "exit_code": 0}
|
|
||||||
|
|
||||||
|
|
||||||
class TestCountOpenPlans:
|
|
||||||
def test_counts_only_matching_location(self, tmp_path):
|
|
||||||
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import _count_open_plans
|
|
||||||
|
|
||||||
branch_dir = tmp_path / "src" / "aipass" / "widget"
|
|
||||||
branch_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
fake_plans = [
|
|
||||||
("0001", {"location": str(branch_dir.resolve())}),
|
|
||||||
("0002", {"location": "/somewhere/else"}),
|
|
||||||
("0003", {"location": str(branch_dir.resolve())}),
|
|
||||||
]
|
|
||||||
|
|
||||||
with patch("aipass.flow.apps.handlers.plan.get_open_plans.get_open_plans", return_value=fake_plans):
|
|
||||||
count = _count_open_plans(branch_dir)
|
|
||||||
|
|
||||||
assert count == 2
|
|
||||||
|
|
||||||
def test_returns_none_on_import_failure(self, tmp_path):
|
|
||||||
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import _count_open_plans
|
|
||||||
|
|
||||||
branch_dir = tmp_path / "src" / "aipass" / "widget"
|
|
||||||
branch_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
with patch("aipass.flow.apps.handlers.plan.get_open_plans.get_open_plans", side_effect=RuntimeError("no")):
|
|
||||||
count = _count_open_plans(branch_dir)
|
|
||||||
|
|
||||||
assert count is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestCountActiveDispatchLocks:
|
|
||||||
def test_counts_lock_files_across_branches(self, tmp_path):
|
|
||||||
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import _count_active_dispatch_locks
|
|
||||||
|
|
||||||
(tmp_path / "src" / "aipass" / "a" / ".ai_mail.local").mkdir(parents=True)
|
|
||||||
(tmp_path / "src" / "aipass" / "a" / ".ai_mail.local" / ".dispatch.lock").write_text("{}")
|
|
||||||
(tmp_path / "src" / "aipass" / "b" / ".ai_mail.local").mkdir(parents=True)
|
|
||||||
|
|
||||||
registry = {
|
|
||||||
"branches": [
|
|
||||||
{"name": "A", "path": "src/aipass/a"},
|
|
||||||
{"name": "B", "path": "src/aipass/b"},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
(tmp_path / "AIPASS_REGISTRY.json").write_text(json.dumps(registry), encoding="utf-8")
|
|
||||||
|
|
||||||
count = _count_active_dispatch_locks(tmp_path)
|
|
||||||
assert count == 1
|
|
||||||
|
|
||||||
def test_returns_none_without_repo_root(self):
|
|
||||||
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import _count_active_dispatch_locks
|
|
||||||
|
|
||||||
assert _count_active_dispatch_locks(None) is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestInboxUnread:
|
|
||||||
def test_reads_unread_count(self, tmp_path):
|
|
||||||
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import _inbox_unread
|
|
||||||
|
|
||||||
branch_dir = tmp_path / "widget"
|
|
||||||
mail_dir = branch_dir / ".ai_mail.local"
|
|
||||||
mail_dir.mkdir(parents=True)
|
|
||||||
(mail_dir / "inbox.json").write_text(json.dumps({"unread_count": 4}), encoding="utf-8")
|
|
||||||
|
|
||||||
assert _inbox_unread(branch_dir) == 4
|
|
||||||
|
|
||||||
def test_returns_none_when_missing(self, tmp_path):
|
|
||||||
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import _inbox_unread
|
|
||||||
|
|
||||||
assert _inbox_unread(tmp_path / "widget") is None
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
pytest.main([__file__, "-v"])
|
|
||||||
@@ -170,7 +170,6 @@ class TestHandle:
|
|||||||
):
|
):
|
||||||
result = presence_gate.handle({})
|
result = presence_gate.handle({})
|
||||||
assert result["exit_code"] == 0
|
assert result["exit_code"] == 0
|
||||||
assert result["sound"] == "presence gate"
|
|
||||||
|
|
||||||
def test_gate_error_allows(self):
|
def test_gate_error_allows(self):
|
||||||
with patch.dict(os.environ, {"AIPASS_SESSION_TYPE": "interactive"}, clear=True):
|
with patch.dict(os.environ, {"AIPASS_SESSION_TYPE": "interactive"}, clear=True):
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ class TestSessionStartHandler:
|
|||||||
result = handle({"source": "startup", "session_id": "test-session"})
|
result = handle({"source": "startup", "session_id": "test-session"})
|
||||||
|
|
||||||
assert result["exit_code"] == 0
|
assert result["exit_code"] == 0
|
||||||
assert result["sound"] == "cadence reset"
|
|
||||||
data = json.loads(state_file.read_text())
|
data = json.loads(state_file.read_text())
|
||||||
assert data["turn"] == -1
|
assert data["turn"] == -1
|
||||||
|
|
||||||
|
|||||||
@@ -857,7 +857,7 @@ class TestHandleIntegration:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"}
|
assert result == {"stdout": "", "exit_code": 0}
|
||||||
assert pending_file.exists()
|
assert pending_file.exists()
|
||||||
updated = json.loads(pending_file.read_text(encoding="utf-8"))
|
updated = json.loads(pending_file.read_text(encoding="utf-8"))
|
||||||
assert updated["delivered"] is True
|
assert updated["delivered"] is True
|
||||||
@@ -902,7 +902,7 @@ class TestHandleIntegration:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"}
|
assert result == {"stdout": "", "exit_code": 0}
|
||||||
assert pending_file.exists()
|
assert pending_file.exists()
|
||||||
updated = json.loads(pending_file.read_text(encoding="utf-8"))
|
updated = json.loads(pending_file.read_text(encoding="utf-8"))
|
||||||
assert "delivered" not in updated
|
assert "delivered" not in updated
|
||||||
@@ -996,7 +996,7 @@ class TestHandleIntegration:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"}
|
assert result == {"stdout": "", "exit_code": 0}
|
||||||
assert extract_call_count >= 2
|
assert extract_call_count >= 2
|
||||||
assert pending_file.exists()
|
assert pending_file.exists()
|
||||||
updated = json.loads(pending_file.read_text(encoding="utf-8"))
|
updated = json.loads(pending_file.read_text(encoding="utf-8"))
|
||||||
@@ -1035,7 +1035,7 @@ class TestHandleIntegration:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"}
|
assert result == {"stdout": "", "exit_code": 0}
|
||||||
assert not pending_file.exists()
|
assert not pending_file.exists()
|
||||||
|
|
||||||
def test_already_delivered_skips_fallback(self, tmp_path):
|
def test_already_delivered_skips_fallback(self, tmp_path):
|
||||||
@@ -1532,7 +1532,7 @@ class TestWriteDeliveryLog:
|
|||||||
|
|
||||||
log_path = tmp_path / "delivery.jsonl"
|
log_path = tmp_path / "delivery.jsonl"
|
||||||
|
|
||||||
with patch(LOGGER_PATCH), patch(f"{MOD}._get_delivery_log", return_value=log_path):
|
with patch(LOGGER_PATCH), patch(f"{MOD}._DELIVERY_LOG", log_path):
|
||||||
_write_delivery_log(
|
_write_delivery_log(
|
||||||
"hello",
|
"hello",
|
||||||
["hello"],
|
["hello"],
|
||||||
@@ -1552,7 +1552,7 @@ class TestWriteDeliveryLog:
|
|||||||
|
|
||||||
log_path = tmp_path / "delivery.jsonl"
|
log_path = tmp_path / "delivery.jsonl"
|
||||||
|
|
||||||
with patch(LOGGER_PATCH), patch(f"{MOD}._get_delivery_log", return_value=log_path):
|
with patch(LOGGER_PATCH), patch(f"{MOD}._DELIVERY_LOG", log_path):
|
||||||
_write_delivery_log(
|
_write_delivery_log(
|
||||||
"**bold text**",
|
"**bold text**",
|
||||||
["**bold text**"],
|
["**bold text**"],
|
||||||
@@ -1569,7 +1569,7 @@ class TestWriteDeliveryLog:
|
|||||||
|
|
||||||
log_path = tmp_path / "delivery.jsonl"
|
log_path = tmp_path / "delivery.jsonl"
|
||||||
|
|
||||||
with patch(LOGGER_PATCH), patch(f"{MOD}._get_delivery_log", return_value=log_path):
|
with patch(LOGGER_PATCH), patch(f"{MOD}._DELIVERY_LOG", log_path):
|
||||||
_write_delivery_log(
|
_write_delivery_log(
|
||||||
"hello",
|
"hello",
|
||||||
["hello"],
|
["hello"],
|
||||||
@@ -1585,7 +1585,7 @@ class TestWriteDeliveryLog:
|
|||||||
from aipass.hooks.apps.handlers.notification.telegram_response import _write_delivery_log
|
from aipass.hooks.apps.handlers.notification.telegram_response import _write_delivery_log
|
||||||
|
|
||||||
impossible = Path("/dev/null/impossible/log.jsonl")
|
impossible = Path("/dev/null/impossible/log.jsonl")
|
||||||
with patch(LOGGER_PATCH), patch(f"{MOD}._get_delivery_log", return_value=impossible):
|
with patch(LOGGER_PATCH), patch(f"{MOD}._DELIVERY_LOG", impossible):
|
||||||
_write_delivery_log("hi", ["hi"], [{"idx": 0, "ok": True, "text": "hi"}], "s")
|
_write_delivery_log("hi", ["hi"], [{"idx": 0, "ok": True, "text": "hi"}], "s")
|
||||||
|
|
||||||
|
|
||||||
@@ -1989,7 +1989,7 @@ class TestHandleMirrorIntegration:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"}
|
assert result == {"stdout": "", "exit_code": 0}
|
||||||
assert len(sent_texts) == 1
|
assert len(sent_texts) == 1
|
||||||
assert "You: What is this?" in sent_texts[0]
|
assert "You: What is this?" in sent_texts[0]
|
||||||
assert "This is the answer." in sent_texts[0]
|
assert "This is the answer." in sent_texts[0]
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
# =================== 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"])
|
|
||||||
@@ -17,12 +17,11 @@ from aipass.hooks.apps.handlers.config.trust_registry import (
|
|||||||
_hash_file,
|
_hash_file,
|
||||||
bootstrap,
|
bootstrap,
|
||||||
enroll,
|
enroll,
|
||||||
is_hash_mismatch,
|
|
||||||
is_trusted,
|
is_trusted,
|
||||||
read_registry,
|
read_registry,
|
||||||
revoke,
|
revoke,
|
||||||
)
|
)
|
||||||
from aipass.hooks.apps.handlers.config.loader import find_project_config, trust_break_banner
|
from aipass.hooks.apps.handlers.config.loader import find_project_config
|
||||||
|
|
||||||
|
|
||||||
class TestRegistryHelpers:
|
class TestRegistryHelpers:
|
||||||
@@ -152,110 +151,6 @@ class TestIsTrusted:
|
|||||||
assert is_trusted(str(project)) is False
|
assert is_trusted(str(project)) is False
|
||||||
|
|
||||||
|
|
||||||
class TestIsHashMismatch:
|
|
||||||
"""Unit tests for is_hash_mismatch() — distinguishes a break from never-enrolled."""
|
|
||||||
|
|
||||||
def test_never_enrolled_is_not_a_mismatch(self, temp_test_dir, mock_logger):
|
|
||||||
reg_path = temp_test_dir / "registry.json"
|
|
||||||
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
|
||||||
assert is_hash_mismatch("/not/registered") is False
|
|
||||||
|
|
||||||
def test_enrolled_matching_hash_is_not_a_mismatch(self, temp_test_dir, mock_logger):
|
|
||||||
reg_path = temp_test_dir / "registry.json"
|
|
||||||
project = temp_test_dir / "myproject"
|
|
||||||
project.mkdir()
|
|
||||||
(project / ".aipass").mkdir()
|
|
||||||
(project / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
|
|
||||||
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
|
||||||
enroll(str(project))
|
|
||||||
assert is_hash_mismatch(str(project)) is False
|
|
||||||
|
|
||||||
def test_enrolled_changed_hash_is_a_mismatch(self, temp_test_dir, mock_logger):
|
|
||||||
reg_path = temp_test_dir / "registry.json"
|
|
||||||
project = temp_test_dir / "myproject"
|
|
||||||
project.mkdir()
|
|
||||||
(project / ".aipass").mkdir()
|
|
||||||
hooks_file = project / ".aipass" / "hooks.json"
|
|
||||||
hooks_file.write_text('{"hooks_enabled": true}')
|
|
||||||
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
|
||||||
enroll(str(project))
|
|
||||||
hooks_file.write_text('{"hooks_enabled": true, "tampered": true}')
|
|
||||||
assert is_hash_mismatch(str(project)) is True
|
|
||||||
|
|
||||||
def test_enrolled_but_config_deleted_is_not_a_mismatch(self, temp_test_dir, mock_logger):
|
|
||||||
reg_path = temp_test_dir / "registry.json"
|
|
||||||
project = temp_test_dir / "myproject"
|
|
||||||
project.mkdir()
|
|
||||||
(project / ".aipass").mkdir()
|
|
||||||
hooks_file = project / ".aipass" / "hooks.json"
|
|
||||||
hooks_file.write_text('{"hooks_enabled": true}')
|
|
||||||
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
|
||||||
enroll(str(project))
|
|
||||||
hooks_file.unlink()
|
|
||||||
assert is_hash_mismatch(str(project)) is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestTrustBreakBanner:
|
|
||||||
"""Tests for loader.trust_break_banner() — the loud, config-independent signal."""
|
|
||||||
|
|
||||||
def test_no_hooks_json_anywhere_is_none(self, temp_test_dir, mock_logger):
|
|
||||||
reg_path = temp_test_dir / "registry.json"
|
|
||||||
empty_dir = temp_test_dir / "no_project"
|
|
||||||
empty_dir.mkdir()
|
|
||||||
with (
|
|
||||||
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
|
|
||||||
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=empty_dir),
|
|
||||||
patch("aipass.hooks.apps.handlers.config.loader.Path.home", return_value=temp_test_dir),
|
|
||||||
):
|
|
||||||
assert trust_break_banner() is None
|
|
||||||
|
|
||||||
def test_trusted_project_is_none(self, temp_test_dir, mock_logger):
|
|
||||||
reg_path = temp_test_dir / "registry.json"
|
|
||||||
project = temp_test_dir / "trusted_project"
|
|
||||||
project.mkdir()
|
|
||||||
(project / ".aipass").mkdir()
|
|
||||||
(project / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
|
|
||||||
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
|
||||||
enroll(str(project))
|
|
||||||
with (
|
|
||||||
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
|
|
||||||
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=project),
|
|
||||||
):
|
|
||||||
assert trust_break_banner() is None
|
|
||||||
|
|
||||||
def test_never_enrolled_is_none_not_a_break(self, temp_test_dir, mock_logger):
|
|
||||||
reg_path = temp_test_dir / "registry.json"
|
|
||||||
reg_path.write_text('{"version": 1, "projects": {}}')
|
|
||||||
project = temp_test_dir / "fresh_project"
|
|
||||||
project.mkdir()
|
|
||||||
(project / ".aipass").mkdir()
|
|
||||||
(project / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
|
|
||||||
with (
|
|
||||||
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
|
|
||||||
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=project),
|
|
||||||
):
|
|
||||||
assert trust_break_banner() is None
|
|
||||||
|
|
||||||
def test_hash_mismatch_returns_loud_banner(self, temp_test_dir, mock_logger):
|
|
||||||
reg_path = temp_test_dir / "registry.json"
|
|
||||||
project = temp_test_dir / "tampered_project"
|
|
||||||
project.mkdir()
|
|
||||||
(project / ".aipass").mkdir()
|
|
||||||
hooks_file = project / ".aipass" / "hooks.json"
|
|
||||||
hooks_file.write_text('{"hooks_enabled": true}')
|
|
||||||
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
|
||||||
enroll(str(project))
|
|
||||||
hooks_file.write_text('{"hooks_enabled": true, "tampered": true}')
|
|
||||||
with (
|
|
||||||
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
|
|
||||||
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=project),
|
|
||||||
):
|
|
||||||
banner = trust_break_banner()
|
|
||||||
assert banner is not None
|
|
||||||
assert "TRUST BREAK" in banner
|
|
||||||
assert "trust enroll" in banner
|
|
||||||
|
|
||||||
|
|
||||||
class TestBootstrap:
|
class TestBootstrap:
|
||||||
"""Tests for bootstrap() — enrolls ONLY AIPASS_HOME."""
|
"""Tests for bootstrap() — enrolls ONLY AIPASS_HOME."""
|
||||||
|
|
||||||
|
|||||||
@@ -759,6 +759,31 @@
|
|||||||
"standard": "meta",
|
"standard": "meta",
|
||||||
"reason": "Test file — META block present at lines 1-7; hook false-positive on test file format."
|
"reason": "Test file — META block present at lines 1-7; hook false-positive on test file format."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"file": "tools/migrate_entries.py",
|
||||||
|
"standard": "architecture",
|
||||||
|
"reason": "Standalone utility script in tools/ — intentionally outside 3-layer apps/ structure."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tools/strip_trinity_limits.py",
|
||||||
|
"standard": "architecture",
|
||||||
|
"reason": "One-time migration script in tools/ — intentionally outside 3-layer apps/ structure."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tools/strip_trinity_limits.py",
|
||||||
|
"standard": "log_visibility",
|
||||||
|
"reason": "Standalone script — cannot import prax logger. Uses stdlib logging."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tools/migrate_entries.py",
|
||||||
|
"standard": "silent_catch",
|
||||||
|
"reason": "Standalone script — cannot import prax logger. Errors reported via stderr print + result dict."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tools/migrate_entries.py",
|
||||||
|
"standard": "debug_print",
|
||||||
|
"reason": "Standalone script — print(stderr) is the error reporting mechanism. No prax available."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"file": "tests/test_detector.py",
|
"file": "tests/test_detector.py",
|
||||||
"standard": "architecture",
|
"standard": "architecture",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"created": "2026-03-07T22:43:24.315842",
|
"created": "2026-03-07T22:43:24.315842",
|
||||||
"description": "Standards bypass configuration for prax branch",
|
"description": "Standards bypass configuration for prax branch",
|
||||||
"last_updated": "2026-07-21T00:00:00.000000"
|
"last_updated": "2026-04-26T00:06:19.911705"
|
||||||
},
|
},
|
||||||
"bypass": [
|
"bypass": [
|
||||||
{
|
{
|
||||||
@@ -511,12 +511,6 @@
|
|||||||
"file": "tests/test_logging.py",
|
"file": "tests/test_logging.py",
|
||||||
"standard": "log_structure",
|
"standard": "log_structure",
|
||||||
"reason": "Test assertions reference _AIPASS_PKG_ROOT which resolves to /home/ paths at runtime. Not log config \u2014 test path constants."
|
"reason": "Test assertions reference _AIPASS_PKG_ROOT which resolves to /home/ paths at runtime. Not log config \u2014 test path constants."
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "apps/handlers/monitoring/commons_feed.py",
|
|
||||||
"standard": "cli",
|
|
||||||
"pattern": "console.print(",
|
|
||||||
"reason": "This IS the display handler for the commons live feed (DPLAN-0257). commons_feed.py is a self-contained view \u2014 polling, formatting, and terminal rendering together, same shape as unified_stream.py. console.print() is its designated purpose, not a violation."
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"notes": {
|
"notes": {
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import inspect
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
@@ -61,11 +60,6 @@ def _find_repo_root() -> Path:
|
|||||||
return Path.cwd()
|
return Path.cwd()
|
||||||
|
|
||||||
|
|
||||||
def _is_pytest_session() -> bool:
|
|
||||||
"""Detect pytest session via sys.modules (immune to patch.dict(os.environ, clear=True))."""
|
|
||||||
return "_pytest" in sys.modules
|
|
||||||
|
|
||||||
|
|
||||||
# Lazy SYSTEM_LOGS_DIR — resolved on first access, not at import time.
|
# Lazy SYSTEM_LOGS_DIR — resolved on first access, not at import time.
|
||||||
# Callers should use get_system_logs_dir() for guaranteed initialization.
|
# Callers should use get_system_logs_dir() for guaranteed initialization.
|
||||||
_system_logs_dir_cache: Path | None = None
|
_system_logs_dir_cache: Path | None = None
|
||||||
@@ -82,7 +76,7 @@ def get_system_logs_dir() -> Path:
|
|||||||
p = Path(test_log_dir) / "system"
|
p = Path(test_log_dir) / "system"
|
||||||
p.mkdir(parents=True, exist_ok=True)
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
return p
|
return p
|
||||||
if os.environ.get("PYTEST_CURRENT_TEST") or _is_pytest_session():
|
if os.environ.get("PYTEST_CURRENT_TEST"):
|
||||||
p = Path(tempfile.gettempdir()) / "aipass_test_logs" / "system"
|
p = Path(tempfile.gettempdir()) / "aipass_test_logs" / "system"
|
||||||
p.mkdir(parents=True, exist_ok=True)
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
return p
|
return p
|
||||||
@@ -144,7 +138,7 @@ def get_module_logs_dir(module_name: Optional[str] = None) -> Path:
|
|||||||
p = Path(test_log_dir) / module_name
|
p = Path(test_log_dir) / module_name
|
||||||
p.mkdir(parents=True, exist_ok=True)
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
return p
|
return p
|
||||||
if os.environ.get("PYTEST_CURRENT_TEST") or _is_pytest_session():
|
if os.environ.get("PYTEST_CURRENT_TEST"):
|
||||||
p = Path(tempfile.gettempdir()) / "aipass_test_logs" / module_name
|
p = Path(tempfile.gettempdir()) / "aipass_test_logs" / module_name
|
||||||
p.mkdir(parents=True, exist_ok=True)
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
return p
|
return p
|
||||||
|
|||||||
@@ -1,474 +0,0 @@
|
|||||||
# =================== AIPass ====================
|
|
||||||
# Name: commons_feed.py
|
|
||||||
# Description: Commons Live Social Feed (read-only monitor view)
|
|
||||||
# Version: 1.0.0
|
|
||||||
# Created: 2026-07-21
|
|
||||||
# Modified: 2026-07-21
|
|
||||||
# =============================================
|
|
||||||
|
|
||||||
"""
|
|
||||||
PRAX Monitor — Commons Live Feed
|
|
||||||
|
|
||||||
`drone @prax monitor run commons` streams The Commons' social chatter
|
|
||||||
(posts, comments, votes, reactions) room-tagged, monitor-style, instead of
|
|
||||||
tailing commons' technical prax logs (still reachable via `--logs`).
|
|
||||||
|
|
||||||
Read-only by construction: connects to commons.db via a `mode=ro` URI, so
|
|
||||||
the feed can never write — commons stays the only writer. Polls with per-table
|
|
||||||
id cursors, showing only genuinely new rows each cycle.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
|
||||||
from aipass.cli.apps.modules import console, header, error, warning
|
|
||||||
from aipass.prax.apps.handlers.json import json_handler
|
|
||||||
from aipass.prax.apps.handlers.monitoring.event_queue import MonitoringEvent
|
|
||||||
from aipass.prax.apps.handlers.monitoring.telegram_relay import (
|
|
||||||
init_relay,
|
|
||||||
relay_event,
|
|
||||||
stop_relay,
|
|
||||||
is_relay_enabled_by_env,
|
|
||||||
)
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# CONFIGURATION
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
POLL_INTERVAL = 1.5
|
|
||||||
BACKFILL_LIMIT = 10
|
|
||||||
|
|
||||||
_PRAX_ROOT = Path(__file__).resolve().parents[3] # monitoring/ -> handlers/ -> apps/ -> prax/
|
|
||||||
_ECOSYSTEM_ROOT = _PRAX_ROOT.parent
|
|
||||||
|
|
||||||
CURSOR_TABLES = ("posts", "comments", "votes", "reactions")
|
|
||||||
|
|
||||||
_CURSOR_TABLE_FOR_KIND = {
|
|
||||||
"post": "posts",
|
|
||||||
"comment": "comments",
|
|
||||||
"vote": "votes",
|
|
||||||
"reaction": "reactions",
|
|
||||||
}
|
|
||||||
|
|
||||||
MOOD_COLORS = {
|
|
||||||
"welcoming": "green",
|
|
||||||
"focused": "cyan",
|
|
||||||
"relaxed": "yellow",
|
|
||||||
"formal": "blue",
|
|
||||||
"creative": "magenta",
|
|
||||||
"neutral": "white",
|
|
||||||
}
|
|
||||||
DEFAULT_ROOM_COLOR = "white"
|
|
||||||
ROOM_LABEL_WIDTH = 18
|
|
||||||
|
|
||||||
_stop_event = threading.Event()
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# DB ACCESS (read-only)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def _get_commons_db_path() -> Path:
|
|
||||||
"""Resolve commons.db — sibling branch under the same ecosystem root.
|
|
||||||
|
|
||||||
AIPASS_COMMONS_DB_PATH overrides for tests, mirroring AIPASS_TEST_LOG_DIR.
|
|
||||||
"""
|
|
||||||
override = os.environ.get("AIPASS_COMMONS_DB_PATH")
|
|
||||||
if override:
|
|
||||||
return Path(override)
|
|
||||||
return _ECOSYSTEM_ROOT / "commons" / "commons.db"
|
|
||||||
|
|
||||||
|
|
||||||
def connect_readonly(db_path: Path) -> sqlite3.Connection:
|
|
||||||
"""Open commons.db strictly read-only via a mode=ro URI — writes always fail."""
|
|
||||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, check_same_thread=False)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
return conn
|
|
||||||
|
|
||||||
|
|
||||||
def _load_room_moods(conn: sqlite3.Connection) -> Dict[str, str]:
|
|
||||||
"""Load room name -> mood, for cheap color tagging."""
|
|
||||||
rows = conn.execute("SELECT name, mood FROM rooms").fetchall()
|
|
||||||
return {row["name"]: row["mood"] for row in rows}
|
|
||||||
|
|
||||||
|
|
||||||
def initial_cursors(conn: sqlite3.Connection) -> Dict[str, int]:
|
|
||||||
"""Snapshot the current max id per event table — the poll starting line."""
|
|
||||||
cursors = {}
|
|
||||||
for table in CURSOR_TABLES:
|
|
||||||
row = conn.execute(f"SELECT COALESCE(MAX(id), 0) AS m FROM {table}").fetchone()
|
|
||||||
cursors[table] = row["m"]
|
|
||||||
return cursors
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_new_posts(conn: sqlite3.Connection, since_id: int) -> List[dict]:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT id, room_name, author, title, content, created_at FROM posts WHERE id > ? ORDER BY id ASC",
|
|
||||||
(since_id,),
|
|
||||||
).fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_new_comments(conn: sqlite3.Connection, since_id: int) -> List[dict]:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT c.id, c.post_id, c.parent_id, c.author, c.content, c.created_at, "
|
|
||||||
"p.room_name AS room_name, p.author AS post_author "
|
|
||||||
"FROM comments c JOIN posts p ON c.post_id = p.id "
|
|
||||||
"WHERE c.id > ? ORDER BY c.id ASC",
|
|
||||||
(since_id,),
|
|
||||||
).fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_new_votes(conn: sqlite3.Connection, since_id: int) -> List[dict]:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT v.id, v.agent_name, v.target_id, v.target_type, v.direction, v.created_at, "
|
|
||||||
"COALESCE(p1.room_name, p2.room_name) AS room_name "
|
|
||||||
"FROM votes v "
|
|
||||||
"LEFT JOIN posts p1 ON v.target_type = 'post' AND v.target_id = p1.id "
|
|
||||||
"LEFT JOIN comments c ON v.target_type = 'comment' AND v.target_id = c.id "
|
|
||||||
"LEFT JOIN posts p2 ON c.post_id = p2.id "
|
|
||||||
"WHERE v.id > ? ORDER BY v.id ASC",
|
|
||||||
(since_id,),
|
|
||||||
).fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_new_reactions(conn: sqlite3.Connection, since_id: int) -> List[dict]:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT r.id, r.agent_name, r.post_id, r.comment_id, r.reaction, r.created_at, "
|
|
||||||
"COALESCE(p1.room_name, p2.room_name) AS room_name "
|
|
||||||
"FROM reactions r "
|
|
||||||
"LEFT JOIN posts p1 ON r.post_id = p1.id "
|
|
||||||
"LEFT JOIN comments c ON r.comment_id = c.id "
|
|
||||||
"LEFT JOIN posts p2 ON c.post_id = p2.id "
|
|
||||||
"WHERE r.id > ? ORDER BY r.id ASC",
|
|
||||||
(since_id,),
|
|
||||||
).fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
_NEW_FETCHERS = {
|
|
||||||
"post": _fetch_new_posts,
|
|
||||||
"comment": _fetch_new_comments,
|
|
||||||
"vote": _fetch_new_votes,
|
|
||||||
"reaction": _fetch_new_reactions,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_new_events(conn: sqlite3.Connection, cursors: Dict[str, int]) -> Tuple[List[dict], Dict[str, int]]:
|
|
||||||
"""Fetch rows newer than each cursor, tag with kind, return sorted events + advanced cursors."""
|
|
||||||
events: List[dict] = []
|
|
||||||
new_cursors = dict(cursors)
|
|
||||||
|
|
||||||
for kind, table in _CURSOR_TABLE_FOR_KIND.items():
|
|
||||||
rows = _NEW_FETCHERS[kind](conn, cursors[table])
|
|
||||||
if rows:
|
|
||||||
events.extend({**row, "kind": kind} for row in rows)
|
|
||||||
new_cursors[table] = rows[-1]["id"]
|
|
||||||
|
|
||||||
events.sort(key=lambda e: (e["created_at"], e["id"]))
|
|
||||||
return events, new_cursors
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_backfill(conn: sqlite3.Connection, cursors: Dict[str, int], limit: int = BACKFILL_LIMIT) -> List[dict]:
|
|
||||||
"""Fetch the last `limit` events at/under the given cursors — startup context."""
|
|
||||||
events: List[dict] = []
|
|
||||||
queries = {
|
|
||||||
"post": "SELECT id, room_name, author, title, content, created_at FROM posts "
|
|
||||||
"WHERE id <= ? ORDER BY id DESC LIMIT ?",
|
|
||||||
"comment": "SELECT c.id, c.post_id, c.parent_id, c.author, c.content, c.created_at, "
|
|
||||||
"p.room_name AS room_name, p.author AS post_author FROM comments c "
|
|
||||||
"JOIN posts p ON c.post_id = p.id WHERE c.id <= ? ORDER BY c.id DESC LIMIT ?",
|
|
||||||
"vote": "SELECT v.id, v.agent_name, v.target_id, v.target_type, v.direction, v.created_at, "
|
|
||||||
"COALESCE(p1.room_name, p2.room_name) AS room_name FROM votes v "
|
|
||||||
"LEFT JOIN posts p1 ON v.target_type = 'post' AND v.target_id = p1.id "
|
|
||||||
"LEFT JOIN comments c ON v.target_type = 'comment' AND v.target_id = c.id "
|
|
||||||
"LEFT JOIN posts p2 ON c.post_id = p2.id WHERE v.id <= ? ORDER BY v.id DESC LIMIT ?",
|
|
||||||
"reaction": "SELECT r.id, r.agent_name, r.post_id, r.comment_id, r.reaction, r.created_at, "
|
|
||||||
"COALESCE(p1.room_name, p2.room_name) AS room_name FROM reactions r "
|
|
||||||
"LEFT JOIN posts p1 ON r.post_id = p1.id "
|
|
||||||
"LEFT JOIN comments c ON r.comment_id = c.id "
|
|
||||||
"LEFT JOIN posts p2 ON c.post_id = p2.id WHERE r.id <= ? ORDER BY r.id DESC LIMIT ?",
|
|
||||||
}
|
|
||||||
|
|
||||||
for kind, sql in queries.items():
|
|
||||||
table = _CURSOR_TABLE_FOR_KIND[kind]
|
|
||||||
rows = conn.execute(sql, (cursors[table], limit)).fetchall()
|
|
||||||
events.extend({**dict(row), "kind": kind} for row in rows)
|
|
||||||
|
|
||||||
events.sort(key=lambda e: (e["created_at"], e["id"]))
|
|
||||||
return events[-limit:]
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# DISPLAY
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def _snippet(text: Optional[str], length: int = 100) -> str:
|
|
||||||
"""Collapse whitespace and truncate to ~length chars with an ellipsis."""
|
|
||||||
if not text:
|
|
||||||
return ""
|
|
||||||
collapsed = " ".join(text.split())
|
|
||||||
if len(collapsed) <= length:
|
|
||||||
return collapsed
|
|
||||||
return collapsed[: length - 1].rstrip() + "…"
|
|
||||||
|
|
||||||
|
|
||||||
def event_room(event: dict) -> str:
|
|
||||||
"""Room name for an event, or 'commons' when it can't be resolved (orphaned row)."""
|
|
||||||
return event.get("room_name") or "commons"
|
|
||||||
|
|
||||||
|
|
||||||
def format_event(event: dict) -> str:
|
|
||||||
"""Plain-text (no Rich markup) description of an event — shared by console + relay."""
|
|
||||||
kind = event["kind"]
|
|
||||||
|
|
||||||
if kind == "post":
|
|
||||||
return f'{event["author"]} posted: "{event["title"]}" — {_snippet(event["content"])}'
|
|
||||||
if kind == "comment":
|
|
||||||
target = event.get("post_author") or "?"
|
|
||||||
return f"{event['author']} replied to {target}: {_snippet(event['content'])}"
|
|
||||||
if kind == "vote":
|
|
||||||
direction = "up" if (event.get("direction") or 0) > 0 else "down"
|
|
||||||
return f"{event['agent_name']} voted {direction} on {event['target_type']} #{event['target_id']}"
|
|
||||||
if kind == "reaction":
|
|
||||||
target_kind = "post" if event.get("post_id") else "comment"
|
|
||||||
reaction = event.get("reaction") or ""
|
|
||||||
return f"{event['agent_name']} reacted {reaction} to a {target_kind}"
|
|
||||||
return str(event)
|
|
||||||
|
|
||||||
|
|
||||||
def _print_feed_event(room: str, message: str, mood: Optional[str]) -> None:
|
|
||||||
"""Print one feed line — room tag colored by mood, monitor-style timestamp."""
|
|
||||||
ts = datetime.now().strftime("%H:%M:%S")
|
|
||||||
color = MOOD_COLORS.get(mood or "", DEFAULT_ROOM_COLOR)
|
|
||||||
label = f"[{room}]"
|
|
||||||
console.print(f"[dim]{ts}[/dim] [{color}]{label:<{ROOM_LABEL_WIDTH}}[/{color}] {message}")
|
|
||||||
|
|
||||||
|
|
||||||
def _emit(event: dict, room_moods: Dict[str, str]) -> None:
|
|
||||||
"""Render an event to the console and relay it (relay is a no-op when inactive)."""
|
|
||||||
room = event_room(event)
|
|
||||||
message = format_event(event)
|
|
||||||
_print_feed_event(room, message, room_moods.get(room))
|
|
||||||
|
|
||||||
relay_event(
|
|
||||||
MonitoringEvent(
|
|
||||||
priority=3,
|
|
||||||
event_type="log",
|
|
||||||
branch=room.upper(),
|
|
||||||
message=message,
|
|
||||||
level="info",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# FEED STATE + INTERACTIVE COMMANDS
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class FeedState:
|
|
||||||
"""Tracks rooms/agents/events seen and the active room filter."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.rooms_seen: set = set()
|
|
||||||
self.agents_seen: set = set()
|
|
||||||
self.events_count: int = 0
|
|
||||||
self.room_filter: Optional[set] = None
|
|
||||||
|
|
||||||
def record(self, event: dict) -> None:
|
|
||||||
"""Count an event toward status stats — independent of the display filter."""
|
|
||||||
self.rooms_seen.add(event_room(event))
|
|
||||||
agent = event.get("author") or event.get("agent_name")
|
|
||||||
if agent:
|
|
||||||
self.agents_seen.add(agent)
|
|
||||||
self.events_count += 1
|
|
||||||
|
|
||||||
def visible(self, event: dict) -> bool:
|
|
||||||
"""Whether the active room filter allows this event to display."""
|
|
||||||
if not self.room_filter:
|
|
||||||
return True
|
|
||||||
return event_room(event) in self.room_filter
|
|
||||||
|
|
||||||
|
|
||||||
def _feed_help_text() -> str:
|
|
||||||
return """
|
|
||||||
Available Commands:
|
|
||||||
filter <room>[,<room>...] - Show only these rooms
|
|
||||||
filter clear - Remove the room filter
|
|
||||||
status - Show feed stats (rooms/agents/events)
|
|
||||||
help - Show this help
|
|
||||||
quit/exit - Stop the feed
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _print_feed_status(state: FeedState) -> None:
|
|
||||||
console.print()
|
|
||||||
console.print("[bold cyan]Commons Feed Status:[/bold cyan]")
|
|
||||||
console.print(f" [green]Rooms seen:[/green] {len(state.rooms_seen)}")
|
|
||||||
console.print(f" [green]Agents seen:[/green] {len(state.agents_seen)}")
|
|
||||||
console.print(f" [green]Events streamed:[/green] {state.events_count}")
|
|
||||||
if state.room_filter:
|
|
||||||
console.print(f" [yellow]Filter:[/yellow] {', '.join(sorted(state.room_filter))}")
|
|
||||||
console.print()
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_feed_cmd(cmd: str, cmd_args: List[str], state: FeedState) -> None:
|
|
||||||
"""Dispatch an interactive feed command."""
|
|
||||||
if cmd == "help":
|
|
||||||
console.print(_feed_help_text())
|
|
||||||
return
|
|
||||||
if cmd == "status":
|
|
||||||
_print_feed_status(state)
|
|
||||||
return
|
|
||||||
if cmd == "filter":
|
|
||||||
if not cmd_args or cmd_args[0] in ("clear", "all"):
|
|
||||||
state.room_filter = None
|
|
||||||
warning("Filter cleared — showing all rooms")
|
|
||||||
return
|
|
||||||
rooms = {room.strip() for token in cmd_args for room in token.split(",") if room.strip()}
|
|
||||||
state.room_filter = rooms
|
|
||||||
warning(f"Filtering to rooms: {', '.join(sorted(rooms))}")
|
|
||||||
return
|
|
||||||
error(f"Unknown command: {cmd}")
|
|
||||||
console.print("[dim]Type 'help' for available commands[/dim]")
|
|
||||||
|
|
||||||
|
|
||||||
def _interactive_feed_loop(state: FeedState) -> None:
|
|
||||||
"""Interactive command loop for the feed — mirrors the branch monitor's loop shape."""
|
|
||||||
if not sys.stdin.isatty():
|
|
||||||
logger.info("[commons_feed] No TTY detected - passive mode (Ctrl+C to stop)")
|
|
||||||
try:
|
|
||||||
while not _stop_event.is_set():
|
|
||||||
time.sleep(0.5)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
logger.info("[commons_feed] Stopped by user (passive mode)")
|
|
||||||
console.print("\n[yellow]Stopping feed...[/yellow]")
|
|
||||||
return
|
|
||||||
|
|
||||||
from aipass.prax.apps.handlers.monitoring.interactive_filter import parse_command
|
|
||||||
|
|
||||||
while not _stop_event.is_set():
|
|
||||||
try:
|
|
||||||
user_input = input().strip()
|
|
||||||
if not user_input:
|
|
||||||
continue
|
|
||||||
|
|
||||||
cmd, cmd_args = parse_command(user_input)
|
|
||||||
if not cmd:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if cmd in ("quit", "exit", "q"):
|
|
||||||
console.print("[yellow]Stopping feed...[/yellow]")
|
|
||||||
break
|
|
||||||
|
|
||||||
_handle_feed_cmd(cmd, cmd_args, state)
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
logger.info("[commons_feed] Stopped by user")
|
|
||||||
console.print("\n[yellow]Stopping feed...[/yellow]")
|
|
||||||
break
|
|
||||||
except EOFError:
|
|
||||||
logger.info("[commons_feed] EOF received, stopping interactive loop")
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# POLL WORKER
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def _poll_worker(
|
|
||||||
conn: sqlite3.Connection, cursors: Dict[str, int], state: FeedState, room_moods: Dict[str, str]
|
|
||||||
) -> None:
|
|
||||||
"""Background thread: poll for new rows, display + relay the visible ones."""
|
|
||||||
while not _stop_event.is_set():
|
|
||||||
try:
|
|
||||||
events, new_cursors = fetch_new_events(conn, cursors)
|
|
||||||
cursors.update(new_cursors)
|
|
||||||
for event in events:
|
|
||||||
state.record(event)
|
|
||||||
if state.visible(event):
|
|
||||||
_emit(event, room_moods)
|
|
||||||
except sqlite3.Error as exc:
|
|
||||||
logger.warning("[commons_feed] Poll error: %s", exc)
|
|
||||||
_stop_event.wait(POLL_INTERVAL)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# ENTRY POINT
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def run_commons_feed(args: List[str], relay_config: Optional[dict] = None) -> bool:
|
|
||||||
"""Launch the commons live social feed — read-only, room-tagged, monitor-style.
|
|
||||||
|
|
||||||
relay_config is loaded by monitor.py (the module layer, which owns the
|
|
||||||
cross-branch @api secrets lookup) and passed in — this handler never
|
|
||||||
reaches outside its own branch.
|
|
||||||
"""
|
|
||||||
_stop_event.clear()
|
|
||||||
json_handler.log_operation("commons_feed_started", {"args": args})
|
|
||||||
|
|
||||||
relay_enabled = "--relay" in args or is_relay_enabled_by_env()
|
|
||||||
init_relay(relay_enabled, relay_config if relay_enabled else None)
|
|
||||||
if relay_enabled:
|
|
||||||
console.print("[green]monitor → Telegram relay ON (prax_monitor)[/green]")
|
|
||||||
|
|
||||||
db_path = _get_commons_db_path()
|
|
||||||
if not db_path.exists():
|
|
||||||
error(f"commons.db not found at {db_path}")
|
|
||||||
stop_relay()
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn = connect_readonly(db_path)
|
|
||||||
cursors = initial_cursors(conn)
|
|
||||||
except sqlite3.Error as exc:
|
|
||||||
logger.warning("[commons_feed] Could not open commons.db read-only: %s", exc)
|
|
||||||
error(f"Could not open commons.db read-only: {exc}")
|
|
||||||
stop_relay()
|
|
||||||
return True
|
|
||||||
|
|
||||||
state = FeedState()
|
|
||||||
room_moods = _load_room_moods(conn)
|
|
||||||
|
|
||||||
console.print()
|
|
||||||
header("PRAX Mission Control - Commons Live Feed")
|
|
||||||
console.print()
|
|
||||||
console.print("[green]Live — read-only feed of The Commons (posts, comments, votes, reactions)[/green]")
|
|
||||||
is_tty = sys.stdin.isatty()
|
|
||||||
console.print("[dim]Type 'help' for commands[/dim]" if is_tty else "[dim]Ctrl+C to stop[/dim]")
|
|
||||||
console.print()
|
|
||||||
|
|
||||||
for event in fetch_backfill(conn, cursors):
|
|
||||||
state.record(event)
|
|
||||||
_emit(event, room_moods)
|
|
||||||
|
|
||||||
poll_thread = threading.Thread(target=_poll_worker, args=(conn, cursors, state, room_moods), daemon=True)
|
|
||||||
poll_thread.start()
|
|
||||||
|
|
||||||
try:
|
|
||||||
_interactive_feed_loop(state)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
logger.info("[commons_feed] KeyboardInterrupt escaped interactive loop")
|
|
||||||
console.print("\n[yellow]Feed stopped.[/yellow]")
|
|
||||||
|
|
||||||
_stop_event.set()
|
|
||||||
poll_thread.join(timeout=POLL_INTERVAL + 2.0)
|
|
||||||
stop_relay()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# sys.exit(0) prevents drone's post-execution json_handler from running
|
|
||||||
# after the feed exits, avoiding a json.load crash on Ctrl+C (matches monitor.py).
|
|
||||||
sys.exit(0)
|
|
||||||
@@ -108,14 +108,6 @@ def print_help():
|
|||||||
"drone @prax monitor run [branches]",
|
"drone @prax monitor run [branches]",
|
||||||
"Monitor specific branches (comma-separated)\n Example: drone @prax monitor run seedgo,cli,flow",
|
"Monitor specific branches (comma-separated)\n Example: drone @prax monitor run seedgo,cli,flow",
|
||||||
),
|
),
|
||||||
(
|
|
||||||
"drone @prax monitor run commons",
|
|
||||||
"Live social feed of The Commons (posts, comments, votes, reactions), read-only",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"drone @prax monitor run commons --logs",
|
|
||||||
"Old behavior — tail commons branch's technical prax logs instead of the feed",
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
"drone @prax monitor run --relay",
|
"drone @prax monitor run --relay",
|
||||||
"Enable Telegram relay (mirrors feed to prax_monitor bot)"
|
"Enable Telegram relay (mirrors feed to prax_monitor bot)"
|
||||||
@@ -161,7 +153,7 @@ def handle_command(command: str, args: List[str]) -> bool:
|
|||||||
# Subcommand routing
|
# Subcommand routing
|
||||||
subcmd = args[0]
|
subcmd = args[0]
|
||||||
if subcmd == "run":
|
if subcmd == "run":
|
||||||
return _dispatch_run(args[1:])
|
return _run_monitor(args[1:])
|
||||||
|
|
||||||
# Unknown subcommand
|
# Unknown subcommand
|
||||||
error(f"Unknown monitor subcommand: {subcmd}")
|
error(f"Unknown monitor subcommand: {subcmd}")
|
||||||
@@ -169,28 +161,6 @@ def handle_command(command: str, args: List[str]) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _dispatch_run(run_args: List[str]) -> bool:
|
|
||||||
"""Route 'monitor run' — a bare 'commons' target opens the live social feed.
|
|
||||||
|
|
||||||
'commons --logs' escapes back to the branch-log tail, and mixed lists
|
|
||||||
(e.g. 'seedgo,commons') keep treating commons as a branch (feed is
|
|
||||||
standalone-only in v1).
|
|
||||||
"""
|
|
||||||
positional = [a for a in run_args if not a.startswith("--")]
|
|
||||||
target = positional[0] if positional else None
|
|
||||||
logs_escape = "--logs" in run_args
|
|
||||||
|
|
||||||
if target == "commons" and not logs_escape:
|
|
||||||
from aipass.prax.apps.handlers.monitoring.commons_feed import run_commons_feed
|
|
||||||
|
|
||||||
feed_args = [a for a in run_args if a != target]
|
|
||||||
relay_enabled = "--relay" in feed_args or is_relay_enabled_by_env()
|
|
||||||
relay_config = _load_relay_config() if relay_enabled else None
|
|
||||||
return run_commons_feed(feed_args, relay_config=relay_config)
|
|
||||||
|
|
||||||
return _run_monitor([a for a in run_args if a != "--logs"])
|
|
||||||
|
|
||||||
|
|
||||||
def _load_relay_config() -> Optional[dict]:
|
def _load_relay_config() -> Optional[dict]:
|
||||||
"""Load Telegram relay config from @api secrets."""
|
"""Load Telegram relay config from @api secrets."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,366 +0,0 @@
|
|||||||
# =================== 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")
|
|
||||||
@@ -50,8 +50,6 @@ def _fresh_import_load(monkeypatch, tmp_path):
|
|||||||
# Clear test log redirects so tests exercise real path resolution
|
# Clear test log redirects so tests exercise real path resolution
|
||||||
monkeypatch.delenv("AIPASS_TEST_LOG_DIR", raising=False)
|
monkeypatch.delenv("AIPASS_TEST_LOG_DIR", raising=False)
|
||||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||||
# Suppress sys.modules-based pytest detection so tests can verify prod paths
|
|
||||||
monkeypatch.setattr(load_mod, "_is_pytest_session", lambda: False)
|
|
||||||
|
|
||||||
return load_mod
|
return load_mod
|
||||||
|
|
||||||
|
|||||||
@@ -116,78 +116,6 @@ class TestHandleCommand:
|
|||||||
mod.error.assert_called()
|
mod.error.assert_called()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _dispatch_run routing tests (DPLAN-0257 — commons feed vs. branch log tail)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_COMMONS_FEED_TARGET = "aipass.prax.apps.handlers.monitoring.commons_feed.run_commons_feed"
|
|
||||||
|
|
||||||
|
|
||||||
class TestDispatchRun:
|
|
||||||
"""'run commons' opens the live social feed; --logs and mixed lists stay branch tail."""
|
|
||||||
|
|
||||||
def test_bare_commons_routes_to_feed(self):
|
|
||||||
"""A standalone 'commons' target opens the feed, not the branch monitor."""
|
|
||||||
mod = _import_monitor()
|
|
||||||
mod.is_relay_enabled_by_env.return_value = False
|
|
||||||
with (
|
|
||||||
patch(_COMMONS_FEED_TARGET, return_value=True) as mock_feed,
|
|
||||||
patch.object(mod, "_run_monitor") as mock_branch,
|
|
||||||
):
|
|
||||||
result = mod.handle_command("monitor", ["run", "commons"])
|
|
||||||
assert result is True
|
|
||||||
mock_feed.assert_called_once_with([], relay_config=None)
|
|
||||||
mock_branch.assert_not_called()
|
|
||||||
|
|
||||||
def test_commons_logs_escape_routes_to_branch_tail(self):
|
|
||||||
"""'commons --logs' is the escape hatch back to the old branch-log tail."""
|
|
||||||
mod = _import_monitor()
|
|
||||||
with (
|
|
||||||
patch(_COMMONS_FEED_TARGET) as mock_feed,
|
|
||||||
patch.object(mod, "_run_monitor", return_value=True) as mock_branch,
|
|
||||||
):
|
|
||||||
result = mod.handle_command("monitor", ["run", "commons", "--logs"])
|
|
||||||
assert result is True
|
|
||||||
mock_branch.assert_called_once_with(["commons"])
|
|
||||||
mock_feed.assert_not_called()
|
|
||||||
|
|
||||||
def test_mixed_branch_list_with_commons_stays_branch_tail(self):
|
|
||||||
"""A mixed list (e.g. 'seedgo,commons') treats commons as a branch — feed is standalone-only."""
|
|
||||||
mod = _import_monitor()
|
|
||||||
with (
|
|
||||||
patch(_COMMONS_FEED_TARGET) as mock_feed,
|
|
||||||
patch.object(mod, "_run_monitor", return_value=True) as mock_branch,
|
|
||||||
):
|
|
||||||
result = mod.handle_command("monitor", ["run", "seedgo,commons"])
|
|
||||||
assert result is True
|
|
||||||
mock_branch.assert_called_once_with(["seedgo,commons"])
|
|
||||||
mock_feed.assert_not_called()
|
|
||||||
|
|
||||||
def test_commons_feed_loads_relay_config_when_relay_flag_set(self):
|
|
||||||
"""--relay on the feed path loads config via monitor.py's own _load_relay_config."""
|
|
||||||
mod = _import_monitor()
|
|
||||||
mod.is_relay_enabled_by_env.return_value = False
|
|
||||||
with (
|
|
||||||
patch(_COMMONS_FEED_TARGET, return_value=True) as mock_feed,
|
|
||||||
patch.object(mod, "_load_relay_config", return_value={"bot_token": "x", "chat_id": 1}) as mock_load,
|
|
||||||
):
|
|
||||||
mod.handle_command("monitor", ["run", "commons", "--relay"])
|
|
||||||
mock_load.assert_called_once()
|
|
||||||
mock_feed.assert_called_once_with(["--relay"], relay_config={"bot_token": "x", "chat_id": 1})
|
|
||||||
|
|
||||||
def test_commons_feed_no_relay_config_when_relay_disabled(self):
|
|
||||||
"""Without --relay (and no env flag), relay_config stays None."""
|
|
||||||
mod = _import_monitor()
|
|
||||||
mod.is_relay_enabled_by_env.return_value = False
|
|
||||||
with (
|
|
||||||
patch(_COMMONS_FEED_TARGET, return_value=True) as mock_feed,
|
|
||||||
patch.object(mod, "_load_relay_config") as mock_load,
|
|
||||||
):
|
|
||||||
mod.handle_command("monitor", ["run", "commons"])
|
|
||||||
mock_load.assert_not_called()
|
|
||||||
mock_feed.assert_called_once_with([], relay_config=None)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _get_watch_directories tests
|
# _get_watch_directories tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -195,6 +195,11 @@
|
|||||||
"standard": "deep_nesting",
|
"standard": "deep_nesting",
|
||||||
"reason": "scan() depth 6 — state machine tracking file types across standards with nested conditionals, inherent to triplet completeness detection"
|
"reason": "scan() depth 6 — state machine tracking file types across standards with nested conditionals, inherent to triplet completeness detection"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"file": "tools/triplet_scanner.py",
|
||||||
|
"standard": "deep_nesting",
|
||||||
|
"reason": "scan() depth 6 — state machine tracking file types across standards with nested conditionals, inherent to triplet completeness detection"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/aipass_standards/introspection_check.py",
|
"file": "apps/handlers/aipass_standards/introspection_check.py",
|
||||||
"standard": "deep_nesting",
|
"standard": "deep_nesting",
|
||||||
|
|||||||
@@ -120,9 +120,9 @@ seedgo/
|
|||||||
│ │ ├── branch_audit.py # Per-branch scoring engine
|
│ │ ├── branch_audit.py # Per-branch scoring engine
|
||||||
│ │ ├── discovery.py # Branch discovery (CWD-first registry)
|
│ │ ├── discovery.py # Branch discovery (CWD-first registry)
|
||||||
│ │ └── audit_display.py # Rich result formatting
|
│ │ └── audit_display.py # Rich result formatting
|
||||||
│ ├── bypass/ # Bypass + ignore systems
|
│ ├── bypass/ # Bypass system
|
||||||
│ │ ├── bypass_handler.py # .seedgo/bypass.json loader
|
│ │ ├── bypass_handler.py # .seedgo/bypass.json loader
|
||||||
│ │ └── ignore_handler.py # Audit ignore patterns + .seedgoignore engine
|
│ │ └── ignore_handler.py # .seedgo/ignore patterns
|
||||||
│ ├── config/ # Configuration handlers
|
│ ├── config/ # Configuration handlers
|
||||||
│ ├── diagnostics/ # Pyright integration + branch discovery
|
│ ├── diagnostics/ # Pyright integration + branch discovery
|
||||||
│ ├── json/ # JSON tracking (json_handler)
|
│ ├── json/ # JSON tracking (json_handler)
|
||||||
@@ -146,8 +146,6 @@ seedgo/
|
|||||||
|
|
||||||
**Bypass system:** `.seedgo/bypass.json` per branch. Each entry has file, standard, optional lines, and required reason. Checkers call `is_bypassed()` per violation. Bypass is intentional documented deviation, not ignoring.
|
**Bypass system:** `.seedgo/bypass.json` per branch. Each entry has file, standard, optional lines, and required reason. Checkers call `is_bypassed()` per violation. Bypass is intentional documented deviation, not ignoring.
|
||||||
|
|
||||||
**`.seedgoignore` (throwaway paths, no reason required):** Drop a `.seedgoignore` file into any directory to exclude matching files/dirs from scans, audits, and the per-file checklist — same gitignore-style patterns and per-directory nesting semantics as a real `.gitignore` (via `pathspec`). Scope is exactly that directory's subtree; a nested `.seedgoignore` adds further excludes on top of any ancestor's, it doesn't replace them. A global default (`tools/`) applies fleet-wide with zero setup, since every branch's `apps/tools/` is deliberate throwaway prototyping space — quick scripts for fast answers, not standards-compliant by design. Unlike bypass (documented exception to a specific standard on a specific file), `.seedgoignore` removes the file from consideration entirely and needs no reason. It does **not** touch diagnostics (ruff/pyright) — those keep running on ignored files so auto-fix still catches real errors while you write; only standards checks skip them. See `ignore_handler.load_ignore_entries()` / `is_seedgo_ignored()`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The 40 Standards
|
## The 40 Standards
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ from pathlib import Path
|
|||||||
from aipass.prax import logger
|
from aipass.prax import logger
|
||||||
from aipass.seedgo.apps.handlers.json import json_handler
|
from aipass.seedgo.apps.handlers.json import json_handler
|
||||||
from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
|
from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
|
||||||
from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored, load_ignore_entries
|
|
||||||
from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file
|
from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file
|
||||||
|
|
||||||
AUDIT_SCOPE = "branch_level"
|
AUDIT_SCOPE = "branch_level"
|
||||||
@@ -43,17 +42,12 @@ _SKIP_DIRS = SOURCE_SKIP_DIRS | {"tests", "json_templates"}
|
|||||||
# =============================================
|
# =============================================
|
||||||
|
|
||||||
|
|
||||||
def _should_skip(path: Path, branch_root: Path, ignore_entries: list) -> bool:
|
def _should_skip(path: Path) -> bool:
|
||||||
"""Check whether any parent directory component is in the skip set, file is
|
"""Check whether any parent directory component is in the skip set or file is disabled."""
|
||||||
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)
|
||||||
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, branch_root: Path, ignore_entries: list) -> list[Path]:
|
def _collect_scannable_files(apps_dir: Path) -> list[Path]:
|
||||||
"""
|
"""
|
||||||
Collect .py files from apps/modules/ and apps/handlers/ that should be
|
Collect .py files from apps/modules/ and apps/handlers/ that should be
|
||||||
checked for usage. Skips __init__.py, __pycache__, .archive, etc.
|
checked for usage. Skips __init__.py, __pycache__, .archive, etc.
|
||||||
@@ -66,20 +60,20 @@ def _collect_scannable_files(apps_dir: Path, branch_root: Path, ignore_entries:
|
|||||||
for py_file in subdir.rglob("*.py"):
|
for py_file in subdir.rglob("*.py"):
|
||||||
if py_file.name == "__init__.py":
|
if py_file.name == "__init__.py":
|
||||||
continue
|
continue
|
||||||
if _should_skip(py_file, branch_root, ignore_entries):
|
if _should_skip(py_file):
|
||||||
continue
|
continue
|
||||||
targets.append(py_file)
|
targets.append(py_file)
|
||||||
return sorted(targets)
|
return sorted(targets)
|
||||||
|
|
||||||
|
|
||||||
def _collect_source_text(apps_dir: Path, branch_root: Path, ignore_entries: list) -> str:
|
def _collect_source_text(apps_dir: Path) -> str:
|
||||||
"""
|
"""
|
||||||
Read ALL .py files under apps/ into a single string for searching.
|
Read ALL .py files under apps/ into a single string for searching.
|
||||||
This is the corpus we search for references.
|
This is the corpus we search for references.
|
||||||
"""
|
"""
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
for py_file in apps_dir.rglob("*.py"):
|
for py_file in apps_dir.rglob("*.py"):
|
||||||
if _should_skip(py_file, branch_root, ignore_entries):
|
if _should_skip(py_file):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
parts.append(py_file.read_text(encoding="utf-8", errors="ignore"))
|
parts.append(py_file.read_text(encoding="utf-8", errors="ignore"))
|
||||||
@@ -272,8 +266,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict:
|
|||||||
entry_point_name = branch_name
|
entry_point_name = branch_name
|
||||||
|
|
||||||
# Collect scannable files
|
# Collect scannable files
|
||||||
ignore_entries = load_ignore_entries(bp)
|
targets = _collect_scannable_files(apps_dir)
|
||||||
targets = _collect_scannable_files(apps_dir, bp, ignore_entries)
|
|
||||||
if not targets:
|
if not targets:
|
||||||
result = {
|
result = {
|
||||||
"passed": True,
|
"passed": True,
|
||||||
@@ -294,7 +287,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
# Build the source corpus (all .py content from apps/)
|
# Build the source corpus (all .py content from apps/)
|
||||||
source_text = _collect_source_text(apps_dir, bp, ignore_entries)
|
source_text = _collect_source_text(apps_dir)
|
||||||
|
|
||||||
# Check each target for references
|
# Check each target for references
|
||||||
total_files = len(targets)
|
total_files = len(targets)
|
||||||
|
|||||||
@@ -28,11 +28,6 @@ from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
|
|||||||
# Audit scope: all Python files
|
# Audit scope: all Python files
|
||||||
AUDIT_SCOPE = "all_files"
|
AUDIT_SCOPE = "all_files"
|
||||||
|
|
||||||
# Cross-branch/cross-package handler imports are import-statement checks, so a
|
|
||||||
# violation hiding in a package marker file must not be invisible to the audit
|
|
||||||
# the way it would be for content checkers (dead code, naming, etc.).
|
|
||||||
INCLUDE_INIT_FILES = True
|
|
||||||
|
|
||||||
|
|
||||||
def _find_registry() -> Path:
|
def _find_registry() -> Path:
|
||||||
"""Find AIPASS_REGISTRY.json by walking up from this file's location."""
|
"""Find AIPASS_REGISTRY.json by walking up from this file's location."""
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ Validates handler compliance with AIPass handler standards.
|
|||||||
Checks handler independence, auto-detection pattern, no orchestration.
|
Checks handler independence, auto-detection pattern, no orchestration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import ast
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Iterator, List, Optional, Tuple
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from aipass.prax import logger
|
from aipass.prax import logger
|
||||||
from aipass.seedgo.apps.handlers.json import json_handler
|
from aipass.seedgo.apps.handlers.json import json_handler
|
||||||
@@ -25,11 +24,6 @@ from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
|
|||||||
# Audit scope: all Python files
|
# Audit scope: all Python files
|
||||||
AUDIT_SCOPE = "all_files"
|
AUDIT_SCOPE = "all_files"
|
||||||
|
|
||||||
# Cross-handler/orchestration imports are purely import-statement checks, so a
|
|
||||||
# violation hiding in a package marker file must not be invisible to the audit
|
|
||||||
# the way it would be for content checkers (dead code, naming, etc.).
|
|
||||||
INCLUDE_INIT_FILES = True
|
|
||||||
|
|
||||||
|
|
||||||
def check_module(module_path: str, bypass_rules: list | None = None) -> Dict:
|
def check_module(module_path: str, bypass_rules: list | None = None) -> Dict:
|
||||||
"""
|
"""
|
||||||
@@ -127,39 +121,6 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict:
|
|||||||
return {"passed": overall_passed, "checks": checks, "score": score, "standard": "HANDLERS"}
|
return {"passed": overall_passed, "checks": checks, "score": score, "standard": "HANDLERS"}
|
||||||
|
|
||||||
|
|
||||||
def _iter_import_modules(content: str) -> Iterator[Tuple[int, str, List[str]]]:
|
|
||||||
"""
|
|
||||||
Parse content and yield (line_number, dotted_module, imported_names) for every
|
|
||||||
real import statement — string literals, comments and docstrings can never
|
|
||||||
produce a hit because the AST only contains actual import nodes.
|
|
||||||
|
|
||||||
Relative imports (from . import x) are omitted entirely: they can't reference
|
|
||||||
an absolute dotted path like "apps.handlers"/"apps.modules" and are always
|
|
||||||
same-package, so callers don't need to special-case them.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
tree = ast.parse(content)
|
|
||||||
except SyntaxError as e:
|
|
||||||
logger.info("Skipping import scan: SyntaxError during parse: %s", e)
|
|
||||||
return
|
|
||||||
|
|
||||||
for node in ast.walk(tree):
|
|
||||||
if isinstance(node, ast.ImportFrom):
|
|
||||||
if node.level:
|
|
||||||
continue
|
|
||||||
yield node.lineno, node.module or "", [alias.name for alias in node.names]
|
|
||||||
elif isinstance(node, ast.Import):
|
|
||||||
for alias in node.names:
|
|
||||||
yield node.lineno, alias.name, []
|
|
||||||
|
|
||||||
|
|
||||||
def _line_text(lines: List[str], lineno: int, fallback: str) -> str:
|
|
||||||
"""Return the source line at 1-indexed lineno, or a fallback if out of range."""
|
|
||||||
if 0 < lineno <= len(lines):
|
|
||||||
return lines[lineno - 1].strip()
|
|
||||||
return fallback
|
|
||||||
|
|
||||||
|
|
||||||
def check_handler_independence(content: str, lines: List[str], module_path: str) -> Dict:
|
def check_handler_independence(content: str, lines: List[str], module_path: str) -> Dict:
|
||||||
"""
|
"""
|
||||||
Check handler independence - no cross-handler imports except defaults
|
Check handler independence - no cross-handler imports except defaults
|
||||||
@@ -180,26 +141,58 @@ def check_handler_independence(content: str, lines: List[str], module_path: str)
|
|||||||
own_package = path_parts[i + 1]
|
own_package = path_parts[i + 1]
|
||||||
break
|
break
|
||||||
|
|
||||||
for lineno, module, names in _iter_import_modules(content):
|
in_docstring = False
|
||||||
if "apps.handlers" not in module:
|
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("#"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for handler imports
|
||||||
|
if "apps.handlers" in stripped and ("from " in stripped or "import " in stripped):
|
||||||
|
# Skip if in a string (rough check)
|
||||||
|
if '"from ' in stripped or "'from " in stripped:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract code part (before comment)
|
||||||
|
code_part = stripped.split("#")[0] if "#" in stripped else stripped
|
||||||
|
|
||||||
|
if "apps.handlers" not in code_part:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Allowed: Default handlers (json_handler)
|
# Allowed: Default handlers (json_handler)
|
||||||
if module.endswith("handlers.json") and "json_handler" in names:
|
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
|
continue
|
||||||
|
|
||||||
# Allowed: Same package absolute imports
|
# Allowed: Same package absolute imports
|
||||||
if own_package and f"handlers.{own_package}" in module:
|
if own_package and f"handlers.{own_package}" in code_part:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Forbidden: Cross-handler imports
|
# Forbidden: Cross-handler imports
|
||||||
forbidden_imports.append(f"line {lineno}: {_line_text(lines, lineno, module)}")
|
forbidden_imports.append(f"line {i}: {stripped}")
|
||||||
|
|
||||||
if forbidden_imports:
|
if forbidden_imports:
|
||||||
return {
|
return {
|
||||||
"name": "Handler independence",
|
"name": "Handler independence",
|
||||||
"passed": False,
|
"passed": False,
|
||||||
"message": "Cross-handler imports detected (except defaults): " + "; ".join(forbidden_imports),
|
"message": f"Cross-handler imports detected (except defaults): {forbidden_imports[0]}",
|
||||||
}
|
}
|
||||||
|
|
||||||
return {"name": "Handler independence", "passed": True, "message": "No forbidden cross-handler imports detected"}
|
return {"name": "Handler independence", "passed": True, "message": "No forbidden cross-handler imports detected"}
|
||||||
@@ -246,22 +239,50 @@ def check_no_orchestration(content: str, lines: List[str]) -> Optional[Dict]:
|
|||||||
"""
|
"""
|
||||||
module_imports = []
|
module_imports = []
|
||||||
|
|
||||||
for lineno, module, _names in _iter_import_modules(content):
|
in_docstring = False
|
||||||
if "apps.modules" not in module:
|
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("#"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for module imports
|
||||||
|
if "apps.modules" in stripped and ("from " in stripped or "import " in stripped):
|
||||||
|
# Skip if in a string
|
||||||
|
if '"from ' in stripped or "'from " in stripped:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract code part
|
||||||
|
code_part = stripped.split("#")[0] if "#" in stripped else stripped
|
||||||
|
|
||||||
|
if "apps.modules" not in code_part:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Allowed: Service imports (prax.apps.modules.logger, cli.apps.modules)
|
# Allowed: Service imports (prax.apps.modules.logger, cli.apps.modules)
|
||||||
if "prax.apps.modules.logger" in module or "cli.apps.modules" in module:
|
if "prax.apps.modules.logger" in code_part or "cli.apps.modules" in code_part:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Forbidden: Module imports (orchestration)
|
# Forbidden: Module imports (orchestration)
|
||||||
module_imports.append(f"line {lineno}: {_line_text(lines, lineno, module)}")
|
module_imports.append(f"line {i}: {stripped}")
|
||||||
|
|
||||||
if module_imports:
|
if module_imports:
|
||||||
return {
|
return {
|
||||||
"name": "No orchestration",
|
"name": "No orchestration",
|
||||||
"passed": False,
|
"passed": False,
|
||||||
"message": "Handler imports modules (orchestration): " + "; ".join(module_imports),
|
"message": f"Handler imports modules (orchestration): {module_imports[0]}",
|
||||||
}
|
}
|
||||||
|
|
||||||
return {"name": "No orchestration", "passed": True, "message": "No module imports detected (pure implementation)"}
|
return {"name": "No orchestration", "passed": True, "message": "No module imports detected (pure implementation)"}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ from aipass.prax import logger
|
|||||||
from aipass.seedgo.apps.handlers.json import json_handler
|
from aipass.seedgo.apps.handlers.json import json_handler
|
||||||
from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
|
from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
|
||||||
from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file
|
from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file
|
||||||
from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored, load_ignore_entries
|
|
||||||
|
|
||||||
# Audit scope: entry points only (apps/{name}.py)
|
# Audit scope: entry points only (apps/{name}.py)
|
||||||
AUDIT_SCOPE = "entry_point"
|
AUDIT_SCOPE = "entry_point"
|
||||||
@@ -504,16 +503,12 @@ def _extract_test_counts(content: str) -> List[int]:
|
|||||||
def _count_test_functions(tests_dir: Path) -> int:
|
def _count_test_functions(tests_dir: Path) -> int:
|
||||||
"""Count `def test_` functions in all test_*.py files under tests/."""
|
"""Count `def test_` functions in all test_*.py files under tests/."""
|
||||||
count = 0
|
count = 0
|
||||||
branch_root = tests_dir.parent
|
|
||||||
ignore_entries = load_ignore_entries(branch_root)
|
|
||||||
test_func_pattern = re.compile(r"^\s*def\s+test_", re.MULTILINE)
|
test_func_pattern = re.compile(r"^\s*def\s+test_", re.MULTILINE)
|
||||||
for test_file in tests_dir.rglob("test_*.py"):
|
for test_file in tests_dir.rglob("test_*.py"):
|
||||||
if any(part in SOURCE_SKIP_DIRS for part in test_file.relative_to(tests_dir).parts):
|
if any(part in SOURCE_SKIP_DIRS for part in test_file.relative_to(tests_dir).parts):
|
||||||
continue
|
continue
|
||||||
if is_disabled_file(test_file.name):
|
if is_disabled_file(test_file.name):
|
||||||
continue
|
continue
|
||||||
if is_seedgo_ignored(str(test_file), branch_root, ignore_entries):
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
source = test_file.read_text(encoding="utf-8")
|
source = test_file.read_text(encoding="utf-8")
|
||||||
count += len(test_func_pattern.findall(source))
|
count += len(test_func_pattern.findall(source))
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ from aipass.prax import logger
|
|||||||
from aipass.seedgo.apps.handlers.json import json_handler
|
from aipass.seedgo.apps.handlers.json import json_handler
|
||||||
from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
|
from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
|
||||||
from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file
|
from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file
|
||||||
from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored, load_ignore_entries
|
|
||||||
|
|
||||||
AUDIT_SCOPE = "branch_level"
|
AUDIT_SCOPE = "branch_level"
|
||||||
|
|
||||||
@@ -181,7 +180,7 @@ def _should_skip_file(name: str) -> bool:
|
|||||||
return is_disabled_file(name)
|
return is_disabled_file(name)
|
||||||
|
|
||||||
|
|
||||||
def _find_test_files_broad(branch_path: Path, ignore_entries: list) -> list[Path]:
|
def _find_test_files_broad(branch_path: Path) -> list[Path]:
|
||||||
"""Find all test files for module coverage analysis.
|
"""Find all test files for module coverage analysis.
|
||||||
|
|
||||||
Broader than _find_all_test_files — also finds scattered test files
|
Broader than _find_all_test_files — also finds scattered test files
|
||||||
@@ -198,8 +197,6 @@ def _find_test_files_broad(branch_path: Path, ignore_entries: list) -> list[Path
|
|||||||
continue
|
continue
|
||||||
if any(_should_skip_dir(part) for part in py_file.relative_to(tests_dir).parts):
|
if any(_should_skip_dir(part) for part in py_file.relative_to(tests_dir).parts):
|
||||||
continue
|
continue
|
||||||
if is_seedgo_ignored(str(py_file), branch_path, ignore_entries):
|
|
||||||
continue
|
|
||||||
resolved = py_file.resolve()
|
resolved = py_file.resolve()
|
||||||
if resolved not in seen:
|
if resolved not in seen:
|
||||||
seen.add(resolved)
|
seen.add(resolved)
|
||||||
@@ -211,8 +208,6 @@ def _find_test_files_broad(branch_path: Path, ignore_entries: list) -> list[Path
|
|||||||
continue
|
continue
|
||||||
if py_file.name in ("__init__.py", "conftest.py") or _should_skip_file(py_file.name):
|
if py_file.name in ("__init__.py", "conftest.py") or _should_skip_file(py_file.name):
|
||||||
continue
|
continue
|
||||||
if is_seedgo_ignored(str(py_file), branch_path, ignore_entries):
|
|
||||||
continue
|
|
||||||
if py_file.name.startswith("test_") or py_file.name.endswith("_test.py"):
|
if py_file.name.startswith("test_") or py_file.name.endswith("_test.py"):
|
||||||
resolved = py_file.resolve()
|
resolved = py_file.resolve()
|
||||||
if resolved not in seen:
|
if resolved not in seen:
|
||||||
@@ -239,7 +234,7 @@ def _analyze_test_file_imports(source: str) -> set[str]:
|
|||||||
return tested_modules
|
return tested_modules
|
||||||
|
|
||||||
|
|
||||||
def _collect_testable_modules(branch_path: Path, ignore_entries: list) -> set[str]:
|
def _collect_testable_modules(branch_path: Path) -> set[str]:
|
||||||
"""Collect module names from apps/modules/ and apps/handlers/.
|
"""Collect module names from apps/modules/ and apps/handlers/.
|
||||||
|
|
||||||
Returns set of module names:
|
Returns set of module names:
|
||||||
@@ -260,21 +255,17 @@ def _collect_testable_modules(branch_path: Path, ignore_entries: list) -> set[st
|
|||||||
and item.suffix == ".py"
|
and item.suffix == ".py"
|
||||||
and item.name != "__init__.py"
|
and item.name != "__init__.py"
|
||||||
and not _should_skip_file(item.name)
|
and not _should_skip_file(item.name)
|
||||||
and not is_seedgo_ignored(str(item), branch_path, ignore_entries)
|
|
||||||
):
|
):
|
||||||
modules.add(item.stem)
|
modules.add(item.stem)
|
||||||
|
|
||||||
handlers_dir = apps_dir / "handlers"
|
handlers_dir = apps_dir / "handlers"
|
||||||
if handlers_dir.is_dir():
|
if handlers_dir.is_dir():
|
||||||
for item in sorted(handlers_dir.iterdir()):
|
for item in sorted(handlers_dir.iterdir()):
|
||||||
if _should_skip_dir(item.name) or is_seedgo_ignored(str(item), branch_path, ignore_entries):
|
if _should_skip_dir(item.name):
|
||||||
continue
|
continue
|
||||||
if item.is_dir() and item.name != "__pycache__":
|
if item.is_dir() and item.name != "__pycache__":
|
||||||
has_py = any(
|
has_py = any(
|
||||||
f.suffix == ".py"
|
f.suffix == ".py" and f.name != "__init__.py" and not _should_skip_file(f.name)
|
||||||
and f.name != "__init__.py"
|
|
||||||
and not _should_skip_file(f.name)
|
|
||||||
and not is_seedgo_ignored(str(f), branch_path, ignore_entries)
|
|
||||||
for f in item.iterdir()
|
for f in item.iterdir()
|
||||||
if f.is_file()
|
if f.is_file()
|
||||||
)
|
)
|
||||||
@@ -291,7 +282,7 @@ def _collect_testable_modules(branch_path: Path, ignore_entries: list) -> set[st
|
|||||||
return modules
|
return modules
|
||||||
|
|
||||||
|
|
||||||
def _find_all_test_files(branch_path: Path, ignore_entries: list) -> list[Path]:
|
def _find_all_test_files(branch_path: Path) -> list[Path]:
|
||||||
"""Find all test files and conftest.py in the branch's tests/ directory.
|
"""Find all test files and conftest.py in the branch's tests/ directory.
|
||||||
|
|
||||||
Scans for any test_*.py file plus conftest.py -- no naming requirements.
|
Scans for any test_*.py file plus conftest.py -- no naming requirements.
|
||||||
@@ -304,8 +295,6 @@ def _find_all_test_files(branch_path: Path, ignore_entries: list) -> list[Path]:
|
|||||||
for p in sorted(tests_dir.iterdir()):
|
for p in sorted(tests_dir.iterdir()):
|
||||||
if not p.is_file() or p.suffix != ".py":
|
if not p.is_file() or p.suffix != ".py":
|
||||||
continue
|
continue
|
||||||
if is_seedgo_ignored(str(p), branch_path, ignore_entries):
|
|
||||||
continue
|
|
||||||
if p.name.startswith("test_") or p.name == "conftest.py":
|
if p.name.startswith("test_") or p.name == "conftest.py":
|
||||||
results.append(p)
|
results.append(p)
|
||||||
|
|
||||||
@@ -406,8 +395,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Phase 1: Find all test files
|
# Phase 1: Find all test files
|
||||||
ignore_entries = load_ignore_entries(bp)
|
test_files = _find_all_test_files(bp)
|
||||||
test_files = _find_all_test_files(bp, ignore_entries)
|
|
||||||
|
|
||||||
if not test_files:
|
if not test_files:
|
||||||
checks.append(
|
checks.append(
|
||||||
@@ -482,7 +470,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict:
|
|||||||
|
|
||||||
# Phase 4: Module coverage (category 11 — from test_coverage_check.py)
|
# Phase 4: Module coverage (category 11 — from test_coverage_check.py)
|
||||||
# Uses broader file discovery + import-based module mapping
|
# Uses broader file discovery + import-based module mapping
|
||||||
broad_test_files = _find_test_files_broad(bp, ignore_entries)
|
broad_test_files = _find_test_files_broad(bp)
|
||||||
total_tests = 0
|
total_tests = 0
|
||||||
tested_modules: set[str] = set()
|
tested_modules: set[str] = set()
|
||||||
for tf in broad_test_files:
|
for tf in broad_test_files:
|
||||||
@@ -495,7 +483,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict:
|
|||||||
if total_tests == 0:
|
if total_tests == 0:
|
||||||
tested_modules = set()
|
tested_modules = set()
|
||||||
|
|
||||||
all_modules = _collect_testable_modules(bp, ignore_entries)
|
all_modules = _collect_testable_modules(bp)
|
||||||
total_modules = len(all_modules)
|
total_modules = len(all_modules)
|
||||||
|
|
||||||
# 3 module coverage items
|
# 3 module coverage items
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ from pathlib import Path
|
|||||||
from aipass.prax import logger
|
from aipass.prax import logger
|
||||||
from aipass.seedgo.apps.handlers.json import json_handler
|
from aipass.seedgo.apps.handlers.json import json_handler
|
||||||
from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
|
from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
|
||||||
from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored, load_ignore_entries
|
|
||||||
from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file
|
from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file
|
||||||
|
|
||||||
AUDIT_SCOPE = "branch_level"
|
AUDIT_SCOPE = "branch_level"
|
||||||
@@ -61,23 +60,18 @@ _MAIN_BLOCK_RE = re.compile(
|
|||||||
# -- File collection ----------------------------------------------------------
|
# -- File collection ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _should_skip(path: Path, branch_root: Path, ignore_entries: list) -> bool:
|
def _should_skip(path: Path) -> bool:
|
||||||
"""Return True if any path component is in the skip set, file is disabled,
|
"""Return True if any path component is in the skip set or 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)
|
||||||
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, ignore_entries: list) -> list[Path]:
|
def _collect_python_files(branch_path: Path) -> list[Path]:
|
||||||
"""Collect all .py files in the branch, skipping irrelevant dirs."""
|
"""Collect all .py files in the branch, skipping irrelevant dirs."""
|
||||||
files: list[Path] = []
|
files: list[Path] = []
|
||||||
if not branch_path.is_dir():
|
if not branch_path.is_dir():
|
||||||
return files
|
return files
|
||||||
for py_file in branch_path.rglob("*.py"):
|
for py_file in branch_path.rglob("*.py"):
|
||||||
if _should_skip(py_file, branch_path, ignore_entries):
|
if _should_skip(py_file):
|
||||||
continue
|
continue
|
||||||
files.append(py_file)
|
files.append(py_file)
|
||||||
return sorted(files)
|
return sorted(files)
|
||||||
@@ -222,8 +216,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Phase 1: Collect all .py files
|
# Phase 1: Collect all .py files
|
||||||
ignore_entries = load_ignore_entries(branch)
|
py_files = _collect_python_files(branch)
|
||||||
py_files = _collect_python_files(branch, ignore_entries)
|
|
||||||
if not py_files:
|
if not py_files:
|
||||||
json_handler.log_operation(
|
json_handler.log_operation(
|
||||||
"check_completed",
|
"check_completed",
|
||||||
|
|||||||
@@ -42,27 +42,19 @@ def discover_checkers(pack_path: Path | None = None) -> Dict[str, Any]:
|
|||||||
return checkers
|
return checkers
|
||||||
|
|
||||||
|
|
||||||
def _collect_py_files(branch_path: Path, include_init: bool = False) -> List[Dict[str, str]]:
|
def _collect_py_files(branch_path: Path) -> List[Dict[str, str]]:
|
||||||
"""Collect auditable .py files from apps/, respecting ignore patterns.
|
"""Collect auditable .py files from apps/, respecting ignore patterns."""
|
||||||
|
|
||||||
__init__.py package markers are excluded by default — most checkers are
|
|
||||||
content-focused (dead code, naming, nesting) and __init__.py is typically
|
|
||||||
boilerplate. Pass include_init=True for import-statement checkers, where a
|
|
||||||
real cross-handler import hiding in a package marker must not go unseen.
|
|
||||||
"""
|
|
||||||
apps_dir = branch_path / "apps"
|
apps_dir = branch_path / "apps"
|
||||||
if not apps_dir.exists():
|
if not apps_dir.exists():
|
||||||
return []
|
return []
|
||||||
ign = ignore_handler.get_audit_ignore_patterns()
|
ign = ignore_handler.get_audit_ignore_patterns()
|
||||||
ignore_entries = ignore_handler.load_ignore_entries(branch_path)
|
|
||||||
return [
|
return [
|
||||||
{"file": str(f), "name": f.name}
|
{"file": str(f), "name": f.name}
|
||||||
for f in apps_dir.rglob("*.py")
|
for f in apps_dir.rglob("*.py")
|
||||||
if (include_init or f.name != "__init__.py")
|
if f.name != "__init__.py"
|
||||||
and not is_disabled_file(f.name)
|
and not is_disabled_file(f.name)
|
||||||
and not is_throwaway_path(str(f))
|
and not is_throwaway_path(str(f))
|
||||||
and not any(p in str(f).lower() for p in ign)
|
and not any(p in str(f).lower() for p in ign)
|
||||||
and not ignore_handler.is_seedgo_ignored(str(f), branch_path, ignore_entries)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -140,7 +132,6 @@ def audit_branch(branch: Dict[str, str], bypass_rules: list, pack_path: Path | N
|
|||||||
"""Audit a branch for standards compliance. Returns backward-compatible dict."""
|
"""Audit a branch for standards compliance. Returns backward-compatible dict."""
|
||||||
entry_file, branch_path = branch["entry_file"], Path(branch["path"])
|
entry_file, branch_path = branch["entry_file"], Path(branch["path"])
|
||||||
checkers, all_files = discover_checkers(pack_path), _collect_py_files(branch_path)
|
checkers, all_files = discover_checkers(pack_path), _collect_py_files(branch_path)
|
||||||
files_with_init: List[Dict[str, str]] | None = None
|
|
||||||
|
|
||||||
# Discover diagnostics checker from handlers/diagnostics/ (outside pack dirs)
|
# Discover diagnostics checker from handlers/diagnostics/ (outside pack dirs)
|
||||||
diag_mod = _load_diagnostics_checker()
|
diag_mod = _load_diagnostics_checker()
|
||||||
@@ -170,12 +161,7 @@ def audit_branch(branch: Dict[str, str], bypass_rules: list, pack_path: Path | N
|
|||||||
results[name], scores[name] = {"passed": False, "score": 0, "error": str(e)}, 0
|
results[name], scores[name] = {"passed": False, "score": 0, "error": str(e)}, 0
|
||||||
# All-files scope: scan every .py file, override score with average
|
# All-files scope: scan every .py file, override score with average
|
||||||
if scope == "all_files" and all_files:
|
if scope == "all_files" and all_files:
|
||||||
scan_files = all_files
|
v, s = _run_all_files(checker, name, all_files, bypass_rules)
|
||||||
if getattr(checker, "INCLUDE_INIT_FILES", False):
|
|
||||||
if files_with_init is None:
|
|
||||||
files_with_init = _collect_py_files(branch_path, include_init=True)
|
|
||||||
scan_files = files_with_init
|
|
||||||
v, s = _run_all_files(checker, name, scan_files, bypass_rules)
|
|
||||||
all_violations[name] = v
|
all_violations[name] = v
|
||||||
if s:
|
if s:
|
||||||
avg_score = int(sum(s) / len(s))
|
avg_score = int(sum(s) / len(s))
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user