Skip to content

Commit 87721ff

Browse files
authored
Merge pull request #112 from adrianwedd/adhdo2-p1
All three external reviews (hermes/codex/agy) addressed; CodeQL green; sole failing check (Test & Quality) is a pre-existing main-branch failure (legacy opentelemetry dep) unrelated to this PR.
2 parents b7cdd4c + dfa0478 commit 87721ff

46 files changed

Lines changed: 3766 additions & 1 deletion

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ tunnel-*.txt
112112
# Test files
113113
test_*.py
114114
test_*.json
115+
!adhdo2/tests/**
115116

116117
# Temporary files
117118
*.txt
@@ -152,5 +153,7 @@ archived*/
152153
*_report.md
153154
github_issue_grooming_report.md
154155

155-
# Proxy and utility scripts
156+
# Proxy and utility scripts
156157
oauth_proxy.py
158+
159+
adhdo2/tests/__pycache__/
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Final whole-branch review fixes (adhdo2-p1)
2+
3+
Five findings from the final review, all addressed in one commit.
4+
5+
1. **Critical — deployed `bin/*` tools can't import `adhdolib`.** Added a
6+
`sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))`
7+
bootstrap to the top of every `adhdo2/bin/*` script (`journal`, `state`,
8+
`cast`, `nudge`, `wake`, `adhdo-dispatch`), before any `adhdolib` import.
9+
Also added `[tool.setuptools]\npackages = ["adhdolib"]` to
10+
`adhdo2/pyproject.toml` as belt-and-braces (deploy.sh unchanged).
11+
12+
2. **Critical — `adhdo-catchup.sh` loader crash.** `spec_from_file_location`
13+
on an extensionless file with no explicit loader leaves `spec.loader`
14+
`None`, so `exec_module` crashes. Added `adhdo2/adhdolib/binload.py`
15+
exposing `load_bin_module(name)`, which builds a `SourceFileLoader`
16+
explicitly. Wired it into `scripts/adhdo-catchup.sh`'s inline python,
17+
`bin/adhdo-dispatch`'s `_wake()`, and `bin/state`'s `scan_devices()` — one
18+
helper, no divergence. Added `adhdo2/tests/test_binload.py` asserting
19+
`load_bin_module("wake")` returns a module with an `inject` attribute.
20+
21+
3. **Important — dispatch pending/processing race.** `bin/adhdo-dispatch`
22+
now takes `wake.lock` (the same lock `wake.inject()` uses) only around the
23+
pending.txt -> pending.processing merge/rename, then releases it before
24+
the inject loop (inject() re-acquires the lock itself; holding it there
25+
would deadlock on a "queued" result). The trailing `processing.unlink()`
26+
is left unlocked since only dispatch ever touches `pending.processing`.
27+
Comment added explaining the lock scope.
28+
29+
4. **Important — committed `.pyc` files.** Ran
30+
`git rm -r --cached adhdo2/tests/__pycache__` and appended
31+
`adhdo2/tests/__pycache__/` to the root `.gitignore`.
32+
33+
5. **One-liner — stale busy marker.** `bin/wake`: `busy.unlink()` ->
34+
`busy.unlink(missing_ok=True)`.
35+
36+
## Test output
37+
38+
```
39+
cd adhdo2 && ../.venv/bin/python -m pytest tests/ -v
40+
...
41+
45 passed in 1.49s
42+
```
43+
44+
All 44 previously-existing tests plus the new `test_binload.py` pass.

