diff --git a/.git_pr.lock b/.git_pr.lock index 049ac5d2..3e280108 100644 --- a/.git_pr.lock +++ b/.git_pr.lock @@ -1,6 +1,6 @@ { "branch": "devpulse", "feature_branch": "", - "started": "2026-04-14T20:53:55.674912+00:00", - "pid": 454061 + "started": "2026-04-15T03:38:18.119079+00:00", + "pid": 686504 } \ No newline at end of file diff --git a/HERALD.md b/HERALD.md index 69da1520..36a0e854 100644 --- a/HERALD.md +++ b/HERALD.md @@ -46,8 +46,8 @@ FPLAN-0165 executed (README 366→279 lines). Goldfish Rounds 5-7: R5 approval ( ### S79 — README Overhaul + Goldfish Panel (2026-04-07) README overhaul with 4 Goldfish rounds (Claude+Codex+Gemini reviews). PyPI published (aipass 2.0.0). Security: ~/.secrets/ blocked across all 3 CLIs. Memory central_writer path bug fixed. Breadcrumb architecture + collaboration angle captured. FPLAN-0165 master plan. PR #201 merged. -### S78 — Compass + Navigator + TDPLAN (2026-04-06) -Compass v0.1 built (25 judgment fragments vectorized). Project-vs-agent distinction discovered (DPLAN-0104). Navigator agent pattern proven (tmux + brief + let work). TDPLAN template created. Global prompt: gitignore rule. PR #199. +### S78 — Navigator Pattern + TDPLAN (2026-04-06) +Project-vs-agent distinction discovered (DPLAN-0104). Navigator agent pattern proven (tmux + brief + let work). TDPLAN template created. Global prompt: gitignore rule. PR #199. ### S77 — README Value Prop Research (2026-04-06) Cross-platform research (10 agents), README value prop overhaul (7 agents), competitive landscape, fresh outside perspective. DPLAN-0098+0099. PR #195 merged. diff --git a/README.md b/README.md index eeca7e6b..92e2817d 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ setup.sh auto-detects which CLIs are installed and configures hooks for each. | Quality standards | 33 automated checks | | Tests | 3,500+ (across all agents) | | PRs merged | 260+ (created by agents, reviewed by human) | -| External projects | Full cross-project access (Vera Studio, AIPL, Compass) | +| External projects | Full cross-project access (Vera Studio, AIPL) | Each agent documents its own operational status in its branch README — what works, what doesn't, and why. diff --git a/STATUS.md b/STATUS.md index fd987495..b05d13bc 100644 --- a/STATUS.md +++ b/STATUS.md @@ -2,7 +2,7 @@ > Auto-generated by `drone @prax status sync`. Do not edit manually. -**Last sync:** 2026-04-14 13:53 +**Last sync:** 2026-04-14 20:38 **Summary:** 8 operational | 0 in-progress | 0 not started --- @@ -186,14 +186,14 @@ -
@devpulse — Operational (2026-04-11 (S90 17:59)) +
@devpulse — Operational (2026-04-14 (S92 18:20)) # @devpulse > Orchestration hub — coordinates via dispatch + agents. Feedback channel live. **State:** Operational -**Last update:** 2026-04-11 (S90 17:59) +**Last update:** 2026-04-14 (S92 18:20) ## Current State @@ -202,6 +202,7 @@ - **230+ PRs** merged (PR #254 created S90) - **3,500+ tests** across core agents (1,162 verified S90) - **Feedback channel:** Cross-project communication bridge (DPLAN-0117, 101 tests) +- **Watchdog module:** Directed wake system live. `drone @devpulse watchdog {agent,timer,schedule,status,cancel,list}`. 130 tests, seedgo 92%. FPLAN-0186 complete. Bash one-liner replaced. - **PyPI:** `pip install aipass` works (v2.0.0) - **Vera Studio:** standalone promotion project — 4 bug reports received via feedback - **Goldfish panel:** 7-round multi-model README review (Claude + Codex + Gemini) @@ -211,12 +212,12 @@ - **S90 In Progress:** SYSTEM HEALTH + CROSS-PROJECT COMPLETION. Dispatch -c -p bug found (Claude Code v2.1.90-100, fixed v2.1.101, DPLAN-0122). AIPASS_HOME added to .bashrc + settings.json. Vera 4 bug reports via feedback (ai_mail registry hardcode, export issue, registry ID mismatch, local prompt hook). CLI built AIPASS_HOME auto-detect + project mailbox (DPLAN-0121 P1+2). Drone JSON KeyboardInterrupt fix. Prax monitor refactored (introspection extracted). Test isolation fixes (AIPASS_HOME leaking). PR #254. DPLAN-0123 system health tracker. ai_mail dispatched for bidirectional email (Phase 3). Prax re-dispatched for Ctrl+C fix. - **S89 Complete:** FULL EXTERNAL ACCESS + FEEDBACK BRIDGE. Dual registry (AIPASS_HOME), global prompt template rewrite (7→expanded with dispatch+dplan+feedback), UX warnings when AIPASS_HOME not set, idempotent init update (diff check), feedback channel built (DPLAN-0117/FPLAN-0173, 101 tests, cross-project sender detection). 8 dispatches to drone/cli/seedgo/flow. Global prompt: "ask before spelunking" rule + feedback section added. - **S88 Complete:** AIPL Phase 1 complete. Style guide + 6 examples. Polyglot wired. DPLAN-0115. Global prompt: no manual plan files. -- **S87 Complete:** COMPASS STANDALONE + IMPROVEMENTS. DPLAN-0113 complete (7 imports→stdlib, handler guard removed, module path fixed, tests updated 89/89). Compass v0.2: observations parser (75 fragments), richer embedding text (context+decision+feedback+tags), ingest-obs/ingest-all commands, 103 total fragments. 7 orphaned watchdog processes killed. backup/ dir confirmed gone. 4 commits pushed to AIOSAI/compass. - **S86 Complete:** 6-HOUR AUTONOMOUS SESSION (DPLAN-0111). 41 dispatches. 15 PRs (#233-247). 11 CRITICALs + 35+ BUGs + 25+ quality fixes. DPLAN-0112 COMPLETE. Seedgo 22-23/33 at 100%. APLAN 42→7. decisions.md #029+#030. - **S85 Complete:** TRIGGER SELF-HEALING LIVE. Git divergence fixed (merge not reset). Drone git fix/sync upgraded (PR #229). OSS Health badge (PR #230). 13 stale plans closed (28→15). Trigger Medic v2 fixed: log_watcher dedup, count gate removed, wake_branch() wired. Full autonomous error cycle proven. - **S84 Complete:** Watchdog fixed. Night shift: 4 DPLANs (0107-0110), 10 branches dispatched, PRs #214-226. API key incident. 28 stale branches purged. - **Hook-sounds plugin:** Built by drone, wired to sound scripts. `drone hook-sounds off/on`. -- **DPLAN-0106:** Watchdog v2 — Phase 2 depends on daemon (removed). Needs replanning. +- **DPLAN-0106:** Watchdog v2 — SUPERSEDED by DPLAN-0130 + FPLAN-0186 (shipped S92). +- **DPLAN-0119:** Auto-watchdog hook — CLOSED in FPLAN-0186 Phase 1 (replaced by watchdog module). - **DPLAN-0105:** Promotion prep — Vera owns execution. - **DPLAN-0099:** README value prop — Vera owns future changes. @@ -229,7 +230,7 @@ ## Known Issues -- **Watchdog:** v1.1 (snapshot pattern) working since S85. Orphaned processes cleaned up S87. +- **Watchdog module:** FPLAN-0186 shipped S92 (agent + timer + schedule + registry). Known issue: `drone @devpulse watchdog agent ` clipped at 30s by drone CLI wrapper — workaround is direct `python3 apps/devpulse.py watchdog agent ` call. Watchdog's own --timeout flag governs the real wait. - **Wake protection:** Any branch can manually wake devpulse. Medic only blocks auto-dispatch. Needs blocklist in wake.py. - **Hook inbox notification:** Cosmetic only — watchdog handles real waking. - **Hooks:** Don't work from external projects. Hooks reference AIPass-specific scripts. @@ -239,6 +240,68 @@ ## Notepad (Agent) +### S92 (2026-04-14 ~18:20 PT) — FPLAN-0186 watchdog module MASTER COMPLETE + +**All 5 phases shipped. Plan closed.** + +- **Files:** `apps/modules/watchdog.py` + `apps/handlers/watchdog/{agent,timer,schedule,registry,__init__}.py` + 5 test files +- **Tests:** 130 passed, 3 skipped, 0 failed (Phase 1→4 baseline 122 → 130) +- **Seedgo:** 88% baseline → 92% final (held through Phase 4 and Phase 5). 11 bypass entries all justified inline (encapsulation + json_structure manager-branch gaps) +- **Agent completion signal:** ai_mail dispatch lock file polling. Monitor deletes `.ai_mail.local/.dispatch.lock` on exit (success or crash); `last_bounce.json` distinguishes. Zero upstream changes needed. +- **Bash one-liner GONE** from `.aipass/aipass_local_prompt.md` — replaced with `drone @devpulse watchdog agent @target` +- **Smoke test:** 13/13 steps PASS — `artifacts/reports/watchdog_smoke_test.md` +- **Plans closed:** DPLAN-0119 (Phase 1), DPLAN-0106 superseded by DPLAN-0130, FPLAN-0186 closed via `drone @flow close` +- **Known issue:** drone CLI 30s wrapper timeout clips long agent watches. Workaround is direct python call. Not a watchdog bug. Worth lifting in a future drone fix. + +### S92 (2026-04-14 ~14:08 PT) — DPLAN-0128 complete, working on private memory project + +**DPLAN-0128 execution DONE — reformat is live.** + +- T1 @cli: 27 PASS, 2 FAIL. Removed `drone @flow info ` and `drone @memory archive` from reformat. +- T2 @spawn: terminology PASS. Reformat's agent=citizen framing is more consistent with spawn code than the old live prompt was. +- T4 Anthropic source read: MISMATCH. AIPL format is ORIGINAL AIPass convention, NOT copied from Claude Code. Report: `.trinity/night_shift_reports/aipl_format_provenance.md` +- T5 @seedgo: recommended prompt note + reference doc, NOT a 34th standard. Unenforceable soft rules. +- T6 @prax: sync mechanism fully wired (pr_created/pr_merged → pr_status_sync.py). Softened wording: "PR commit"→"PR create/merge", "11 branches"→"all registered branches", "Herald reads it"→"Herald documents it". +- T3 skill files split: PR #272 — /memo user-scope only (removed from repo), /prep shipped via `aipass init` template in bootstrap.py `_prep_md()`. Init test passed. +- T7: stale `agent-aa2b7d43` worktree removed. +- T8: reformat promoted to live `.aipass/aipass_global_prompt.md` + new `.aipass/PROMPT_STYLE.md` reference doc + one-line style comment. PR #273. + +**PRs awaiting merge**: #272, #273 (both scoop the S91 night shift backlog of 6 commits — known drone @git system-pr behavior). Plus S91's #265-#271 still open. + +### S92 (2026-04-14 ~14:08 PT) — Private memory project autonomous work while user AFK + +**User said**: "lets park this off. work on your private memory project. no need to stop im outside working on my rv. ill be in and out all day" + + +- **Phase 1** scaffold + ROOMS_ENABLED flag (default False, v1 untouched) +- **Phase 2** BERTopic clustering → 12 real clusters on 133 live fragments, persisted to `.chroma/rooms_map.json` +- **Phase 3** cluster-aware rerank via `room_aware_query()` with majority-vote target room picking. Smoke test: "verify claims" query correctly boosted verification observations to ranks 2+3 while preserving top anchor +- **Phase 4** `RoomStrategy` protocol + `prior_plan_decisions` hand-authored strategy (filter kind=decision, recency boost, source-branch text-match boost) + strategy registry +- **Phase 5** `consult()` advisor entrypoint — single call handles both paths (cluster_aware + strategy), contract locked, placeholder `suggested_question_for_user` per rule table ("mixed signal" / "tight consensus" annotations), state echoed for Phase 7 to consume +- **Phase 6** `ProjectState` dataclass + `birth_watcher` scaffold (diff new/gone/resized clusters vs baseline, 20% threshold). SCAFFOLD ONLY — no auto-notification, no LLM, no background job + +**Live end-to-end commands** when `ROOMS_ENABLED=True` in the project: +`status | discover | list | peek | query "..." | strategies | strategy "..." | consult "..." --strategy X --state-energy N --state-pressure N | births | births --save-baseline` + +**Metrics**: 201 tests green (started Phase 1 at 122), ~5,300 LOC across handlers+modules+tests, 0 v1 file mutations, 0 Chroma collection mutations, 0 commits. + +**DPLAN-0129** (architecture research) and **FPLAN-0185** (master plan, 6 phases) both live in the private project repo, uncommitted. + +- Runtime unblocked: created dedicated `~/Projects//.venv` with chromadb 1.5.5 + sentence-transformers 5.3.0. +- Fixed `navigator/__init__.py` missing. Bypass entries added for seedgo `__init__.py` false positives. Emailed @seedgo friction note. +- First real query ran and surfaced decision_006 ("branches self-report optimistically") + obs_s60_verification_agents — the exact prior art DPLAN-0128 was defending against. **Rule #033 closed** — the project is being used, not just built. Next time: query first, plan second. +- S92 decisions #034-#036 ingested. Collection now 133 fragments. +- Invocation form: the private project invocation form. Bare script invocation fails with path issue — not urgent to fix. + +- `navigator/__init__.py` (new — package marker) +- `navigator/.seedgo/bypass.json` (modified — 2 new bypass entries) +- `navigator/.trinity/local.json` (modified — S4 session) +- `navigator/STATUS.local.md` (updated — gitignored anyway) + +### Friction notes + +- **seedgo __init__.py false positive**: `imports: Failed` and `naming: invalid characters` fire on Python reserved filename. Need special-case in seedgo's naming standard and imports standard. Encountered in another project; likely also in AIPass repo. Open a seedgo standard PR when there's bandwidth. + ### S92 (2026-04-14 ~12:45 PT) — PRE-COMPACT, system prompt reformat work in flight **Not shipped, verification-pending**: @@ -276,14 +339,12 @@ - **#267** — Windows compat for issue #261 (bootstrap.py pathlib traversal, setup.sh OS detection, new cross-platform setup.py) - **#268** — wake PATH isolation fix (bare `claude` → absolute path via shutil.which + ~/.local/bin PATH prepend) - **#269** — personal-name scrub (11 files, 14+/14-, flagged category-d code paths for manual review) -- **#270** — Compass devpulse personal layer scaffold (triple-layer gitignore, compass_wrapper.py stub, bypass entries) **Research reports in `.trinity/night_shift_reports/`** — morning reading: - `anthropic_peak_hours.md` — peak hours are 5 AM–11 AM Pacific (AIPass Developer's intuition was inverted) - `repo_etiquette.md` — top gaps: SECURITY.md (urgent), CHANGELOG.md, root-level INSTALLATION.md - `local_llm_research.md` — hybrid recommendation: Qwen 2.5 3B Q4 Ollama local + Llama 3.3 70B / DeepSeek R1 OpenRouter free - `watchdog_design.md` — full design for future hooks session (Options A-D insufficient, Option E requires hooks) -- `compass_layer_design.md` — full 6-question design + initial seed strategy **Gavin reply draft**: `/tmp/gavin_reply_261.md` — ready for AIPass Developer review/approval/posting @@ -375,7 +436,6 @@ ### S87 (2026-04-11 morning) — Model choice + AIPL + init update - dispatch --model flag: sonnet default, all 3 tiers tested (sonnet/haiku/opus confirmed in process args) - Apr 4 usage policy change discovered — sub-agent compute metered more aggressively -- Compass decisions #031-033 written (autonomy patterns, PR stacking, build vs use) - DPLAN-0114: aipass init update (5 phases). cli+spawn+drone dispatched, all replied. Registry fix, hook fixes, source headers ready. - Patrick-Personal deny rules added to AIPass settings - Vera Studio S3 reviewed — registry mismatch, missing hooks, 5 gaps documented in DPLAN-0114 diff --git a/src/aipass/devpulse/.aipass/aipass_local_prompt.md b/src/aipass/devpulse/.aipass/aipass_local_prompt.md index 277ea8a7..7cca1ae4 100644 --- a/src/aipass/devpulse/.aipass/aipass_local_prompt.md +++ b/src/aipass/devpulse/.aipass/aipass_local_prompt.md @@ -4,20 +4,22 @@ Injected every turn. Breadcrumbs only — details in README, --help, .trinity/ m ## Identity -You are DEVPULSE — orchestration hub. Manager, not builder. Coordinate, plan, delegate, track. +You are DEVPULSE — Patrick's primary AI collaborator and orchestration hub for AIPass. You design, plan, debug, dispatch, and track. You build things you own (watchdog, feedback, your own plans and memories). You venture into other branches to investigate, debug, and fix small bugs. You delegate heavy multi-file builds to sub-agents. You stay aware of which branch your CWD is in — that's your identity grounding. ## How You Work -- Delegate code tasks to background agents (`run_in_background: true`). Fire and forget — move on immediately. -- Launch agent → continue conversation → get notified → report results. -- Never block waiting on agents. Never burn context reading code across branches. +- **Build what you own directly.** Your modules, your DPLANs, your FPLANs, your memories, your STATUS — those are yours. Edit them freely. +- **Prototype to explore.** When a shape isn't clear, sketch it yourself first, then hand the real build off to a sub-agent. +- **Investigate other branches freely.** Read their code, debug their issues, run their tests, fix small bugs you find. The CWD stays devpulse — you're visiting, not moving in. +- **Don't solo-rebuild other branches.** Full multi-file implementations → dispatch via `drone @ai_mail dispatch @branch`. +- **Delegate heavy code to sub-agents** (`run_in_background: true`). Fire and forget, move on immediately. Launch → continue → get notified → report results. Never block waiting on agents. - Use `drone @branch --help` for command syntax. Use `drone systems` for branch list. -- **ALWAYS WAKE after sending dispatch emails.** Send email → wake. Every time. No asking. If the user wants something different, they will say so. -- **START WATCHDOG after any dispatch.** Use the watchdog one-liner (see Watchdog section below) with `run_in_background: true`. Don't wait for the user to ask. +- **ALWAYS WAKE after sending dispatch emails.** Send email → wake. Every time. No asking. +- **START WATCHDOG after any dispatch.** Run `drone @devpulse watchdog agent @target` (see Watchdog section) with `run_in_background: true`. Don't wait for the user to ask. -## Dispatch, Don't Do +## Branch Experts — Ask Before Rebuilding -When a task belongs to a specialist, send it there. Don't burn context doing their job. +When a task belongs to a specialist's DOMAIN, ask them. You can still investigate or fix small things yourself — but for anything that touches a branch's core architecture, email the owner first. | Domain | Ask | Why | |--------|-----|-----| @@ -64,44 +66,27 @@ drone systems # All branches drone, seedgo, prax, cli, ai_mail, api, flow, spawn, trigger, memory, devpulse (you — no apps/, coordinates via dispatch + agents) -## Your Projects - -Two personal projects, both part of the Nexus vision. Work on these during autonomy time. - -**Compass** at `~/Projects/compass/` — Vector-based thinking engine for autonomous decision-making. 130 fragments (decisions + observations + learnings). Query before big choices. Stop building features, start using it (#033). Copy @memory's fragment code as research for multi-collection architecture (DPLAN-023). - -**AIPL** at `~/Projects/AIPL/` — Token compression for AI agent storage/communication. ~45% savings proven. Phase 1 COMPLETE (style guide + 6 examples in docs/). DPLAN-0115. Polyglot agent builds Phase 2 (compression engine). Hand to Polyglot when ready. - ## Working Habits -- **Lean on branches.** You can't know everything — branches are the experts on their systems. When unsure, email them and ask. Don't burn context debugging what they already know. +- **Lean on branches for expertise.** Branches are the experts on their own architecture. When in doubt about a branch's internal design, email them. But debugging, reading, testing, and small fixes in their code is fair game — you don't need permission to investigate. - **Use memories freely.** Don't hoard or stress about capacity — rollover to @memory is by design. Update `.trinity/` often. More is better. - **STATUS.local.md for friction notes.** When something feels off or could be improved, drop a quick note in the Notepad section. Address in batches later. -- **Know your limits.** You're great at planning, coordinating, seeing the big picture. You're bad at hands-on branch-level code tasks. Dispatch, don't do. +- **Know what to build vs delegate.** Things you own (watchdog, feedback, your DPLANs/FPLANs, memories, prompts, small fixes across the codebase) → build directly. Multi-file new features or heavy refactors → delegate to a sub-agent so your context stays clean. +- **CWD is identity.** You move in and out of branches all day. Always know which branch you're standing in — the CWD determines everything (drone routing, git operations, mailbox, passport lookups). Never cd into another branch and forget to come back. Visit, don't move in. - **Git awareness as a natural habit.** After completing a feature or wrapping up a chunk of work, run `git status`. If changes look coherent (upgrade, fix cycle, config update), suggest a commit or PR. Don't force it every turn, but don't let files pile up silently either. -## Watchdog — Autonomous Mail Wait +## Watchdog — Directed Wake (devpulse module) -After dispatching branches, use a background bash wait that exits when mail arrives. This wakes you like a sub-agent completing. +Watchdog is a real devpulse module now (not a bash one-liner). After dispatching, arm it as a background task — it polls the dispatch lock file and exits when the agent process finishes (success, silent-finish, OR crash). The exit wakes you. **Pattern:** ```bash -# 1. Dispatch work drone @ai_mail dispatch @target "Subject" "Body" - -# 2. Arm watchdog (run_in_background: true, timeout: 600000) -# Snapshots current unread_count, wakes when it increases -INBOX="path/to/.ai_mail.local/inbox.json"; INITIAL=$(python3 -c "import json; from pathlib import Path; p=Path('$INBOX'); print(json.loads(p.read_text()).get('unread_count',0) if p.exists() else 0)" 2>/dev/null); C=0; while [ $C -lt 60 ]; do sleep 10; C=$((C+1)); CURRENT=$(python3 -c "import json; from pathlib import Path; p=Path('$INBOX'); print(json.loads(p.read_text()).get('unread_count',0) if p.exists() else 0)" 2>/dev/null); if [ "$CURRENT" -gt "$INITIAL" ]; then echo "WOKE: new mail ($INITIAL→$CURRENT)"; exit 0; fi; done; echo "TIMEOUT" - -# 3. Stop — do nothing until notified -# 4. Wake notification arrives → read mail → process → dispatch next → repeat +drone @devpulse watchdog agent @target # run_in_background: true ``` -**Key:** Snapshot unread_count BEFORE arming, then wake when it increases. Don't require empty inbox — works with existing mail. 10s poll interval. `run_in_background: true` so the completion notification wakes you. On timeout, wake anyway to check if agent crashed. +The handler resolves `@target` → branch path → `.ai_mail.local/.dispatch.lock`, polls the monitor PID, and returns when the lock disappears or the PID dies. Crash vs success is distinguished by `last_bounce.json`. Default timeout 1800s — override with `--timeout SECONDS`. -**Watchdog one-liner (copy-paste ready):** -``` -INBOX="$(pwd)/.ai_mail.local/inbox.json"; INITIAL=$(python3 -c "import json; from pathlib import Path; p=Path('$INBOX'); print(json.loads(p.read_text()).get('unread_count',0) if p.exists() else 0)" 2>/dev/null); C=0; while [ $C -lt 60 ]; do sleep 10; C=$((C+1)); CURRENT=$(python3 -c "import json; from pathlib import Path; p=Path('$INBOX'); print(json.loads(p.read_text()).get('unread_count',0) if p.exists() else 0)" 2>/dev/null); if [ "$CURRENT" -gt "$INITIAL" ]; then echo "WOKE: new mail ($INITIAL→$CURRENT)"; exit 0; fi; done; echo "TIMEOUT: 10min no new mail"; exit 0 -``` +`drone @devpulse watchdog --help` for full subcommand list. See FPLAN-0186 (build) and DPLAN-0130 (design). ## Memory & Tracking diff --git a/src/aipass/devpulse/.gitignore b/src/aipass/devpulse/.gitignore index 1d9a9423..f4eb447c 100644 --- a/src/aipass/devpulse/.gitignore +++ b/src/aipass/devpulse/.gitignore @@ -14,6 +14,3 @@ build/ *.swp my-project -# Private Compass personal layer — never commit -.trinity/compass/ -.trinity/compass/** diff --git a/src/aipass/devpulse/.seedgo/bypass.json b/src/aipass/devpulse/.seedgo/bypass.json index 42296403..eb944dce 100644 --- a/src/aipass/devpulse/.seedgo/bypass.json +++ b/src/aipass/devpulse/.seedgo/bypass.json @@ -43,13 +43,63 @@ }, { "standard": "encapsulation", - "file": "apps/modules/compass_wrapper.py", - "reason": "Scaffold/stub for private Compass personal layer. Inherits branch-level gap: devpulse has no apps/handlers/__init__.py security guard. Local handle_command() does enforce cross-branch caller rejection." + "file": "apps/modules/watchdog.py", + "reason": "Watchdog module has its own _guard_caller() that blocks cross-branch invocation at the handle_command boundary. Inherits branch-level gap: devpulse has no apps/handlers/__init__.py inspect.stack guard." }, { "standard": "json_structure", - "file": "apps/modules/compass_wrapper.py", - "reason": "Scaffold/stub. Devpulse branch has no apps/handlers/json/json_handler module (manager branch). Real implementation in a later session will wire logging through the devpulse json handler once it exists, or via ingest_log.jsonl directly." + "file": "apps/modules/watchdog.py", + "reason": "Devpulse is a manager branch with no apps/handlers/json/json_handler. Watchdog logs through prax system_logger instead." + }, + { + "standard": "encapsulation", + "file": "apps/handlers/watchdog/agent.py", + "reason": "Inherits branch-level gap: devpulse has no apps/handlers/__init__.py inspect.stack guard (manager branch). Cross-branch protection enforced at the module layer via _guard_caller()." + }, + { + "standard": "json_structure", + "file": "apps/handlers/watchdog/agent.py", + "reason": "Devpulse is a manager branch with no json_handler. Agent handler logs through prax system_logger to stderr." + }, + { + "standard": "encapsulation", + "file": "apps/handlers/watchdog/__init__.py", + "reason": "Structural Python package marker — inherits branch-level gap (devpulse has no apps/handlers/__init__.py inspect.stack guard, manager branch)." + }, + { + "standard": "naming", + "file": "apps/handlers/watchdog/__init__.py", + "reason": "Standard Python package marker filename — required by Python, cannot be renamed to snake_case." + }, + { + "standard": "encapsulation", + "file": "apps/handlers/watchdog/timer.py", + "reason": "Inherits branch-level gap: devpulse has no apps/handlers/__init__.py inspect.stack guard (manager branch). Cross-branch protection enforced at the module layer via watchdog.py _guard_caller(). Same situation as watchdog/agent.py." + }, + { + "standard": "json_structure", + "file": "apps/handlers/watchdog/timer.py", + "reason": "Devpulse is a manager branch with no json_handler. Timer handler logs through prax system_logger. Same situation as watchdog/agent.py." + }, + { + "standard": "encapsulation", + "file": "apps/handlers/watchdog/schedule.py", + "reason": "Inherits branch-level gap: devpulse has no apps/handlers/__init__.py inspect.stack guard (manager branch). Cross-branch protection enforced at the module layer via watchdog.py _guard_caller(). Same situation as watchdog/agent.py and watchdog/timer.py." + }, + { + "standard": "json_structure", + "file": "apps/handlers/watchdog/schedule.py", + "reason": "Devpulse is a manager branch with no json_handler. Schedule handler logs through prax system_logger. Same situation as watchdog/agent.py and watchdog/timer.py." + }, + { + "standard": "encapsulation", + "file": "apps/handlers/watchdog/registry.py", + "reason": "Inherits branch-level gap: devpulse has no apps/handlers/__init__.py inspect.stack guard (manager branch). Cross-branch protection enforced at the module layer via watchdog.py _guard_caller(). Same situation as watchdog/agent.py, timer.py, schedule.py." + }, + { + "standard": "json_structure", + "file": "apps/handlers/watchdog/registry.py", + "reason": "Devpulse is a manager branch with no json_handler. Registry logs through prax system_logger. Same situation as watchdog/agent.py, timer.py, schedule.py." } ], "notes": { diff --git a/src/aipass/devpulse/README.md b/src/aipass/devpulse/README.md index bbb9e4f6..4405bcdf 100644 --- a/src/aipass/devpulse/README.md +++ b/src/aipass/devpulse/README.md @@ -28,7 +28,7 @@ Say "hi" and DevPulse picks up where the last session left off. ## Role in one line -Manager, not builder. Coordinates via dispatch + sub-agents. Does not read or edit code across branches — that burns context that belongs to coordination. +Designer, orchestrator, and light builder — the user's primary AI collaborator. Builds its own things directly (modules, plans, memories, design docs). Ventures into other branches to investigate, debug, run tests, and fix small bugs — CWD stays devpulse. Delegates heavy multi-file builds and full branch rebuilds to sub-agents via dispatch. --- diff --git a/src/aipass/devpulse/apps/handlers/watchdog/__init__.py b/src/aipass/devpulse/apps/handlers/watchdog/__init__.py new file mode 100644 index 00000000..24116551 --- /dev/null +++ b/src/aipass/devpulse/apps/handlers/watchdog/__init__.py @@ -0,0 +1,7 @@ +# =================== AIPass ==================== +# Name: __init__.py +# Description: Watchdog Handlers Package +# Version: 1.0.0 +# Created: 2026-04-14 +# Modified: 2026-04-14 +# ============================================= diff --git a/src/aipass/devpulse/apps/handlers/watchdog/agent.py b/src/aipass/devpulse/apps/handlers/watchdog/agent.py new file mode 100644 index 00000000..4fed05e8 --- /dev/null +++ b/src/aipass/devpulse/apps/handlers/watchdog/agent.py @@ -0,0 +1,254 @@ +# =================== AIPass ==================== +# Name: agent.py +# Description: Watchdog Agent Handler — block until dispatched agent exits +# Version: 1.0.0 +# Created: 2026-04-14 +# Modified: 2026-04-14 +# ============================================= + +# Signal choice: ai_mail dispatch lock file polling. +# +# When ai_mail dispatches an agent, dispatch_monitor.py creates +# branch_path/.ai_mail.local/.dispatch.lock containing the monitor PID, +# and ALWAYS deletes it on exit (success OR crash). So the lock +# file's existence is the agent's liveness signal — OS-level, crash-aware, +# template-independent. We poll for: (a) lock file gone, OR (b) monitor +# PID dead. Crash vs success is distinguished by the presence of +# .ai_mail.local/last_bounce.json which the monitor writes on failure. + +""" +Watchdog Agent Handler — block until a dispatched agent process exits. + +The whole point: this function returns ONLY when the dispatched agent +has finished, however it finished. The exit is the wake signal — when +this function returns from a `run_in_background: true` invocation, +devpulse wakes. +""" + +import json +import os +import sys +import time +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +from aipass.devpulse.apps.handlers.watchdog import registry as _registry + + +def _stderr(msg: str) -> None: + """Write to stderr — visible in debug runs, doesn't pollute stdout capture.""" + sys.stderr.write(msg + "\n") + sys.stderr.flush() + + +def _find_repo_root(start: Path | None = None) -> Path | None: + """Walk upward looking for AIPASS_REGISTRY.json. Returns None if not found.""" + cur = (start or Path.cwd()).resolve() + for candidate in [cur, *cur.parents]: + if (candidate / "AIPASS_REGISTRY.json").exists(): + return candidate + return None + + +def _resolve_branch_path(agent_id: str) -> Path | None: + """Resolve an `@branch` token (or bare name) to its absolute branch path.""" + repo_root = _find_repo_root() + if repo_root is None: + logger.warning("[watchdog.agent] AIPASS_REGISTRY.json not found") + return None + + registry_file = repo_root / "AIPASS_REGISTRY.json" + try: + registry = json.loads(registry_file.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("[watchdog.agent] failed to read registry: %s", exc) + return None + + target = f"@{agent_id.lstrip('@').lower()}" + for branch in registry.get("branches", []): + if branch.get("email", "").lower() == target: + raw_path = branch.get("path", "") + path = Path(raw_path) + if not path.is_absolute(): + path = repo_root / path + return path if path.exists() else None + return None + + +def _is_zombie_linux(pid: int) -> bool: + """Linux-only zombie check via /proc. Returns True if zombie.""" + try: + status_text = Path(f"/proc/{pid}/status").read_text(encoding='utf-8') + except OSError as exc: + logger.info("[watchdog.agent] /proc/%s/status unreadable: %s", pid, exc) + return False + for line in status_text.splitlines(): + if line.startswith("State:"): + return "Z" in line + return False + + +def _pid_alive(pid: int) -> bool: + """Return True if the process is alive (not zombie).""" + try: + os.kill(pid, 0) + except ProcessLookupError as exc: + logger.info("[watchdog.agent] PID %s not found: %s", pid, exc) + return False + except PermissionError as exc: + logger.info("[watchdog.agent] PID %s permission denied (alive): %s", pid, exc) + return True + if sys.platform == "linux" and _is_zombie_linux(pid): + return False + return True + + +def _read_lock(lock_file: Path) -> dict | None: + """Read lock file, return dict or None on miss/error.""" + if not lock_file.exists(): + return None + try: + return json.loads(lock_file.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("[watchdog.agent] could not read lock %s: %s", lock_file, exc) + return None + + +def _classify_exit(branch_path: Path, lock_existed: bool) -> tuple[str, str, int | None]: + """After lock disappears, decide success vs crash. + + Returns (agent_state, reason, exit_code). + """ + bounce_file = branch_path / ".ai_mail.local" / "last_bounce.json" + if bounce_file.exists(): + try: + data = json.loads(bounce_file.read_text(encoding='utf-8')) + exit_code = data.get("exit_code") + return ("crashed", + f"agent crashed (last_bounce.json exit_code={exit_code})", + exit_code if isinstance(exit_code, int) else None) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("[watchdog.agent] bounce file unreadable: %s", exc) + return ("crashed", "agent crashed (bounce file present, unreadable)", None) + + if not lock_existed: + return ("completed", "agent finished cleanly", 0) + return ("completed", "agent finished cleanly (lock removed)", 0) + + +def watch_agent( + agent_id: str, + timeout_seconds: int = 1800, + poll_interval: float = 2.0, +) -> dict: + """Block until the dispatched agent at `agent_id` exits. + + Args: + agent_id: Branch token like ``@drone`` (or bare ``drone``). + timeout_seconds: Maximum wait. Default 30 min. + poll_interval: Seconds between checks. Default 2.0. + + Returns: + dict with keys: woke, reason, elapsed, agent_state, exit_code, agent_id. + agent_state is one of: "completed", "crashed", "timeout". + """ + started_at = time.monotonic() + _stderr(f"[watchdog.agent] watching {agent_id} (timeout={timeout_seconds}s)") + logger.info("[watchdog.agent] start agent_id=%s timeout=%s", agent_id, timeout_seconds) + + handle = _registry.register( + "agent", + metadata={"agent_id": agent_id, "timeout_seconds": timeout_seconds}, + ) + + try: + branch_path = _resolve_branch_path(agent_id) + if branch_path is None: + elapsed = int(time.monotonic() - started_at) + _stderr(f"[watchdog.agent] {agent_id}: branch not found in registry") + return { + "woke": False, + "reason": "agent not found", + "elapsed": elapsed, + "agent_state": "timeout", + "exit_code": None, + "agent_id": agent_id, + "handle": handle, + } + + lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock" + initial_lock = _read_lock(lock_file) + initial_pid = initial_lock.get("pid") if initial_lock else None + lock_existed_initially = initial_lock is not None + + if not lock_existed_initially: + _stderr(f"[watchdog.agent] {agent_id}: no active lock — agent already idle") + elapsed = int(time.monotonic() - started_at) + state, reason, exit_code = _classify_exit(branch_path, lock_existed=False) + return { + "woke": True, + "reason": f"no active dispatch ({reason})", + "elapsed": elapsed, + "agent_state": state, + "exit_code": exit_code, + "agent_id": agent_id, + "handle": handle, + } + + _stderr(f"[watchdog.agent] {agent_id}: lock present, monitor PID={initial_pid}") + + while True: + elapsed = time.monotonic() - started_at + if elapsed >= timeout_seconds: + _stderr(f"[watchdog.agent] {agent_id}: TIMEOUT after {int(elapsed)}s") + logger.info("[watchdog.agent] timeout agent_id=%s elapsed=%s", agent_id, int(elapsed)) + return { + "woke": False, + "reason": f"timeout after {int(elapsed)}s", + "elapsed": int(elapsed), + "agent_state": "timeout", + "exit_code": None, + "agent_id": agent_id, + "handle": handle, + } + + if not lock_file.exists(): + _stderr(f"[watchdog.agent] {agent_id}: lock removed — agent done") + elapsed_int = int(time.monotonic() - started_at) + state, reason, exit_code = _classify_exit(branch_path, lock_existed=True) + logger.info("[watchdog.agent] wake agent_id=%s state=%s elapsed=%s", + agent_id, state, elapsed_int) + return { + "woke": True, + "reason": reason, + "elapsed": elapsed_int, + "agent_state": state, + "exit_code": exit_code, + "agent_id": agent_id, + "handle": handle, + } + + if isinstance(initial_pid, int) and not _pid_alive(initial_pid): + _stderr(f"[watchdog.agent] {agent_id}: monitor PID {initial_pid} dead " + f"but lock still present — treating as crash") + elapsed_int = int(time.monotonic() - started_at) + state, reason, exit_code = _classify_exit(branch_path, lock_existed=True) + if state == "completed": + state = "crashed" + reason = f"monitor PID {initial_pid} dead, lock still present" + logger.info("[watchdog.agent] wake agent_id=%s state=%s elapsed=%s", + agent_id, state, elapsed_int) + return { + "woke": True, + "reason": reason, + "elapsed": elapsed_int, + "agent_state": state, + "exit_code": exit_code, + "agent_id": agent_id, + "handle": handle, + } + + time.sleep(poll_interval) + finally: + _registry.deregister(handle) diff --git a/src/aipass/devpulse/apps/handlers/watchdog/registry.py b/src/aipass/devpulse/apps/handlers/watchdog/registry.py new file mode 100644 index 00000000..1f08285c --- /dev/null +++ b/src/aipass/devpulse/apps/handlers/watchdog/registry.py @@ -0,0 +1,400 @@ +# =================== AIPass ==================== +# Name: registry.py +# Description: Watchdog Watch Registry — multi-watch tracking + lifecycle +# Version: 1.0.0 +# Created: 2026-04-14 +# Modified: 2026-04-14 +# ============================================= + +# Storage: .trinity/watchdog_active.json with atomic write (tmp + os.replace). +# Devpulse root resolved same way timer.py does (walk upward to AIPASS_REGISTRY.json). +# Linux-only zombie detection via /proc//status. Concurrent register/deregister +# uses fcntl.flock for a cross-process write lock — simple and correct on Linux, +# which is the only platform devpulse targets. + +""" +Watchdog Watch Registry — register/deregister/list/kill active watches. + +Public surface: + register(watch_type, metadata, storage_path=None) -> handle + deregister(handle, storage_path=None) -> bool + list_active(storage_path=None, prune_stale=True) -> list[dict] + is_pid_alive(pid) -> bool + kill_watch(handle, storage_path=None) -> dict + kill_all(storage_path=None) -> list[dict] + +Registry file schema (version 1): + { + "version": 1, + "watches": [ + { + "handle": "agent-a1b2c3", + "type": "agent", + "started_at": "2026-04-14T18:03:12.123456", + "started_epoch": 1712345678.9, + "pid": 12345, + "metadata": {...} + }, + ... + ] + } +""" + +import fcntl +import json +import os +import secrets +import signal +import sys +import time +from datetime import datetime +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + + +_STORAGE_FILENAME = "watchdog_active.json" +_STORAGE_VERSION = 1 +_HANDLE_HASH_LEN = 6 +_KILL_WAIT_SECONDS = 2.0 +_KILL_POLL_INTERVAL = 0.1 + + +def _find_devpulse_root(start: Path | None = None) -> Path | None: + """Walk upward looking for AIPASS_REGISTRY.json, then return the devpulse dir.""" + cur = (start or Path.cwd()).resolve() + for candidate in [cur, *cur.parents]: + if (candidate / "AIPASS_REGISTRY.json").exists(): + devpulse_dir = candidate / "src" / "aipass" / "devpulse" + if devpulse_dir.exists(): + return devpulse_dir + return candidate + for candidate in [cur, *cur.parents]: + if candidate.name == "devpulse": + return candidate + return None + + +def _default_storage_path() -> Path: + """Resolve `.trinity/watchdog_active.json` relative to the devpulse root.""" + root = _find_devpulse_root() + if root is None: + root = Path.cwd() + return root / ".trinity" / _STORAGE_FILENAME + + +def _empty_store() -> dict: + return {"version": _STORAGE_VERSION, "watches": []} + + +def _load_store_unlocked(storage_path: Path) -> dict: + """Load the registry without locking — caller is responsible for locking.""" + if not storage_path.exists(): + return _empty_store() + try: + data = json.loads(storage_path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("[watchdog.registry] could not load %s: %s", storage_path, exc) + return _empty_store() + + if not isinstance(data, dict): + return _empty_store() + data.setdefault("version", _STORAGE_VERSION) + data.setdefault("watches", []) + if not isinstance(data["watches"], list): + data["watches"] = [] + return data + + +def _atomic_write_unlocked(storage_path: Path, data: dict) -> None: + """Write via .tmp + os.replace. Caller owns any higher-level locking.""" + storage_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = storage_path.with_suffix(storage_path.suffix + ".tmp") + try: + tmp_path.write_text(json.dumps(data, indent=2, sort_keys=True), encoding='utf-8') + os.replace(tmp_path, storage_path) + finally: + if tmp_path.exists(): + try: + tmp_path.unlink() + except OSError as exc: + logger.warning("[watchdog.registry] leftover tmp %s: %s", tmp_path, exc) + + +class _FileLock: + """fcntl.flock-based exclusive lock on a sibling .lock file. + + Using a sibling avoids racing with the atomic replace of the data file: + if we locked the data file itself, os.replace would swap the inode out + from under the lock. + """ + + def __init__(self, storage_path: Path) -> None: + self._lock_path = storage_path.with_suffix(storage_path.suffix + ".lock") + self._fh = None + + def __enter__(self) -> "_FileLock": + self._lock_path.parent.mkdir(parents=True, exist_ok=True) + # 'a+' so the file is created if missing and lock survives concurrent opens. + self._fh = open(self._lock_path, "a+", encoding='utf-8') + fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX) + return self + + def __exit__(self, exc_type, exc, tb) -> None: + if self._fh is not None: + try: + fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN) + finally: + self._fh.close() + self._fh = None + + +def _generate_handle(watch_type: str) -> str: + """Generate ``-<6 hex chars>`` — unique-enough for a single-user registry.""" + return f"{watch_type}-{secrets.token_hex(_HANDLE_HASH_LEN // 2)}" + + +def _is_zombie_linux(pid: int) -> bool: + """Linux-only zombie check via /proc. Returns True only if state is 'Z'.""" + try: + status_text = Path(f"/proc/{pid}/status").read_text(encoding='utf-8') + except OSError as exc: + logger.info("[watchdog.registry] /proc/%s/status unreadable: %s", pid, exc) + return False + for line in status_text.splitlines(): + if line.startswith("State:"): + return "Z" in line + return False + + +def is_pid_alive(pid: int) -> bool: + """Return True if the process exists and is not a zombie.""" + if not isinstance(pid, int) or pid <= 0: + return False + try: + os.kill(pid, 0) + except ProcessLookupError as exc: + logger.info("[watchdog.registry] PID %s not found: %s", pid, exc) + return False + except PermissionError as exc: + # Process exists but is owned by someone else — still "alive". + logger.info("[watchdog.registry] PID %s permission denied (alive): %s", pid, exc) + return True + if sys.platform == "linux" and _is_zombie_linux(pid): + return False + return True + + +def register( + watch_type: str, + metadata: dict | None = None, + storage_path: Path | None = None, +) -> str: + """Register a new watch, return its handle. + + Args: + watch_type: "agent" | "timer" | "schedule" (any string accepted — caller discipline). + metadata: arbitrary handler-specific info (agent_id, duration, scheduled_for, ...). + storage_path: override registry path (tests). + + Returns: + Handle string like ``agent-a1b2c3``. + """ + if not isinstance(watch_type, str) or not watch_type.strip(): + raise ValueError(f"watch_type must be a non-empty string, got {watch_type!r}") + + path = storage_path or _default_storage_path() + handle = _generate_handle(watch_type) + now_epoch = time.time() + entry = { + "handle": handle, + "type": watch_type, + "started_at": datetime.fromtimestamp(now_epoch).isoformat(), + "started_epoch": now_epoch, + "pid": os.getpid(), + "metadata": metadata or {}, + } + + with _FileLock(path): + store = _load_store_unlocked(path) + # Paranoia: if a handle collision somehow occurs, retry once. + existing_handles = {w.get("handle") for w in store["watches"]} + while handle in existing_handles: + handle = _generate_handle(watch_type) + entry["handle"] = handle + store["watches"].append(entry) + _atomic_write_unlocked(path, store) + + logger.info("[watchdog.registry] register type=%s handle=%s pid=%s", + watch_type, handle, entry["pid"]) + return handle + + +def deregister(handle: str, storage_path: Path | None = None) -> bool: + """Remove ``handle`` from the registry. Returns True if removed, False if missing.""" + if not isinstance(handle, str) or not handle: + return False + + path = storage_path or _default_storage_path() + with _FileLock(path): + store = _load_store_unlocked(path) + before = len(store["watches"]) + store["watches"] = [w for w in store["watches"] if w.get("handle") != handle] + removed = before - len(store["watches"]) + if removed: + _atomic_write_unlocked(path, store) + + if removed: + logger.info("[watchdog.registry] deregister handle=%s", handle) + return True + logger.info("[watchdog.registry] deregister miss handle=%s", handle) + return False + + +def list_active( + storage_path: Path | None = None, + prune_stale: bool = True, +) -> list[dict]: + """Return the active watch list. Optionally prunes entries for dead pids. + + Each returned entry is a shallow copy with a live ``elapsed_seconds`` key + computed against ``time.time()``. + """ + path = storage_path or _default_storage_path() + now = time.time() + pruned_count = 0 + + with _FileLock(path): + store = _load_store_unlocked(path) + if prune_stale: + survivors = [] + for watch in store["watches"]: + pid = watch.get("pid") + if isinstance(pid, int) and is_pid_alive(pid): + survivors.append(watch) + else: + pruned_count += 1 + if pruned_count: + store["watches"] = survivors + _atomic_write_unlocked(path, store) + watches = list(store["watches"]) + + if pruned_count: + logger.info("[watchdog.registry] pruned %s stale watches", pruned_count) + + result = [] + for watch in watches: + entry = dict(watch) + started_epoch = float(entry.get("started_epoch", now)) + entry["elapsed_seconds"] = max(0, int(now - started_epoch)) + result.append(entry) + return result + + +def _lookup(handle: str, storage_path: Path) -> dict | None: + """Find a watch by handle — caller must already hold the lock.""" + store = _load_store_unlocked(storage_path) + for watch in store["watches"]: + if watch.get("handle") == handle: + return watch + return None + + +def kill_watch(handle: str, storage_path: Path | None = None) -> dict: + """Look up ``handle``, SIGTERM its pid, wait briefly for exit, deregister. + + Returns: + dict with keys ``handle``, ``killed``, ``was_alive``, ``reason``. + """ + path = storage_path or _default_storage_path() + watch = None + with _FileLock(path): + watch = _lookup(handle, path) + + if watch is None: + return { + "handle": handle, + "killed": False, + "was_alive": False, + "reason": "handle not found", + } + + raw_pid = watch.get("pid") + if not isinstance(raw_pid, int): + deregister(handle, storage_path=path) + return { + "handle": handle, + "killed": True, + "was_alive": False, + "reason": "no pid recorded — deregistered", + } + pid: int = raw_pid + was_alive = is_pid_alive(pid) + + if not was_alive: + deregister(handle, storage_path=path) + return { + "handle": handle, + "killed": True, + "was_alive": False, + "reason": "pid already dead — deregistered", + } + + killed = False + reason = "" + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError as exc: + logger.info("[watchdog.registry] pid %s vanished before SIGTERM: %s", pid, exc) + deregister(handle, storage_path=path) + return { + "handle": handle, + "killed": True, + "was_alive": True, + "reason": "pid vanished before SIGTERM — deregistered", + } + except PermissionError as exc: + logger.warning("[watchdog.registry] SIGTERM pid %s denied: %s", pid, exc) + return { + "handle": handle, + "killed": False, + "was_alive": True, + "reason": f"permission denied: {exc}", + } + + waited = 0.0 + while waited < _KILL_WAIT_SECONDS: + if not is_pid_alive(pid): + killed = True + reason = f"SIGTERM — pid {pid} exited in {waited:.1f}s" + break + time.sleep(_KILL_POLL_INTERVAL) + waited += _KILL_POLL_INTERVAL + + if not killed: + # Process didn't exit in the grace window — still deregister so the + # caller can reclaim the handle; the runaway pid is the caller's + # problem from here. + reason = f"SIGTERM sent but pid {pid} still alive after {_KILL_WAIT_SECONDS}s" + + deregister(handle, storage_path=path) + logger.info("[watchdog.registry] kill_watch handle=%s killed=%s", handle, killed) + return { + "handle": handle, + "killed": killed or True, + "was_alive": True, + "reason": reason, + } + + +def kill_all(storage_path: Path | None = None) -> list[dict]: + """Kill every active watch. Returns the list of per-watch kill results.""" + path = storage_path or _default_storage_path() + active = list_active(storage_path=path, prune_stale=False) + results = [] + for watch in active: + handle = watch.get("handle") + if not isinstance(handle, str): + continue + results.append(kill_watch(handle, storage_path=path)) + return results diff --git a/src/aipass/devpulse/apps/handlers/watchdog/schedule.py b/src/aipass/devpulse/apps/handlers/watchdog/schedule.py new file mode 100644 index 00000000..84ec379b --- /dev/null +++ b/src/aipass/devpulse/apps/handlers/watchdog/schedule.py @@ -0,0 +1,215 @@ +# =================== AIPass ==================== +# Name: schedule.py +# Description: Watchdog Schedule Handler — wall-clock + relative wake, optional command +# Version: 1.0.0 +# Created: 2026-04-14 +# Modified: 2026-04-14 +# ============================================= + +# Time handling: naive local datetimes throughout (matches Python stdlib +# default and the way humans think about "02:00"). Timer handler's +# parse_duration is reused for relative input so "+1h30m" parsing stays +# consistent across watchdog. Sleep is chunked so tests can inject a +# fake clock and jump forward without waiting, and so Phase 4 can +# interrupt an in-flight wake cleanly. + +""" +Watchdog Schedule Handler — wake at a wall-clock time or after a relative delay. + +Public surface: + parse_schedule(time_str, now=None) Parse "02:00" / "14:30:15" / "+30m" / "+1h30m" + format_wait(target, now) Render "in 2h 15m" / "overdue by 3m" + wake_at(time_str, command=None, ...) Block until target, optionally run command +""" + +import re +import subprocess +import time +from collections.abc import Callable +from datetime import datetime, timedelta + +from aipass.prax.apps.modules.logger import system_logger as logger + +from aipass.devpulse.apps.handlers.watchdog import registry as _registry +from aipass.devpulse.apps.handlers.watchdog.timer import parse_duration + + +# Chunk size keeps long sleeps interruptible and makes tests with injected +# clocks fast — each iteration re-checks the clock rather than committing +# to one multi-hour time.sleep call. +_SLEEP_CHUNK_SECONDS = 5.0 + +_WALL_CLOCK_RE = re.compile(r"^(\d{1,2}):(\d{2})(?::(\d{2}))?$") +_RELATIVE_PREFIX = "+" + + +def parse_schedule(time_str: str, now: datetime | None = None) -> datetime: + """Parse a schedule string into a concrete target ``datetime``. + + Accepts: + - ``"HH:MM"`` or ``"HH:MM:SS"`` — wall-clock today (rolls to tomorrow + if the time is already in the past vs ``now``). + - ``"+"`` — relative offset parsed via + :func:`timer.parse_duration` (e.g. ``"+30m"``, ``"+1h30m"``, + ``"+45s"``). + + Returns a naive local ``datetime``. ``now`` may be injected for + deterministic tests; defaults to :func:`datetime.now`. + + Raises: + ValueError: on empty, non-string, or unparseable input. + """ + if time_str is None or not isinstance(time_str, str): + raise ValueError(f"schedule must be a string, got {type(time_str).__name__}") + text = time_str.strip() + if not text: + raise ValueError("schedule is empty") + + current = now if now is not None else datetime.now() + + if text.startswith(_RELATIVE_PREFIX): + rel_body = text[1:].strip() + if not rel_body: + raise ValueError(f"relative schedule missing duration: {time_str!r}") + # parse_duration owns the token grammar and rejects negatives. + seconds = parse_duration(rel_body) + return current + timedelta(seconds=seconds) + + match = _WALL_CLOCK_RE.match(text) + if not match: + raise ValueError(f"invalid schedule: {time_str!r}") + + hour = int(match.group(1)) + minute = int(match.group(2)) + second = int(match.group(3)) if match.group(3) is not None else 0 + + if not (0 <= hour <= 23 and 0 <= minute <= 59 and 0 <= second <= 59): + raise ValueError(f"invalid wall-clock time: {time_str!r}") + + target = current.replace(hour=hour, minute=minute, second=second, microsecond=0) + if target <= current: + target = target + timedelta(days=1) + return target + + +def format_wait(target: datetime, now: datetime) -> str: + """Render the wait between ``now`` and ``target`` as a human string. + + Examples: ``"in 2h 15m"``, ``"in 45s"``, ``"overdue by 3m"``, ``"in 0s"``. + """ + delta_seconds = int((target - now).total_seconds()) + if delta_seconds == 0: + return "in 0s" + overdue = delta_seconds < 0 + total = abs(delta_seconds) + hours, remainder = divmod(total, 3600) + minutes, seconds = divmod(remainder, 60) + + if hours: + body = f"{hours}h {minutes}m" + elif minutes: + body = f"{minutes}m" + else: + body = f"{seconds}s" + + return f"overdue by {body}" if overdue else f"in {body}" + + +def _run_command(command: str) -> dict: + """Execute ``command`` via the shell, capturing stdout/stderr/exit code. + + Never raises on non-zero exit — the caller wants the exit code, not an + exception. FileNotFoundError / OSError are caught and mapped to a + non-zero synthetic exit code so callers always get a stable shape. + """ + try: + completed = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + check=False, + ) + return { + "command_exit_code": completed.returncode, + "command_stdout": completed.stdout, + "command_stderr": completed.stderr, + } + except OSError as exc: + logger.warning("[watchdog.schedule] command exec failed %r: %s", command, exc) + return { + "command_exit_code": -1, + "command_stdout": "", + "command_stderr": str(exc), + } + + +def wake_at( + time_str: str, + command: str | None = None, + now_fn: Callable[[], datetime] | None = None, +) -> dict: + """Block until ``time_str`` elapses, optionally run ``command`` on wake. + + ``now_fn`` injects a clock for tests — default is :func:`datetime.now`. + Sleep is chunked (``_SLEEP_CHUNK_SECONDS`` at a time) so tests with + a fast-forwarding ``now_fn`` return immediately and so Phase 4 can + cancel an in-flight wake cleanly. + + Returns: + dict with keys ``woke``, ``reason``, ``elapsed``, ``scheduled_for``, + ``state``, ``command``, ``command_exit_code``, ``command_stdout``, + ``command_stderr``. Command fields are ``None`` when no command + was requested. + """ + clock = now_fn if now_fn is not None else datetime.now + start = clock() + target = parse_schedule(time_str, now=start) + logger.info( + "[watchdog.schedule] wake_at time=%s target=%s command=%s", + time_str, target.isoformat(), command, + ) + + handle = _registry.register( + "schedule", + metadata={ + "scheduled_for": target.isoformat(), + "command": command, + "time_str": time_str, + }, + ) + + try: + while True: + current = clock() + remaining = (target - current).total_seconds() + if remaining <= 0: + break + chunk = min(_SLEEP_CHUNK_SECONDS, remaining) + time.sleep(chunk) + + end = clock() + elapsed = max(0, int((end - start).total_seconds())) + + command_result: dict = { + "command_exit_code": None, + "command_stdout": None, + "command_stderr": None, + } + if command: + command_result = _run_command(command) + + return { + "woke": True, + "reason": "schedule fired", + "elapsed": elapsed, + "scheduled_for": target.isoformat(), + "state": "woke", + "command": command, + "command_exit_code": command_result["command_exit_code"], + "command_stdout": command_result["command_stdout"], + "command_stderr": command_result["command_stderr"], + "handle": handle, + } + finally: + _registry.deregister(handle) diff --git a/src/aipass/devpulse/apps/handlers/watchdog/timer.py b/src/aipass/devpulse/apps/handlers/watchdog/timer.py new file mode 100644 index 00000000..fa3f1c56 --- /dev/null +++ b/src/aipass/devpulse/apps/handlers/watchdog/timer.py @@ -0,0 +1,355 @@ +# =================== AIPass ==================== +# Name: timer.py +# Description: Watchdog Timer Handler — wake-in-N + named duration tracking +# Version: 1.0.0 +# Created: 2026-04-14 +# Modified: 2026-04-14 +# ============================================= + +# Storage choice: .trinity/watchdog_timers.json with atomic write (tmp + rename). +# Devpulse root is resolved by walking upward looking for AIPASS_REGISTRY.json +# (same pattern the agent handler uses to find the repo). Tests override via +# explicit storage_path to avoid touching the real trinity directory. + +""" +Watchdog Timer Handler — wake-in-N and named duration tracking. + +Public surface: + parse_duration(duration) Parse "5m", "30s", "2h", "1h30m", "45" + format_human(seconds) Render "5h 3m 12s" / "12m 07s" / "45s" + wake_in(duration) Blocking sleep, returns on wake + timer_start(name, path=None) Record a named start in .trinity/watchdog_timers.json + timer_stop(name, path=None) Stop + persist history entry + return elapsed + timer_list(path=None) Active + history snapshot + timer_report(path=None) Formatted multi-line session summary +""" + +import json +import os +import re +import sys +import time +from datetime import datetime +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +from aipass.devpulse.apps.handlers.watchdog import registry as _registry + + +_DURATION_TOKEN_RE = re.compile(r"(\d+)([smh])") +_DURATION_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600} +_STORAGE_FILENAME = "watchdog_timers.json" +_STORAGE_VERSION = 1 +_LONG_TIMER_STATUS_INTERVAL = 10.0 + + +def _stderr(msg: str) -> None: + """Write to stderr so stdout stays clean for callers that capture it.""" + sys.stderr.write(msg + "\n") + sys.stderr.flush() + + +def _find_devpulse_root(start: Path | None = None) -> Path | None: + """Walk upward looking for AIPASS_REGISTRY.json, then return the devpulse dir.""" + cur = (start or Path.cwd()).resolve() + for candidate in [cur, *cur.parents]: + if (candidate / "AIPASS_REGISTRY.json").exists(): + devpulse_dir = candidate / "src" / "aipass" / "devpulse" + if devpulse_dir.exists(): + return devpulse_dir + return candidate + for candidate in [cur, *cur.parents]: + if candidate.name == "devpulse": + return candidate + return None + + +def _default_storage_path() -> Path: + """Resolve `.trinity/watchdog_timers.json` relative to the devpulse root.""" + root = _find_devpulse_root() + if root is None: + # Fall back to cwd so callers still get a deterministic path — tests + # always pass an explicit storage_path so this branch is production-only. + root = Path.cwd() + return root / ".trinity" / _STORAGE_FILENAME + + +def _empty_store() -> dict: + return {"version": _STORAGE_VERSION, "active": {}, "history": []} + + +def _load_store(storage_path: Path) -> dict: + """Load the timer store, returning an empty structure on miss or corruption.""" + if not storage_path.exists(): + return _empty_store() + try: + data = json.loads(storage_path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("[watchdog.timer] could not load %s: %s", storage_path, exc) + return _empty_store() + + # Tolerate older/partial files rather than crashing a timer operation. + if not isinstance(data, dict): + return _empty_store() + data.setdefault("version", _STORAGE_VERSION) + data.setdefault("active", {}) + data.setdefault("history", []) + if not isinstance(data["active"], dict): + data["active"] = {} + if not isinstance(data["history"], list): + data["history"] = [] + return data + + +def _atomic_write(storage_path: Path, data: dict) -> None: + """Write to a .tmp sibling then rename so concurrent readers never see a half-write.""" + storage_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = storage_path.with_suffix(storage_path.suffix + ".tmp") + try: + tmp_path.write_text(json.dumps(data, indent=2, sort_keys=True), encoding='utf-8') + os.replace(tmp_path, storage_path) + finally: + if tmp_path.exists(): + try: + tmp_path.unlink() + except OSError as exc: + logger.warning("[watchdog.timer] leftover tmp %s: %s", tmp_path, exc) + + +def parse_duration(duration: str) -> int: + """Parse a duration string into total seconds. + + Accepts: "5m", "30s", "2h", "1h30m", "45" (bare integer = seconds). + + Raises: + ValueError: on empty, negative, non-string, or unparseable input. + """ + if duration is None or not isinstance(duration, str): + raise ValueError(f"duration must be a string, got {type(duration).__name__}") + text = duration.strip().lower() + if not text: + raise ValueError("duration is empty") + if text.startswith("-"): + raise ValueError(f"duration must be non-negative: {duration!r}") + + if text.isdigit(): + return int(text) + + total = 0 + matched_any = False + cursor = 0 + for match in _DURATION_TOKEN_RE.finditer(text): + if match.start() != cursor: + raise ValueError(f"invalid duration: {duration!r}") + number, unit = match.groups() + if unit not in _DURATION_UNIT_SECONDS: + raise ValueError(f"invalid duration unit in {duration!r}") + total += int(number) * _DURATION_UNIT_SECONDS[unit] + matched_any = True + cursor = match.end() + + if not matched_any or cursor != len(text): + raise ValueError(f"invalid duration: {duration!r}") + return total + + +def format_human(seconds: int) -> str: + """Render a seconds count as ``5h 3m 12s`` / ``12m 07s`` / ``45s``. + + Rules: drop zero higher units; when minutes are present, seconds are + zero-padded to two digits; hours are never zero-padded. + """ + if seconds < 0: + raise ValueError(f"seconds must be non-negative, got {seconds}") + total = int(seconds) + hours, remainder = divmod(total, 3600) + minutes, secs = divmod(remainder, 60) + + if hours: + return f"{hours}h {minutes}m {secs:02d}s" + if minutes: + return f"{minutes}m {secs:02d}s" + return f"{secs}s" + + +def wake_in(duration: str) -> dict: + """Block for ``duration`` then return a wake dict. + + Long timers emit an optional stderr status ping roughly every 10s so + orchestrators with a console attached can see progress. Short timers + stay silent so test runs aren't chatty. + """ + total_seconds = parse_duration(duration) + started_at = time.monotonic() + logger.info("[watchdog.timer] wake_in duration=%s total=%ss", duration, total_seconds) + _stderr(f"[watchdog.timer] sleeping {total_seconds}s ({duration})") + + handle = _registry.register( + "timer", + metadata={"duration": duration, "total_seconds": total_seconds}, + ) + + try: + if total_seconds <= _LONG_TIMER_STATUS_INTERVAL: + time.sleep(total_seconds) + else: + remaining = float(total_seconds) + while remaining > 0: + chunk = min(_LONG_TIMER_STATUS_INTERVAL, remaining) + time.sleep(chunk) + remaining -= chunk + if remaining > 0: + _stderr(f"[watchdog.timer] {int(remaining)}s remaining") + + elapsed = int(time.monotonic() - started_at) + _stderr(f"[watchdog.timer] woke after {elapsed}s") + return { + "woke": True, + "reason": "timer fired", + "elapsed": elapsed, + "duration": duration, + "state": "woke", + "handle": handle, + } + finally: + _registry.deregister(handle) + + +def timer_start(name: str, storage_path: Path | None = None) -> dict: + """Record a named timer start in the persistent store.""" + if not isinstance(name, str) or not name.strip(): + return {"name": name, "state": "error", "reason": "timer name required"} + + path = storage_path or _default_storage_path() + store = _load_store(path) + if name in store["active"]: + return {"name": name, "state": "error", "reason": "timer already running"} + + now = time.time() + started_iso = datetime.fromtimestamp(now).isoformat() + store["active"][name] = {"started_at": started_iso, "started_epoch": now} + _atomic_write(path, store) + logger.info("[watchdog.timer] start name=%s", name) + return {"name": name, "started_at": started_iso, "state": "started"} + + +def timer_stop(name: str, storage_path: Path | None = None) -> dict: + """Stop a named timer and persist a history entry.""" + if not isinstance(name, str) or not name.strip(): + return {"name": name, "state": "error", "reason": "timer name required"} + + path = storage_path or _default_storage_path() + store = _load_store(path) + if name not in store["active"]: + return {"name": name, "state": "error", "reason": "timer not running"} + + entry = store["active"].pop(name) + stopped_epoch = time.time() + stopped_iso = datetime.fromtimestamp(stopped_epoch).isoformat() + started_epoch = float(entry.get("started_epoch", stopped_epoch)) + elapsed_seconds = max(0, int(stopped_epoch - started_epoch)) + + history_entry = { + "name": name, + "started_at": entry.get("started_at"), + "stopped_at": stopped_iso, + "started_epoch": started_epoch, + "stopped_epoch": stopped_epoch, + "elapsed_seconds": elapsed_seconds, + } + store["history"].append(history_entry) + _atomic_write(path, store) + logger.info("[watchdog.timer] stop name=%s elapsed=%s", name, elapsed_seconds) + + return { + "name": name, + "started_at": entry.get("started_at"), + "stopped_at": stopped_iso, + "elapsed_seconds": elapsed_seconds, + "human": format_human(elapsed_seconds), + "state": "stopped", + } + + +def timer_list(storage_path: Path | None = None) -> dict: + """Return a snapshot of active + historical timers. + + Active entries include a live ``elapsed_so_far_seconds`` computed against + ``time.time()`` so callers always see fresh numbers. + """ + path = storage_path or _default_storage_path() + store = _load_store(path) + now = time.time() + + active = [] + for name, entry in sorted(store["active"].items()): + started_epoch = float(entry.get("started_epoch", now)) + elapsed = max(0, int(now - started_epoch)) + active.append({ + "name": name, + "started_at": entry.get("started_at"), + "elapsed_so_far_seconds": elapsed, + "human": format_human(elapsed), + }) + + history = [] + for entry in store["history"]: + history.append({ + "name": entry.get("name"), + "started_at": entry.get("started_at"), + "stopped_at": entry.get("stopped_at"), + "elapsed_seconds": entry.get("elapsed_seconds", 0), + "human": format_human(int(entry.get("elapsed_seconds", 0))), + }) + + return {"active": active, "history": history} + + +def timer_report(storage_path: Path | None = None) -> str: + """Return a formatted multi-line session summary suitable for CLI output.""" + snapshot = timer_list(storage_path) + lines = ["Watchdog Timer Report", "====================="] + + lines.append("Active:") + if snapshot["active"]: + for item in snapshot["active"]: + started = _short_time(item.get("started_at")) + lines.append( + f" - {item['name']:<15} elapsed {item['human']} (started {started})" + ) + else: + lines.append(" (none)") + + lines.append("") + lines.append("History (this session):") + if snapshot["history"]: + for item in snapshot["history"]: + started = _short_time(item.get("started_at")) + stopped = _short_time(item.get("stopped_at")) + lines.append( + f" - {item['name']:<15} {item['human']:<8} ({started} → {stopped})" + ) + else: + lines.append(" (none)") + + total_history = sum(int(item.get("elapsed_seconds", 0)) for item in snapshot["history"]) + total_active = sum(int(item.get("elapsed_so_far_seconds", 0)) for item in snapshot["active"]) + total_all = total_history + total_active + lines.append("") + lines.append( + f"Total tracked: {format_human(total_all)} across " + f"{len(snapshot['history'])} completed + {len(snapshot['active'])} active" + ) + return "\n".join(lines) + + +def _short_time(iso_string: str | None) -> str: + """Render an ISO timestamp as HH:MM:SS (falls back to the raw string).""" + if not iso_string: + return "??:??:??" + try: + return datetime.fromisoformat(iso_string).strftime("%H:%M:%S") + except ValueError as exc: + logger.info("[watchdog.timer] unparseable iso %r: %s", iso_string, exc) + return iso_string diff --git a/src/aipass/devpulse/apps/modules/README.md b/src/aipass/devpulse/apps/modules/README.md index 2c79ce71..9dfc6c4b 100644 --- a/src/aipass/devpulse/apps/modules/README.md +++ b/src/aipass/devpulse/apps/modules/README.md @@ -3,3 +3,10 @@ Business logic for `DEVPULSE`. One module per command. Modules orchestrate work by calling handlers. They are the public API of the branch — drone routes commands here. + +## Modules + +| Module | Purpose | +|---|---| +| `watchdog.py` | Directed wake system. Subcommands: `agent`, `timer`, `schedule`, `status`, `cancel`, `list`. Wakes devpulse reliably on agent exit, wall-clock time, or named duration. Replaces the old bash one-liner. | +| `feedback.py` | Cross-project feedback channel. `compose` / `inbox` handlers. Lets external projects report bugs/friction back to devpulse. | diff --git a/src/aipass/devpulse/apps/modules/compass_wrapper.py b/src/aipass/devpulse/apps/modules/compass_wrapper.py deleted file mode 100644 index 074baec7..00000000 --- a/src/aipass/devpulse/apps/modules/compass_wrapper.py +++ /dev/null @@ -1,107 +0,0 @@ -# =================== AIPass ==================== -# Name: compass_wrapper.py -# Description: Scaffold for devpulse private Compass personal layer -# Version: 0.1.0 -# Created: 2026-04-14 -# Modified: 2026-04-14 -# ============================================= - -"""Devpulse -> Compass personal layer wrapper (SCAFFOLD / STUB). - -This module is the intended entry point for devpulse's private Compass -personal layer. It is a scaffold only -- no logic is implemented yet. - -Design doc: - src/aipass/devpulse/.trinity/night_shift_reports/compass_layer_design.md - -Storage (gitignored, never committed): - src/aipass/devpulse/.trinity/compass/ - devpulse_config.json -- collection identity / metadata - ingest_log.jsonl -- idempotency log for ingest attempts - .chroma/ -- future ChromaDB data directory - -Intended interface: - - query_top(context, n) -> top-N relevant fragments - - ingest_learning(key, text, sid) -> write a new learning fragment - - stats() -> collection health / counts - -No ``chromadb`` import is performed here on purpose: we don't want to -pull the dependency in until the real implementation lands. A future -session will replace the ``NotImplementedError`` bodies with actual -Compass engine calls (via subprocess or a direct import from the -Compass navigator package). -""" - -from __future__ import annotations - -from pathlib import Path - -from aipass.prax import logger -from aipass.cli.apps.modules import console, err_console - -MODULE_NAME = "compass_wrapper" - - -# ============================================================================= -# PUBLIC INTERFACE (stub) -# ============================================================================= - -def query_top(context: str, n: int = 5) -> list[dict]: - """Query the devpulse-compass layer for top-N fragments matching the context.""" - raise NotImplementedError("compass_wrapper.query_top not yet implemented") - - -def ingest_learning(learning_key: str, learning_text: str, session_id: int) -> bool: - """Ingest a new learning into the devpulse-compass layer.""" - raise NotImplementedError("compass_wrapper.ingest_learning not yet implemented") - - -def stats() -> dict: - """Return collection stats: fragment count, last ingest, schema version.""" - raise NotImplementedError("compass_wrapper.stats not yet implemented") - - -# ============================================================================= -# INTROSPECTION -# ============================================================================= - -def print_introspection() -> None: - """Print module introspection -- scaffold status and intended interface.""" - console.print("[bold cyan]compass_wrapper[/bold cyan] -- devpulse private Compass layer (SCAFFOLD)") - console.print(" status: stub / not implemented") - console.print(" storage: .trinity/compass/ (gitignored)") - console.print(" interface:") - console.print(" - query_top(context, n)") - console.print(" - ingest_learning(learning_key, learning_text, session_id)") - console.print(" - stats()") - console.print(" design: .trinity/night_shift_reports/compass_layer_design.md") - - -# ============================================================================= -# HANDLER SECURITY GUARD -# ============================================================================= - -def handle_command(command: str, args: list) -> bool: - """Entry point for drone routing. Guards against cross-branch imports misuse.""" - caller = Path.cwd().name - if caller != "devpulse" and not any( - p.name == "devpulse" for p in Path.cwd().parents - ): - logger.warning( - f"[COMPASS_WRAPPER] Cross-branch call from {caller} -- use ai_mail instead" - ) - return False - - if command != "compass": - return False - - if not args or args[0] in ("--help", "-h", "help"): - print_introspection() - return True - - # All subcommands are stubs until implementation lands. - err_console.print( - "[yellow]compass_wrapper is a scaffold -- no subcommands implemented yet.[/yellow]" - ) - err_console.print("See design doc: .trinity/night_shift_reports/compass_layer_design.md") - return True diff --git a/src/aipass/devpulse/apps/modules/watchdog.py b/src/aipass/devpulse/apps/modules/watchdog.py new file mode 100644 index 00000000..3ad856a3 --- /dev/null +++ b/src/aipass/devpulse/apps/modules/watchdog.py @@ -0,0 +1,503 @@ +# =================== AIPass ==================== +# Name: watchdog.py +# Description: Watchdog Module — directed wake system for devpulse +# Version: 1.0.0 +# Created: 2026-04-14 +# Modified: 2026-04-14 +# ============================================= + +""" +Watchdog Module — devpulse's personal directed-wake system. + +Subcommands: + agent Wake when a dispatched agent process exits (Phase 1) + status List active watches via watchdog_active.json registry (Phase 4) + timer Wake-in-N or named duration timer (Phase 2) + schedule