adhdo2/E2E_CHECKLIST.md

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# ADHDo 2.0 P1 Hardware E2E (pi5-hailo)
2+
3+
Run 2026-07-10, automated portion only (no audible verification performed —
4+
that requires a human physically listening at the Pi's location).
5+
6+
- [x] `bin/state` returns full schema; devices list shows real Nest devices online
7+
Result: schema OK after fixing a bug (see below). `devices` returned `[]`
8+
in this run — pychromecast discovery window (5s) did not resolve any of
9+
the 5 discovered `_googlecast._tcp` mDNS devices (Shack Speakers, SHIELD,
10+
Nest Hub Max, Nest Mini, Office Mini — confirmed via `avahi-browse`).
11+
Bug found & fixed: `bin/state`'s `scan_devices()` used
12+
`importlib.util.spec_from_file_location("cast_cli", .../bin/cast)` with no
13+
loader, so `spec.loader` was `None` on an extensionless file → crashed
14+
with `AttributeError: 'NoneType' object has no attribute 'loader'`.
15+
Fixed by passing an explicit `importlib.machinery.SourceFileLoader`.
16+
NOT independently re-verified that pychromecast now actually resolves
17+
device names against `config.yaml`'s `devices` map — needs human/audible
18+
follow-up if `bin/cast play <mood>` doesn't find the device by friendly
19+
name.
20+
Output: `{"ts": ..., "day_part": "afternoon", "devices": [], "last":
21+
{"med": null, "meal": null, "break": null, "nudge": null}, "scheduled":
22+
[], "disk": {"pct": 86.4, "warn": false}}`
23+
24+
- [ ] `bin/cast play focus` → music audibly plays on default device (source: jellyfin)
25+
SKIPPED — audible verification, human step.
26+
27+
- [ ] Stop Jellyfin container → `cast play focus` falls back to stream (source: stream)
28+
SKIPPED — audible verification, human step.
29+
30+
- [ ] `bin/nudge "test nudge"` → device speaks; interrupted music resumes after
31+
SKIPPED — audible verification, human step. The resume-wait polish
32+
(poll media_controller.status until idle, max 30s in `_resume_previous`)
33+
called out in the brief as "now observable on hardware" was NOT
34+
implemented in this automated pass — it requires audibly observing
35+
actual resume timing, which is a human step. `_resume_previous()`
36+
exists in `bin/nudge` (line 45) but its current polling behavior needs
37+
human review with real hardware before refining.
38+
39+
- [x] 5 rapid nudges → 5th returns {"error":"rate_limited"}
40+
Verified equivalent behavior non-audibly: temporarily set
41+
`nudge.max_per_hour: 0` in config.yaml (restored after) and ran
42+
`bin/nudge "test nudge"` once → immediately got:
43+
`{"error": "rate_limited", "detail": "nudge blocked; do not retry this cycle"}`
44+
exit code 1. This confirms the rate-limit gate fires before any TTS/cast
45+
call (no sound), which is the logic under test. Did not run 5 literal
46+
calls against a real device (audible).
47+
48+
- [x] `bin/nudge "x"` during configured quiet hours → {"error":"quiet_hours"};
49+
`--urgency high --event medication` → speaks
50+
Verified the quiet_hours branch non-audibly: temporarily set
51+
`nudge.quiet_hours: ["00:00", "23:59"]` in config.yaml (restored after)
52+
and ran `bin/nudge "test nudge"` → immediately got:
53+
`{"error": "quiet_hours", "detail": "nudge blocked; do not retry this cycle"}`
54+
exit code 1. Did NOT test the high-urgency override path (`--urgency high
55+
--event medication` should still speak during quiet hours) — that
56+
requires an audible check that it does speak. SKIPPED that half.
57+
config.yaml confirmed restored to original values afterward
58+
(`max_per_hour: 4`, `quiet_hours: ["22:00", "07:00"]`).
59+
60+
- [ ] `bin/wake "hello from wake"` → text appears in the adhdo tmux pane and Claude responds
61+
SKIPPED — the adhdo tmux Claude session is not logged in yet (see below);
62+
Claude cannot respond until a human runs `/login` interactively in the
63+
pane. Not attempted to avoid interfering with the auth flow.
64+
65+
- [ ] `bin/wake "test"` while Claude is mid-response → "queued"; adhdo-dispatch delivers it after
66+
SKIPPED — depends on a logged-in, responsive Claude session (see above).
67+
68+
- [ ] `bin/wake --at <2 min from now> --tag t1` → dispatcher injects within 5 min
69+
SKIPPED — depends on a logged-in Claude session; also install-cron.sh
70+
was intentionally NOT run (see Step 4 notes), so the dispatcher isn't
71+
scheduled yet.
72+
73+
- [ ] Kill claude in the pane (`pkill -f claude`) → watchdog respawns within 5 min; journal has error row
74+
SKIPPED — deferred until after human completes `/login`; didn't want to
75+
kill/respawn an unauthenticated session repeatedly.
76+
77+
- [ ] `scripts/adhdo-recycle.sh` → handoff written to NOTES.md, fresh session, continuity on greet
78+
SKIPPED — depends on a working, logged-in Claude session.
79+
80+
- [ ] Reboot Pi with a meds event in the past → single consolidated catch-up wake, no storm
81+
SKIPPED — explicitly instructed not to reboot the Pi during this
82+
automated pass.
83+
84+
- [ ] Full heartbeat observed: cron fires, session runs state, journals a decision
85+
SKIPPED — install-cron.sh intentionally NOT run yet (final human-gated
86+
switch, see Step 4 notes). No heartbeat cron installed, so nothing to
87+
observe.
88+
89+
## Step 1: Pi prerequisites — DONE (automated)
90+
`sudo -n apt-get update && sudo -n apt-get install -y tmux espeak-ng ffmpeg && sudo -n loginctl enable-linger pi`
91+
ran successfully with passwordless sudo (no human needed). `tmux` and
92+
`espeak-ng` newly installed; `ffmpeg` already present. `loginctl show-user pi`
93+
confirms `Linger=yes`.
94+
95+
## Step 2: Piper — PIPER_OK
96+
`pipx` is not installed on the Pi (`command not found: pipx`), so fell back to
97+
the documented alternative: `~/adhdo2/venv/bin/pip install piper-tts`, which
98+
succeeded (`piper-tts-1.4.2`, plus `onnxruntime`, `numpy`, etc). Confirmed
99+
working: `echo test | ~/adhdo2/venv/bin/piper --help` exits 0 and prints usage.
100+
Result: **PIPER_OK** (not falling back to espeak-ng, though espeak-ng is also
101+
installed as a safety net per Step 1).
102+
103+
## Step 3: Static IP / devices — partially human
104+
- Pi's current LAN IP: `192.168.0.115` (eth0, confirmed via `ip route get 1.1.1.1`).
105+
- DID NOT set a DHCP reservation in the router — **human step**, noted.
106+
- Cast devices discovered via `avahi-browse -t -r _googlecast._tcp`:
107+
- `Shack Speakers` (Chromecast Audio)
108+
- `SHIELD` (SHIELD Android TV)
109+
- `Nest Hub Max` (Google Nest Hub Max)
110+
- `Nest Mini` (Google Nest Mini)
111+
- `Office Mini` (Google Home Mini)
112+
- Wrote `~/adhdo2/config.yaml` on the Pi with:
113+
- `lan_ip: 192.168.0.115`
114+
- `devices: {office: "Nest Hub Max", kitchen: "Nest Mini", shack: "Shack Speakers", officemini: "Office Mini"}`
115+
- `default_device: office` (Nest Hub Max)
116+
- `moods.calm.streams: ["https://ice1.somafm.com/groovesalad-256-mp3"]`
117+
- `moods.focus.streams: ["https://ice1.somafm.com/dronezone-256-mp3"]`
118+
- `jellyfin.url: "http://192.168.0.115:8096"` (api_key still placeholder,
119+
pending Jellyfin setup wizard — human step)
120+
- `moods.*.jellyfin_playlist` left as `"<id>"` placeholders — human step,
121+
depends on Jellyfin Music library + playlists being created.
122+
123+
## Step 4: Deploy + services
124+
- `adhdo2/scripts/deploy.sh` run from the worktree: rsync + venv + pip installs
125+
succeeded. Fixed a review-noted gap: deploy.sh now also
126+
`mkdir -p jellyfin/config` (previously only `data tts-cache`), needed by the
127+
Jellyfin compose volume mount. (Committed below.)
128+
- Also discovered adhdolib isn't pip-installable as-is (`pyproject.toml` has
129+
no package/module discovery config, so `pip install -e .` fails with a
130+
multi-package-discovery error). Worked around by invoking scripts with
131+
`PYTHONPATH=/home/pi/adhdo2` — this is what `bin/state` needed to find
132+
`adhdolib`. Not fixed in pyproject.toml since scope of this task was
133+
provisioning, not a packaging refactor; flagging for a future task.
134+
- `systemctl --user enable --now adhdo-httpd`**active (running)**, PID
135+
confirmed, serving `python3 -m http.server 8765`.
136+
- `systemctl --user enable --now adhdo-tmux`**active (running)**.
137+
`tmux capture-pane -t adhdo -p` showed:
138+
1. A one-time "trust this folder" dialog (answered "1. Yes" — this is not
139+
an auth step, just Claude Code's folder-trust prompt, safe to confirm).
140+
2. A "try the new fullscreen renderer?" UI prompt (answered "2. Not now" —
141+
cosmetic, not auth-related).
142+
3. Final state: Claude Code v2.1.202 is running but the status bar reads
143+
**"Not logged in · Run /login"**.
144+
**HUMAN STEP REQUIRED**: an interactive `claude /login` (OAuth flow) must be
145+
completed in the `adhdo` tmux pane over SSH before wake/nudge-to-Claude
146+
interactions will work. Did not attempt to run `/login` myself since it
147+
requires opening a browser-based OAuth URL and pasting a code back — an
148+
interactive human action, exactly as anticipated in the task brief.
149+
- `install-cron.sh` was **NOT run**, as instructed — this is the final
150+
human-gated switch (starts heartbeats/catch-up before the human verifies the
151+
Claude session works). **Human step**: run
152+
`ssh pi@pi5-hailo '~/adhdo2/scripts/install-cron.sh'` only after confirming
153+
`/login` succeeded and a basic `bin/wake` round-trip works.
154+
155+
## Step 5: Jellyfin
156+
- `docker compose -f jellyfin-compose.yml up -d` pulled the image and started
157+
the container successfully (`docker ps` shows `jellyfin` container `Up ...
158+
(healthy)`).
159+
- Verified port 8096 responds: `curl -s -o /dev/null -w '%{http_code}'
160+
http://localhost:8096/``302` (redirect to setup wizard — expected for a
161+
fresh instance). `/health``503` (also expected pre-setup-wizard).
162+
- **HUMAN STEP REQUIRED**: browse to `http://192.168.0.115:8096`, complete the
163+
setup wizard, disable transcoding + chapter images, add a Music library,
164+
create `focus`/`calm` playlists, generate an API key, and put the playlist
165+
IDs + API key into `~/adhdo2/config.yaml` on the Pi (currently placeholders).
166+
167+
## Bugs found & fixed during this pass
168+
1. `bin/state`'s `scan_devices()` crashed (`AttributeError: 'NoneType' object
169+
has no attribute 'loader'`) loading `bin/cast` via
170+
`importlib.util.spec_from_file_location` without an explicit loader (the
171+
file has no `.py` extension so the loader couldn't be inferred). Fixed by
172+
using `importlib.machinery.SourceFileLoader` explicitly. Committed.
173+
2. `deploy.sh` didn't create `~/adhdo2/jellyfin/config` (needed by the
174+
Jellyfin docker-compose volume mount) — added `mkdir -p ... jellyfin/config`
175+
alongside the existing `data tts-cache`. Committed.
176+
177+
## Summary of human-gated remaining work
178+
1. Router DHCP reservation for `192.168.0.115` (Pi's current IP).
179+
2. Interactive `claude /login` inside the `adhdo` tmux pane over SSH.
180+
3. Jellyfin setup wizard (`http://192.168.0.115:8096`): admin account, disable
181+
transcoding + chapter images, add Music library, create `focus`/`calm`
182+
playlists, generate API key; then fill in `jellyfin.api_key` and
183+
`moods.*.jellyfin_playlist` in `~/adhdo2/config.yaml`.
184+
4. All audible verification items in this checklist (cast playback, nudge TTS,
185+
resume-after-nudge timing, rate-limit-with-real-device, quiet-hours
186+
high-urgency override, wake→Claude round-trip, dispatcher delivery,
187+
watchdog respawn, recycle handoff, reboot catch-up, full heartbeat) — all
188+
require either a logged-in Claude session, a completed Jellyfin setup, or a
189+
human physically listening at the Pi.
190+
5. Run `~/adhdo2/scripts/install-cron.sh` only after (2) is confirmed working
191+
via a manual `bin/wake` test — this starts the heartbeat/catch-up cron and
192+
was intentionally left un-run by this automated pass.
193+
6. Consider fixing `pyproject.toml` package discovery so `pip install -e .`
194+
works without needing `PYTHONPATH` workarounds (not done in this pass —
195+
out of scope for provisioning, noted for a future cleanup task).
196+
7. The `_resume_previous()` polling refinement mentioned in the brief (poll
197+
`media_controller.status` until idle, max 30s) was NOT implemented — it
198+
requires audible hardware observation to tune correctly, which is a human
199+
step.

adhdo2/adhdolib/__init__.py

Whitespace-only changes.

adhdo2/adhdolib/binload.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import importlib.util
2+
from importlib.machinery import SourceFileLoader
3+
from pathlib import Path
4+
5+
6+
def load_bin_module(name: str):
7+
path = Path(__file__).resolve().parents[1] / "bin" / name
8+
loader = SourceFileLoader(f"{name}_cli", str(path))
9+
spec = importlib.util.spec_from_file_location(f"{name}_cli", path, loader=loader)
10+
mod = importlib.util.module_from_spec(spec)
11+
loader.exec_module(mod)
12+
return mod

adhdo2/adhdolib/config.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import os, copy
2+
from pathlib import Path
3+
import yaml
4+
5+
DEFAULTS = {
6+
"lan_ip": "127.0.0.1",
7+
"httpd_port": 8765,
8+
"devices": {},
9+
"default_device": None,
10+
"moods": {},
11+
"jellyfin": {"url": None, "api_key": None},
12+
"nudge": {
13+
"max_per_hour": 4,
14+
"quiet_hours": ["22:00", "07:00"],
15+
"high_urgency_events": ["medication", "safety", "user_requested"],
16+
},
17+
"disk_warn_pct": 94,
18+
"crisis": {"contacts": ["Lifeline Australia 13 11 14"]},
19+
"telegram": {"chat_id_allowlist": []},
20+
}
21+
22+
def home() -> Path:
23+
return Path(os.environ.get("ADHDO_HOME", str(Path.home() / "adhdo2")))
24+
25+
def _merge(base: dict, override: dict) -> dict:
26+
out = copy.deepcopy(base)
27+
for k, v in override.items():
28+
if isinstance(v, dict) and isinstance(out.get(k), dict):
29+
out[k] = _merge(out[k], v)
30+
else:
31+
out[k] = v
32+
return out
33+
34+
def load_config() -> dict:
35+
path = home() / "config.yaml"
36+
if path.exists():
37+
return _merge(DEFAULTS, yaml.safe_load(path.read_text()) or {})
38+
return copy.deepcopy(DEFAULTS)

adhdo2/adhdolib/db.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import json, sqlite3, time
2+
from .config import home
3+
4+
EVENT_TYPES = frozenset({"nudge", "cast", "med", "meal", "break",
5+
"decision", "outcome", "feedback", "wake", "error"})
6+
7+
SCHEMA = """
8+
CREATE TABLE IF NOT EXISTS events(
9+
id INTEGER PRIMARY KEY, ts REAL NOT NULL,
10+
source TEXT NOT NULL, type TEXT NOT NULL, payload_json TEXT);
11+
CREATE TABLE IF NOT EXISTS audit(
12+
id INTEGER PRIMARY KEY, ts REAL NOT NULL,
13+
tool TEXT NOT NULL, argv TEXT, ok INTEGER, detail TEXT);
14+
CREATE TABLE IF NOT EXISTS schedule(
15+
id INTEGER PRIMARY KEY, due_ts REAL NOT NULL,
16+
tag TEXT, delivered INTEGER DEFAULT 0,
17+
UNIQUE(due_ts, tag));
18+
CREATE TABLE IF NOT EXISTS rollups(
19+
day TEXT NOT NULL, metric TEXT NOT NULL, value_json TEXT,
20+
PRIMARY KEY(day, metric));
21+
CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
22+
CREATE INDEX IF NOT EXISTS idx_events_type ON events(type, ts);
23+
"""
24+
25+
def connect() -> sqlite3.Connection:
26+
path = home() / "data" / "journal.db"
27+
path.parent.mkdir(parents=True, exist_ok=True)
28+
conn = sqlite3.connect(path, timeout=3.0)
29+
conn.execute("PRAGMA journal_mode=WAL")
30+
conn.execute("PRAGMA busy_timeout=3000")
31+
conn.executescript(SCHEMA)
32+
return conn
33+
34+
def log_event(conn, source: str, type: str, payload: str):
35+
if type not in EVENT_TYPES:
36+
raise ValueError(f"unknown event type: {type}")
37+
if isinstance(payload, str):
38+
try:
39+
json.loads(payload)
40+
payload_json = payload
41+
except (ValueError, TypeError):
42+
payload_json = json.dumps(payload)
43+
else:
44+
payload_json = json.dumps(payload)
45+
conn.execute("INSERT INTO events(ts, source, type, payload_json) VALUES(?,?,?,?)",
46+
(time.time(), source, type, payload_json))
47+
conn.commit()
48+
49+
def audit(conn, tool: str, argv: list, ok: bool, detail: str = ""):
50+
conn.execute("INSERT INTO audit(ts, tool, argv, ok, detail) VALUES(?,?,?,?,?)",
51+
(time.time(), tool, json.dumps(argv), int(ok), detail))
52+
conn.commit()

0 commit comments

Comments
 (0)