diff options
Diffstat (limited to 'docs/specs')
| -rw-r--r-- | docs/specs/2026-07-02-bluetooth-panel-spec.org | 476 | ||||
| -rw-r--r-- | docs/specs/2026-07-02-desktop-settings-panel-spec.org | 161 | ||||
| -rw-r--r-- | docs/specs/2026-07-02-file-manager-swallow-spec.org | 147 | ||||
| -rw-r--r-- | docs/specs/2026-07-02-net-panel-other-interfaces-spec.org | 195 | ||||
| -rw-r--r-- | docs/specs/2026-07-02-timer-panel-spec.org | 221 | ||||
| -rw-r--r-- | docs/specs/2026-07-03-audio-panel-spec.org | 166 | ||||
| -rw-r--r-- | docs/specs/2026-07-03-instrument-console-panels-spec.org | 174 | ||||
| -rw-r--r-- | docs/specs/2026-07-06-audio-panel-signal-metering-spec.org | 232 | ||||
| -rw-r--r-- | docs/specs/2026-07-07-maintenance-console-spec.org | 350 | ||||
| -rw-r--r-- | docs/specs/2026-07-09-audio-doctor-spec.org | 291 | ||||
| -rw-r--r-- | docs/specs/2026-07-10-audio-doctor-input-side-spec.org | 494 | ||||
| -rw-r--r-- | docs/specs/2026-07-11-bt-doctor-expansion-spec.org | 250 | ||||
| -rw-r--r-- | docs/specs/2026-07-11-net-doctor-expansion-spec.org | 266 |
13 files changed, 3423 insertions, 0 deletions
diff --git a/docs/specs/2026-07-02-bluetooth-panel-spec.org b/docs/specs/2026-07-02-bluetooth-panel-spec.org new file mode 100644 index 0000000..f1b3ac1 --- /dev/null +++ b/docs/specs/2026-07-02-bluetooth-panel-spec.org @@ -0,0 +1,476 @@ +#+TITLE: Bluetooth Panel — CLI-Driven, Net-Panel Kin +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-02 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED Bluetooth Panel — CLI-Driven, Net-Panel Kin +:PROPERTIES: +:ID: 8af6a76a-5665-4d20-9efd-ffdf7460c981 +:END: +- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to IMPLEMENTED (reason: Shipped through phase 3; build task DONE and manual tests filed.) + +* IMPLEMENTED Status +:PROPERTIES: +:ID: 1271a845-4463-4831-9902-990eda6b2265 +:END: +- [2026-07-02 Thu] IMPLEMENTED — all five phases shipped the same day + (dotfiles eb2230f / 76b2c05 / e372de3 / 2a026b1; archsetup d8d8c53): + engine, panel, bar module + blueman retirement, bt-priv + package swap, + install wiring proven by VM assertions. 43 dotfiles suites green, both + AT-SPI smokes green, panels verified live; the phase 4-5 VM assertions + run on the next VM pass. +- [2026-07-02 Thu] DOING — spec-response decomposed the five phases into + build sub-tasks under the todo.org parent (:SPEC_ID: bound); build + started same day per Craig ("4 first, then 1" — bugs then bluetooth). +- [2026-07-02 Thu] READY — spec-review passed the gate: all four + decisions resolved, phases decomposable, CLI verbs verified against + bluez 5.86. Two non-blocking findings recorded and dispositioned in + the same pass (donor-pattern answers). +- [2026-07-02 Thu] DRAFT — initial spec from Craig's request: a bluetooth + module driving a CLI underneath, consistent with the net panel, minimal + interface, full functionality, diagnostics section, visual mockups. + +* Metadata + +| Field | Value | +|--------+---------------------------------------------------------------------------------| +| Status | implemented | +|--------+---------------------------------------------------------------------------------| +| Owner | Craig Jennings | +|--------+---------------------------------------------------------------------------------| +| Repo | dotfiles (bt module); archsetup (packages, sudoers, keybind defaults) | +|--------+---------------------------------------------------------------------------------| +| Kin | net panel (architecture donor), desktop-settings panel (same donor, shared css) | +|--------+---------------------------------------------------------------------------------| + +* Problem + +Bluetooth on both daily drivers runs through blueman: a tray applet plus a +GTK3 manager window (Super+Shift+B). It's the odd one out on the desktop — +a foreign visual style next to the dupre-themed panels, a tray icon where +every other indicator is a first-class waybar module, and no diagnostics +story at all. When the BT mouse fails to reconnect at boot (a recurring +gotcha — touchpad-auto exists because of it) or headphones pair but route +no audio, the fix is a terminal séance: bluetoothctl, rfkill, systemctl, +wpctl, in whatever order folklore suggests. + +The net panel proved the shape that fixes this: a minimal layer-shell +popup over a GTK-free engine that drives a CLI, with a diagnostics tab +that names the failure and offers the repair. Bluetooth is the same +problem with a smaller surface: one adapter, a handful of devices, a +short list of well-known failure modes. + +* Goals + +1. Visibility: adapter power state and every known device with live state + (connected, battery, signal) in one glance — panel and bar module agree. +2. Control: power, scan, pair, connect, disconnect, forget — full + functionality from the panel, zero terminals (the net panel's V2 + contract). +3. Diagnostics: a doctor that walks the known failure chain (adapter → + rfkill → service → power → device → audio profile), names the broken + link in evidence rows, and offers tiered repairs. +4. Consistency: same stack, same window shape, same interaction grammar, + same palette as the net panel. A user who knows one panel knows both. + +Audio-profile switching is in scope for v1 (Craig, 2026-07-02 — "bitten +by this too many times to count"): the doctor's audio-profile step +carries a one-click repair, not just a diagnosis, and connected audio +devices surface their active profile (details in the doctor chain below). + +Non-goals (this iteration): OBEX file transfer, multi-adapter support +(both machines have one controller), BLE sensor/GATT browsing. + +* Design sketch + +** Architecture — the net panel's stack, verbatim + +- GTK4 + gtk4-layer-shell, Blueprint .blp compiled to committed .ui + (=make ui=), PyGObject at runtime. +- Humble-object split: GTK-free =PanelModel= presenter (unit-tested like + net's), thin composite-widget pages, =bg(work, done)= worker-thread + helper for every slow call. +- Engine: a new =bt= package in dotfiles (=bluetooth/src/bt/=, sibling of + =net/=), CLI entry =bt= with =bt status= / =bt panel= / =bt doctor= — + the same cmd/cli layout as net. +- Layer-shell OVERLAY popup anchored TOP+RIGHT, 380x520, Esc closes, + focus-out auto-hides, single-instance toggle via a =bt-panel= wrapper. + Dupre palette css shared with the net panel (the factored css asset the + desktop-settings spec calls for — three consumers now, so the factoring + happens in this project's phase 1 if settings hasn't landed it). +- Testing: engine TDD with fake binaries on a temp PATH (fake-bluetoothctl, + fake-rfkill, fake-systemctl, fake-wpctl); PanelModel unit suite; one + gated AT-SPI smoke (=make test-panel= pattern). + +** CLI backing — bluetoothctl one-shot verbs + +bluez 5.86 (installed) supports everything non-interactive: + +- Adapter: =bluetoothctl show= (powered, discoverable, pairable), + =bluetoothctl power on|off=. +- Device lists: =bluetoothctl devices Paired|Connected|Trusted= — the + Paired view is a merge of Paired + Connected states; =bluetoothctl info + <mac>= per row fills caption detail (battery percentage rides bluez's + built-in Battery1 profile and appears in info output; RSSI appears + during discovery). +- Scan: =bluetoothctl --timeout N scan on= (bounded discovery burst), + then =devices= diffed against Paired for the Nearby list. The panel + scans in 8s bursts with a live "Scanning…" state rather than an + unbounded scan. +- Connect/disconnect/forget: =bluetoothctl connect|disconnect|remove <mac>=. +- Pairing: the one interactive corner. =bluetoothctl pair <mac>= can demand + a passkey confirmation. The engine drives bluetoothctl's line protocol + over a pty with a bounded state machine (expect "Confirm passkey", + reply yes/no); a passkey prompt surfaces as a panel dialog showing the + six digits, mirroring the net panel's password dialog. NoInputNoOutput + devices (mice, most headphones) sail through without the dialog. +- rfkill: the user is in the =rfkill= group, so block/unblock is + unprivileged (=rfkill unblock bluetooth=). +- Privileged path: exactly one verb needs root — =systemctl restart + bluetooth= — so =bt-priv= is a one-verb closed helper with its own + NOPASSWD sudoers rule placed by archsetup, cloning net-priv's + regex-validated pattern rather than widening net-priv's scope. + +** Panel anatomy + +Two tabs. Devices is the panel; Diagnostics is the escape hatch. + +Devices tab, Paired sub-view (the default — daily use is reconnecting +known devices, not discovering new ones): + +#+begin_example +╭──────────────────────────────────────────────╮ +│ [ Devices ] [ Diagnostics ] │ ← top switcher +│ │ +│ Bluetooth ●──○ hci0 on │ ← adapter row: power switch +│ ──────────────────────────────────────────── │ +│ [ Paired ] [ Nearby ] │ ← sub-view switcher +│ ┌──────────────────────────────────────────┐ │ +│ │ MX Master 3 │ │ +│ │ Connected · battery 80% │ │ +│ │ WH-1000XM4 │ │ +│ │ Paired, not connected │ │ +│ │ K380 Keyboard │ │ +│ │ Paired, not connected │ │ +│ │ │ │ +│ └──────────────────────────────────────────┘ │ +│ [ Disconnect ] [ Forget ] │ ← acts on selected row +╰──────────────────────────────────────────────╯ +#+end_example + +The primary button is one control with a state-following label: +"Connect" when the selection is disconnected (suggested-action styling), +"Disconnect" when connected. Row-activate (Enter / double-click) +connects — never disconnects — matching the net panel's asymmetry. +Captions carry the human state line; the MAC lives in the row tooltip, +not the visible caption. + +Devices tab, Nearby sub-view: + +#+begin_example +╭──────────────────────────────────────────────╮ +│ [ Devices ] [ Diagnostics ] │ +│ │ +│ Bluetooth ●──○ hci0 on │ +│ ──────────────────────────────────────────── │ +│ [ Paired ] [ Nearby ] │ +│ ┌──────────────────────────────────────────┐ │ +│ │ Scanning… (6s) │ │ ← overlay state label +│ │ JBL Flip 6 −58 dBm │ │ +│ │ Pixel 9 −71 dBm │ │ +│ │ (unnamed) 74:A5:… −83 dBm │ │ +│ └──────────────────────────────────────────┘ │ +│ [ Pair ] [ Rescan ] [ Discoverable ⊙ ] │ +╰──────────────────────────────────────────────╯ +#+end_example + +Pair does the whole intended thing — pair, then trust, then connect — +because pairing a device means "use it now and reconnect on its own +later" (decision below). Discoverable is a toggle for the inbound case +(pairing a phone TO the laptop), off by default, auto-off with bluez's +discoverable-timeout. Rows sort by RSSI, strongest first; named devices +above unnamed ones. + +Diagnostics tab (mirrors the net panel's shape: one big verb + streaming +evidence rows + tiered repairs behind confirmation): + +#+begin_example +╭──────────────────────────────────────────────╮ +│ [ Devices ] [ Diagnostics ] │ +│ │ +│ [ Get Bluetooth Working ] [ Advanced ▸]│ +│ ┌──────────────────────────────────────────┐ │ +│ │ ✓ Adapter present (hci0) │ │ +│ │ ✓ Not blocked (rfkill clear) │ │ +│ │ ✓ bluetooth.service active │ │ +│ │ ✓ Adapter powered │ │ +│ │ ✗ MX Master 3: paired but unreachable │ │ +│ │ … Re-pair suggested — see below │ │ +│ │ │ │ +│ │ Fix: [ Reconnect ] [ Re-pair device ] │ │ +│ └──────────────────────────────────────────┘ │ +│ power-cycle · restart service · unblock │ ← tiered repairs (confirm) +╰──────────────────────────────────────────────╯ +#+end_example + +The doctor chain, in order, each an evidence row: + +1. Adapter present — =bluetoothctl list= / rfkill has an hci entry. + Absent → hardware/driver verdict, no repair offered. +2. rfkill state — soft-blocked names the likely cause when the + airplane-mode state file says airplane is on ("Blocked by airplane + mode — turn airplane mode off"), otherwise offers Unblock (no root + needed, rfkill group). +3. bluetooth.service — inactive/failed → offer restart (the one bt-priv + verb), evidence quotes the last journal line. +4. Adapter powered — off → offer power on (and note if a boot-time + policy keeps turning it off). +5. Per-device reachability — paired-but-connect-fails distinguishes + "device off/out of range" (RSSI absent in a scan burst) from "bond + corrupt" (connect error string), and only the latter suggests the + re-pair repair (remove + pair + trust + connect, confirmed first — + it's the destructive tier). +6. Audio profile (audio devices only) — device connected but no wpctl + sink/source, or the card stuck in HSP/HFP when A2DP is expected: + evidence names the active profile and offers the repair inline — + "Switch to A2DP" drives =wpctl set-profile <card> <index>= (profile + inventory from =pw-dump= — ground truth 2026-07-02: wpctl can't + enumerate a card's profiles, and the card's =bluez5.profile= prop + reads "off" mid-stream; the card's Profile param and the sink node's + =api.bluez5.profile= are authoritative), verifies the sink came back + in the expected profile, and reports fixed or no-change. In v1 per + Craig (2026-07-02): this failure mode has bitten repeatedly, so it + gets the one-click fix, not just a diagnosis. Connected audio-device + row captions also show the profile when it's the degraded one + ("Connected · mic mode (HSP)") so the state is visible before the + doctor runs. + +Repairs confirm with the net panel's future-tense scope copy ("This will +restart the Bluetooth service. Connected devices will drop and +reconnect."), run on the worker thread, verify after (re-read state, +report "fixed" or "no change"), and never chain silently. + +** Bar module + +=custom/bluetooth= replacing the blueman-applet tray icon: the panel's +glanceable layer, one glyph, state-following like =custom/net=: + +#+begin_example + off / blocked (dim; red slash variant when rfkill-blocked) + on, nothing connected (dim) + connected (white; tooltip lists devices + battery) +#+end_example + +Tooltip carries device names, battery percentages, and the keybind hints +(the module-tooltip convention shipped 2026-07-02). Click opens the +panel (=bt-panel= toggle wrapper); the existing Super+Shift+B bind moves +from blueman-manager to =bt panel=. Low-battery on a connected device +(<15%) adds a red percentage to the glyph text — the mouse dying +mid-meeting is the one state worth surfacing unprompted. + +** UX conformance notes + +Named against the heuristics the panel family follows (Nielsen's ten, +plus the rulesets patterns catalog): + +- Visibility of status: live captions, scan countdown, elapsed ticker on + long ops, verify-after-repair rows. +- Match to the real world: device-kind glyphs + plain state lines; MACs + demoted to tooltips; "Forget" not "Remove bond". +- User control: Esc closes, Rescan is idempotent, scan bursts are + bounded, repairs confirm, running ops show a Stop where stoppable. +- Consistency: interaction grammar is the net panel's — same switcher + layout, same primary-button contract, same confirm copy shape. +- Error prevention: Forget and Re-pair confirm; power-off while devices + are connected states the consequence in the confirm body. +- Recognition over recall: every action is a visible button; no context + menus, no hidden gestures (transient-state-buttons pattern). +- Minimalism: two tabs, one primary action per view, detail behind + tooltips and the Advanced reveal. +- Help users recover: the doctor's evidence rows name the broken link + and carry the repair inline (default-most-common-friction-proportional: + the likely fix is one click, the destructive one is confirmed). + +Tension found with the net panel while writing this (filed as todo.org +tasks per Craig's instruction, 2026-07-02): transient error toasts +auto-dismiss in 4s, and the V2 spec's keyboard-navigation claims +(tab-between-sections, arrow rows, type-to-filter) aren't verifiably +implemented. Both filed against the net panel rather than cloned here; +this panel adopts whatever resolution those tasks land on. + +* Decisions (Craig) [4/4] + +** DONE Pair implies trust + connect? +CLOSED: [2026-07-02 Thu] +Decided (Craig, 2026-07-02): yes — one Pair verb does pair → trust → +connect. A device that shouldn't auto-reconnect gets untrusted later; a +per-device auto-reconnect toggle can ride a later pass. + +** DONE Retire blueman entirely? +CLOSED: [2026-07-02 Thu] +Decided (Craig, 2026-07-02): drop it outright, no bake-in period — the +package leaves archsetup and both machines once phase 2 lands, +bluetoothctl stays as the terminal fallback. Craig's framing: any issue +after retirement is a signal the doctor needs another check or the panel +has a real bug, and it gets fixed there rather than papered over by +keeping blueman around. + +** DONE Battery in the row caption or tooltip only? +CLOSED: [2026-07-02 Thu] +Approved (Craig, 2026-07-02): caption when the device reports it +("Connected · battery 80%"), tooltip otherwise. + +** DONE Scan burst length and auto-rescan? +CLOSED: [2026-07-02 Thu] +Approved (Craig, 2026-07-02): 8s bursts, no auto-repeat — Rescan stays +explicit, matching the net panel's Available view. + +* Review findings [2/2] + +** DONE Empty-state and no-adapter presentation copy undefined :nonblocking: +CLOSED: [2026-07-02 Thu] +The mockups show populated lists; the spec didn't say what an empty Paired +list, an empty post-scan Nearby list, or a machine with no adapter shows +in the panel and on the bar glyph. Dispositioned same pass: clone the +donor — the net panel's in-box overlay message pattern (=show_loading= / +placeholder label) carries the copy. Paired empty: "No paired devices — +switch to Nearby to pair one." Nearby post-scan empty: "Nothing found — +Rescan, or make the device discoverable." No adapter: adapter row reads +"No Bluetooth adapter", Devices controls disable, Diagnostics stays +usable (the doctor's step 1 names the hardware/driver verdict); bar +glyph shows the off/blocked state. Non-blocking; recorded so the +implementer doesn't invent copy mid-build. + +** DONE Logging/redaction carry-over unstated :nonblocking: +CLOSED: [2026-07-02 Thu] +The spec says "the net panel's stack, verbatim" but didn't name whether +the engine adopts net's =eventlog= (structured op log) and =redact= +(sensitive-field scrubbing) modules. Dispositioned same pass: yes, both +carry over — every mutating verb (pair/connect/forget/repair) logs an +eventlog entry, and MACs are the redaction surface (device names stay, +MACs redact in copied reports, mirroring net's report redaction). +Non-blocking; it's the donor default made explicit. + +* Implementation phases + +1. Engine =bt= package: adapter/device/scan probes over fake-bluetoothctl, + status + doctor chain (rfkill, service, powered, reachability, audio + profile probe + A2DP switch repair over fake-wpctl) — pure TDD, no + GTK. =bt status= and =bt doctor= work from a terminal. Shared dupre + css factored to the common asset if the settings panel hasn't already + done it. +2. Panel: PanelModel presenter + Blueprint pages (Devices with + Paired/Nearby, Diagnostics), worker-thread wiring, pairing-dialog + state machine, bt-panel toggle wrapper, AT-SPI smoke. Super+Shift+B + rebind. +3. Bar module =custom/bluetooth= (glyph states, tooltip, low-battery + surface, refresh signal), waybar config + suite coverage; blueman + retirement per the decision. +4. bt-priv one-verb helper + sudoers rule in archsetup; package-list + swap (blueman out per decision, bluez-utils stays); VM test + assertions. +5. archsetup keybind/config defaults so a fresh install lands the panel + wired (waybar module present, bind set, sudoers placed). + +* Review and iteration history + +** 2026-07-02 Thu @ 15:19:58 -0400 — Claude Code (archsetup) — phase 5 builder, spec closed +- *What changed or was recommended:* Phase 5 shipped and the spec flipped + to IMPLEMENTED. No new install code was needed — the waybar module, the + =Super+Shift+B= bind, and the shared panel css all ride the dotfiles + hyprland tier that a fresh install already clones and stows, and sudoers + is covered by the blanket grant. The phase's substance is proof: + =test_desktop.py= gained hyprland-gated assertions for the four stowed + bt bins, the =custom/bluetooth= waybar entry, the =bt-panel= keybind, + and the stowed =panel.css=. +- *Why:* Final phase of the DOING decomposition; with it the todo parent + closed and the lifecycle keyword flipped with a history line. +- *Artifacts:* archsetup =scripts/testing/tests/test_desktop.py=; todo.org + parent DONE + dated phase 5 / test-surface entries; this spec's Status + heading. + +** 2026-07-02 Thu @ 15:16:51 -0400 — Claude Code (archsetup) — phase 4 builder +- *What changed or was recommended:* Phase 4 shipped. Dotfiles =2a026b1=: + the stowed =bt-priv= shim (one verb, verified against the fake-systemctl) + and the sxhkd =Super+Shift+B= bind repointed from blueman-manager to + =st -e bluetoothctl= (terminal fallback per the retirement decision — the + panel is Wayland-only). archsetup: blueman dropped from the + =desktop_environment= package loop; VM assertions added (bluez/bluez-utils + present, blueman absent). blueman also removed live from velox. +- *Why:* Build order per the DOING decomposition. The spec's "sudoers rule" + item resolved as net-priv's did: archsetup already grants the primary + user blanket =NOPASSWD: ALL= (archsetup:1089), so a narrow bt-priv rule + would be dead config — no new sudoers needed, and phase 5's "sudoers + placed" is satisfied by the existing grant. +- *Artifacts:* dotfiles =hyprland/.local/bin/bt-priv=, + =common/.config/sxhkd/sxhkdrc=; archsetup =archsetup= (bluetooth loop), + =scripts/testing/tests/test_packages.py=; dated phase 4 entry under the + todo.org parent. + +** 2026-07-02 Thu @ 15:06:00 -0400 — Claude Code (archsetup) — phase 3 builder +- *What changed or was recommended:* Phase 3 shipped (dotfiles =e372de3=): + the =custom/bluetooth= bar module (state-following glyph, low-battery red + percentage, device+battery tooltip with the keybind hint, signal 10 with + the panel poking it after each reload) and the blueman retirement from the + Hyprland session (exec-once + windowrules removed, applet killed live). + The phase 2 deferred items also closed this pass: both AT-SPI smokes green + (the bt smoke's primary-button assertion fixed for the state-following + label, =c1a8219=), both panels eyeballed correct in dupre, and the + net-panel keyboard claims verified live (archsetup =e80df2b= — false + claims struck from the net spec). +- *Why:* Build order per the DOING decomposition; the Zoom meeting ended, + unblocking the visual work. Phases 4-5 (bt-priv/sudoers/packages, install + defaults — archsetup side) remain. +- *Artifacts:* dotfiles =bluetooth/src/bt/indicator.py=, =waybar-bt=, + waybar config + three css files; dated phase 3 entry under the todo.org + parent. + +** 2026-07-02 Thu @ 14:15:27 -0400 — Claude Code (archsetup) — phase 2 builder +- *What changed or was recommended:* Phase 2 shipped (dotfiles =76b2c05=): + the GTK panel — PanelModel/viewmodel presenter pair (69 tests), Blueprint + pages, pairing pty state machine with default-deny passkey confirms, + manage.py op envelopes shared by CLI and panel (power + discoverable verbs + added), =bt-panel= toggle, Super+Shift+B rebind. The shared dupre css + factoring landed as planned: net's inline =_CSS= became + =themes/dupre/panel.css= with =dupre-*= classes, both panels consume it. + 43 suites green. The AT-SPI smoke (=make test-panel-bt=) is written but + not yet run live — a Zoom meeting occupied the compositor; it runs when + the meeting ends, along with a visual check of both panels. +- *Why:* Build order per the DOING decomposition; phases 3-5 (bar module, + bt-priv/sudoers, install defaults) remain. +- *Artifacts:* dotfiles =bluetooth/src/bt/{panel,viewmodel,pairing,manage, + gui,pages}.py=, =ui/*.blp=, =tests/bt/test_btpanel.py=, the panel smoke; + dated phase 2 entry under the todo.org parent. + +** 2026-07-02 Thu @ 13:31:00 -0400 — Claude Code (archsetup) — phase 1 builder +- *What changed or was recommended:* Phase 1 shipped (dotfiles =eb2230f=): + the =bt= engine package, 101 tests over fakes, live-verified read-only + on velox. Two spec corrections from ground truth: profile inventory + comes from =pw-dump= (wpctl can't enumerate profiles), and the active + profile reads from the card's Profile param / sink's + =api.bluez5.profile= (the card's =bluez5.profile= prop is unreliable). + The shared-css factoring moved into phase 2 — net's css is an inline + string in its =gui.py=, so extracting it belongs with the first second + consumer rather than as a standalone poke at the working net panel. +- *Why:* Build order per the DOING decomposition; corrections keep the + spec honest for the phase 2 implementer. +- *Artifacts:* dotfiles =bluetooth/src/bt/=, =tests/bt/=, the stowed + =bt= shim; dated phase 1 entry under the todo.org parent. + +** 2026-07-02 Thu @ 13:10:00 -0400 — Claude Code (archsetup) — reviewer + responder +- *What changed or was recommended:* Ran the spec-review gate: passed. + All four decisions were already DONE (cookie added to the heading); + the five phases are each a clean single-session stop; CLI verbs are + verified against installed bluez 5.86. Two non-blocking findings + recorded and dispositioned in the same fused pass (empty-state / + no-adapter copy, eventlog + redaction carry-over) — both resolve to + "clone the net-panel donor," now stated explicitly. Flipped DRAFT → + READY → DOING and decomposed the phases into build sub-tasks under the + todo.org parent with :SPEC_ID: bound. +- *Why:* Craig queued the build ("4 first, then 1", 2026-07-02) after + resolving all decisions the same morning; the gate held nothing back, + so review and response fused to keep the speedrun moving. +- *Artifacts:* Findings in =* Review findings [2/2]= above; build parent + in todo.org ("Bluetooth panel + bar module"); net-panel toast fix the + UX-conformance note references landed as dotfiles =0f017d4=. diff --git a/docs/specs/2026-07-02-desktop-settings-panel-spec.org b/docs/specs/2026-07-02-desktop-settings-panel-spec.org new file mode 100644 index 0000000..50853f3 --- /dev/null +++ b/docs/specs/2026-07-02-desktop-settings-panel-spec.org @@ -0,0 +1,161 @@ +#+TITLE: Desktop-Settings Dropdown Panel +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-02 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* DRAFT Desktop-Settings Dropdown Panel +:PROPERTIES: +:ID: d6bb1e73-ec90-4327-85ee-bfa762da5bce +:END: +- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to DRAFT (evidence-based, human-confirmed) + +* DRAFT Status +:PROPERTIES: +:ID: fb7eec22-a214-4568-82c4-903612f4832f +:END: +- [2026-07-04 Sat] DRAFT — all four decisions resolved by Craig (dim + airplane collapse into the panel, touchpad + caffeine stay on the bar; Super+Shift+G keybind; code in dotfiles settings/ beside net/; 5% brightness floor). The four collapse/keybind/location/floor decisions are closed; one open scoping question remains ("few other things" — see Decisions) before a spec-review can flip it READY. +- [2026-07-03 Fri] DRAFT update — from the waybar/panel-family design + discussion: adopt the instrument-console faceplate aesthetic net + bt + shipped (lamps, engraved sections, console keys, machined plate), not just + the raw palette; add the audio panel as a sibling in the panel family; + cross-reference the shared faceplate CSS. Toggle-consolidation scope (the + "few other things") still open — see the Decisions section. +- [2026-07-02 Thu] DRAFT — initial spec from the todo.org task "Desktop-settings + dropdown panel" (2026-06-24 review), updated for the Blueprint/GTK4 pipeline + the net panel stood up 2026-07-01. + +* Metadata + +| Field | Value | +|--------+----------------------------------------------| +| Status | draft | +|--------+----------------------------------------------| +| Owner | Craig Jennings | +|--------+----------------------------------------------| +| Repo | dotfiles | +|--------+----------------------------------------------| +| Kin | net panel (architecture donor), theme studio | +| | audio panel (sibling), bt panel (aesthetic) | +|--------+----------------------------------------------| + +* Problem + +Desktop toggles are scattered: dim, caffeine/idle, touchpad/mouse, airplane +mode each own a bar module and a keybind; brightness and keyboard-backlight +have keybinds but no visible control or level readout. The bar is running out +of glanceable width (hence the collapse arrows), and sliders can't live in +waybar at all. One settings dropdown — a gear glyph opening a small panel — +gathers them. + +* Goals + +1. One panel with every desktop toggle + slider: auto-dim, idle/caffeine, + touchpad, mouse, airplane (laptop-only), screen brightness, keyboard + backlight. +2. Conditional rows appear only when the hardware/context applies (mouse + present, trackpad present, battery present) — reuse the detection the + airplane/touchpad indicators already do. +3. Every control reflects live state and verifies its action took (the net + panel's verify-everything contract). +4. Bar stays the quick layer: which standalone indicators survive is a + decision below. + +* Design sketch + +** Architecture — clone the net panel's proven stack + +- GTK4 + gtk4-layer-shell, Blueprint .blp sources compiled to committed .ui + (=make ui=; dev-only build dependency, fresh clones run without the + compiler). +- Humble-object split: a GTK-free PanelModel presenter (unit-tested to 100% + like the net PanelModel) + thin composite-widget pages. Backing actions in + a GTK-free settings.py that shells out to brightnessctl / hyprctl / the + existing toggle scripts, TDD'd with fake binaries like every dotfiles + suite. +- One gated AT-SPI smoke (the run-panel-smoke.sh pattern), no bespoke + headless widget suite. +- Instrument-console faceplate aesthetic, consistent with net + bt + audio: + the machined gradient plate, glowing status lamps, engraved section labels, + physical console keys for the toggles, and (where a level applies) needle + gauges. Load the shared instrument-console palette/faceplate CSS asset all + the family panels use — factor it once, don't duplicate (feeds the + theme-studio task later). + +** Controls and their backings + +| Control | Backing | +|--------------------+----------------------------------------------| +| Auto-dim toggle | hyprctl decoration:dim_inactive (dim-toggle) | +|--------------------+----------------------------------------------| +| Idle / caffeine | hypridle start/stop (caffeine-toggle) | +|--------------------+----------------------------------------------| +| Touchpad toggle | toggle-touchpad + touchpad-state file | +|--------------------+----------------------------------------------| +| Mouse toggle | same mechanism, mouse-state file | +|--------------------+----------------------------------------------| +| Airplane mode | airplane-mode script (laptop-only row) | +|--------------------+----------------------------------------------| +| Screen brightness | brightnessctl (backlight class), slider + % | +|--------------------+----------------------------------------------| +| Keyboard backlight | brightnessctl (kbd_backlight class), slider | +|--------------------+----------------------------------------------| + +Slider changes apply live (throttled) and read back the actual level after +apply — verify-everything. Toggles re-read their source of truth after +firing, same as the bar indicators do, and the bar modules get their refresh +signals so both surfaces agree. + +** Open/close behavior + +Gear glyph module on the bar right cluster; click toggles the panel +(layer-shell anchored under the bar, right-aligned). Focus-out auto-hide + +Close button, matching the net panel. Keybind: Super+Shift+G (decision B). + +* Decisions (Craig) + +** DONE Which standalone bar indicators collapse into the panel? +CLOSED: [2026-07-04 Sat] +Resolved (Craig, 2026-07-04): touchpad and caffeine stay on the bar (glanceable state); auto-dim and airplane move into the panel (panel-only, freeing bar width). The airplane Super+Shift+A toggle keybind stays as the quick lane — only its bar indicator collapses in. + +** DONE Keybind for the panel? +CLOSED: [2026-07-04 Sat] +Resolved (Craig, 2026-07-04): Super+Shift+G (gear), for parity with the other panels' fast path. + +** DONE Where does the code live? +CLOSED: [2026-07-04 Sat] +Resolved (Craig, 2026-07-04): dotfiles =settings/= sibling to =net/= (same src-layout, tests in tests/settings/), sharing the palette css. The net panel is the architecture donor; the old in-tree pocketbook-style note is out. + +** DONE Slider granularity and floor +CLOSED: [2026-07-04 Sat] +Resolved (Craig, 2026-07-04): 5% floor on the brightness slider, so a dark-room drag can't black the screen out and lock you out. brightnessctl's 0-100% range clamps to a 5% minimum. + +** TODO What are the "few other things" beyond the toggles? +The 2026-07-03 discussion named consolidating the toggle buttons "and a few +other things" into this panel, but the extras weren't enumerated. Current +control list (above): auto-dim, idle/caffeine, touchpad, mouse, airplane, +screen brightness, keyboard backlight. Candidates raised or adjacent — +confirm which belong here vs the audio panel vs the bar: night-light / color +temperature, a theme/dupre-vs-hudson switch (theme-studio kin), volume or a +master-mute mirror (or leave all audio to the audio panel), a +notifications/do-not-disturb toggle (dunst), lock/suspend actions. Craig to +name the set. + +*** 2026-07-04 Sat — Craig's input (roam capture): the set includes a wallpaper manager +Confirmed the panel gathers the mouse/trackpad toggle, a no-sleep (idle-inhibit) toggle, and the auto-dim toggle, and adds a *wallpaper manager* (this is where the displaced waypaper functionality lands — see the media/keybind change that freed Super+Shift+P). The wallpaper manager needs its own depth: +- take a number of directories to look in; +- switch the wallpaper with the change persisting across sessions; +- switch between two pictures at sunup / sundown (a day/night pair). +That last one implies a sun-time source (a lat/long or a sunrise/sunset lookup). The wallpaper manager is sizable enough it may want its own sub-spec rather than a single panel row; decide during the spec-review whether it's a row that opens a sub-view or a separate panel. Remaining "few other things" candidates above (night-light, theme switch, DND, lock/suspend) still await Craig's yes/no. + +* Implementation phases + +1. settings.py backings (brightness get/set, kbd backlight, toggle + state readers) — pure engine, TDD with fake brightnessctl/hyprctl. +2. PanelModel presenter (rows, conditional visibility, verify-after-apply + semantics) — unit-tested, no GTK. +3. Blueprint UI + gear bar module + open/close wiring; palette css factored + to a shared asset; AT-SPI smoke. +4. Bar-module consolidation per decision A: drop the dim and airplane bar + modules (now panel-only), keep touchpad and caffeine on the bar, wire the + refresh signals so bar and panel agree, and bind Super+Shift+G. diff --git a/docs/specs/2026-07-02-file-manager-swallow-spec.org b/docs/specs/2026-07-02-file-manager-swallow-spec.org new file mode 100644 index 0000000..b898f11 --- /dev/null +++ b/docs/specs/2026-07-02-file-manager-swallow-spec.org @@ -0,0 +1,147 @@ +#+TITLE: File-Manager Swallow Pattern +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-02 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* CANCELLED File-Manager Swallow Pattern +:PROPERTIES: +:ID: 179a1cd2-7a02-4c44-a09d-685c5a154895 +:END: +- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to CANCELLED (reason: Native swallow ruled out by test; reassigned to .emacs.d dirvish handling.) + +* CANCELLED Status +:PROPERTIES: +:ID: d92e0074-f594-4e83-81a0-faf282e15ed0 +:END: +- [2026-07-02 Thu] CANCELLED — targeted the wrong file manager. Craig's ask + is about the dirvish popup (Super+F, an Emacs frame), not nautilus (the + Super+Shift+F bind that misled the grounding). For dirvish the right + design is elisp-side and strictly better: Emacs is the launcher, so it + can spawn the handler directly (=start-process=), hide the popup frame, + and restore it from a process sentinel — exact exit tracking plus a + failure notify, no window-event heuristics. Reassigned to .emacs.d via + its inbox (2026-07-02-2231-from-archsetup-dirvish-popup-swallow-handoff). + The gio double-fork finding below stands for any gio-launching file + manager; the daemon design is kept for reference only. +- [2026-07-02 Thu] DRAFT — initial spec from Craig's roam capture ("when the + file manager launches another app, it should hide and return when that + process ends"). Feasibility ground truth sampled live on velox same + evening: Hyprland's native swallow cannot work here (see Problem), so the + design is an event-listener daemon. + +* Metadata + +| Field | Value | +|--------+----------------------------------------------------| +| Status | cancelled | +|--------+----------------------------------------------------| +| Owner | Craig Jennings | +|--------+----------------------------------------------------| +| Repo | dotfiles (daemon + config); archsetup (none) | +|--------+----------------------------------------------------| +| Kin | touchpad-auto (socket-listener donor), | +| | hypr-refocus-scratchpad (event-daemon sibling) | +|--------+----------------------------------------------------| + +* Problem + +Opening a file from nautilus (Super+Shift+F, tiled, class +=org.gnome.Nautilus=) spawns a viewer window while nautilus stays in the +layout. The wanted behavior is the swallow pattern: the file manager hides +while the app it launched runs, and returns when that app exits. Today +there's no signal connecting the two windows — the viewer lands wherever +the layout puts it, nautilus lingers, and quitting is manual. + +*Hyprland's native swallow is ruled out — measured, not assumed.* +=misc:enable_swallow= + =swallow_regex= would be exactly this feature in two +config lines, but it matches by walking the new window's PID ancestry to +the swallow candidate's PID. Nautilus launches handlers through GLib +(=g_app_info_launch_default_for_uri=), and that path orphans the child: +reproduced live on velox 2026-07-02 with a python-gi launcher — feh came up +with PPID 1 (reparented to init) while the launcher was still alive. The +ancestry walk hits init before it hits nautilus, every time, for every +handler. Any design that depends on PID parentage is dead on arrival; the +signal has to come from window events instead. + +Ground truth on handlers (velox, 2026-07-02): pdf → zathura, image → feh, +video → mpv, text/code → emacsclient (window belongs to the emacs daemon). +Side-note, out of scope here: feh is X11 — an XWayland viewer on a +no-XWayland-by-preference setup; a default-handler review is its own task. + +* Goals + +- Double-click a file in nautilus → the viewer takes its place; nautilus is + gone (special workspace, not killed — state and tabs survive). +- Quit the viewer → nautilus returns and has focus. +- Nothing else changes: terminals, scratchpads, and every other window keep + their current behavior. +- Config-driven, testable logic, one small daemon — the touchpad-auto shape. + +* Design sketch + +A =hypr-swallow= daemon (dotfiles, =hyprland/.local/bin/=) listening on the +Hyprland IPC event socket (socket2), same as =touchpad-auto=: + +- Track the active window (=activewindow>>= events carry class + title; + =activewindowv2>>= carries the address). +- On =openwindow>>= (address, workspace, class, title) while the active + window's class is a configured *parent* (nautilus): dispatch + =movetoworkspacesilent special:swallow,address:0x<parent>=, record + child-address → {parent-address, origin workspace}. +- On =closewindow>>= of a recorded child: bring the parent back + (=movetoworkspace=) and focus it; drop the record. +- On =closewindow>>= of a hidden parent (nautilus quit while hidden): drop + the record, nothing to restore. +- Exception classes (fuzzel, dunst, scratchpad classes, the panels) never + trigger a swallow even when they open over nautilus. +- Pure event-machine core (parse lines → state transitions → dispatch list), + unit-tested against recorded event streams; a thin socket loop around it. + +Known edge, handled: Super+Shift+F while nautilus is hidden re-runs +=nautilus=, which activates the existing (hidden) instance instead of +opening a window. The daemon (or the bind) must restore-and-untrack in that +case so the bind never appears dead. + +Known limitation, accepted: the emacsclient case never swallows — the +window belongs to the long-running emacs daemon and =closewindow= for it +means a frame closed, not "the file is done." The parent-class trigger plus +exception list naturally leaves it alone only if we exclude it explicitly — +see decision 2. + +* Decisions (Craig) + +** TODO Trigger breadth: any new window while nautilus is active, or an allowlist of viewer classes? +"Any window" is simple and catches every handler, but a false positive +exists: an app you launched seconds earlier from elsewhere finishes starting +while you're focused on nautilus → nautilus gets swallowed by an unrelated +window. An allowlist (zathura, mpv, imv, feh, …) can't be surprised but +needs maintaining. Recommendation: any-window + exception list — the false +positive is rare and self-healing (close the window or refocus). + +** TODO The emacs frame case: swallow or exempt? +Opening a text file from nautilus raises/creates an emacs frame. Swallowing +nautilus under it "works" going in, but the restore fires when *any* frame +closes, which may be much later or never. Recommendation: exempt =emacs= — +text files just open, nautilus stays. + +** TODO Restore destination: the workspace nautilus came from, or the one you're on when the viewer closes? +If you move the viewer to another workspace and quit it there, "origin" +teleports you back; "current" brings nautilus to you. Recommendation: +current workspace — the restore should land where your attention is. + +** TODO Multiple children: refcount or single-slot? +You can only launch a second file after restoring nautilus manually, so +overlap is rare — but a fast double-launch can record two children. +Recommendation: refcount — restore when the last tracked child closes. + +* Implementation phases + +1. =hypr-swallow= core: pure event-machine (TDD over recorded event + streams; fake hyprctl for dispatch assertions), config block at the top + (parent classes, exception classes), unittest suite in =tests/=. +2. Socket loop + wiring: exec-once in hyprland.conf, the Super+Shift+F + restore-if-hidden interplay, daemon single-instance guard. +3. Live verification on velox (zathura + mpv round-trips, the emacs case, + the false-positive probe) + manual-testing entries; ratio rides the + dotfiles pull. diff --git a/docs/specs/2026-07-02-net-panel-other-interfaces-spec.org b/docs/specs/2026-07-02-net-panel-other-interfaces-spec.org new file mode 100644 index 0000000..0d63feb --- /dev/null +++ b/docs/specs/2026-07-02-net-panel-other-interfaces-spec.org @@ -0,0 +1,195 @@ +#+TITLE: Net Panel — Tailscale, VPN, and WireGuard Interfaces +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-02 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED Net Panel — Tailscale, VPN, and WireGuard Interfaces +:PROPERTIES: +:ID: 09f4cd40-f391-4eba-a4ff-c22bad00ad7f +:END: +- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to IMPLEMENTED (reason: Tunnels track shipped detection, diagnose, and panel bring-up; build task DONE.) + +* IMPLEMENTED Status +:PROPERTIES: +:ID: 79a1075a-4b56-4f25-a861-b69f120a636a +:END: +- [2026-07-02 Thu] IMPLEMENTED — all six phases shipped (dotfiles 2d9d060, + 21db05a, 31ba056, b4010bf, b5c8442; archsetup 0389790 + the wireguard + import script): probes, panel Tunnels view, diagnose/doctor route + awareness, bar badge, installer swap + operator, velox config migration. + Residual human steps filed under todo.org "Manual testing and + validation": proton CLI sign-in (per machine) and the first live + badge/tunnel round-trip. Ratio picks up the import + package swap on its + trip. +- [2026-07-02 Thu] DOING — decomposed into six build phases under the + todo.org parent (:SPEC_ID: bound); build started same evening per Craig + ("tunnels build now + audio-panel spec alongside"). +- [2026-07-02 Thu] READY — fused review passed the gate: 4/4 decisions + resolved, phases decomposable, claims re-verified live (proton-vpn-cli + 1.0.1 in extra, binary =/usr/bin/protonvpn=, no package conflict with the + GTK app; =tailscale status --json= shape confirmed on velox — Self/Peer/ + CurrentTailnet.Name/MagicDNSSuffix; zero NM wireguard connections yet, + seven configs in assets awaiting the phase 6 import). +- [2026-07-02 Thu] DRAFT — initial spec from the roam capture "other network + interfaces (tailscale, VPNs, wireguard)" filed in todo.org 2026-07-02. + +* Metadata + +| Field | Value | +|--------+---------------------------------------------------| +| Status | implemented | +|--------+---------------------------------------------------| +| Owner | Craig Jennings | +|--------+---------------------------------------------------| +| Repo | dotfiles (net module); archsetup (packages) | +|--------+---------------------------------------------------| +| Parent | Waybar network module spec (2026-06-29), V2 panel | +|--------+---------------------------------------------------| + +* Problem + +The net panel's Connections tab shows what NetworkManager knows: WiFi networks +and wired links. The machines also run overlay and tunnel interfaces the panel +is blind to: + +- Tailscale (tailscaled, both daily drivers; the tailnet is how the machines + reach each other; not an NM device) +- WireGuard configs (assets/wireguard-config/ carries Proton VPN configs; + importable as NM connections of type wireguard or run via wg-quick) +- Commercial VPN clients (Proton VPN GTK app is installed on velox; owns its + own tunnel device) + +When one of these is up it changes routing, DNS, and reachability — exactly +the things the Diagnostics tab reasons about — yet the panel neither shows nor +controls them, and the doctor can misattribute a VPN-caused failure to the +underlying link. + +* Goals + +1. Visibility: the Connections tab shows overlay/tunnel interfaces with live + state (up/down, address, and for tailscale the tailnet peers summary). +2. Control: bring each up or down from the panel row, same interaction shape + as Join/Disconnect on WiFi rows (no terminals — V2 contract). +3. Diagnostics awareness: diagnose/doctor know when a tunnel owns the default + route or DNS, name it in evidence rows, and stop misattributing its + failures to the physical link. + +Non-goals (this iteration): installing or configuring VPN providers, tailnet +ACL management, exit-node selection UI (a "use exit node" affordance can ride +a later pass), kill-switch management (tracked separately in the spec's +failure catalog). + +* Design sketch + +** Data sources — one probe per backend, engine-side + +New GTK-free module net/src/net/overlays.py with one probe per backend, +each returning the same small dict shape ({kind, name, state, addr, detail, +can_toggle}): + +- tailscale: =tailscale status --json= (rich: self, peers, exit node, health + messages). Daemon down → state "stopped". Binary absent → backend absent. +- wireguard-nm: =nmcli -t connection show= filtered to type wireguard — + up/down via the existing nmcli wrapper (activate/deactivate connection). + The seven Proton configs in assets/wireguard-config/ import cleanly + (=nmcli connection import type wireguard file <conf>=, then + =connection.autoconnect no= immediately — imports default to autoconnect + yes). They use only PrivateKey/Address/DNS + PublicKey/AllowedIPs/Endpoint, + no PostUp/PostDown anywhere, so no wg-quick path is needed at all + (Craig, 2026-07-02). All are full-tunnel (AllowedIPs 0.0.0.0/0) — the + panel should treat them as mutually exclusive. +- proton: drive the official proton-vpn-cli (Arch extra repo, v1.0.x, + stable since 2026-04) — connect/disconnect/status verbs. It drives NM + underneath (python-proton-vpn-network-manager), so the panel still sees + connection events through NM. Runtime-exclusive with the GTK app, which + gets dropped from the install. The imported NM wireguard configs remain + a raw fallback when the CLI/API path is down; the CLI stays primary + because the raw configs lack kill switch, port forwarding, and server + rotation. + +** Panel + +A fourth Connections group "Tunnels" (after Saved / Available now / Wired) +using the existing group-header + row machinery. Row: glyph per kind, name, +state caption; primary action Up/Down where can_toggle, else Open app. +Tailscale row detail (subtitle or tooltip): tailnet name, peer count online, +exit node if any. + +** Privileged path + +- tailscale up/down: needs root or operator — =tailscale set --operator= at + install time (archsetup) makes the user an operator, so no sudo needed at + runtime. Fallback: the V2 net-priv helper gains tailscale-up/down verbs. +- NM wireguard connections: no privilege needed (NM polkit default for the + active user). + +** Diagnostics awareness + +- diag gains an "overlay owns default route/DNS" detection step: when the + default route or resolv.conf points at a tunnel interface, evidence names + it ("default route via tailscale0") and failure classification runs the + physical-link checks against the underlying device instead. +- doctor: a tunnel-caused egress failure (VPN up but its endpoint dead) + classifies fixable with next_action "bring the tunnel down / reconnect", + not a WiFi reset. + +** Bar indicator + +Part of v1 (Craig, 2026-07-02 — "shouldn't be optional"): a small overlay +badge on the net glyph when a tunnel owns the default route. Rides the same +route/DNS-ownership detection the diagnostics step adds. + +* Decisions (Craig) + +** DONE Which backends ship in the first pass? +CLOSED: [2026-07-02 Thu] +Approved (Craig, 2026-07-02): tailscale + NM-managed wireguard. Craig asked +whether the wireguard configs can be ported to NM so wg-quick drops out +entirely — yes: all seven configs in assets/wireguard-config/ use only the +six directives NM imports cleanly (verified 2026-07-02; import command and +autoconnect caveat now in the design sketch). wg-quick is out of the spec, +not deferred. Proton control is CLI-driven per the Proton decision below, +superseding the detection-only recommendation here. + +** DONE Tailscale control path: operator flag at install vs net-priv verbs? +CLOSED: [2026-07-02 Thu] +Approved (Craig, 2026-07-02): =tailscale set --operator=$USER= in archsetup's +tailscale step (declarative, no sudo at runtime); net-priv verbs only if +operator mode proves insufficient (e.g. up with flags). +** DONE Does "Tunnels" belong in Connections or its own tab? +CLOSED: [2026-07-02 Thu] +Approved (Craig, 2026-07-02): a Connections group. A fourth top tab dilutes +the V2 nav for three rows. + +** DONE Proton VPN: detect-only or drive its CLI? +CLOSED: [2026-07-02 Thu] +Decided (Craig, 2026-07-02): drive it through a CLI. Research (2026-07-02): +Proton shipped an official Linux CLI — first release 2025-11, stable v1.0.0 +2026-04, packaged in Arch extra as proton-vpn-cli (1.0.1 at check time), +with kill switch, port forwarding, NetShield, server selection, and a +status command. It drives NM underneath, so the panel sees its connections +through the existing NM event path. Spec changes: the proton backend calls +protonvpn connect/disconnect/status instead of device-detection +(can_toggle true); archsetup installs proton-vpn-cli and drops +proton-vpn-gtk-app (the two can't run concurrently per the project README — +untested locally); the imported NM wireguard configs stay as a raw fallback. +Sources: [[https://protonvpn.com/support/linux-cli][Proton Linux CLI guide]], +[[https://protonvpn.com/support/release-notes-linux-cli][CLI release notes]], +[[https://github.com/ProtonVPN/proton-vpn-cli][proton-vpn-cli repo]]. +* Implementation phases + +1. overlays.py probes (tailscale JSON, nmcli wireguard filter, proton-vpn-cli + status) — pure engine, TDD with fake binaries; =net status= grows an + overlays section. +2. Panel Tunnels group + Up/Down wiring through the worker thread; AT-SPI + smoke extension. +3. Diagnose/doctor overlay awareness (route/DNS ownership step, classifier + rows, evidence text) — TDD against the diag harness. +4. waybar-net tunnel badge on the net glyph (v1 per the bar-indicator + decision), riding phase 3's route-ownership detection; suite coverage. +5. archsetup: tailscale operator flag in the tailscale install step; + proton-vpn-cli replaces proton-vpn-gtk-app in the package list; VM test + assertions. +6. One-time per-machine migration: import the seven assets/wireguard-config + configs into NM with autoconnect off (scriptable; both daily drivers). diff --git a/docs/specs/2026-07-02-timer-panel-spec.org b/docs/specs/2026-07-02-timer-panel-spec.org new file mode 100644 index 0000000..275bb2c --- /dev/null +++ b/docs/specs/2026-07-02-timer-panel-spec.org @@ -0,0 +1,221 @@ +#+TITLE: Timer GTK Panel +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-02 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED Timer GTK Panel +:PROPERTIES: +:ID: 25ed5321-f035-42b3-b115-69364d775f41 +:END: +- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to DRAFT (evidence-based, human-confirmed) + +* IMPLEMENTED Status +:PROPERTIES: +:ID: 1770af2e-b093-4024-a512-ae4324a2869f +:END: +- [2026-07-05 Sun] IMPLEMENTED — redesign built and shipped to dotfiles in a no-approvals speedrun (5 commits =c7ac193=..=5a863b5=): the wtimer engine (timer repeat, recurring alarms with snooze/ringing/dismiss, =@half=/=@hour=/=+dur= alarm parse, the rebuilt configurable pomodoro cycle, bar-tooltip parity), the PanelModel view-data rebuild (=row_view=, ringing-first sort, per-type create options as flags, locked presets + half-past + named pomodoro cycles), the GTK hero-on-top panel (Cairo progress ring + stopwatch sweep dial, per-type create strips, one transport row, close ✕/Esc), and the bar tooltip parity. wtimer + timer suites 231 green, full =make test= green. Live GTK render is the manual checklist (todo.org). Stopwatch run-save deferred to vNext. +- [2026-07-05 Sun] DOING — UI/UX redesign decided through a prototype process (research → brainstorm → several directions → iterate to final; see Prototype iterations below). The shipped v1 panel stands, but this rewrite supersedes its layout and adds functionality: a hero-on-top + rack layout, a live waybar-module preview at parity with =wtimer render=, per-type create-strip features (timer auto-repeat; alarm recurring weekdays + snooze + a ringing state; a configurable pomodoro cycle with work/rest short+long and a long-break interval; a stopwatch analog sweep dial + last-lap badge), presets (renamed from "chips") whose shipped defaults are locked and whose load flashes the fields, and a header close button. Stopwatch run-save is cut to a vNext. Rebuild pending — folds this decided design into the shipped =timer/= package. +- [2026-07-05 Sun] IMPLEMENTED — v1 built and shipped to dotfiles in a no-approvals speedrun (4 commits 1f4f270..78d3cbb): wtimer watch/lap/save; a new timer/ package with a GTK-free PanelModel (62 tests) + the GTK instrument-console panel; bar integration (custom/timer opens the panel, the fuzzel creation flow retired, Hyprland float rule added). This is the base the 2026-07-05 redesign iterates on. +- [2026-07-05 Sun] DOING — Craig directed the build (no-approvals speedrun). Folded in the cj input from the sibling waybar-timer-module spec (GTK app styled like the panels; a queue/output-wall auto-sorted by fire time; stopwatch lap/stop + saveable runs; notify integration; 5/25-min configurable+deletable defaults; up to 10 timers; widget-gallery elements) — see Build scope below. Bypassed the READY spec-review step at Craig's direction; the four decisions were already resolved. +- [2026-07-04 Sat] DRAFT — all four decisions resolved by Craig (standalone; retire fuzzel once the panel lands; timer chips gain 10m/30m/2h; wtimer watch mode over polling). Decision-complete; ready for a spec-review to flip it READY before build. +- [2026-07-02 Thu] DRAFT — initial spec from Craig's roam capture "give the + timer a gtk UI/UX like the network panel. spec this out." + +** Prototype iterations +The redesign ran through the UI/UX prototype process (see the =ui-prototyping= rule proposed to rulesets, 2026-07-05). Full working HTML prototypes over one shared engine, in the dupre instrument-console aesthetic; each iteration links here, newest last, so the design history is walkable. +- [[file:../prototypes/2026-07-02-timer-panel-prototype-1.html][prototype-1]] — three initial directions over one shared engine: rack unit (faithful vertical list), transport deck (hero + track list), channel-strip board (vertical faders). Predates the formalized five-direction count. +- [[file:../prototypes/2026-07-02-timer-panel-prototype-2.html][prototype-2]] — chose the rack direction; flipped to hero-on-top → create strip → list; made pomodoro a configurable cycle; locked the shipped presets (default cycle undeletable); dropped the stopwatch/pomodoro value entry. +- [[file:../prototypes/2026-07-02-timer-panel-prototype-3.html][prototype-3]] — FINAL. Live waybar preview; hero donut moved right with one full-width button row; stopwatch sweep dial + ghost lap badge; alarm recurring days + snooze + ringing state; timer repeat; half-past alarm preset; presets flash on load; header close (Esc / bar-click reopen); verbatim tooltip labels; stopwatch save deferred. + +* Metadata + +| Field | Value | +|--------+-----------------------------------------------------------------------------------------| +| Status | implemented | +|--------+-----------------------------------------------------------------------------------------| +| Owner | Craig Jennings | +|--------+-----------------------------------------------------------------------------------------| +| Repo | dotfiles | +|--------+-----------------------------------------------------------------------------------------| +| Kin | net panel (architecture donor), wtimer (backing), desktop-settings panel spec (sibling) | +|--------+-----------------------------------------------------------------------------------------| + +* Problem + +The timer's whole UI is a chain of three fuzzel prompts (type, value, label) +plus a fourth for cancel. That flow can't show what's already running while +you create, can't offer one-tap presets, gives no feedback on a typo until +the add silently fails, and pomodoro state (phase, cycle) is only visible in +a tooltip. The 2026-07-02 styling pass made the dialogs presentable, but the +shape is still four blind modals for what is really one small control +surface. + +* Goals + +1. One panel, opened from the bar's timer module, that shows everything + running (live countdowns, pomodoro phase/cycle, paused state) and creates + new items without leaving it. +2. One-tap presets for the common cases (tea, pomodoro, quick alarm) next to + freeform entry, with inline validation before the add. +3. Per-item controls: pause/resume, cancel, promote to primary (the bar + glyph slot). +4. wtimer stays the single owner of timer state and the notification path; + the panel is a view over it, never a second engine. + +* Design sketch + +** Architecture — clone the net panel's proven stack + +- GTK4 + gtk4-layer-shell dropdown anchored under the timer module, Blueprint + .blp compiled to committed .ui (=make ui=; compiler is dev-only). +- Humble-object split: GTK-free PanelModel presenter, unit-tested to 100%, + with thin widget bindings; one gated AT-SPI smoke via the + run-panel-smoke.sh pattern. +- Backing: shell out to the existing wtimer CLI (=add=, =toggle=, =cancel=, + =cycle=, =render=). =render= already emits a JSON payload. Live state comes + from a new wtimer watch/subscribe mode (decision D), which the panel + subscribes to for push updates instead of polling =render= on a timer. + wtimer's 89-case suite keeps owning the logic; panel tests fake the CLI + like every dotfiles suite fakes binaries. +- Dupre WIP palette CSS shared with the net panel (same factoring the + desktop-settings spec calls for — one palette asset, three panels). + +** Layout sketch (decided in prototype-3) + +Top-to-bottom, one column: + +- Header: brand + live item count + Clear All + a flat circular close ✕ + (tooltip "Close (Esc)"), matching the net/bt/audio panels. Esc closes; + clicking the bar's timer module reopens it (mirrors =on-click: timer-panel=). +- Hero (the primary / bar-slot item): the info block (type badge, any feature + badges, pomodoro cycle dots, label, big countdown, subline) on the left with + the progress donut on the right, and all its controls in one full-width, + left-justified button row beneath. Countdown types show a filling progress + ring; a stopwatch shows an analog sweep dial (a gold second-hand, one + revolution per minute) with its last lap as a bordered ghost badge beside the + count — no fake progress ring for a count-up. The ‹ › keys cycle the primary + through the whole queue, wrapping at either end. +- Create strip: the four type buttons, then a per-type body — presets (renamed + from "chips") + a freeform entry validated by wtimer's parsers + an optional + label, plus per-type extras (see Build scope). Loading a preset flashes the + target fields rather than toasting. Shipped presets are locked (no delete); + only presets you add carry a ×. +- Queue list: the rest of the items (everything but the hero), soonest-fire + first, one rack row each — lamp, glyph, label, subline, countdown, and inline + pause / promote / cancel (two-stage arm). Stopwatches are promotable to the + hero like any other item. With a single item the list reads "Only one item is + queued. Add more above." Empty state: hero shows "No timers running", create + strip below. + +** Waybar module parity + +A live preview above the panel renders exactly what =wtimer render= emits for +the bar: =<large glyph> <countdown>= plus =+N= for the other items, recoloured +by state (urgent < 60 s terracotta, paused dim, pomodoro-work gold, +pomodoro-break sage, idle silver), with the full per-item hover tooltip. Tooltip +lines show each item's label verbatim — no phase word appended. The panel and +the bar stay one source of truth via the wtimer watch subscription. + +** What happens to the fuzzel flow + +Decision B (below) resolved this: the fuzzel chain retires once the panel +lands. The panel becomes the single creation surface, replacing both the +click-driven bar path and the keybind/fuzzel path. Until the panel ships the +fuzzel flow stays (it's styled and tested); phase 4 removes it after the +panel proves out. + +* Build scope (decided design — folds the prototype-3 redesign into the shipped =timer/= package) + +The panel is the existing =timer/= dotfiles package (src-layout, GTK4 + gtk4-layer-shell, humble-object PanelModel, instrument-console faceplate). wtimer stays the state engine; the panel is a view over it. This rebuild reshapes the layout (see Layout sketch) and adds the per-type functionality below. UI idioms draw from the widget gallery (=docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html=); the reference build is prototype-3. + +Queue + primary: +- Up to 10 items, auto-sorted by soonest fire time (four buckets: active countdown < paused countdown < active stopwatch < paused stopwatch). The soonest-firing is the hero/primary (the bar glyph slot). Promote via a row's promote key or by cycling ‹ ›; cycling and promotion include stopwatches and wrap around the whole list. +- The hero shows the primary big; the list shows the rest. Clear All cancels everything. + +Types + create strip: +- *Timer*: preset durations 5m / 25m / 10m / 15m / 30m / 60m / 2h (5m and 25m first), freeform entry (wtimer parser), optional label, and a *repeat* toggle — a repeating timer re-arms itself on fire instead of clearing. +- *Alarm*: presets +30m / top-of-hour / *half-past* (next X:30) / 07:00, freeform HH:MM, optional label, a *recurring weekday* selector (S M T W T F S, with weekdays / daily shortcuts) and a *snooze* duration. An alarm fires into a *ringing* state rather than silently vanishing: the hero/row shows SNOOZE (re-arm by the snooze minutes) and DISMISS (a recurring alarm re-arms to its next matching day; a one-shot clears). +- *Stopwatch*: no value entry — counts up from zero. Lap (unlimited) and Stop. The hero shows an analog sweep dial and the last lap as a ghost badge beside the count. *Run-save is deferred to a vNext* (cut from v1's org-save plan — see the status history). +- *Pomodoro*: a configurable cycle — Work and Rest each with a short and a long duration, a "long break every N cycles" interval, and an auto-advance toggle. Every Nth ("deep") cycle uses the long work + long rest together. Cycle progress shows as dots in the hero and row. With auto-advance off, each phase end waits for a Start press. Preset cycles (Classic 25/5/15, Deep 50/10/30, Sprint 15/3/10) load the fields. +- *Presets*: shipped defaults are locked (undeletable — the pomodoro default cycle can't be removed); presets you add carry a × and are deletable. Loading any preset flashes the target fields (no toast). + +Live updates + notifications: +- A =wtimer watch= subcommand emits state on every change (state-file watch → JSON lines on stdout); the panel subscribes for push updates instead of polling (decision D). Notifications for alarms and timers go through the =notify= script — wtimer stays the single notification owner. + +Bar + window: +- =custom/timer= left-click opens the panel; =wtimer render= stays the bar indicator (glyph + countdown + =+N=, state-coloured, verbatim tooltip labels). A header close ✕ and Esc close the panel; clicking the bar module reopens it. The =wtimer new= fuzzel creation flow is retired (decision B). + +* Decisions (Craig) + +** DONE Panel scope: standalone timer panel, or a page in the desktop-settings panel? +CLOSED: [2026-07-04 Sat] +Resolved (Craig, 2026-07-04): standalone, sharing the palette/css asset. Matches the net panel's one-domain-one-panel shape and keeps the timer dropdown small. + +** DONE Fuzzel flow: keep as keyboard fast lane, or retire once the panel lands? +CLOSED: [2026-07-04 Sat] +Resolved (Craig, 2026-07-04): retire the fuzzel flow once the panel lands. The panel becomes the single creation surface; the keybind chain goes away rather than staying as a parallel path. (Implementation phase 4's "decide the fuzzel flow's future" is now decided — retire, don't keep.) + +** DONE Presets: which chips per type? +CLOSED: [2026-07-04 Sat] +Resolved (Craig, 2026-07-04): timer chips are 5m / 10m / 15m / 25m / 30m / 60m / 2h (the strawman plus 10m, 30m, 2h). Alarm +30m / top-of-hour / 07:00, pomodoro default cycle only, stopwatch none — as the strawman. + +** DONE Live updates: poll render (1s, like the bar) or a wtimer "watch" mode? +CLOSED: [2026-07-04 Sat] +Resolved (Craig, 2026-07-04): a wtimer watch/subscribe mode, not 1s polling. This grows wtimer with a new watch capability that the panel (and potentially the bar) subscribes to for live state, rather than reusing the poll cadence — cleaner at the cost of a wtimer addition. Fold the watch mode into the phase 1 CLI-backing seam. + +The decisions below were resolved live through the prototype iteration (2026-07-05), each seen working in a prototype before being written down. + +** DONE Layout: hero on top, then create strip, then the queue list +CLOSED: [2026-07-05 Sun] +Resolved: the primary item rides a hero at the top (info left, donut right, all controls in one full-width button row), the create strip sits under it, the rest of the queue below. Chosen over the transport-deck and channel-strip directions in prototype-1. + +** DONE Stopwatch hero visual: analog sweep dial, not a progress ring +CLOSED: [2026-07-05 Sun] +Resolved: a count-up stopwatch shows a gold second-hand sweeping once per minute, with its last lap as a bordered ghost badge beside the count — not a fake progress ring (a stopwatch has no target to be a fraction of). + +** DONE Alarm: recurring weekdays + snooze + a ringing state; add a half-past preset +CLOSED: [2026-07-05 Sun] +Resolved: alarms carry a weekday-repeat selector and a snooze duration, and fire into a ringing state with SNOOZE / DISMISS rather than vanishing. A half-past preset joins top-of-hour (fires at the next X:30). Drawn from Alarm Clock Xtreme / Alarmy. + +** DONE Timer: auto-repeat toggle +CLOSED: [2026-07-05 Sun] +Resolved: a timer can repeat — it re-arms itself on fire instead of clearing. Drawn from MultiTimer / Multi Timer. + +** DONE Pomodoro: a fully configurable cycle +CLOSED: [2026-07-05 Sun] +Resolved: Work and Rest each get a short and a long duration, plus a long-break-every-N interval and an auto-advance toggle; every Nth deep cycle uses the long work + long rest; progress shows as cycle dots. The default cycle preset is locked (undeletable). Drawn from Pomofocus / the classic technique. + +** DONE Presets (formerly "chips"): lock defaults, flash on load +CLOSED: [2026-07-05 Sun] +Resolved: rename "chips" to "presets"; shipped defaults are locked (no delete), presets you add are deletable; loading a preset flashes the target fields instead of firing a toast. + +** DONE Stopwatch run-save: deferred to a vNext +CLOSED: [2026-07-05 Sun] +Resolved: v1's "save the run's splits to an org file on stop" is cut from this build. Stop just stops. Revisit in a vNext if the need is real. + +* Implementation phases (redesign rebuild) + +Folding prototype-3 into the shipped =timer/= package. TDD throughout — GTK-free +logic first, GUI last — reviewing between phases. Each phase is a dotfiles commit +under the archsetup-owns-dotfiles rule. + +1. wtimer engine: alarm recurring-days + snooze + a ringing state, timer repeat, + the configurable pomodoro cycle (work/rest short+long, long-break interval, + auto-advance, deep cycle), half-past parsing, and the watch/subscribe mode + (decision D). Extend wtimer's own suite per addition. +2. PanelModel: the four-bucket soonest-fire sort, promote/cycle wrap (stopwatches + included), per-type create validation + presets (locked defaults, custom + delete, flash-on-load), and the row/hero view data (sweep fraction, cycle + dots, last lap, feature badges, ringing controls). GTK-free, unit-tested like + the net PanelModel. +3. GTK GUI: the hero (progress ring / sweep dial, one full-width button row, lap + badge), the per-type create strip (timer repeat toggle; alarm weekday selector + + snooze; pomodoro config grid; presets that flash), the header close ✕, + Esc-to-close, and bar-click reopen. +4. Bar parity: =wtimer render= tooltip labels verbatim, state classes confirmed; + panel and bar track one state via the watch subscription. +5. AT-SPI smoke + a manual-testing checklist (todo.org). Retire the =wtimer new= + fuzzel flow (decision B) after the panel proves out. + +Deferred to a vNext: stopwatch run-save (an org log of splits). diff --git a/docs/specs/2026-07-03-audio-panel-spec.org b/docs/specs/2026-07-03-audio-panel-spec.org new file mode 100644 index 0000000..5b678a8 --- /dev/null +++ b/docs/specs/2026-07-03-audio-panel-spec.org @@ -0,0 +1,166 @@ +#+TITLE: Audio Panel — the pulsemixer console +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-03 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED Audio Panel — the pulsemixer console +:PROPERTIES: +:ID: 9175e017-46ad-4887-ae45-887e9551c005 +:END: +- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to IMPLEMENTED (reason: Shipped; build task DONE, manual tests filed.) + +* IMPLEMENTED Status +:PROPERTIES: +:ID: 71f556c6-ee02-47cc-a3be-68c8289380f3 +:END: +- [2026-07-03 Fri] IMPLEMENTED — built in a no-approvals speedrun in the + dotfiles repo (branch panel-bugfixing): engine (pactl), presenter, GTK + panel, PTT arming, bar indicator, and the bar/keybind wiring, across four + commits 65e5bb0..9601420. 102 unit tests + a passing AT-SPI smoke on velox. + All five Decisions below resolved. Live-eyeball validation (visual polish, + PTT-in-a-meeting, fader feel, the master-mute hardware key) is the one open + follow-up, tracked as a manual-testing task in todo.org. +- [2026-07-03 Fri] DRAFT — stub from the todo.org task "Audio panel spec" + (roam ask 2026-07-02) plus the 2026-07-03 waybar/sound design discussion. + Written to iterate alongside the prototype + (=docs/prototypes/2026-07-03-sound-panel-prototype.html=). Spine is present; the + Decisions and Design detail get filled in as we go. + +* Metadata + +| Field | Value | +|--------+---------------------------------------------------| +| Status | implemented | +|--------+---------------------------------------------------| +| Owner | Craig Jennings | +|--------+---------------------------------------------------| +| Repo | dotfiles | +|--------+---------------------------------------------------| +| Kin | net panel + bt panel (architecture + aesthetic | +| | donors), desktop-settings panel (sibling) | +|--------+---------------------------------------------------| + +* Problem + +Audio control today is the pyprland audio scratchpad (Super+A) — a floating +pulsemixer TUI — plus scattered bar affordances: =pulseaudio= (volume, click +to mute sink), =pulseaudio#mic= (mic glyph + mic-toggle), Super+M audio-cycle +ring, Super+Shift+A mic-toggle. There's no single glanceable surface that +shows every sink and source, lets you set the default output/input, and +carries the meeting-grade mic controls Craig wants (a clean muted mode and a +hold-to-talk mode). The net + bluetooth panels set the pattern for exactly +this shape; audio is the third instrument in the family. + +* Goals + +1. One panel, opened from the bar's sound glyph, exposing the full pulsemixer + surface: every sink and source, per-device volume, per-device mute, and + switching the default output and input. +2. Replace the pyprland audio scratchpad (Super+A) as the primary audio UI. +3. Mic modes for meetings: *live*, *muted*, and *push-to-talk* (mic stays + muted except while Space is held, releasing re-mutes). +4. A *master quick-mute* — one action mutes all output — reachable from the + faceplate and a keybind. +5. Instrument-console aesthetic and architecture consistent with net + bt: + same faceplate, lamps, engraved sections, console keys, needle gauges, + verify-everything contract. +6. The bar glyph reflects live state: speaker + three arcs normally, a + speaker-with-✕ when muted (Craig's called glyphs). + +* Design sketch + +Prototype: =docs/prototypes/2026-07-03-sound-panel-prototype.html= (the reference for +layout + idioms below). + +** Surface (from the prototype) + +- *Faceplate* — status lamp, sound glyph, state word (PLAYBACK / MUTED), a + MUTED badge, the SND·01 unit label, and the *master quick-mute switch* + (same switch idiom as net wifi / bt power), plus the close ✕. +- *OUTPUTS section* — one row per sink. Row body click = set default (gold + DEF tag). A machined fader sets that sink's volume; the trailing glyph + mutes just that device. Active/default row is emphasized (cream name, gold + lamp/glyph). +- *INPUTS section* — one row per source, same idioms. +- *Mic mode* — three console keys: LIVE / MUTED / PUSH·TALK. Push-to-talk + keeps the mic muted (red IN needle) and un-mutes only while Space is held. +- *Twin VU needles* — output level + input level, the sound analog of net + throughput and bt battery gauges. Needle goes red when its side is muted. + +** Architecture — clone the net/bt panel stack + +- GTK4 + gtk4-layer-shell, Blueprint =.blp= → committed =.ui= (=make ui=, + dev-only build dep). +- Humble-object split: a GTK-free PanelModel presenter (unit-tested like the + net/bt PanelModels) + thin composite-widget pages. Backing actions in a + GTK-free =audio.py= that shells to the audio control layer (pactl / + wpctl / pulsemixer — pick below), TDD'd with fake binaries. +- One gated AT-SPI smoke (=run-panel-smoke.sh= pattern). +- Shared instrument-console palette CSS asset (the one net/bt/settings all + load) — do not duplicate the palette block. +- Code lives in dotfiles =audio/= sibling to =net/= (src-layout, tests in + =tests/audio/=). + +* Decisions (Craig) + +** DONE Audio control backend — pactl vs wpctl vs pulsemixer +CLOSED: [2026-07-03 Fri] +Resolved: =pactl= (the engine module is =pactl.py=). Both ratio and velox run +PipeWire with the pipewire-pulse compat layer and no PulseAudio daemon, so +pactl and wpctl hit the same graph — but =pactl -f json= gives structured, +name-addressable output where wpctl offers only a volatile-id tree. Reads go +through =pactl -f json list sinks|sources= + =get-default-*=; writes target +devices by stable name behind an argv-charset guard. + +** DONE Push-to-talk mechanism under Wayland (feasibility — phase 1) +CLOSED: [2026-07-03 Fri] +Resolved: route (a), Hyprland dynamic binds. The phase-1 spike confirmed all +three primitives on velox (Hyprland 0.55.4): =hyprctl keyword bind/unbind= +adds and removes a bind live, =bindr= fires on release, and =pactl +set-source-mute @DEFAULT_SOURCE@ 0|1= toggles the mic cleanly. =ptt.py= arms a +press bind (un-mute) + a bindr (re-mute) on entering PTT mode and unbinds on +leaving, so the talk key isn't grabbed globally otherwise. No evdev needed. +Documented behavior: while PTT is armed, the talk key is the talk key. + +** DONE Quick-mute keybind + scope +CLOSED: [2026-07-03 Fri] +Resolved: the XF86AudioMute hardware key (Super+Shift+M turned out to be taken +by the monocle-layout bind, so the spec's assumption was wrong). The mute key +now runs =audio quick-mute=, which mutes every output (master), not just the +default sink — identical on a single-sink machine, correct on a multi-sink +one. Also reachable from the faceplate master switch and the panel. Scope: +master mute of all sinks, with verify-after-apply per sink. + +** DONE Bar glyph click map +CLOSED: [2026-07-03 Fri] +Resolved with the low-regret wiring: kept the existing =pulseaudio= waybar +module (left-click mute, scroll volume — no regression) and repointed its +right-click from the retired pulsemixer scratchpad to =audio-panel=. So: left += mute, right = open panel, scroll = volume. A fuller =custom/audio= indicator +(state-following speaker glyph + its own click map) is built and tested +(=indicator.py= + =waybar-audio=) but stays unwired until the new bar glyph +gets a live eyeball — the swap is a one-line waybar edit when Craig's ready. + +** DONE Fate of the existing audio affordances +CLOSED: [2026-07-03 Fri] +Resolved: Super+A repurposed from =pypr toggle audio= (the pulsemixer +scratchpad) to =audio-panel= — the panel is the primary audio UI now, so the +scratchpad is retired. Its definition still sits in the machine-local +=pyprland.toml= (not stowed) and can be deleted by hand. Kept: =pulseaudio= + +=pulseaudio#mic= waybar modules (glance + scroll + the mic-mute glance), +Super+M cycle, Super+Shift+A + XF86AudioMicMute mic-toggle. Changed: +XF86AudioMute → master quick-mute (see the quick-mute decision above). + +* Implementation phases + +1. Push-to-talk feasibility spike (decision above) — the one unknown; settle + the mechanism before committing the mic-mode design. +2. =audio.py= backings (list/get/set/mute/default for sinks + sources) — + pure engine, TDD with a fake audio backend. +3. PanelModel presenter (rows, default tracking, mic modes, master mute, + verify-after-apply) — unit-tested, no GTK. +4. Blueprint UI + sound bar glyph (normal / muted / ptt states) + open/close + wiring; shared palette css; AT-SPI smoke. +5. Bar-affordance consolidation per the decision above; retire the Super+A + scratchpad; keybinds. diff --git a/docs/specs/2026-07-03-instrument-console-panels-spec.org b/docs/specs/2026-07-03-instrument-console-panels-spec.org new file mode 100644 index 0000000..2c80aa9 --- /dev/null +++ b/docs/specs/2026-07-03-instrument-console-panels-spec.org @@ -0,0 +1,174 @@ +#+TITLE: Instrument-console rebuild — net + bluetooth panels +#+DATE: 2026-07-03 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED Instrument-console rebuild — net + bluetooth panels +:PROPERTIES: +:ID: ac23e996-a51a-466b-ad80-2faff46447bf +:END: +- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to IMPLEMENTED (reason: Panel rebuild shipped (dotfiles e993c3f); build task DONE.) + +* IMPLEMENTED Status +:PROPERTIES: +:ID: e73877f5-4f5b-4f81-b946-dbaa6145e0d5 +:END: +- 2026-07-03 Fri @ 17:35 -0400 :: Post-impl increment (stays IMPLEMENTED): added + a manual rescan/scan ⟳ affordance to both panels — net NETWORKS header + (drives manage.rescan) and bt NEARBY header (drives on_scan / pair-mode + discovery), with the approved "all" busy style (Gtk.Spinner throbber + a + GLib breathe on "scanning…" + a one-shot list fade; no CSS keyframes exist in + this GTK setup). Prototype: archsetup docs/prototypes/2026-07-03-net-panel-rescan-prototype.html. Code shipped + UNCOMMITTED into the dotfiles repo from an archsetup session (cross-project); + handoff at ~/.dotfiles/inbox/2026-07-03-1733-from-archsetup-rescan-handoff.org. + Verified: net 584 + bt 223 unit OK, both AT-SPI smokes green (⟳ present); + live busy-animation feel pending Craig's eyeball. +- 2026-07-03 Fri @ 06:49 -0400 :: DOING → IMPLEMENTED: all six phases shipped + (net GTK-free layer 81ec9c3, net view 800ef60; bt GTK-free layer 5318b34, bt + view 66f03d9; phase-6 dead-code removal f4e688e). Both panels are single-screen + instrument consoles, verified live on velox — 46 suites + full make test green, + both AT-SPI smokes green end to end, render matching the approved prototype. The + three folded tasks (network panel redesign, bt switch placement + title, bt + rename devices) closed with the build. +- 2026-07-03 Fri @ 02:07 -0400 :: DRAFT → READY → DOING in one stroke: Craig + approved the design through five interactive prototype iterations and + authorized the no-approvals speedrun ("let's build them now... go"). The + review gate was the live prototype session itself. +- 2026-07-03 Fri @ 02:07 -0400 :: Created (DRAFT) from the prototype session. + +* Metadata + +| Field | Value | +|---------------------+------------------------------------------------------------| +| Status | implemented | +|---------------------+------------------------------------------------------------| +| Owner | Craig Jennings | +|---------------------+------------------------------------------------------------| +| Repos | dotfiles (net/, bluetooth/, themes), archsetup | +|---------------------+------------------------------------------------------------| +| Normative reference | [[file:../prototypes/2026-07-03-instrument-console-panels-prototype.html][docs/prototypes/2026-07-03-instrument-console-panels-prototype.html]] | +|---------------------+------------------------------------------------------------| + +* Summary + +Rebuild both GTK layer-shell panels (net, bluetooth) from the tabbed layout +to the instrument-console design: one screen, no tabs, a faceplate with a +state word + badges + radio switch + close, engraved section labels, lamp +rows that act on click, dial meters under the console keys, and a doctor +that does it all. The interactive prototype =panel-console-v3.html= is the +normative design reference — when this spec and the prototype disagree on a +visual or interaction, the prototype wins. + +* Decisions (all resolved — Craig, prototype session 2026-07-02/03) + +- Replace the tabbed panels outright. No fallback flag; git history is the + rollback. Net panel first, bluetooth second. +- Advanced repair tiers leave the panel entirely. DOCTOR runs the full + diagnose → classify → lightest-repair → re-verify escalation (the engine + already does this). The surgical tiers stay CLI-only (=net repair ...=). +- Faceplate (both panels): state lamp + state word, badges, unit label + (NET·01 / BT·01), radio switch (wifi radio / bt adapter power), close ✕. + Badges: TUNNEL (gold, net), AIRPLANE (gold, both), LOW BATT (red, bt). +- Sections in order — net: CHANNEL, NETWORKS (+ hidden action), TUNNELS, + CONSOLE (DOCTOR / SPEED TEST keys), meters, output. bt: ADAPTER (with + clickable =discoverable= chip), PAIRED, NEARBY (+ scanning note), CONSOLE + (DOCTOR / SCAN), battery gauges, output. +- Section row budgets, half-row peek, internal scroll (thin slate + scrollbar): NETWORKS 5.5 rows, TUNNELS 4.5, PAIRED 5.5, NEARBY 4.5. + In-range networks sort active-first then strongest-signal-first. Counts on + the engraved headers ("networks · 12 in range", "tunnels · 1 up of 9", + "paired · 3", "nearby · 12"). The panel silhouette never grows with list + length; only the output well is variable and it caps at ~170px. +- Lamp-row grammar: green = live/connected, gold = available/actionable, + off = down/stored, red = failed; busy = pulsing gold during transitions. + Rows act on click (tunnels toggle, networks join, paired devices + connect/disconnect toggle, nearby devices pair). +- Arm-first for anything disruptive or destructive, 3s auto-disarm: + - forget (network or bt device): hover reveals ✕; first click arms the + row terracotta ("forget? click ✕ again"), second fires. No dialog. + - disconnect (active network): click the active row; first click arms in + GOLD ("disconnect? click again") — disruptive, not destructive — second + fires. +- Meters (net): two dials, RX·DOWN / TX·UP, gold needles, mode tag top-left + (LIVE green / TEST gold), HOLD tag top-right. Idle: live link throughput. + Speed test: cards flash gold, needles sweep the measured rate, then PIN + the final value with HOLD; clicking a held meter releases it to LIVE. + Scale 0–100 Mbps, auto-relabel to 0–1000 when a reading exceeds 100. + Dial top inset ~13px so the corner tags never touch the arc. +- Speed test output well gets ONLY: location line ("location: <city> by + <sponsor>"), ping (+jitter), final line, conditioned tip(s). The rates + live in the meters, not the text. +- Battery gauges (bt): same dial chrome; one per connected device (two + slots; empty slot dim "NO DEVICE"/"ADAPTER OFF"); needle+value red under + 15% and the LOW BATT faceplate badge lights. +- Output well: doctor streams the checks with their narration lines + (viewmodel.STEP_NARRATION) and repair steps in gold; verdict line closes + (olive for pass/fixed). A dismiss ✕ appears in the well's corner whenever + it has content. Both panels. +- WiFi radio switch: =nmcli radio wifi on|off=. Off empties NETWORKS to one + dim "wifi radio off" row, drops the connection, kills tailscale rows' + reachability; on rejoins the last network (NM autoconnect does this for + real). Airplane mode is system-level (Super+Shift+A owns it): both panels + reflect it (state word AIRPLANE, gold badge, switches down); a switch + flipped under airplane refuses with a toast naming the exit. A routed + ethernet link keeps the net panel ONLINE through airplane mode. +- Ethernet: presence-based row pinned atop NETWORKS when a cable is up + ("enp… · active · wired · 1.0 Gbps" / "wired · standby"); CHANNEL swaps + the signal ladder for "wired · <speed> full-duplex" when routed; clicking + the row toggles route ownership via device disconnect/connect. +- Pairing (bt): nearby row click → busy → passkey-confirm dialog (large + gold digits) → device moves to PAIRED and connects. SCAN key refreshes + with a "scanning…" note on the header. +- Rename (bt): hover ✎ on a paired row → dialog prefilled → bluez + =set-alias= (closes the filed rename task). +- Tooltips: any ellipsized row label carries its full text as the tooltip. +- Dialogs (join / hidden SSID / passkey / rename) keep the in-panel dupre + dialog style (gold border, dark well inputs, gold caret). +- Close: ✕ on the faceplate + Esc (already shipped; keep). +- Folded tasks: "Network panel redesign", "Bluetooth panel: switch placement + + panel title", "Bluetooth panel: rename devices" — all close with this + build's phases. + +* Engine gaps (small, close during phases) + +- radio verb: =nmcli radio wifi on|off= helper (manage or sysio) + tests. +- hidden-SSID join: =manage.add= grows a hidden flag + (=802-11-wireless.hidden yes=). +- ethernet: device rows from =nmcli dev= (type ethernet) + disconnect/ + connect verbs (device-level; =net down --iface= already disconnects). +- bt rename: btctl =set-alias= one-shot verb + verify-after read. +- bt battery: already exposed (indicator uses it). +- speedtest meters: =run_speedtest_stream= on_update already ticks (pty). +- link speed for wired channel line: =ethtool=-free read from + =/sys/class/net/<dev>/speed=. + +* Implementation phases + +1. [X] Spec + task wiring (this file; todo.org parent task with :SPEC_ID:). +2. [X] Net GTK-free layer (TDD): viewmodel row composers for the console + sections (network rows sorted+counted, tunnel rows, channel facts, + faceplate state word derivation, meter scale logic, arm state machines + for forget/disconnect), PanelModel restructure (sections, no tabs). + Engine gaps: radio verb, hidden join, ethernet rows, wired link speed. +3. [X] Net view rebuild: gui.py single-page console built in Python + (faceplate, engraved scrolled sections, console keys, cairo dial meters + with mode/hold tags, output well + dismiss), panel.css additions + (engrave, lamps, dial, badges, arm tints). AT-SPI smoke + driver + rewritten for the console layout. Shipped with phase 4 (dotfiles + 800ef60): a view-only intermediate is a broken panel (rows and switches + that do nothing), so view + interactions landed together. +4. [X] Net interactions: join/hidden/forget (arm terracotta)/disconnect + (arm gold)/radio switch/ethernet toggle/doctor stream/speed-test-drives- + meters, toasts. Verified live on velox (DOCTOR streams, SPEED TEST sweeps + both dials then HOLD). Shipped in dotfiles 800ef60 with phase 3. +5. [X] Bluetooth panel: same treatment end to end (faceplate + power + switch, adapter chip, paired/nearby lamp rows, pair passkey flow, + rename via set-alias, forget arm, battery gauges + LOW BATT, DOCTOR / + SCAN keys, output). bt smoke rewritten. Shipped in two commits mirroring + net: dotfiles 5318b34 (GTK-free layer + engine gaps) and 66f03d9 (view + + interactions + smoke). rename lands on the bluez Alias via busctl + (set-alias has no MAC-addressed one-shot); verified live on velox (smoke + green end to end, screenshot matches the prototype). +6. [X] Live verification both panels on velox + all suites + smokes green; + summary of findings written to file; folded tasks closed; dead code + removed; session context finalized. diff --git a/docs/specs/2026-07-06-audio-panel-signal-metering-spec.org b/docs/specs/2026-07-06-audio-panel-signal-metering-spec.org new file mode 100644 index 0000000..f23e97d --- /dev/null +++ b/docs/specs/2026-07-06-audio-panel-signal-metering-spec.org @@ -0,0 +1,232 @@ +#+TITLE: Audio Panel — signal metering (which inputs/outputs actually have sound) +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-06 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED Audio panel signal metering +:PROPERTIES: +:ID: baef9e7b-d255-4e80-9d10-68a7a3dd04fd +:END: +- 2026-07-06 Mon @ 10:40:00 -0500 — IMPLEMENTED — all 4 phases built TDD in dotfiles (6054d3d activity-state engine, 174ce14 three-state lamp, 55ab9f9 peak engine, 21437b4 live needles + lifecycle). Audio suite 124→161; full dotfiles make test green (49 suites). One residual: live eyeball checks (needle deflects under real audio, PTT INPUT registration, three-state lamp, no-CPU-when-closed) filed as a manual-testing task in todo.org. +- 2026-07-06 Mon @ 10:02:00 -0500 — DOING — decomposing the four phases into the build (no-approvals speedrun). +- 2026-07-06 Mon @ 10:00:00 -0500 — READY — spec-review round 2 passed (all 4 round-1 findings resolved DONE; both cookies complete; no blockers). +- 2026-07-06 Mon @ 09:37:52 -0500 — DRAFT — drafted. Scopes "option 2" from the 2026-07-06 design discussion: an activity/peak metering layer on the audio panel, with a per-device-keyed engine so per-device rendering (option 3) is a later GUI-only change. + +* Metadata + +| Field | Value | +|----------+--------------------------------------------------------------| +| Status | implemented | +|----------+--------------------------------------------------------------| +| Owner | Craig Jennings | +|----------+--------------------------------------------------------------| +| Reviewer | Claude Code (archsetup) | +|----------+--------------------------------------------------------------| +| Repo | dotfiles (=audio/= package); archsetup owns the lifecycle | +|----------+--------------------------------------------------------------| +| Related | [[file:2026-07-03-audio-panel-spec.org][audio-panel-spec (parent, IMPLEMENTED)]]; net/bt panels (kin) | +|----------+--------------------------------------------------------------| + +* Summary + +The audio panel shows volume and mute per device, but nothing tells you which output or input actually has sound moving through it right now. The "twin VU needles" today are fed by =dev.get("volume")= (=gui.py:522=) — they track the fader, not the signal, so a muted-at-source-but-fader-up device reads full and an actively-playing device at low volume reads low. This spec adds a real signal-presence layer: a cheap activity state (is audio flowing?) driving a three-state lamp, plus a windowed peak meter (is there actual level?) feeding the needles, both keyed per device in the engine so the aggregate v1 view and a future per-device view (option 3) share one metering core. + +* Problem / Context + +Craig's ask, verbatim: "I would like an indication on the audio panel which outputs or inputs actually have sound coming out of them." The panel (parent spec =2026-07-03-audio-panel-spec.org=, IMPLEMENTED) presents each sink/source with volume + mute and an aggregate INPUT/OUTPUT console-key pair whose lamp is green-when-unmuted / red-when-muted (=panel.py:133= =control_lamp=). Neither surface answers "is this device carrying sound." Two things are conflated today: + +- *Mute state* — is the device armed? (Have now.) +- *Signal presence* — is audio actually flowing / audible through it? (Missing.) + +"Which have sound coming out" is really two questions with two mechanisms: + +1. *Audio is flowing* (routing level) — the device is =RUNNING= with an uncorked stream. Cheap, already 90% available: =pactl -f json list sinks|sources= returns a per-device =state= field the engine currently drops in =pactl._device()= (=pactl.py:94=). Answers "is this device in use." Won't distinguish a stream playing silence from one making real sound. +2. *Audible signal* (peak level) — real PCM peak above a floor, read from the sink's =.monitor= source (outputs) or the source directly (inputs). The only thing that truly answers "sound coming out," and what pavucontrol's moving bars show. Costs a live capture stream. + +The parent panel already carries the widgets to express both (the INPUT/OUTPUT lamps and the twin VU needles); they just have no signal behind them. + +* Goals and Non-Goals + +** Goals +1. A per-device *activity* flag in the engine (device is carrying audio now), derived from the =pactl= =state= field, exposed through the status envelope. +2. A *three-state* INPUT/OUTPUT lamp: muted, live-idle (unmuted, no signal), live-active (unmuted, audio flowing). +3. A *windowed peak meter* feeding the twin VU needles from real level on the default sink's monitor and the default source — replacing the current volume-fed needle. +4. Build the metering *engine keyed by device name* so a later per-device view (option 3) runs the same core over N devices with no engine rework. +5. Zero cost when the panel is closed — the live meter runs only while the panel is visible. + +** Non-Goals +- *Per-device rows with their own meters* (option 3). The engine is built to support it; the v1 GUI renders aggregate INPUT/OUTPUT only. Deferred to vNext. +- *Live peak on the bar indicator.* The bar glyph stays state-based (=indicator.py=); a bar that meters continuously would run a capture stream forever. Out of scope. +- *Per-application / per-stream metering* (which app is making the sound). Device-level only. +- *Changing volume/mute/default/PTT behavior.* This is an additive read-only display layer. +- *Configurable meter ballistics beyond a rate + a floor.* No user-facing VU calibration. + +** Scope tiers +- v1: activity flag in engine; three-state aggregate INPUT/OUTPUT lamps; peak-fed twin VU needles for default sink + default source; panel-open-gated metering; per-device-keyed engine. +- Out of scope: bar-indicator live peak; per-app metering; VU calibration UI. +- vNext (log to todo.org): per-device *live level meter* — one =parec= reader per active device on each row (the activity *lamp* part of option 3 shipped in Phase 5); uncorked-stream refinement of the activity flag (see Decisions). + +* Design + +** For the user + +Open the panel. Each of the INPUT and OUTPUT console keys now carries a lamp with three readings instead of two: dark-red when that side is muted, a dim/idle green when it's live but nothing is playing, and a bright green when audio is actually flowing. The twin VU needles below move with the *real* signal — the OUTPUT needle rises when sound is genuinely coming out of the speakers (not merely because the fader is up), and the INPUT needle rises when the mic is picking up sound. Hold PTT and watch your own voice register on the INPUT needle — a live confirmation the mic works. Close the panel and nothing keeps running. + +The distinction is honest: the lamp says "this device is carrying audio"; the needle says "and here's how loud." A device that's RUNNING but playing silence shows a live-active lamp with a flat needle — which is the truth. + +** For the implementer + +Two independent signals, both per-device, layered onto the existing humble-object stack (=pactl.py= engine → =status.py= envelope → =panel.py= PanelModel → =viewmodel.py= pure helpers → =gui.py= thin view). + +*Activity (cheap, no new process).* Extend =pactl._device()= to carry =state= (=entry.get("state")=, normalized lower-case: =running= / =idle= / =suspended= / =None=). It rides through =build_status()= on each device dict already flagged =default=. In =panel.py=, add =control_active(control)= (the control's default device has =state == "running"=) and a three-state =control_lamp3(control)= → =muted= / =live-idle= / =live-active=. =gui.py= maps those to CSS classes on the existing lamp. Fully unit-testable — feed a status dict, assert the lamp. + +*Peak (richer, one managed process per metered device).* A new GTK-free =peak.py=: a =PeakReader(device_name, kind)= that spawns a native-rate mono float capture in its own process group (=parec -d <dev> --format=float32le --channels=1 --rate=44100 --latency-msec=40 --raw=, =start_new_session=True=; for a sink =<dev>= is its monitor resolved from =pactl.list_sources(include_monitors=True)= with =@DEFAULT_MONITOR@= as the default-sink fallback, for a source it's the source name), reads each ~40 ms flush on a worker thread, and reduces it to a window-peak in =[0.0, 1.0]=. A =PeakMeter= manager holds readers keyed by device name and kills each reader's process group on stop — v1 starts two (default sink's monitor, default source); option 3 starts N. Pure ballistics (decimate window-peaks to the display rate =AUDIO_METER_RATE=, normalize, attack/decay smoothing, noise floor) live in =viewmodel.peak_fraction()=, replacing the =volume=-fed =vu_fraction()= call at =gui.py:523=. =gui.py= starts the meter when the panel is shown and tears it down from a =close-request= handler on the window and =do_shutdown= on the application — the real close/quit path, since the app has no =hide= signal and quits when its last window closes — pushing peaks to the needle via =GLib.idle_add= (the existing =bg()= worker pattern). The lamp and the needle are independent: the lamp works with no peak stream at all, so a metering failure degrades to activity-only, never to a dead panel. + +Integration points (named): =pactl._device()= / =pactl.py:94=; =status.build_status()=; =panel.PanelModel.control_lamp= / =:133=; =viewmodel.vu_fraction= / =:72= and its caller =gui._set_vu= / =:520-523=; the panel =close-request= / =do_shutdown= lifecycle in =gui.py=; =tests/audio/= (fake-binary-on-PATH harness, mirroring the =pactl= fakes). + +* Alternatives Considered + +** Peak tool: parec vs pw-cat/pw-record vs a PA peak-detect stream +- Good, because =parec= ships with =pipewire-pulse= (already the whole stack — =pactl= is the same layer), needs no new dependency, and float32 raw output reduces to a peak with trivial code. The classic VU-from-a-pipe approach. +- Bad, because it's a managed subprocess per device — start/stop discipline matters (orphan =parec= = silent CPU leak). +- Neutral, because =pw-cat --record= would work equally and =pw-dump= can't stream peaks; the choice is ergonomic, not capability-bound. + +** Activity source: state field alone vs state + uncorked sink-input check +- Good (state alone), because it's one field already in the JSON, zero extra calls, and =RUNNING= is a strong "audio flowing" signal. +- Bad, because PipeWire lingers a sink in =RUNNING= briefly after playback stops and can show =IDLE= with a corked stream attached — so state alone is slightly coarse. +- Neutral, because the uncorked =sink-input=/=source-output= cross-check is a strictly-additive refinement (vNext) that doesn't change the v1 shape. + +** Metering lifecycle: always-on vs panel-open-only +- Good (open-only), because it's zero cost when closed and the panel is the only place the meter renders. +- Bad, because the bar indicator can't show live peak (acceptable — it stays state-based). +- Neutral, because always-on would only matter if the bar wanted a live meter, which is a non-goal. + +** Aggregate v1 vs jump straight to per-device (option 3) +- Good (aggregate first), because it de-risks the live-metering runtime (does peak read cleanly, is CPU acceptable, does the needle feel right) against one lamp before investing in per-device row layout + hotplug; and the parent panel's "aggregate only" decision stands. +- Bad, because a second GUI pass is needed later for option 3. +- Neutral, because the engine is keyed per-device now, so option 3 is GUI-only — no throwaway. + +* Decisions [/] + +** DONE Meter only while the panel is open — reap on app-quit, not just a hide signal +CLOSED: [2026-07-06 Mon] +- Context: a live peak stream is a running =parec= subprocess; leaving it on continuously (e.g. to feed the bar) burns CPU forever. *Round-1 review (code fact):* =gui.py= connects no =show=/=map=/=hide= signals, the window has no =set_hide_on_close(True)=, and the app takes no =hold()= — so closing the window (=do_activate= toggle =gui.py:218=, close button =:280=, Escape/q =:456=) destroys the last window and the =Gtk.Application= quits, exiting the process. Children spawned via =subprocess= are *not* reaped on interpreter exit; they reparent to init and keep running — exactly the orphan-CPU leak this feature names as its top risk. +- Decision: We will (a) spawn each =parec= in its own process group (=start_new_session=True=) and kill the group on teardown, and (b) tear down from the real lifecycle — a =close-request= handler on the window *and* =do_shutdown= on the application — not a =hide= signal this app doesn't emit. Start metering when the panel is realized/shown. Resolve the sink's monitor from the graph (=pactl.list_sources(include_monitors=True)=, since =list_sources= drops monitors via =_is_monitor= at =pactl.py:83=), falling back to =@DEFAULT_MONITOR@= for the default sink. The bar indicator stays state-based; only the open panel meters. +- Consequences: easier — near-zero cost when closed, bounded process lifetime, no orphans even on a straight quit; harder — teardown must hook the actual close/quit path, verified by a test that drives a real window-close / app-quit (not a synthetic hide) and asserts no surviving =parec= children. + +** DONE Peak via parec — capture at native rate, window in Python to the display rate +CLOSED: [2026-07-06 Mon] +- Context: need a per-device level stream; the stack is pipewire-pulse (no PulseAudio daemon), and =pactl= gives no peak. *Round-1 review verified live on ratio (2026-07-06):* =parec --rate= is the *sample* rate, not an update rate — there is no window flag — and at =--rate=25= with default latency parec flushed ~0 bytes in 3s; only an explicit =--latency-msec= produced timely output. parec has no peak-detect resampler (unlike pavucontrol's PA_RESAMPLER_PEAK), so a low =--rate= just decimates/aliases the transients a meter should show. +- Decision: We will *capture at a real rate with a low flush latency and window in Python*: =parec -d <dev> --format=float32le --channels=1 --rate=44100 --latency-msec=40 --raw=, reading each ~40 ms flush and reducing it to =max(abs(sample))=, then decimating those window-peaks to the *display* rate =AUDIO_METER_RATE= (default 25 Hz). Capture rate and display rate are separate concepts: capture stays native/high for real transients; the needle updates at the display rate. =<dev>= is the sink's monitor for outputs (resolved from the graph — see the metering-lifecycle decision — with =@DEFAULT_MONITOR@= as the default-sink fallback) and the source name for inputs. +- Consequences: easier — no new dependency, a genuine windowed level, testable with a fake =parec= emitting scripted float windows; harder — the reader owns the capture-vs-display-rate windowing (pure math in =viewmodel=), subprocess management, and a charset/validity guard on the device name before argv (reuse =pactl.valid_name=). This is a *windowed peak/level meter*, not a hardware true-peak meter — the wording elsewhere is tempered to match. + +** DONE Activity from the pactl state field (RUNNING = active) +CLOSED: [2026-07-06 Mon] +- Context: the three-state lamp needs an "is audio flowing" bit that's cheap and event-cheap. +- Decision: We will capture the =state= field in =pactl._device()= and treat =running= as active; =idle=/=suspended=/=None= are not active. The uncorked-stream cross-check is deferred to vNext. +- Consequences: easier — one field, no extra call, the lamp updates on the panel's existing re-reads; harder — a brief RUNNING linger after playback and a corked-stream-on-IDLE case make the lamp slightly coarse (documented; refinement is vNext). + +** DONE Engine keyed per device; v1 GUI renders aggregate +CLOSED: [2026-07-06 Mon] +- Context: Craig will want per-device metering (option 3) later; the peak primitive is inherently per-device. +- Decision: We will build the activity flag on every device dict and the =PeakMeter= manager keyed by device name, while the v1 GUI meters only the default sink + default source. Option 3 becomes a GUI-only change. +- Consequences: easier — no engine rework for option 3, per-device state is unit-testable now; harder — the manager carries multi-reader machinery the v1 view doesn't exercise (kept minimal, exercised by tests). + +** DONE Lamp and needle are independent; metering failure degrades gracefully +CLOSED: [2026-07-06 Mon] +- Context: the peak path can fail (no =parec=, monitor unavailable, permission) where the cheap activity path still works. +- Decision: We will drive the three-state lamp from the (cheap, always-available) activity flag and the needle from peak, as independent signals. A peak failure leaves the lamp working and the needle at rest; it never breaks the panel. +- Consequences: easier — honest partial degradation, matches the panel's verify-everything contract; harder — two code paths to keep decoupled (no shared failure). + +* Review findings [4/4] + +** DONE parec --rate=25 produces no usable meter stream :blocking: +Round-1 review verified live on ratio: =parec --rate= is the sample rate, not an update rate, and at 25 with default latency it flushed ~0 bytes; there's no peak resampler. Resolved: the "Peak via parec" decision now captures at native rate with =--latency-msec=40= and windows to the display rate in Python; "true peak meter" tempered to "windowed peak/level meter"; acceptance now demands a live-audio deflection check. + +** DONE Orphan parec on app-quit (teardown hooks absent) +Round-1 review (code fact): =gui.py= emits no show/hide, and the app quits when its last window closes, leaking =subprocess= children. Resolved: the metering-lifecycle decision now spawns each reader in its own process group (=start_new_session=True=), kills the group, and tears down from =close-request= + =do_shutdown= (not a hide); acceptance verifies reap through the real close/quit path. + +** DONE Three-state lamp needs the _set_lamp removal set extended +Round-1 review (code fact): =_set_lamp= (=gui.py:100-104=) removes a hardcoded class list and treats no-class as green, so new lamp classes stack. Resolved: Phase 2 now specifies adding the three classes to the removal set (or a dedicated setter) via a =control_lamp3= render path. + +** DONE state is uppercase; monitors filtered from list_sources +Round-1 review (live fact): =pactl -f json= emits =state= uppercase and =list_sources= drops monitors via =_is_monitor=. Resolved: Phase 1 now =.lower()=s state with uppercase fixtures; monitor resolution uses =list_sources(include_monitors=True)= with =@DEFAULT_MONITOR@= fallback. + +* Implementation phases + +** Phase 1 — Engine: capture device activity state +Extend =pactl._device()= to carry a normalized =state= — =pactl -f json= emits it *uppercase* (=RUNNING=/=IDLE=/=SUSPENDED=), so =.lower()= it to =running=/=idle=/=suspended=/=None=; thread it through =status.build_status()= so each sink/source dict exposes it. Pure parser change, TDD against real =pactl -f json= sample shapes (fixtures carry the uppercase inputs so the =.lower()= is exercised). Tree stays working — added field, nothing consumes it yet. + +** Phase 2 — PanelModel + GUI: three-state activity lamp +Add =panel.control_active(control)= and =panel.control_lamp3(control)= (=muted= / =live-idle= / =live-active=); unit-test the state machine. Wire =gui.py= via a three-state render path — the existing =_set_lamp= (=gui.py:100-104=) removes a *hardcoded* class list (=gold/red/off/busy=) and treats no-class as green, so the three new classes must be added to that removal set (or a dedicated lamp-setter) or stale classes stack. Ships the activity indicator (aggregate) with no peak stream involved. + +** Phase 3 — Peak engine: per-device peak reader +New GTK-free =peak.py=: =PeakReader(device_name, kind)= (spawns =parec= at native rate + low =--latency-msec= in its own process group, reads/reduces flush windows, validity-guards the name) and a =PeakMeter= manager keyed by device name (start/stop/read, kills each reader's process group on stop). Pure ballistics in =viewmodel.peak_fraction()= (decimate window-peaks to the display rate, normalize, attack/decay, noise floor). TDD with a fake =parec= on PATH emitting scripted float windows + pure math unit tests, plus a reader-lifecycle test asserting the process group is reaped. Nothing renders it yet — tree works. + +** Phase 5 — per-device activity lamp (option 3, GUI-only) +Promoted from vNext at Craig's request right after Phase 4. Each OUTPUTS/INPUTS row leads with its own three-state activity lamp (=viewmodel.device_lamp3(dev)= → =lamp3_class=): muted (red) / live-idle (dim) / live-active (bright green) for THAT device, so the panel shows which specific output/input is carrying audio, not just the default. Pure GUI + a pure viewmodel helper over the per-device =state= field already shipped in Phase 1 — no engine change, no new processes, rebuilt with each row on the status refresh. The per-row *live level meter* (N =parec= readers, one per active device) is the heavier remaining increment, still deferred. +Repoint =gui._set_vu= from =viewmodel.vu_fraction(volume)= to the peak source via =viewmodel.peak_fraction=. Start the =PeakMeter= (default sink's monitor + default source) when the panel is shown; tear it down from a =close-request= handler *and* =do_shutdown= (the real quit path — the app quits when its last window closes), pushing peaks to the needles via =GLib.idle_add=. Update the AT-SPI smoke to assert the meter starts on show and that a window-close/app-quit leaves no =parec= child. Ships windowed signal metering (aggregate). + +* Acceptance criteria +- [ ] Each device dict in =build_status()= carries a =state= of =running=/=idle=/=suspended=/=None=. +- [ ] INPUT/OUTPUT lamp reads muted when the default device is muted, live-idle when unmuted and not =running=, live-active when unmuted and =running=. +- [ ] With *real audio playing* to the default sink, the OUTPUT needle deflects and tracks level (manual live check — a resting needle under silence does not confirm this); with the sink silent (fader up, no stream), the needle rests near zero. +- [ ] With the mic capturing sound, the INPUT needle deflects; muted or silent, it rests. +- [ ] A window-close *and* an app-quit each leave no =parec= process running (verified through the real close/quit path, not a synthetic hide — the process-group kill reaps children). +- [ ] A missing/failing =parec= leaves the three-state lamp working and the needle at rest — the panel still opens and functions. +- [ ] Full dotfiles =make test= green; audio suite grows with new unit tests; AT-SPI smoke passes. + +* Readiness dimensions +- *Data model & ownership:* all signal data is generated/live (never user-authored) — =state= per read, =peak= per window. Owned by the engine; the view renders, never persists. Device identity keyed by stable =name=. +- *Errors, empty states & failure:* peak-path failure (no =parec=, monitor unavailable, permission) degrades to activity-lamp-only + resting needle, never a broken panel (Decision 5). Empty/absent default device → lamp reads muted/absent (existing =control_lamp= behavior), needle rests. No new user-facing error strings; failures are silent-graceful by design since this is a passive display layer. +- *Security & privacy:* the peak stream reads amplitude only, never audio content, and nothing is recorded or logged — only a float level reaches the needle. Device names pass the existing =pactl.valid_name= charset guard before hitting =parec= argv. No secrets. +- *Observability:* the meter *is* the observability surface. A =parec= spawn failure is not surfaced as an error toast (passive layer) but leaves the needle at rest; a debug hook (log the reader lifecycle under the audio package's existing debug gate, if any) is a nice-to-have, not required for v1. +- *Performance & scale:* one =parec= per metered device, mono, 25 Hz — negligible CPU, and only while the panel is open. v1 meters exactly 2 devices. Option 3 scales to N sinks+sources; the manager caps readers to the visible set. Window reduction is O(samples/window); no per-frame allocation beyond the small buffer. +- *Reuse & lost opportunities:* reuses the existing VU-needle Cairo widget (=gui.py:160=, shared with net/bt), =pactl.valid_name=, the =bg()= worker pattern, the fake-binary test harness, and the humble-object split. Repoints the already-present but volume-fed needle rather than adding a new widget. +- *Architecture fit & weak points:* fits the =pactl→status→panel→viewmodel→gui= layering exactly; peak is a new peer engine module (=peak.py=) beside =pactl.py=. Weak point: subprocess lifecycle (orphan =parec=) — mitigated by process-group spawn + =close-request=/=do_shutdown= teardown + a child-reaping test. Second weak point: two independent signal paths — mitigated by keeping them decoupled (Decision 5). +- *Config surface:* two optional keys with safe defaults — =AUDIO_METER_RATE= (the *display*/needle update rate, default 25 Hz; distinct from the fixed native =parec= capture rate, which is not a user knob) and =AUDIO_METER= on/off (default on; off falls back to activity-lamp-only). Named in the audio package =config.py=. No calibration surface (non-goal). +- *Documentation plan:* update the audio package README/help to describe the three-state lamp and the signal needles (vs the old volume needle) and the two config keys. No migration doc — additive. +- *Dev tooling:* existing =make test= / audio suite / AT-SPI smoke cover it; a fake =parec= binary joins the existing fakes. No new make targets needed. +- *Rollout, compatibility & rollback:* additive display layer, no persisted data or API change. Rollback = set =AUDIO_METER=off= (needle falls back to resting / the prior volume feed can be kept as the off-state fallback) — spelled out at build. Ships to both machines via the dotfiles =common=/hyprland stow path like the rest of the panel. +- *External APIs & deps:* =parec= (pipewire-pulse) and the =pactl -f json= =state= field are the two external assumptions. =parec= presence verified on ratio at build (it ships with the already-installed pipewire-pulse); the =state= field presence verified against live =pactl -f json list sinks= output in Phase 1 fixtures. No new package install expected — confirm =parec= on ratio before Phase 3, add to archsetup deps only if absent. + +* Risks, Rabbit Holes, and Drawbacks +- *Orphan =parec= processes* — the top risk. Mitigation: process-group spawn (=start_new_session=True=), a single =close-request=/=do_shutdown= teardown owner in =gui.py= calling =PeakMeter.stop_all()= (kills each group), and a test asserting no surviving children through the real close/quit path. +- *Monitor naming edge cases* — a sink whose =.monitor= name doesn't follow =<sink>.monitor= (rare, but bluez/virtual sinks vary). Mitigation: resolve the monitor source from the sinks/sources graph rather than string-appending =.monitor= where possible; fall back to the appended name; degrade to resting needle if the monitor can't be opened. +- *RUNNING linger / corked-on-IDLE coarseness* — the activity lamp may show live-active a beat after sound stops. Accepted for v1; the uncorked cross-check is the vNext refinement. +- *Meter ballistics feel* — attack/decay tuning is a taste call; the pure =peak_fraction= math makes it adjustable without touching GTK, and the live feel is Craig's eyeball check (manual-testing task). + +* Testing / Verification / Rollout +Unit: =pactl._device()= state parsing (each state + missing); =panel.control_active/control_lamp3= truth table (muted / live-idle / live-active / absent); =viewmodel.peak_fraction= ballistics (floor, clamp, decay, None); =peak.PeakReader= window→peak reduction and name-guard rejection via a fake =parec=; =PeakMeter= start/stop/reap (no orphan). Integration/smoke: AT-SPI smoke asserts meter starts on show and stops on close. Manual (Craig, filed to todo.org): play audio and confirm the OUTPUT needle tracks real level not fader; speak and confirm the INPUT needle + PTT registration; confirm the lamp's three states live; confirm no CPU when closed. Rollout: per-phase commit+push to dotfiles main, =make test= green gate each phase, note dotfiles at the end (archsetup-owns-dotfiles). + +* Review and iteration history +** 2026-07-06 Mon @ 09:37:52 -0500 — Claude Code (archsetup) — author +- What: initial draft scoping option 2 (activity + peak metering, per-device-keyed engine, aggregate GUI). +- Why: Craig asked for an indication of which inputs/outputs actually have sound; the design was settled in the 2026-07-06 session (two signal levels, three-state lamp, engine keyed per device so option 3 is GUI-only). +- Artifacts: parent spec 2026-07-03-audio-panel-spec.org; engine facts grounded in pactl.py:94, panel.py:133, viewmodel.py:72, gui.py:520-523; audio suite green baseline 124 tests. + +** 2026-07-06 Mon @ 09:52:00 -0500 — Claude Code (archsetup) — reviewer +- What: round-1 spec-review with live verification on ratio. One blocking finding (parec --rate is sample-rate not update-rate; no peak resampler; flushes nothing at rate 25 without --latency-msec) and three non-blocking (orphan parec on app-quit since gui has no hide signal; _set_lamp hardcoded class-removal list; uppercase state + monitors filtered from list_sources). Rubric: Not-ready pending the parec-command fix. Every other code reference confirmed accurate. +- Why: the peak half of the feature rested on an incorrect external assumption verified false live; the teardown risk the spec named as top-risk had no concrete hook in the real lifecycle. +- Artifacts: findings recorded in * Review findings. + +** 2026-07-06 Mon @ 09:54:00 -0500 — Claude Code (archsetup) — responder +- What: accepted all four findings and folded them in — rewrote the parec decision (native capture + --latency-msec + Python windowing, display/capture rates separated), the metering-lifecycle decision (process-group spawn + close-request/do_shutdown teardown + graph-resolved monitor with @DEFAULT_MONITOR@ fallback), Phase 1 (.lower() uppercase state), Phase 2 (_set_lamp removal set), Phase 3/4 (command + real-quit teardown), acceptance (live deflection + real-quit reap), and config (display vs capture rate). +- Why: close the blocking gap and the three code-accuracy gaps before a re-review. +- Artifacts: * Review findings all DONE; Decisions updated. + +** 2026-07-06 Mon @ 10:15:00 -0500 — Claude Code (archsetup) — reviewer +- What: round-2 re-review of the folded spec. Confirmed the parec fix (native 44100 + --latency-msec=40 → ~25 workable window-peaks/sec), the process-group + close-request/do_shutdown teardown, the _set_lamp removal-set fix, and the uppercase-state/.lower() + @DEFAULT_MONITOR@ fixes. Rubric: Ready, no remaining blockers; one cosmetic wording note (stale "show/hide"/"true peak" in Goals/Risks) cleaned in the same pass. Flipped DRAFT→READY→DOING. +- Why: verify the fixes resolve the round-1 findings before build. +- Artifacts: review verdict; spec cosmetic cleanup. + +** 2026-07-06 Mon @ 10:40:00 -0500 — Claude Code (archsetup) — implementer +- What: built all four phases TDD in the dotfiles audio/ package and flipped the spec IMPLEMENTED. Phase 1 pactl state capture (6054d3d); Phase 2 three-state activity lamp + viewmodel.lamp3_class + CSS (174ce14); Phase 3 peak.py windowed-peak engine + viewmodel.peak_fraction + fake-parec harness (55ab9f9); Phase 4 live VU needles + PeakMeter lifecycle + config keys + smoke orphan-check (21437b4). Audio suite 124→161; full make test green (49 suites); no-orphan reaping unit-tested. +- Why: complete the no-approvals speedrun through to shipped code. +- Artifacts: dotfiles commits 6054d3d..21437b4; manual-testing task filed in archsetup todo.org. + +** 2026-07-06 Mon @ 11:10:00 -0500 — Claude Code (archsetup) — implementer +- What: promoted the per-device activity lamp (option 3) from vNext to shipped (Phase 5). viewmodel.device_lamp3 + a leading three-state lamp on every OUTPUTS/INPUTS row; dotfiles 1faae65. Audio suite 161→166. Verified live via geometry-cropped grim: with a tone to the default sink, pactl reads it RUNNING (others SUSPENDED) and the row lamps render red=muted / green=live per device. The per-row live level meter (N parec) stays the deferred vNext. +- Why: Craig wanted the per-device view; the engine was already keyed per device so this was a GUI-only follow-on. +- Artifacts: dotfiles 1faae65; smoke green; manual check added to todo.org. diff --git a/docs/specs/2026-07-07-maintenance-console-spec.org b/docs/specs/2026-07-07-maintenance-console-spec.org new file mode 100644 index 0000000..9810fe8 --- /dev/null +++ b/docs/specs/2026-07-07-maintenance-console-spec.org @@ -0,0 +1,350 @@ +#+TITLE: Maintenance Console — Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-07 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED Maintenance console +:PROPERTIES: +:ID: 9d9df833-c592-4aec-a7df-50d588e943ce +:END: +- 2026-07-08 Wed @ 06:18:14 -0500 — IMPLEMENTED — all 14 build phases shipped (1-11 dotfiles 43a39ac..10033be, 11b 3ee22a8, 12 archsetup d6993d3 + dotfiles 0636554, 13 archsetup bef7053 + 18c081f + dotfiles 9a3f0c7). Closing state: maint CLI + GTK panel + waybar glyph live on both hosts; installer wires TOML, timers, and deps; system-health-check workflow adopted and TOML-rewired; VM scenario harness 9/9, nspawn 4/4, AT-SPI smoke green; per-phase review subagents all landed Approve. Residuals tracked in todo.org: manual-testing checklist, zfs base-image DKMS bug [#C], vNext [#D]. +- 2026-07-07 Tue @ 19:54:04 -0500 — DOING — decomposed into the build: 13 phase tasks + manual-testing + flip task under the todo.org parent "Maintenance console build" (:SPEC_ID: stamped); vNext logged [#D]. +- 2026-07-07 Tue @ 19:05:00 -0500 — READY — round-2 spec-review passed (all 10 round-1 findings resolved, no new blockers); Craig approved the config-paths proposal and the sysmon right-click re-homing, closing the last open decision (cookies 13/13, 10/10). +- 2026-07-07 Tue @ 18:30:04 -0500 — DRAFT — drafted from the completed design arc (docs/design/maintenance-console-design-ideas.org, all decisions dated 2026-07-06/07) and the converged E5 interactive prototype. + +* Metadata + +| Field | Value | +|----------+-----------------------------------------------------------------------------------------------------------| +| Status | implemented | +|----------+-----------------------------------------------------------------------------------------------------------| +| Owner | Craig Jennings | +|----------+-----------------------------------------------------------------------------------------------------------| +| Reviewer | Claude Code (archsetup) | +|----------+-----------------------------------------------------------------------------------------------------------| +| Repo | dotfiles (=maint/= package); archsetup owns lifecycle, thresholds TOML, VM test harness | +|----------+-----------------------------------------------------------------------------------------------------------| +| Related | [[file:../design/maintenance-console-design-ideas.org][design doc (source of decisions)]] ; [[file:../prototypes/2026-07-07-maint-console-E5-selector-subpanel.html][E5 prototype (converged UI)]] ; [[file:2026-07-03-instrument-console-panels-spec.org][instrument-console-panels-spec]] ; | +| | todo.org "Maintenance console" | +|----------+-----------------------------------------------------------------------------------------------------------| + +* Summary + +A single-host maintenance console for the two daily drivers (ratio, velox): a =maint= CLI + GTK panel in dotfiles (fourth panel sibling beside net/bt/audio) that surfaces every Arch health metric and runs the safe remediations behind Confirm/arm guards, streaming results to a live output wall. archsetup owns the severity thresholds (one TOML both the console and the system-health-check workflow read) and the VM harness that tests the destructive remedies. + +* Problem / Context + +Routine maintenance on these machines today is either the ~1000-line agent-driven =system-health-check.org= workflow (forensic, slow, requires a session) or ad-hoc habit — and things fall through: ratio ran at ~75% unclean shutdowns for weeks unnoticed (2026-06-08), a snapper TIMELINE misconfig nearly filled =/home=, a live =-Syu= swapped mesa+hyprland under the running session and crashed the desktop (2026-06-07), and =rust= sat looking orphaned until a manual review found it was intentional. There is no glanceable surface that says "your machine is healthy" or "these three things need a press." + +The console owns the routine-maintenance and at-a-glance slice; the workflow remains the escalation path for forensic investigation. The design converged over 2026-07-06/07 through five divergent prototypes to E5 (selector + dense subpanels + results wall); every open question in the design doc is resolved and dated there. This spec makes that design buildable. + +* Goals and Non-Goals + +** Goals +1. Every metric in the design doc's tables collected read-only on both hosts, capability-dispatched (btrfs/AMD on ratio, ZFS/Intel/battery on velox), exposed as =maint status --json=. +2. Every determinate remedy as =maint fix <thing>= — allowlisted exact argv, global =--dry-run=, privileged verbs through a validated priv table. +3. Two doctor actions — CLEAN UP (all Auto metrics, unattended) and REVIEW & FIX (Confirm metrics, per-item approval) — streaming one lamp per action to the results wall. +4. The E5 GUI: category selector, dense faceplate subpanels, rotary band selectors on tall subpanels, evidence digests, curation lifecycles (MARK KNOWN / KEEP / MARK EXPECTED with UNMARK/CLEAR), arm-to-fire guards, updates strip with the live-update guard. +5. Severity thresholds in one archsetup-owned TOML; user curation in a separate layer a template sync can never eat. +6. Waybar glyph (replacing =custom/sysmon=) tracking the worst *Diagnostic* state, fed by a background timer so the bar stays honest with the panel closed; on battery hosts the glyph doubles as the bar's battery level display. +7. Four-layer test coverage: unit fakes, read-only live, VM remedies via archsetup's harness with qcow2 snapshot/restore, AT-SPI smoke on fixtures derived from the E5 GOOD/BAD snapshots. + +** Non-Goals +- *No AI/workflow assistance from the panel.* Workflow buttons were removed in prototyping (2026-07-07); metrics that need judgment render read-only. vLater. +- *No auto-updates, ever.* Updates run only via the guarded Confirm levers; the guard arms, the user fires. +- *No file-deletion keys in the disk top-consumers digest* — evidence only. +- *No SIGKILL escalation* on the KILL lever (SIGTERM only; escalation is vLater). +- *No btrfs device-error counter reset button* — resetting without diagnosis masks a dying drive; stays manual. +- *No per-ecosystem update metrics* (cargo/npm/pipx/…): one topgrade-freshness metric + the fwupd count instead. +- *No net/bt doctor retrofit here* — separate todo task; only the results-wall idiom is shared. + +** Scope tiers +- v1: everything under Goals — all metrics from the design-doc tables including the full-sweep findings ("all committed to v1", 2026-07-07), both hosts, CLI + GUI + glyph + timers + four test layers + archsetup TOML install. +- Out of scope: the Non-Goals above. +- vNext (logged to todo.org at hand-off): AI-assist actions on read-only metrics; SIGKILL escalation; per-row live meters where evidence rows could stream. + +* Design + +** For the user + +Click the bar glyph (it replaced the old sysmon readout; its color is the worst diagnostic state — green/amber/red; on the laptop its text is the live battery level). The console opens: a faceplate row of category keys (STORAGE · SNAPSHOTS · PACKAGES · SYSTEMD · LOGS · MEM·PWR · NETWORK · SERVICES), each carrying its own status lamp and a "N fixable · M watch" split so you know your agency before reading a cell. Selecting a category renders its dense subpanel: every metric a row with lamp, value, and — only when off-nominal — a contextual lever. Tall subpanels (Packages, Logs, Services) carry a rotary band selector (click to cycle: e.g. ORPHANS · PACNEW · ADVISORIES), one band rendered at a time at full width, each band showing its own lamp + count on the dial. + +Counts never hide detail: orphans list name + size with per-package REMOVE (armed) and KEEP; journal errors group by identifier with first/last seen and the exact =journalctl= command; listeners show process · port · bind address. Where "expected" is knowledge, it's curated: MARK KNOWN / MARK EXPECTED / KEEP move items to a dim section (never vanish) with UNMARK per row and CLEAR for the layer; every mark logs to the wall. + +Anything consequential arms before it fires: first press turns the key red and shows exactly what will run (the dry-run argv); second press runs it. The two doctor keys — CLEAN UP and REVIEW & FIX — and every remedy stream into the results wall at the bottom: one lamp per action, amber while running, green with the result inline (reclaimed MBs, restarted unit), red on failure; date+time stamped, HIDE/COPY, capped at 3.5 visible entries with the half-entry as scroll cue. The updates strip shows pending/AUR/firmware/CVE counts with a state-tiered border; UPDATE and TOPGRADE sit behind the live-update guard — when mesa/hyprland/wayland runtime is in the pending set the key arms with "press again to run anyway — or apply from a TTY". After an update lands, a REBOOT key (armed) appears. + +Temps, memory, and throttle re-read every ~3 s while visible; local probes every ~30 s and immediately after any action that touches them; network-tier counts (updates, CVEs, AUR, firmware) come from an hourly timer cache with their age shown. Close the panel and nothing keeps running except the light half-hourly background scan that feeds the glyph. + +** For the implementer + +*Package shape.* =maint/= in dotfiles mirrors the sibling humble-object stack (=net/=, =audio/=): =src/maint/= with =probes/= (one module per category, read-only collectors that parse command output), =schema.py= (the status envelope), =status.py= (assembles =maint status --json=), =thresholds.py= (TOML load + user-layer merge), =curation.py= (marks/keeps/expected stores, read+write), =remedies.py= (every lever as an allowlisted exact argv), =priv.py= (the closed privileged-verb table, validated args, =sudo= via =MAINT_SUDO= env — the net =priv.py= pattern verbatim), =guard.py= (pure live-update guard over a package list), =doctor.py= (CLEAN UP / REVIEW & FIX sequencing, streaming), =cli.py=, =viewmodel.py= (pure render helpers), =gui.py= (thin GTK4 view), =indicator.py= (glyph state). Tests in dotfiles =tests/maint/= with fake binaries on PATH (the fake-pactl/fake-parec pattern) — fake-smartctl, fake-btrfs, fake-zpool, fake-pacman, fake-journalctl, fake-ss, fake-docker, fake-systemctl, etc. + +*The contract.* =maint status --json= emits one envelope: host capabilities (fs type, pstate driver, battery present, docker/libvirt present — "battery present" means a =/sys/class/power_supply/= entry with =type == Battery=, never a non-empty dir: ratio carries Mains + USB-C PD entries with no battery), then per-category metric dicts each carrying =id=, =value=, =severity= (=ok=/=warn=/=crit=), =label=, optional =evidence= (list of digest rows), optional =levers= (list of remedy ids applicable right now), =probed_at=, and =cache_age= for cached metrics (network-tier and slow-local alike). Severity is always computed engine-side from the thresholds TOML — the GUI never holds a threshold. The E5 prototype's GOOD/BAD data snapshots are reshaped to conform to this schema and become the GUI fixtures (=MAINT_PANEL_FIXTURE=bad= renders the degraded board on a healthy machine). + +*Remedies.* Each remedy is =(id, argv, priv_verb_or_none, arm_required, re_probe_ids)=. =maint fix <id> [--dry-run]= prints the exact argv under =--dry-run= — the same string the GUI's arm-press displays. Collectors never elevate; only =priv.py= verbs touch =sudo=, and archsetup's install already grants NOPASSWD (=archsetup:1178=). After a remedy fires, the engine re-probes the metrics named in =re_probe_ids= (fire CLEAN → cache re-measured, not assumed). Long-running remedies (btrfs/zfs scrub, SMART self-test, rsyncshot RUN NOW) report a running-% state to the wall, not an instant reset. KILL revalidates PID + process name at fire time and refuses session-critical names (systemd, the compositor, the panel itself → disabled key). + +*Curation stores.* Shipped defaults (known-noise patterns, expected listeners sshd/mpd/tailscaled, expected containers winvm, pacnew safe-delete allowlist) live in the archsetup-shipped TOML; user marks live in the user layer, merged over it. Journal noise patterns bind to identifier + message snippet, never a whole unit. Unmarking a shipped default writes a disable flag in the user layer; CLEAR MARKS empties the user layer only. + +*Refresh tiers* (all engine-side, GUI subscribes): live group ~3 s panel-open + subpanel-visible; fast local ~30 s panel-open + post-action re-probe; hydration on open (fast reads first, process probes behind, sub-second perceived); network tier from the hourly timer cache, on-demand refresh only; *slow local tier* — probes whose cost class matches the network tier despite being local (full =pacman -Qkk=, measured 47 s on ratio; the disk top-consumers scan) ride the same hourly timer cache with age shown, on-demand refresh only, never in hydration or fast-local; closed-panel glyph fed by a ~30-min systemd user timer running =maint scan --glyph= to write the state file =waybar-maint= reads. Two user units ship with the package: =maint-scan.timer= (glyph) and =maint-net-scan.timer= (hourly network-tier + slow-local cache). + +*Waybar wiring.* =custom/maint= replaces =custom/sysmon= in the modules list and config; glyph state file + SIGRTMIN signal refresh like =custom/ptt=. Gotcha (from the PTT build): waybar runs a *generated runtime config* — the module lands via =waybar-active-config= + SIGUSR2, not just a canonical config edit. Theme CSS mirrored in both themes (dupre, hudson). + +*Cross-links, not duplicates.* DNS/NetworkManager repair deep-links to the net panel's doctor; OPEN JOURNAL launches a terminal running =journalctl -p err -b= (the NET DOCTOR delegation pattern). + +* Alternatives Considered + +** Console vs workflow-only maintenance +- Good (console), because glanceable health + one-press remedies catch the rot the workflow only finds when explicitly run; the workflow stays for forensics. +- Bad, because it's a large build (this spec's phase count) duplicating some workflow probe logic. +- Neutral, because the shared thresholds TOML keeps the two from drifting — the duplication is probes, not policy. + +** GUI-first vs CLI-first +- Good (CLI-first), because =maint status --json= + =maint fix= are testable without a display, the VM harness needs exactly that, and the GUI reduces to a face over a verified engine (the sibling panels' proven shape). +- Bad, because the GUI lands late in the phase order — no visible console until ~P7. +- Neutral, because the E5 prototype already de-risked the visual design; what's left to prove is the engine. + +** Layout: A–E divergents, then 3-column compression vs rotary bands +- Good (E5 selector + subpanel), because Craig picked E from five divergent prototypes and iterated it live through E2–E5; the selector gives every category full width. +- Bad, because one-category-at-a-time hides cross-category state — mitigated by per-key lamps + fixable/watch splits on the selector row. +- Neutral, because the rotary band selector replaced the failed 3-column compression (third-width truncation lost row detail — Craig's verdict after use); MEM·PWR keeps its 3-column strip because its rows are short. + +** Update visibility: per-ecosystem metrics vs topgrade freshness +- Good (freshness), because topgrade's step set has no cheap offline "updates available?" probe — mirroring it costs a network round-trip per registry at panel-open. +- Bad, because the panel can't say "3 cargo crates stale" — only "topgrade last ran N days ago". +- Neutral, because fwupd refreshes its own metadata cache, so firmware gets a real count for free. + +** Elevation: polkit/pkexec per action vs priv-verb table + sudo +- Good (priv table), because the net panel already proved it: a closed validated verb set, NOPASSWD covered by archsetup's install, no auth dialog stacking on top of the arm-to-fire press. +- Bad, because the sudoers grant is broad (archsetup currently writes NOPASSWD: ALL) — the verb table is the real narrow waist, so it must stay small and auditable. +- Neutral, because pkexec would add a second confirmation UI the arm press already provides. + +* Decisions [13/13] + +** DONE CLI-first architecture; GUI is a face +CLOSED: [2026-07-07 Tue] +- Context: four test layers need a display-free engine; the sibling panels' humble-object stack is proven; the VM harness can only drive a CLI. +- Decision: We will ship =maint= as engine + =cli.py= with =maint status --json= as the contract and =maint fix <id>= as every lever; =gui.py= renders the same code and never does anything the CLI can't. Global =--dry-run= prints the exact argv. +- Consequences: easier — testability, VM scenarios, the arm-press preview is the dry-run string for free; harder — the GUI arrives late in the phase order, and the JSON schema becomes a real contract that fixtures and tests pin. + +** DONE Determinate remedies only in v1 +CLOSED: [2026-07-07 Tue] +- Context: workflow buttons were prototyped and removed (E5, 2026-07-07); indeterminate fixes (failed units with unknown cause, journal errors, OOM) need judgment. +- Decision: We will ship only fixed, scriptable actions with predictable outcomes — the design doc's determinate-remedies list. Judgment metrics render read-only with expectation tags ("hardware — watch only", "evidence below"). AI/workflow assistance is vLater. +- Consequences: easier — every remedy is an exact argv, auditable and VM-testable; harder — red diagnostic cells offer no in-panel next step beyond evidence + the named command. + +** DONE Updates behind an arming live-update guard +CLOSED: [2026-07-07 Tue] +- Context: the 2026-06-07 crash was a live =-Syu= swapping mesa+hyprland under the session; the standing rule is never update the graphics/wayland runtime live. Hard-refusing infantilizes the user. +- Decision: We will put UPDATE (repo+AUR) and TOPGRADE on the updates strip as Confirm levers; when the pending set contains mesa/hyprland/wayland-runtime packages the key arms red with "press again to run anyway — or apply from a TTY". The topgrade wrapper always passes =--disable git= (its git step rebase-autostashes =~/code/*/=). After an update lands, an armed REBOOT key appears and the reboot-required metric flips. The strip border is state-tiered (green/amber/red by pending/CVE/staleness thresholds in the TOML). +- Consequences: easier — updates finally have a sanctioned in-panel path with the footgun named at press time; harder — the guard's package-set matcher must be right (pure function, heavily unit-tested), and the wrapper owns topgrade's flag discipline. + +** DONE Doctor actions stream to a results wall +CLOSED: [2026-07-06 Mon] +- Context: fire-and-forget buttons hide failures; Q1 resolved 2026-07-06. +- Decision: We will stream every doctor and every individual remedy to the wall — one lamp per action, amber running / green with result inline / red failed, always shown, date+time stamped, HIDE/COPY, 3.5-entry scroll cap. Long ops report running-%. +- Consequences: easier — failures are visible the moment they happen, and mark/unmark curation events get a durable in-session log; harder — remedies need incremental output plumbing (engine yields events; GUI appends), not just exit codes. + +** DONE Both hosts first-class; capability dispatch from day one +CLOSED: [2026-07-06 Mon] +- Context: Q2 resolved 2026-07-06 — velox travels and suspends; its GAP metrics (battery health, unclean-shutdown rate) motivated the console. +- Decision: We will probe host capabilities at startup (fs types, pstate driver, battery, docker/libvirt) and run only applicable collectors; ZFS paths (scrub, autotrim, capacity, frag) are built and tested, not stubbed; velox-only metrics ship in v1. +- Consequences: easier — one codebase, no ratio-first debt; harder — every collector's tests need both-host fixtures. ZFS remedy scenarios are possible in the VM (the harness supports =FS_PROFILE=zfs= base images — =vm-utils.sh:23,53-55=; prerequisite: confirm the zfs base image builds); the floor if that image proves troublesome is argv-construction tests + read-only live runs on velox. + +** DONE Thresholds and curation as layered TOML, archsetup-owned +CLOSED: [2026-07-06 Mon] +- Context: Q3 resolved 2026-07-06 — severity values must not drift between the console and the system-health-check workflow; template syncs must never eat user curation. +- Decision: We will put every severity value (cache trigger, disk %, scrub-age bands, snapshot limits, temp bands, staleness windows, "a lot" of updates) plus shipped curation defaults in =maintenance-thresholds.toml=, owned and installed by archsetup, seeded from the workflow's hard-won values. User marks/keeps/expected live in a separate user-layer file merged over it. Both the console and the workflow read the installed TOML. +- Consequences: easier — one source of truth, workflow and console can't disagree, thresholds tunable without touching code; harder — archsetup grows an install/update step for the TOML, and the merge semantics (including disable-flags for shipped defaults) need explicit tests. + +** DONE Evidence digests + curation lifecycles on every count metric +CLOSED: [2026-07-07 Tue] +- Context: the full-sweep audit (2026-07-07) committed all findings to v1; a bare count trains you to ignore it, and "expected" is configuration knowledge that today lives in Craig's head (the rust orphan lesson, winvm, sshd). +- Decision: We will give every count an evidence digest (orphans name+size, journal groups by identifier with first/last seen + next command, listeners process·port·bind, coredumps by binary, docker df by type, snapshots split timeline/single/pre-post, top-5 RAM, recent boots clean/unclean) and every "expected"-shaped metric the full curation lifecycle: MARK KNOWN / KEEP / MARK EXPECTED with per-row UNMARK, CLEAR for the user layer, marked items dimmed but never hidden, every transition logged to the wall. Guarded per-item remedies ride the digests (orphan REMOVE armed + REMOVE ALL skipping keeps, pacnew MERGE via terminal diff, per-unit RESTART+RESET, per-container START, per-socket STOP/KILL armed, DELETE STALE keeping newest 2). KILL carries four guards: arm shows exact victim, SIGTERM only, PID+name revalidated at fire, session-critical names disabled. +- Consequences: easier — the panel is honest and the curation encodes system knowledge durably; harder — this is most of the GUI surface area and the reason the subpanel phases are the fattest. + +** DONE Rotary band selector on tall subpanels +CLOSED: [2026-07-07 Tue] +- Context: 3-column compression on Packages/Logs/Services lost row detail to third-width truncation (Craig's verdict after use). +- Decision: We will render tall subpanels one band at a time behind a rotary selector (click to cycle; engraved band labels each with own lamp + count; gold underline + needle on the selected band): Packages ORPHANS·PACNEW·ADVISORIES, Logs SIGNAL·KNOWN NOISE·COREDUMPS·KERNEL/HW, Services CONTAINERS·DOCKER DISK·CRON & BACKUPS. MEM·PWR keeps its 3-column strip (short rows fit). +- Consequences: easier — full-width row detail restored, and each selection idiom in the panel keeps a distinct visual voice; harder — off-band state must surface on the dial lamps or a red band goes unseen. + +** DONE Four-tier refresh cadence + glyph timer; glyph replaces custom/sysmon, shows battery on laptops +CLOSED: [2026-07-07 Tue] +- Context: probe cost varies from a sysfs read to a network round-trip; the bar must stay honest with the panel closed. Craig (2026-07-07, spec session): the maint glyph replaces =custom/sysmon= rather than adding a module beside it, and on a laptop it should function as the bar's battery level display. +- Decision: We will refresh in tiers matched to probe cost (live ~3 s visible; fast local ~30 s open + post-action; hydration on open; network hourly cache with age shown, on-demand refresh; a slow-local tier for expensive local probes — full =-Qkk= at 47 s measured, disk top-consumers — riding the same hourly cache), run a ~30-min =maint-scan.timer= writing the glyph state file, and wire =custom/maint= in waybar in place of =custom/sysmon=. The glyph's *color* tracks the worst *Diagnostic* state only — actionable clutter (a fat cache) never colors the bar. On battery hosts (capability-gated, same host probe) the module's *text* is the battery level with a charging indicator, read directly by =waybar-maint= on waybar's own interval (a 30-min scan is too coarse for charge %); a critically-low charge (threshold in the TOML) feeds the diagnostic state so the color stays honest when the battery is the emergency. Any existing waybar battery module on the laptop retires in favor of this one. +- Consequences: easier — panel-open cost is bounded, the bar signal stays high-trust, and the laptop bar loses a module instead of gaining one; harder — the glyph script has two data sources (state file for diagnostics, sysfs for charge), and the old sysmon affordances resolve per Craig's 2026-07-07 ruling: the btop scratchpad (=pypr toggle monitor=) re-homes to the maint glyph's right-click; =sysmon-cycle= and the =waybar-sysmon= readout retire. + +** DONE Privileged remedies via a maint-priv verb table +CLOSED: [2026-07-07 Tue] +- Context: most remedies need root (systemctl, paccache, journalctl --vacuum, scrub, charge threshold); collectors must never elevate; the net panel's =priv.py= already proved the pattern. +- Decision: We will route every privileged remedy through =maint/priv.py=: a closed verb table, one exact command per verb, args validated by type-specific regexes before reaching sudo, =MAINT_SUDO= env override so tests run fakes directly. Read/write split is hard: probes never import priv. +- Consequences: easier — one small auditable module is the entire privilege surface, VM tests exercise the same verbs; harder — every new remedy is a deliberate verb-table addition, never an inline =sudo=. + +** DONE Four-layer test strategy; E5 snapshots become fixtures +CLOSED: [2026-07-07 Tue] +- Context: remedies mutate systems; the archsetup VM harness (=scripts/testing/run-test.sh=) exists; the sibling panels' fake-binary and AT-SPI patterns are proven. +- Decision: We will test in four layers — (1) unit with fake binaries on PATH (~90% of surface: probes as parsers, remedies as argv construction, guard as pure function), (2) read-only =maint status --json= on the live hosts, (3) remedies in the VM with scenario scripts that break things over ssh then assert post-state, systemd-nspawn for pure pacman-level tests, (4) AT-SPI GUI smoke on the host driven by fixtures conforming to the JSON schema, derived from the E5 GOOD/BAD snapshots. No GUI in the VM. VM isolation policy: the snapshot primitives already exist (=lib/vm-utils.sh:303-357= — create/restore/delete via =qemu-img=, VM must be stopped for any snapshot op; =run-test.sh= already reverts to a =clean-install= snapshot), so the new work is scenario *orchestration* only. Scenarios are grouped into non-conflicting batches that run within one VM boot (a stopped-cronie scenario doesn't contaminate a fat-cache scenario); a stop → restore → reboot cycle runs only *between destructive groups* whose post-state would contaminate the next batch. The scenario runner declares each scenario's group so the batching is explicit, not inferred. +- Consequences: easier — destructive coverage without risking real machines, and the harness work is smaller than feared (orchestration over existing primitives); harder — VM runs are slow (~40–60 min), so grouping discipline matters, and a scenario mis-grouped as non-conflicting can produce order-dependent failures (the runner can randomize in-group order to surface these). + +** DONE system-health-check workflow moves home → archsetup +CLOSED: [2026-07-06 Mon] +- Context: Craig's call (2026-07-06) — home is scoped to finances/health/personal; system maintenance is archsetup's domain, and the move collapses the TOML ownership coupling. +- Decision: We will move =system-health-check.org= + =homelab-inventory/*.org= into archsetup in the final phase, rewire the workflow to read the installed TOML instead of prose severity rules, and hand the home-side removal (originals, startup references, index) to home via an inbox note — never edited blind from here. +- Consequences: easier — TOML producer and both consumers land in one project; harder — sequencing: the move waits for the TOML to exist and ship, so it anchors the last phase. + +** DONE Config and state file locations +CLOSED: [2026-07-07 Tue] +- Context: four files need stable homes both consumers reach: the shipped thresholds+defaults TOML (archsetup-installed, design doc suggests =~/.config/archsetup/maintenance-thresholds.toml=), the user curation layer, the glyph state file, and the hourly network-tier + slow-local cache (round-1 review: this one must survive reboots, or every boot shows "no data" for up to an hour — =$XDG_RUNTIME_DIR= is wrong for it). Sibling precedent: panels read =~/.config/<pkg>/config= (audio =config.py:29=). Craig approved the proposal 2026-07-07. +- Decision: We will install the shipped TOML at =~/.config/archsetup/maintenance-thresholds.toml=; the user layer at =~/.config/maint/curation.toml= (alongside an optional =~/.config/maint/config= for GUI prefs per sibling convention); the glyph state and the hourly cache both under =~/.local/state/maint/= (XDG state — persists across reboots; the glyph file is cheap to rebuild but gains nothing from being ephemeral). Env overrides for all four so tests and fixtures never touch real files. +- Consequences: easier — XDG-conventional, stow-safe, sync-safe (curation outside any synced tree), honest counts right after boot; harder — two config roots (=archsetup/= for shipped, =maint/= for user) must be documented or they'll confuse future edits. + +* Review findings [10/10] + +** DONE Automation-tier normativity contradiction :blocking: +Round-1: the References section declared the design-doc tables normative for Automation tier, but the tables mark failed-unit restart / pending updates / AUR staleness / =-Qkk= reinstall as Workflow while the 2026-07-07 decisions give them Confirm levers — an implementer couldn't derive the REVIEW & FIX set. Resolved: References now scopes the tables to metric *existence* only; the dated decisions win on tier. + +** DONE VM scenario isolation policy undefined :blocking: +Round-1 (code fact): =vm-utils.sh:301= requires the VM stopped for snapshot ops, contradicting "restore between scenarios" + "batch per boot" as written; snapshot primitives also already exist (=vm-utils.sh:303-357=), so "new harness work" overstated. Resolved: Decision 11 now states the grouped-batch policy (non-conflicting scenarios per boot; stop/restore/reboot only between destructive groups; runner declares groups) and reframes the work as orchestration over existing primitives. + +** DONE Expensive local probes had no refresh tier :blocking: +Round-1 (measured live): full =pacman -Qkk= runs 47 s on ratio; disk top-consumers is the same cost class; neither fit fast-local (~30 s) nor hydration. Resolved: a slow-local tier added — both ride the hourly timer cache with age shown, on-demand refresh only. + +** DONE ZFS-in-VM claim verified false as a constraint +Round-1 (code fact): the harness supports =FS_PROFILE=zfs= base images (=vm-utils.sh:23,53-55=). Resolved: Decision 5 corrected — ZFS scenarios are possible (prerequisite: zfs base image builds); argv+live-read-only becomes the fallback, not the plan. + +** DONE Battery capability detection underspecified +Round-1 (verified live): ratio's =/sys/class/power_supply/= is non-empty (Mains + 2 USB entries) — a naive non-empty-dir probe false-positives. Resolved: contract now defines battery present ⇔ an entry with =type == Battery=. + +** DONE sysmon retirement collateral unnamed +Round-1: retiring =waybar-sysmon=/=sysmon-cycle= without touching =tests/waybar-sysmon/= + =tests/sysmon-cycle/= reds =make test= in the same commit; =#custom-sysmon= CSS lives in three files. Resolved: Phase 11 now names the scripts, suites, and CSS blocks as same-commit retirement collateral. + +** DONE Test-gate discovery answered +Round-1 (code fact): =make test= discovers =tests/maint/test_*.py= via glob (=Makefile:266-271=) — no enumeration needed; AT-SPI smokes ARE enumerated (=Makefile:279-286=). Resolved: Dev-tooling records both; =test-panel-maint= target added to Phase 12 deliverables. + +** DONE arch-audit absent on ratio +Round-1 (verified live): every other assumed tool present; =arch-audit= is not. Resolved: External deps flips it from verify-item to known — add to archsetup deps + install both hosts before Phase 3's CVE collector is live-testable. + +** DONE Fourth unhomed persistent file +Round-1: the hourly network-tier/slow-local cache must survive reboots (else "no data" for up to an hour after boot) — =$XDG_RUNTIME_DIR= wrong for it. Resolved: added to the open config-paths decision; proposal now puts glyph state + cache under =~/.local/state/maint/=. + +** DONE Panel-CSS "both themes" ambiguity +Round-1 (code fact): sibling panels load =themes/dupre/panel.css= hardcoded (=audio/gui.py:52-61=); hudson has no panel.css. Resolved: Phase 7 follows the sibling dupre-only panel-CSS convention; Phase 11 scopes both-themes CSS to the waybar glyph stylesheets. + +* Implementation phases + +Each phase leaves the tree working and gates on dotfiles =make test= green (archsetup phases gate on their own checks). Commit + push per phase; dotfiles note at milestones (archsetup-owns-dotfiles). + +** Phase 1 — Package skeleton, thresholds, contract +=maint/= scaffold (src layout, stow wiring, =__main__=), =thresholds.py= (TOML load + user-layer merge + disable flags, env overrides), =schema.py= severity model, capability probe, =maint status --json= envelope with three pilot collectors (disk usage, package cache size, failed-unit count), fake-binary harness in =tests/maint/=. Seed =maintenance-thresholds.toml= content in archsetup (workflow's values: snapshot limits, 10 GB cache trigger, scrub-age bands). + +** Phase 2 — Collectors: storage & snapshots +btrfs (unallocated, scrub age, per-device error counters cross-checked vs SMART), ZFS (health, capacity, frag, autotrim, scrub age), SMART (health, wear, temp, last self-test), fstrim.timer, disk top-consumers digest, snapper/ZFS snapshot counts split timeline·single·pre-post with oldest-single named. Both-host fixtures. + +** Phase 3 — Collectors: packages, security, systemd +Cache size, orphans (name+size), pacnew classified safe-delete vs needs-merge, keyring freshness, reboot-required, pacman -Qkk; network-tier cache readers (checkupdates, arch-audit CVEs named, AUR staleness, fwupd count, topgrade freshness stamp) with age; is-system-running + cause, failed-unit roster (name·since·exit·journalctl hint), maintenance-timers meta-metric, taint letters decoded. + +** Phase 4 — Collectors: logs, memory, power +Journal error digest (grouped by identifier, first/last seen, next command, two noise layers applied), coredumps by binary, kernel/hw events; memory free + top-5 consumers, OOM kills, swap/zram, temps, throttle state, EPP mode read, battery health + charge threshold read (velox), unclean-shutdown rate from recent boots. + +** Phase 5 — Collectors: network posture, services +DNS/NM reachability, firewall state (+ public-bind exposure naming when down), listeners digest with expected-layer merge and unexpected/public severity, tailscale, fail2ban + recent bans, NTP offset; rsyncshot freshness, docker system df by type + stopped containers with expected-layer, libvirt state, cron expected-entries drift. Curation read side complete. + +** Phase 6 — Remedies, priv, guard, doctor CLI +=remedies.py= (full allowlisted table with =re_probe_ids= + running-% for long ops), =priv.py= verb table, =guard.py= live-update matcher (pure), curation writes (mark/unmark/clear/keep/expected), =maint fix <id> [--dry-run]=, =maint doctor clean|review= streaming an event feed (text wall in the terminal). Topgrade wrapper with =--disable git=. KILL's four guards. Argv-construction tests for every remedy; no execution. Splits naturally into 6a (remedies/priv/guard) + 6b (curation writes + doctor + CLI) if the session runs long — 6a alone leaves a working tree. + +** Phase 7 — GUI shell +GTK4 window, faceplate, category selector row (lamps + fixable/watch splits), subpanel scaffolding, hydration tiers + refresh cadence (live/fast, visibility-gated like the audio meters), =MAINT_PANEL_FIXTURE= loading, panel CSS following the sibling convention (=themes/dupre/panel.css=, the shared panel stylesheet — hudson carries waybar CSS only, no panel.css). Renders real =status= data read-only — no levers yet. + +** Phase 8 — GUI subpanels: storage, snapshots, packages, updates strip +Evidence digests, armed per-item keys (orphan REMOVE/KEEP, pacnew MERGE, DELETE STALE, scrub with running-%), the Packages rotary band selector, updates strip with state-tiered border, UPDATE/TOPGRADE behind the arming guard, armed REBOOT on completion. + +** Phase 9 — GUI subpanels: systemd, logs, mem·pwr, network, services +Failed-unit roster keys, journal digest + MARK KNOWN lifecycle UI + OPEN JOURNAL, Logs/Services rotary bands, MEM·PWR 3-column strip + CPU-mode segmented control + velox SET 80% + guarded KILL, listeners curation + STOP/KILL, firewall ENABLE, container START, RECLAIM SPACE composite macro. The fattest phase — split by subpanel pair (9a systemd+logs, 9b mem·pwr+network+services) if a session runs long; each pair gates independently. + +** Phase 10 — Results wall + doctor keys +The wall widget (lamp stream, date+time, inline results, running-%, HIDE/COPY, 3.5-entry cap, dark scrollbar), CLEAN UP / REVIEW & FIX wired through =doctor.py=, every armed key streaming, curation events logged, post-action re-probes visible. + +** Phase 11 — Waybar glyph + timers +=indicator.py= + =waybar-maint=, =custom/maint= replacing =custom/sysmon= (canonical config + =waybar-active-config= runtime path + SIGUSR2), signal-driven refresh, =maint-scan.timer= + =maint-net-scan.timer= user units, glyph color tracks worst diagnostic from the state file; on battery hosts the module text is the live battery level (sysfs read per waybar interval, charging indicator, low-charge threshold feeding the diagnostic state) and any existing battery module retires. Glyph CSS lands in both theme waybar stylesheets (dupre, hudson). Retirement collateral in the same commit (Craig ruled 2026-07-07): =custom/maint= right-click re-homes the btop scratchpad (=pypr toggle monitor=); the =waybar-sysmon= + =sysmon-cycle= scripts and their suites (=tests/waybar-sysmon/=, =tests/sysmon-cycle/=) retire, and the =#custom-sysmon= CSS blocks come out of =waybar/style.css= and both themes. + +** Phase 11b — Prototype fidelity pass (added 2026-07-08) +Added mid-build from Craig's live-board review: phases 7-10 delivered E5's structure and behavior but rendered subpanel metrics as one-line list rows (the sibling panels' idiom) instead of the prototype's instrument-card grid; the per-phase screenshot checks verified structure, not presentation. Scope: card-grid subpanels (4-up metric cards — big-number value, caption line, progress bars, radial gauges for scrub cadence and NVMe wear, status chips, corner lever key), two-row selector tiles with compact count chips, subpanel attention/ok/fixable/watch header line, evidence digests as full-width rosters under the grid. Engine and viewmodel contracts unchanged; =gui.py= layout + =panel.css=. Sequenced before Phase 12 so the AT-SPI smoke targets the final layout. Verification is a pixel-level comparison against the settled E5 render (headless Chrome), not structural spot checks. + +** Phase 12 — VM remedy scenarios + AT-SPI smoke (archsetup + dotfiles) +Scenario orchestration over the existing snapshot primitives (=vm-utils.sh:303-357=): grouped batches per Decision 11, maint scenario scripts (stop cronie, mask fstrim, orphan packages, fill cache, break timers → =maint fix= → assert post-state); systemd-nspawn variant for pacman-level cases; AT-SPI smoke on GOOD/BAD fixtures asserting board render, arm behavior, and no leaked processes, plus the enumerated =test-panel-maint= Makefile target. May split VM work (archsetup) from AT-SPI smoke (dotfiles) into two sessions — each half gates independently. + +** Phase 13 — Install wiring, workflow move, docs +archsetup installs the TOML (idempotent, update-safe); deps added; =maint= README + keybind/user docs; =system-health-check.org= + homelab inventories moved into archsetup and rewired to the TOML; handoff note to home's inbox for the home-side removal; spec flipped IMPLEMENTED with history line. + +* Acceptance criteria +- [ ] =maint status --json= runs read-only on both hosts; ratio emits btrfs/AMD metrics and no ZFS/battery; velox emits ZFS/Intel/battery — same binary, capability-dispatched. +- [ ] Every metric's severity comes from the installed TOML; editing a threshold changes the verdict with no code change. +- [ ] =maint fix <id> --dry-run= prints the exact argv for every remedy; the GUI arm-press shows the same string. +- [ ] Probe modules import no elevation path (verifiable: nothing outside =priv.py= references sudo). +- [ ] With mesa/hyprland in the pending set, UPDATE arms with the override wording instead of running; without them it runs on confirm. Topgrade always receives =--disable git=. +- [ ] Doctor runs stream per-action lamps to the wall with results inline; a long op shows running-%; a failure shows red without aborting the remaining stream. +- [ ] Marks/keeps/expected survive a dotfiles template sync and a package update; marked items render dimmed, UNMARK restores, CLEAR empties only the user layer. +- [ ] KILL never fires on a recycled PID (name revalidated) and renders disabled for session-critical names. +- [ ] The bar glyph reflects worst diagnostic within one scan interval with the panel closed, and never colors for actionable-only findings. +- [ ] On velox the glyph shows live battery level + charging state; a charge below the TOML low threshold turns the diagnostic state (and the glyph) red; on ratio no battery text renders. +- [ ] =MAINT_PANEL_FIXTURE=bad= renders the fully degraded board on a healthy machine; AT-SPI smoke passes against it. +- [ ] VM scenarios: each broken-state → =maint fix= → asserted post-state passes from a pristine qcow2 snapshot. +- [ ] Full dotfiles =make test= green; closing the panel leaves no maint processes beyond the timers. + +* Readiness dimensions +- *Data model & ownership:* live probe data is generated, never persisted (results wall is session-only, in-memory); curation files are user-authored (user layer) vs shipped (archsetup TOML); glyph state file and hourly cache are generated state rebuilt by the timers (their home is the open config-paths decision). The JSON envelope is the one contract fixtures pin. +- *Errors, empty states & failure:* a failing probe degrades its own metric to an "unprobed" state with the failure named — never a dead board; remedy failures stream red to the wall with stderr excerpt and the metric re-probed (truth, not assumption); network-tier cache absent → counts show "no data · refresh" with the timer named; every guard names the operation and the next step at arm time. +- *Security & privacy:* privilege surface is the priv verb table alone; args regex-validated pre-sudo; listeners digest shows local process/port data only in the local GUI — never logged off-machine; no secrets read; charge/EPP writes are fixed-value verbs. +- *Observability:* the results wall is the action log; =maint status --json= + =--dry-run= make any GUI claim reproducible in a terminal; cache ages displayed; scan timers visible via systemctl --user. +- *Performance & scale:* hydration budget sub-second perceived (fast reads first); live tier only while visible; N of everything is small (tens) except journal lines — the digest groups engine-side with a top-10 cap. Long ops never block the GUI thread (worker + event feed, the sibling =bg()= pattern). +- *Reuse & lost opportunities:* reuses the sibling panels' humble-object stack, fake-binary harness, AT-SPI smoke pattern, priv.py design, console-key/lamp CSS kit from the widget gallery, the existing archsetup VM harness, and the E5 prototype as both fixture source and pixel reference. Deliberately not reused: waybar-sysmon (replaced), the workflow's prose probes (superseded by TOML-driven collectors). +- *Architecture fit & weak points:* fourth panel sibling, same layering. Weak points: subprocess sprawl (every probe shells out — bounded by tiering + hydration order); the guard matcher (a miss means a live mesa swap — mitigated by pure-function pairwise tests over package-set cases); scenario orchestration is new harness surface, though the snapshot primitives already exist in =vm-utils.sh= (mitigated: archsetup-side, tested by the scenario suite itself); ZFS scenarios depend on the =FS_PROFILE=zfs= base image building (fallback: argv tests + read-only live on velox + Confirm-tier gating). +- *Config surface:* the thresholds TOML (all severity values, shipped curation defaults, staleness windows, "a lot" threshold), user layer (marks/keeps/expected/disables), =~/.config/maint/config= optional GUI prefs (refresh rates, fixture env), =MAINT_SUDO= / =MAINT_PANEL_FIXTURE= / path-override envs for tests. All named with defaults in Phase 1/6 docs. +- *Documentation plan:* maint package README (user: what each key does, guard semantics; developer: schema, adding a remedy = verb-table discipline); archsetup docs for the TOML install + VM scenario harness; the moved workflow's own doc updates. Keybind doc deferred to the panel-chord-family task. +- *Dev tooling:* dotfiles =make test= discovers =tests/maint/test_*.py= automatically via its glob (=Makefile:266-271= — verified, no enumeration needed); the AT-SPI smoke *is* enumerated per panel (=Makefile:279-286=), so a new =test-panel-maint= target is a Phase 12 deliverable; archsetup gains a =make= target or documented invocation for the maint VM scenario; fixture regeneration script from E5 snapshots. +- *Rollout, compatibility & rollback:* additive package; stow to install, unstow to remove; waybar module swap is one config change reverted by restoring =custom/sysmon=; TOML install is idempotent and never overwrites user layer; remedies themselves are the dangerous surface — each is Confirm/armed, dry-run-previewable, and the destructive ones (snapshot delete, docker prune tiers 2–3, updates) arm individually. +- *External APIs & deps:* all local binaries, no web APIs. Verified on ratio (2026-07-07 review): =checkupdates= (1.13.1), =topgrade= (=--disable=/=--dry-run= flags confirmed), =paccache=, =snapper=, =smartctl= 7.5 with working =--json=, =coredumpctl=, =ss=, =docker=, =fwupdmgr=, =ufw=, =fail2ban-client=, =chronyc=, =tailscale= all present. Known: =arch-audit= is ABSENT on ratio — add to archsetup deps and install on both hosts before Phase 3's CVE collector is live-testable. Still prerequisites: same presence sweep on velox; =zpool status -j= support (OpenZFS ≥ 2.3) — fall back to text parsing where absent; =charge_control_end_threshold= sysfs path on velox's hardware; =coredumpctl=/=journalctl= field names pinned by fixtures from live output. =pacman -Qkk= exits 0 even with altered files (summary on stdout, warnings on stderr) — the collector parses output, never trusts the exit code. + +* Risks, Rabbit Holes, and Drawbacks +- *Scope mass* — the biggest risk is the build's sheer surface. Dodge: CLI-first phasing means every phase lands standalone value (collectors are useful from P2 via the CLI), and the GUI phases consume a verified engine. The phase order is also an abort-friendly ladder — stopping after P6 still yields a complete maintenance CLI. +- *Parsing drift* — probes parse a dozen tools' output. Dodge: prefer machine formats where they exist, pin real both-host output as fixtures, and treat a parse failure as an "unprobed" metric, never a crash. +- *Guard false-negative* — the one place a bug bricks a session. Dodge: pure function, pairwise-tested over package-name sets, and the TTY escape wording keeps the human in the loop. +- *VM harness time* — 40–60 min per run makes remedy-test iteration slow. Dodge: nspawn for pacman-level cases; scenarios grouped into non-conflicting batches per VM boot, with a stop/restore/reboot cycle only between destructive groups (Decision 11 — snapshot ops need the VM stopped, =vm-utils.sh:301=). +- *Curation-merge subtleties* — disable-flags over shipped defaults is the fiddly corner. Dodge: it's a pure merge function; exhaustive unit cases in Phase 1 before anything depends on it. +- *Results-wall event plumbing* — streaming per-action state through a worker into GTK is the panel's newest moving part. Dodge: the event feed is engine-side and CLI-proven in Phase 6 before the GUI touches it (the doctor's terminal wall is the same feed). + +* Testing / Verification / Rollout +The four-layer strategy is Decision 11; per-phase gates are in the phase list. Verification beyond tests: read-only =maint status --json= eyeballed on both hosts after P5; the doctor CLI exercised live on ratio (Auto tier only) after P6; GUI walked on fixtures + live after P10; glyph observed through a full timer cycle after P11; the E5 prototype is the pixel reference for GUI review. Manual eyeball checks (needle-level look-and-feel, arm wording, wall readability) get filed as a structured manual-testing task in todo.org at the end, per house convention. Rollout: per-phase dotfiles commits to main; archsetup commits for TOML/harness/workflow phases; both machines pick the package up via the normal stow path — velox needs a one-time stow after P11 lands (flagged in the dotfiles note). + +* References / Appendix +- Design doc (all dated decisions + the full metric tables with per-metric Automation/levers/notes): [[file:../design/maintenance-console-design-ideas.org][maintenance-console-design-ideas.org]]. The metric inventory is deliberately not duplicated here — the tables there are normative for *which metrics exist*. Where a table's Automation column disagrees with the doc's later dated decisions (failed-unit restart, pending updates, AUR staleness, and =-Qkk= reinstall all read Workflow in the tables but gained Confirm levers in the 2026-07-07 determinate-remedies and updates decisions), the dated decision prose — mirrored in Decisions 2, 3, and 7 here — wins. The Confirm set that defines REVIEW & FIX membership derives from the decisions, never from the table column. +- Converged prototype (interactive; SIM FAIL / SIM BAD DAY / SIM ZFS controls): [[file:../prototypes/2026-07-07-maint-console-E5-selector-subpanel.html][E5]]; exploration path A–E4 beside it. +- Sibling precedent: net =priv.py= (privilege pattern), audio =peak.py=/meters (visibility-gated live refresh), instrument-console-panels-spec (visual kit), PTT build (waybar runtime-config gotcha). +- Workflow to move in from its previous owner: =system-health-check.org= + the host capability inventories. + +* Review and iteration history +** 2026-07-07 Tue @ 18:30:04 -0500 — Claude Code (archsetup) — author +- What: initial draft from the completed 2026-07-06/07 design arc; consolidated the design doc's dated decisions into twelve DONE decisions + one open (config paths), decomposed the build into thirteen phases, grounded implementer facts against the live tree (net priv.py, audio config.py:29, archsetup:1178 NOPASSWD, waybar custom/sysmon, tests/ fake-binary layout). +- Why: the [#B] todo task queued spec-create as the next step after the design converged; Craig gave the go this session and added two calls mid-authoring: the glyph replaces custom/sysmon, and on laptops it doubles as the battery level display. +- Artifacts: design doc; E5 prototype; todo.org task (cross-link updated this session). + +** 2026-07-07 Tue @ 18:50:00 -0500 — Claude Code (archsetup) — reviewer +- What: round-1 review with live read-only verification on ratio. Rubric: Not ready — three blocking findings (Automation-tier normativity contradiction, VM isolation policy undefined vs vm-utils.sh:301, no refresh tier for 47 s-class local probes) + seven non-blocking, all small spec-text fixes. Every code citation in the draft verified accurate; tool sweep on ratio held except arch-audit (absent); found the qcow2 snapshot primitives already implemented (vm-utils.sh:303-357) and make test's glob auto-discovery (Makefile:266-271). +- Why: the spec's buildability rested on harness and cadence claims that only a code read + live measurement could confirm. +- Artifacts: findings recorded in * Review findings; verified facts cited inline per finding. + +** 2026-07-07 Tue @ 18:53:51 -0500 — Claude Code (archsetup) — responder +- What: accepted all ten findings and folded them in — References normativity scoping, Decision 11 grouped-batch VM isolation policy, the slow-local refresh tier (Design + Decision 8), Decision 5 ZFS-in-VM correction, battery-detection definition in the contract, Phase 11 retirement collateral, Dev-tooling gate answers + Phase 12 test-panel-maint, External-deps verified-on-ratio state + arch-audit known-absent, the fourth file added to the open config-paths decision, and the panel-CSS convention clarification. Added session-split notes to Phases 6/9/12 per the decomposition check. +- Why: close the three blocking gaps and bank the verified facts before a re-review. +- Artifacts: * Review findings all DONE; Decisions 5/8/11 updated; one decision (config paths) remains open for Craig. + +** 2026-07-07 Tue @ 18:55:50 -0500 — Claude Code (archsetup) — reviewer + responder +- What: round-2 re-review confirmed all ten findings resolved with no substantive new gaps. Rubric: Ready pending the one open decision (config/state paths — Craig's call). Three wording nits found and fixed in the same pass: cache_age scoped to network-tier and slow-local alike, the stale "ephemeral" glyph-state wording reconciled to the open decision, the Decisions cookie recomputed to 12/13, plus a CI-that-doesn't-exist phrase softened in Decision 11. +- Why: verify the round-1 folds before handing the last decision to Craig; the spec stays DRAFT until he rules and the cookie reads complete. +- Artifacts: round-2 verdict (per-finding resolved list); Craig paged with the config-paths proposal and the sysmon-affordance re-homing question. + +** 2026-07-07 Tue @ 19:54:04 -0500 — Claude Code (archsetup) — responder +- What: folded Craig's two rulings (config/state paths as proposed — shipped TOML =~/.config/archsetup/=, user curation =~/.config/maint/=, glyph state + hourly cache =~/.local/state/maint/=; btop scratchpad re-homed to the maint glyph's right-click, sysmon-cycle + waybar-sysmon retired), flipped DRAFT → READY → DOING, and decomposed the build into todo.org: 13 phase tasks (all =:solo:=), a Manual-testing task seeded with the eyeball checks, and the flip-to-IMPLEMENTED closer, under a parent stamped =:SPEC_ID:=. vNext (AI assistance, SIGKILL escalation, evidence-row meters) logged =[#D]=. +- Why: Craig approved both recommendations ("go with your recommendations", 2026-07-07); Phase 6 of spec-response owns the READY → DOING flip when the decomposition lands. +- Artifacts: todo.org "Maintenance console build" parent + children; status heading history lines. diff --git a/docs/specs/2026-07-09-audio-doctor-spec.org b/docs/specs/2026-07-09-audio-doctor-spec.org new file mode 100644 index 0000000..5851332 --- /dev/null +++ b/docs/specs/2026-07-09-audio-doctor-spec.org @@ -0,0 +1,291 @@ +#+TITLE: Audio Doctor — diagnose and repair a broken sound stack +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-09 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED Audio Doctor — diagnose and repair a broken sound stack +:PROPERTIES: +:ID: 6ce3f2ef-052e-40cb-9067-42f8e665f813 +:END: +- [2026-07-10 Fri] Superseded in part, later the same day: the DOCTOR section-header key and its DIAGNOSE key are replaced by a doctor key per direction, and the classifier grows an input side. See [[file:2026-07-10-audio-doctor-input-side-spec.org][2026-07-10-audio-doctor-input-side-spec.org]] (DRAFT). This spec's other decisions stand. The gap: nothing here ever examined the microphone, and the Non-Goals never said it would not. +- [2026-07-10 Fri] IMPLEMENTED — Phases 0 through 4 shipped (dotfiles =4d42eb3=, =01a1d80=, =76857e3=, =7223c51=, =1550ac8=, =4320255=, =bd33440=). Xruns stayed out of v1 as decided. Per-application stream routing remains a Non-Goal. The wall carries the net panel's copy + close controls, added past the spec at Craig's request; standardizing them across the other panels is tracked as its own task. +- [2026-07-09 Thu] READY — all eight Decisions closed. Written from the roam-inbox ask "look into a doctor button for the audio panel: what happens if pulseaudio or its equivalent is borked? would we know? how could we test for it? what remedies would we have?" Probe set, classifier, and remedy tiers are grounded in a live survey of ratio's stack. Phase 0 shipped ahead of the rest. + +* IMPLEMENTED Status +:PROPERTIES: +:ID: 1cc2b355-9450-4ace-bdb3-b935f7051918 +:END: +- [2026-07-10 Fri] IMPLEMENTED — the build closed three things the Decisions did not anticipate. A tenth verdict =tooling-missing= (=remedy: None=), because an absent =pactl= is an unobserved stack rather than a broken one. A =re-probed= marker between a fix run's two row blocks, because unlabelled they read as the doctor contradicting itself. And the read-only key is named DIAGNOSE beside the engraved DOCTOR header rather than being named DOCTOR itself (Craig's call, 2026-07-10, after asking why the panel carries two keys where net's carries one; the two presses stand on the audibility decision, and net's single key auto-fixes because its remedies are cheap). + + Live verification on ratio replaced the planned VM run for remedies 1, 4 and 5, and immediately found two bugs no fake could surface: =pactl get-default-sink= prints the literal =@DEFAULT_SINK@= placeholder when no default is set, and remedy 1 re-probed before WirePlumber had re-created the devices, reporting a repaired stack as broken. Both fixed. +- [2026-07-09 Thu] READY — Craig closed the six open Decisions, all to the recommended option: DOCTOR is a section-header key; CLI-first with the GUI as a face; hung and dead are two verdicts sharing one remedy; tiers stay Auto/Confirm/Arm as drafted; a stream-active guard refuses the audible remedies and is overridable by pressing again; xruns are out of v1. + + Two consequences worth naming, because they were not obvious when the questions were asked. Taking the guard is *why* remedy 4 can stay at Confirm — the danger lives in the state of the machine, not the identity of the remedy. And the guard must read its stream state from =pw-dump= rather than =pactl=, because the remedies it protects are exactly the ones you reach for when the Pulse layer is dead; a =pactl=-fed guard would go blind precisely when it is needed. +- [2026-07-09 Thu] DRAFT — Phase 0 shipped (dotfiles =4d42eb3=) and its Decision closed. The first draft claimed =build_status()= had no timeout and froze the panel; driving it disproved that — =pactl.run()= already times out. The real bug was narrower: two of the four reads sat outside the degrade guard, killing waybar's audio module. The claim is corrected in place rather than quietly dropped. +- [2026-07-09 Thu] DRAFT — spine complete, Decisions open. Not ready to build: the remedy tiers touch a running meeting's audio, and the classifier's precedence needs Craig's call before code. + +* Metadata + +| Field | Value | +|--------+-------------------------------------------------------------------------------------------------------------| +| Status | implemented | +|--------+-------------------------------------------------------------------------------------------------------------| +| Owner | Craig Jennings | +|--------+-------------------------------------------------------------------------------------------------------------| +| Repo | dotfiles (=audio/= package); archsetup owns this spec | +|--------+-------------------------------------------------------------------------------------------------------------| +| Kin | net panel doctor (=net/diag.py=, =classify.py=, =repair.py=, =doctor.py=) — the architecture to mirror. | +| | Maintenance console (results wall, arm-to-fire) — the interaction idiom. | +|--------+-------------------------------------------------------------------------------------------------------------| + +* Summary + +The audio panel shows the sound graph and lets you drive it. It has no answer for the graph being *broken*. When PipeWire, WirePlumber, or the Pulse compatibility layer fails, the panel shows an empty or stale board and the user is left to remember service names. + +This adds a DOCTOR key to the audio panel — read-only diagnose, then a lightest-repair-first fix — mirroring the net panel's doctor, which already solves this shape for networking. + +* Problem / Context + +Craig's questions, answered from a live survey of ratio (2026-07-09): + +** "What happens if pulseaudio or its equivalent is borked?" + +There is no PulseAudio. The stack is three user-scope services: + +| Unit | Role | Failure looks like | +|--------------------------+---------------------------------------------+---------------------------------------------| +| =pipewire.service= | the media graph itself | no devices at all; everything silent | +|--------------------------+---------------------------------------------+---------------------------------------------| +| =wireplumber.service= | session manager: routing, policy, device | devices exist but nothing routes; defaults | +| | profiles, default picking | unset; profiles never applied | +|--------------------------+---------------------------------------------+---------------------------------------------| +| =pipewire-pulse.service= | Pulse protocol compat, via | =pactl= fails; the panel goes blind while | +| | =pipewire-pulse.socket= | audio may still work for others | +|--------------------------+---------------------------------------------+---------------------------------------------| + +The third row is the trap. The panel speaks =pactl=, so a dead =pipewire-pulse= blinds the panel even though PipeWire-native clients still play. The panel's own emptiness is therefore *not* evidence that audio is broken, and the doctor must not treat it as such. + +** "Would we know?" + +Today, no — and worse than no. Two findings from the live survey: + +1. *=pactl= hangs on a hung server.* Against a refused socket it fails in ~0ms ("Connection refused"). Against a socket that accepts and never answers, it blocks indefinitely — measured: still blocked at the 5s timeout. A hung server is precisely the case the doctor exists for, so *every probe carries a timeout*. The engine's own =pactl.run()= already does this and raises =PactlTimeout=; the doctor must do the same, and must never call a bare =subprocess.run=. + +2. *The panel cannot diagnose itself.* Its only data source is the layer most likely to be down. The doctor's probes therefore read =systemctl --user= (never blocks on the audio graph) and =pw-dump= / =wpctl= (PipeWire-native, alive when the Pulse layer is not) *before* they touch =pactl=. + +** "How could we test for it?" + +Four probe tiers, cheapest and most reliable first. Each is independently useful; the classifier below reads all four. + +*** Tier 1 — unit state (no audio IO, cannot hang) +=systemctl --user show -p ActiveState -p Result <unit>= for =pipewire=, =wireplumber=, =pipewire-pulse=, and the two sockets. Distinguishes =active= / =failed= / =inactive= / =not-found=. + +*** Tier 2 — graph liveness (PipeWire-native, survives a dead Pulse layer) +=pw-dump= with a timeout. Answers: does the graph exist, how many nodes, are there sinks and sources at all. =wpctl status= gives the same in human form. A graph with zero sinks is a different fault from a graph that will not answer. + +*** Tier 3 — Pulse compat reachability (the panel's own dependency) +=pactl info= under a hard timeout. Three outcomes, and they are *different faults*: answered / refused / hung. The hung case is the one that currently freezes tooling, and it is invisible to a naive "does pactl work" check because that check hangs too. + +*** Tier 4 — semantic health (only meaningful once 1–3 pass) +Default sink and source resolve to devices that exist; at least one sink is not =SUSPENDED= when a stream is playing. Ratio's sink states today are =RUNNING= and =SUSPENDED=; =SUSPENDED= alone is normal idle, not a fault. + +Xruns are deliberately *not* probed in v1 — see the Decision "Do xruns belong in v1?" below. They are a quality signal with no remedy in this feature's vocabulary, and a finding nobody can act on trains the user to ignore the wall. + +** "What remedies would we have?" + +All three units are =--user= scope. *No privilege escalation is needed anywhere in this feature*, so the maintenance console's =priv.py= verb table and its =sudo -n= machinery are dead weight here and must not be copied in. This is the single largest architectural difference from the maint console, and the reason the audio doctor mirrors the *net* panel instead. + +Remedies, lightest first: + +| # | Remedy | Blast radius | Tier | +|---+-----------------------------------------------+----------------------------------------------+---------| +| 1 | =systemctl --user restart wireplumber= | routing/profiles re-applied; streams survive | Auto | +|---+-----------------------------------------------+----------------------------------------------+---------| +| 2 | Set default sink/source to a present device | one setting | Auto | +|---+-----------------------------------------------+----------------------------------------------+---------| +| 3 | Unmute + restore volume on the default device | audible, but reversible | Confirm | +|---+-----------------------------------------------+----------------------------------------------+---------| +| 4 | =systemctl --user restart pipewire-pulse= | Pulse clients drop and reconnect | Confirm | +|---+-----------------------------------------------+----------------------------------------------+---------| +| 5 | =systemctl --user restart pipewire= | *every* audio client's stream drops | Arm | +|---+-----------------------------------------------+----------------------------------------------+---------| + +Remedy 5 is the one that cuts audio out from under a live meeting. It gets the arm-to-fire treatment, and its arm line says so in words. + +* Goals and Non-Goals + +** Goals + +- Answer "is my sound stack broken, and if so where" without the user knowing a service name. +- Work *when the panel's own data source is down* — a doctor that needs =pactl= to answer is useless in the case it exists for. +- Never hang. Every probe is bounded; a probe that times out is a *finding*, not a stall. +- Repair from the lightest remedy that could plausibly fix the classified fault, exactly as the net doctor does. +- Read-only by default. Diagnose without touching anything; fixing is a separate, explicit press. + +** Non-Goals + +- Fixing a Bluetooth audio device that will not take an A2DP profile. That is the bluetooth panel's problem and it already has a doctor-shaped surface. +- Per-application stream routing or a full patchbay. Helvum and qpwgraph exist. +- Diagnosing hardware failure (a dead DAC, an unplugged cable). The doctor can report "sink present but never leaves SUSPENDED"; it cannot see a cable. +- Sample-rate, quantum, or latency tuning. A separate concern with a separate audience. +- Any use of =sudo=. If a remedy seems to need it, the remedy is wrong. + +* Design + +** For the user + +A DOCTOR key on the audio panel, beside the existing console keys. Pressing it streams findings into a results wall — the same wall idiom the maintenance console uses, so the interaction is already learned: + +#+begin_example +DOCTOR diagnose · read-only + ok pipewire.service active + ok wireplumber.service active + FAIL pipewire-pulse.service failed (Result=exit-code) + ok graph 4 sinks · 7 sources (via pw-dump) + FAIL pulse compat not answering — pactl refused + -- defaults not checked (pulse compat down) + + verdict: the Pulse compatibility layer is down. + PipeWire itself is healthy — native clients still play. + FIX restarts pipewire-pulse. +#+end_example + +The verdict names one fault, in the user's terms, and names the remedy it would run. Then a FIX key applies the lightest remedy for that verdict, streaming each step and re-probing after, so the wall shows repaired truth rather than an optimistic claim. + +When everything passes, the wall says so and offers nothing to press. + +** For the implementer + +Mirror the net panel's module split, which already separates these concerns cleanly: + +| Module | Responsibility | +|---------------------+------------------------------------------------------------------------------------------------| +| =audio/diag.py= | The four probe tiers. Pure IO, each bounded by a timeout. Returns a context dict; never raises | +| | on a broken stack, never blocks. | +|---------------------+------------------------------------------------------------------------------------------------| +| =audio/classify.py= | Pure. Context dict → one verdict, by precedence. Unit-tested against fixture contexts, no | +| | services involved. | +|---------------------+------------------------------------------------------------------------------------------------| +| =audio/repair.py= | One function per remedy. Each returns a result envelope; each re-probes the thing it claimed | +| | to fix. | +|---------------------+------------------------------------------------------------------------------------------------| +| =audio/doctor.py= | Orchestration: gather → classify → (optionally) attempt the lightest matching remedy → | +| | re-probe → emit the event stream. | +|---------------------+------------------------------------------------------------------------------------------------| + +Three constraints that fall out of the survey and are easy to get wrong: + +1. *Timeouts everywhere, and guard every read.* Bound every probe. Beyond that: a function that promises to degrade must guard *all* of its reads, not the first two. That was the Phase 0 bug. Test with a fault that answers early calls and hangs on later ones — a server that hangs on the first call masks everything behind it, and a refused socket does not reproduce the hang at all. + +2. *Probe order is load-bearing.* =systemctl= first (cannot hang), then =pw-dump= (alive when Pulse is dead), then =pactl= (may hang). Reversing this makes the doctor unable to report on the case it was built for. + +3. *The GTK thread never probes.* The panel already has a =bg()= worker; the doctor runs there and lands events on the main loop, as the maint console's =_stream= does. + +*** Classifier precedence (draft — see Decision 3) + +Terminal faults first, so a lower-layer failure is never reported as an upper-layer symptom: + +1. =pipewire.service= not active → *graph down*. Nothing else is meaningful. +2. =wireplumber.service= not active → *no session manager*. Devices exist, routing does not. +3. =pactl= hangs → *pulse compat hung*. Distinct from dead: a restart is the remedy, but the diagnosis differs and the user should see which. +4. =pipewire-pulse= not active, or =pactl= refused → *pulse compat down*. +5. Graph healthy, zero sinks → *no output devices*. +6. Default sink/source names a device that is absent → *stale default*. +7. Everything up, default device muted or at zero volume → *silenced, not broken* (a finding, not a fault; offer to unmute). +8. Otherwise → *healthy*. + +*** Testing + +Follows the four-layer pattern the maint console established: + +- *Unit, fakes.* =classify.py= against fixture contexts covering every verdict, including the hung-vs-dead distinction. =diag.py= against a fake =systemctl= / =pactl= / =pw-dump= on a temp PATH, one case per outcome. Pairwise over (unit state × graph state × pulse reachability) — three parameters with three, three, and four values each is 36 exhaustive, and pairwise covers the interactions in far fewer. +- *Timeout regression.* A test that stands up a Unix socket which accepts and never answers, points =PULSE_SERVER= at it, and asserts the probe returns a =hung= finding within its timeout rather than blocking. This is the test that would have caught the real behavior; write it first. +- *Live read-only.* =audio doctor= (no fix) on ratio and velox, asserting a healthy verdict. +- *Remedy, in a VM.* Restarting =pipewire= is not something to exercise on a live desktop. The archsetup VM harness runs remedies 1, 4, and 5 against a real stack. +- *GUI smoke.* AT-SPI over the DOCTOR key and the wall, on fixtures. + +* Alternatives Considered + +** Reuse the maintenance console's doctor rather than building one in the audio panel + +Rejected. The maint doctor's remedies are system-scope and route through =priv.py= and =sudo -n=. Every audio remedy is =--user= scope. Bolting audio onto that table means either giving audio remedies a privilege path they do not need, or special-casing them out of the mechanism that defines the table. The net panel already demonstrates the right shape for a user-scope doctor. + +** A single RESTART AUDIO button + +Rejected. It is remedy 5 with no diagnosis: it drops every client's stream to fix a problem that is usually remedy 1 or 2. It also teaches nothing — the user learns to press it for every symptom, including symptoms it cannot fix, such as a stale default device. + +** Probe with =pactl= only, since the panel already speaks it + +Rejected on evidence. =pactl= hangs against a hung server, and it is dead when the Pulse compat layer is dead — the two most interesting faults. It is the *last* probe, not the first. + +** Auto-repair on detection, without asking + +Rejected. Remedies 3 through 5 are audible and, in a meeting, disruptive. Diagnosis is free and should be; repair is a press. + +* Decisions [8/8] + +** DONE Where does the DOCTOR key live, and does it replace anything? +CLOSED: [2026-07-09 Thu] +Resolved: a section-header key, the maint console's CLEAN UP / REVIEW & FIX shape. Diagnose and fix are two presses with different consequences, and the header is where the console already teaches that distinction. The console row (INPUT / OUTPUT / PUSH TALK) stays device controls; a doctor is not one. Nothing is replaced. + +** DONE Is there an =audio doctor= CLI, and is it the same code path? +CLOSED: [2026-07-09 Thu] +Resolved: CLI-first, GUI as a face, one code path — the maint console's architecture. =audio doctor= (read-only) and =audio doctor --fix= print the same findings the wall streams. + +The deciding reason: when the desktop's sound is dead, a TTY is where you will be standing. A GUI-only doctor cannot serve the case that motivates the whole feature. It also makes the classifier testable without a display. + +** DONE Confirm the classifier precedence above, particularly rows 3 and 4. +CLOSED: [2026-07-09 Thu] +Resolved: precedence as drafted, and rows 3 and 4 stay *two verdicts sharing one remedy*. They share a fix but not a diagnosis, and the hung case is the one that freezes other tooling — collapsing them would hide the more interesting fault behind the more common one. + +** DONE Does the panel's own status build get the same timeout treatment? +CLOSED: [2026-07-09 Thu] +Investigated and answered: it already had one. =pactl.run()= defaults to =timeout=8= and raises =PactlTimeout=, so the panel degrades rather than freezing. The draft of this spec claimed otherwise; the claim was wrong. + +What the investigation *did* find is a narrower, real bug, now fixed (dotfiles =4d42eb3=): =build_status()= guarded its two device-list reads but called =default_sink()= and =default_source()= outside the guard, so a server that answered the lists and then stopped answering raised =PactlTimeout= out of a function whose contract is to degrade. =waybar-audio= calls it with nothing catching it, so the bar's audio module died rather than dimming. A server that hangs on the *first* call never exposed this — =list_sinks= raised inside the guard and masked everything behind it, which is why the obvious test would have passed against broken code. + +** DONE Which remedies are Auto, which Confirm, which Arm? +CLOSED: [2026-07-09 Thu] +Resolved: the table as drafted. Remedies 1–2 (restart wireplumber, set a present default) are Auto; 3–4 (unmute + restore volume, restart pipewire-pulse) are Confirm; 5 (restart pipewire) is Arm. + +Remedy 4 stays at Confirm rather than Arm *because* the stream guard below was taken. The guard, not the tier, is what catches the mid-meeting case — which is the honest place for it, since the danger is the state of the machine, not the identity of the remedy. Had the guard been declined, remedy 4 would have moved to Arm. + +** DONE Should the doctor refuse to run a disruptive remedy while audio is playing? +CLOSED: [2026-07-09 Thu] +Resolved: yes. A stream-active guard refuses a disruptive remedy and is overridable by pressing again, mirroring the maintenance console's live-update guard. Same shape of problem: an operation that is fine when idle and destructive mid-use, where wording alone does not stop a press. + +Implementation note for the builder: the guard's input is "is any node running" — and it must come from *Tier 2* (=pw-dump=), not from =pactl=. The remedies the guard protects are precisely the ones you reach for when the Pulse layer is dead or hung, so a guard that asked =pactl= would go blind exactly when it is needed, and a PipeWire-native client can be mid-playback while =pactl= answers nothing at all. + +A cold or unreadable graph means the guard cannot see a stream. Treat that as *no stream* and let the remedy proceed: refusing on an unreadable graph would wedge the doctor shut in the worst case, which is the fault it exists to repair. Say so in the wall, so the user knows the guard abstained rather than passed. + +The guard applies to remedies 3, 4, and 5 — the audible ones. Its refusal names what is playing, not just the remedy. + +** DONE Do xruns belong in v1? +CLOSED: [2026-07-09 Thu] +Resolved: left out. They are a quality signal, not a broken/working one, and they have no remedy in this feature's vocabulary. A finding you cannot act on trains you to ignore the wall — the opposite of what a doctor is for. Revisit when there is something to do about them. + +* Implementation phases + +Sequenced so each phase is independently green and independently useful. Nothing here starts until the Decisions are closed. + +** DONE Phase 0 — the degrade-on-hang bug fix (prerequisite) +CLOSED: [2026-07-09 Thu] +Shipped ahead of the rest (dotfiles =4d42eb3=). =build_status()= now guards all four pactl reads, not just the two list reads, so a half-wedged server dims the bar's audio module instead of killing it. =fake-pactl= grew =FAKE_PACTL_SLEEP_DEFAULTS= to express "answers the lists, hangs on the defaults" — the blanket sleep knob cannot, because the first call would hang and mask the bug. + +** DONE Phase 1 — =diag.py=, the four probe tiers +Bounded probes, a context dict, fakes for every outcome including hung. No classifier, no GUI. =audio diag --json= prints the context. + +** DONE Phase 2 — =classify.py=, the verdict +Pure function, fixture-driven, pairwise over the parameter space. =audio doctor= (read-only) prints findings and a verdict in the CLI. Already useful from a TTY at this point. + +** DONE Phase 3 — =repair.py= + =doctor.py=, the fix path +One function per remedy, each re-probing what it claims to fix. =audio doctor --fix= applies the lightest matching remedy. VM-tested for remedies 1, 4, and 5. + +** DONE Phase 4 — the DOCTOR key and the results wall +CLOSED: [2026-07-10 Fri] +Shipped (dotfiles =4320255=). GUI face over the CLI. Streams events to a wall, off the GTK thread. AT-SPI smoke over the key, the wall, and the fix path on fixtures. + +** DONE Phase 5 — flip this spec to IMPLEMENTED +CLOSED: [2026-07-10 Fri] +With a history line naming what shipped and what was left out. diff --git a/docs/specs/2026-07-10-audio-doctor-input-side-spec.org b/docs/specs/2026-07-10-audio-doctor-input-side-spec.org new file mode 100644 index 0000000..0e7e529 --- /dev/null +++ b/docs/specs/2026-07-10-audio-doctor-input-side-spec.org @@ -0,0 +1,494 @@ +#+TITLE: Audio Doctor, Input Side — a doctor per direction +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-10 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* DRAFT Audio Doctor, Input Side +:PROPERTIES: +:ID: 45efdd66-6d22-45c0-a633-ec41079e38ec +:END: +- [2026-07-10 Fri] DRAFT — drafted. Extends the audio doctor ([[file:2026-07-09-audio-doctor-spec.org][2026-07-09-audio-doctor-spec.org]], IMPLEMENTED) to the microphone, and replaces its single DOCTOR header key with one doctor per direction. Grounded in a live survey of ratio, not memory. + +* Metadata + +| Field | Value | +|----------+----------------------------------------------------------------------------| +| Status | draft | +|----------+----------------------------------------------------------------------------| +| Owner | Craig Jennings | +|----------+----------------------------------------------------------------------------| +| Reviewer | Craig Jennings | +|----------+----------------------------------------------------------------------------| +| Related | [[file:../../todo.org][todo.org]] : "The audio doctor never checks the microphone" | +|----------+----------------------------------------------------------------------------| +| Parent | [[file:2026-07-09-audio-doctor-spec.org][2026-07-09-audio-doctor-spec.org]] — this supersedes its DOCTOR-key decision | +|----------+----------------------------------------------------------------------------| + +* Summary + +The audio doctor is output-only. It never examines the microphone, so a muted mic, a default source naming an unplugged device, and a mic the sound server cannot see all classify as =healthy=. + +This adds the input side, and in doing so answers a question no probe can: whether a machine with no microphone is broken or merely mic-less. The answer comes from the user's finger. A doctor key on the INPUTS section header means "there should be a mic here", so an empty source list under that press is a failure rather than a shrug. + +* Problem / Context + +Three separate failures, all currently invisible. + +** The classifier does not look at the source + +=diag.probe_semantic= already collects =default_source= and =default_source_present=. =classify.py= reads neither. The word "source" appears once in that module, in the graph row that counts how many exist. Every semantic rule the classifier applies to the sink (is a default set, is it present, is it muted, is it at zero volume) has no source counterpart. + +So the =healthy= verdict prints "Units are up, the graph answers, the Pulse layer answers, and the default output is present and audible." Output. Only. A user reading "the sound stack is healthy" concludes the mic is fine, and the doctor never checked. + +** Nobody can tell an absent microphone from a broken one + +A desktop with no microphone is a normal machine. So an empty source list cannot be a fault the way =no-output-devices= is, and the classifier has no way to know which machine it is on. This is not a probe problem. No amount of reading the graph settles it, because the missing fact lives in the user's head. + +** The doctor cannot see below PipeWire + +When Chrome stopped recognizing the microphone on 2026-07-10, the stack was genuinely healthy: the Shure MV7+ was the default source, unmuted, at 82%, with push-to-talk off. The doctor would have said =healthy= and been right about the stack and useless to the user. + +The layer that would have helped is the one the doctor never reads. The kernel's own capture list distinguishes "the sound server lost a microphone the kernel can see" from "nothing is plugged in", and those two faults have nothing in common. + +** Live survey (ratio, 2026-07-10) + +Facts this spec is built on, verified rather than remembered: + +- =/proc/asound/card*/pcm*c= lists capture-capable ALSA devices. On ratio: =Generic_1= (ALC623 analog), =MV7= (Shure), =BRIO= (Logitech). A =pcm*c= node means the card has a capture PCM; =pcm*p= would be playback. +- That path is a kernel filesystem read. It needs no package, spawns no process, and *cannot hang* — unlike every existing probe tier. =arecord -l= reports the same set but is owned by =alsa-utils=, which is a dependency the engine does not otherwise carry. +- =diag= already tags each stream =input= or =output= (=_STREAM_CLASSES=, mapping =Stream/Input/Audio= and =Stream/Output/Audio=). =active_streams()= returns both without distinction. +- Push-to-talk mutes the default source and leaves it muted between holds (=ptt.toggle_plan=). =ptt.read_state()= persists =armed=, so the deliberate mute is already knowable. +- A monitor source is a legitimate default source. =probe_semantic= passes =include_monitors=True= on purpose. + +* Goals and Non-Goals + +** Goals + +- Classify the input side with the same rigor as the output side: default set, default present, muted, at zero volume. +- Never report an armed push-to-talk mic as a fault. +- Distinguish "the sound server does not see a microphone the kernel does" from "no capture hardware is attached", and say which. +- Let the user assert that a microphone should exist, without a dialog. +- One doctor at a time. + +** Non-Goals + +- Rebuilding push-to-talk. See the decision below; the mute is the safety property, not the bug. +- Per-application capture routing. If Chrome cannot see a source the graph plainly has, that is Chrome's fault, and Helvum and qpwgraph exist. This spec makes the doctor able to *say* the stack is fine, which is the sentence that was missing. It does not chase the client. +- Diagnosing hardware below the kernel. If =/proc/asound= shows no capture device, this spec reports that fact. It does not test cables, USB ports, or drivers. +- Sample-rate, latency, or xrun concerns. Still out, as in the parent spec. +- Silent privileged action. The doctor may use =sudo= for the input and output expansion (see the decision "The doctor may use sudo, resolved by context at run time"), but never silently: every privileged or reboot-tail remedy defaults to Confirm or Arm tier. Auto stays reserved for user-scope, reversible remedies. + +** Scope tiers + +- *v1:* the kernel capture tier; PTT-aware source classification; input verdicts and their remedies; the two section-header doctor keys with mutual exclusion; explicit =audio doctor --output= / =--input= flags, output aliased as the default. +- *Out of scope:* PTT rebuild; per-application routing; hardware diagnosis. +- *vNext:* per-device correlation between the kernel capture set and graph sources (v1 uses the coarse set-emptiness rule instead); and a third sighting of "the graph is fine and one client cannot use it", which turns into its own design question about whether the doctor should name the application layer. Both logged to =todo.org=, not built here. + +* Design + +** For the user + +The DOCTOR section header we shipped on 2026-07-10 goes away. In its place, the OUTPUTS and INPUTS engraved section headers each grow a doctor key, beside the device counts they already carry. The keys diagnose the direction they sit above, and both write to the same results wall below. + +The keys do not repeat the direction words. The CONTROLS section already carries INPUT and OUTPUT keys — the mute toggles that read LIVE / MUTE — so a doctor key labelled OUTPUT a few rows up would read as a fourth mute control. The doctor keys are named for what they examine instead: SPEAKERS on the OUTPUTS header, MICROPHONE on the INPUTS. Each carries a distinct diagnostic treatment so a user reads it as a different kind of key, not a device control. See the decision below; that treatment is the same on every panel's doctor. + +Pressing MICROPHONE says something a probe cannot: that this machine is supposed to have a microphone. That single bit resolves the ambiguity the classifier cannot. An empty source list is normal on a desktop; an empty source list *under a press of the MICROPHONE doctor* is a failure, and the doctor now has license to say so. + +The wall names which doctor ran, and only one runs at a time. Pressing SPEAKERS while a MICROPHONE run is in flight does nothing, because the wall holds one verdict and a second run would overwrite the first mid-read. + +Diagnosis remains free and read-only. The escalation the user asked for — "we tried reloading all the devices and there is still no microphone" — is a *repair*, not a diagnosis, because reloading devices means restarting WirePlumber. So the diagnose verdict names what is wrong, and FIX is what tries the reload and re-probes. The wall then carries a claim the doctor earned rather than one it assumed: + +#+begin_example +DOCTOR · MICROPHONE diagnose · read-only + ok pipewire.service active + ok wireplumber.service active + ok pipewire-pulse.service active + ok graph 4 sinks, 3 sources (via pw-dump) + ok pulse compat answering + FAIL capture hardware kernel sees MV7, BRIO, Generic_1 — the graph has none + -- default input not checked (no input devices) + + verdict: The sound server does not see your microphone. + The kernel has 3 capture devices and PipeWire has none, so the + hardware is attached and the software lost it. FIX reloads the + device profiles. +#+end_example + +And when the kernel sees nothing either, the doctor stops rather than offering a remedy for a fault that is not there: + +#+begin_example + FAIL capture hardware no capture device is attached + + verdict: No microphone is attached. + The kernel sees no capture hardware at all, so this is a cable, a + port, or a device that is off. Nothing in the sound stack can fix it. +#+end_example + +** For the implementer + +*** A tier below the graph + +The probe ladder gains a tier at the bottom, and its position is the point. Today's order is =systemctl= (cannot hang), =pw-dump= (alive when Pulse is dead), =pactl= (may hang). The kernel tier reads =/proc/asound=, which is a filesystem read that cannot hang, cannot fail for want of a package, and answers when every other layer is dead. It is the only probe in the doctor that carries no timeout, because it cannot block. + +It answers one question: which cards expose a capture PCM. A card directory containing a =pcm*c= entry has one. That set is what separates a lost device from an absent one. + +The comparison is deliberately coarse in v1, because the two layers speak different namespaces. The kernel tier names cards by ALSA id (=MV7=, =BRIO=); the graph names sources by PipeWire =node.name=. Mapping one to the other device-by-device is a rabbit hole, and the doctor does not need it. The v1 rule is a set-emptiness test, not a per-device match: =mic-unrecognized= fires when the kernel capture set is non-empty *and* the graph has zero non-monitor hardware sources. A monitor source does not count as a microphone (it is desktop-audio capture), so a graph carrying only monitors still reads as "no real input the kernel's hardware could be feeding." That coarse rule catches the whole-mic-lost case this feature exists for without ever having to prove that card X is the same device as source Y. Per-device correlation is a vNext refinement, logged, not built. + +*** The classifier takes a direction + +=classify(ctx)= becomes =classify(ctx, side)= where side is =output= or =input=. The terminal-fault ladder is shared and unchanged: PipeWire down, WirePlumber down, Pulse hung, Pulse dead, tooling missing. All of them break both directions, and the first one that fires wins regardless of side. + +Below that the ladder forks. The output side keeps its rules exactly as they are. The input side gets, in order: no capture hardware; hardware present but no source in the graph; a default source that names an absent device; a mic muted or at zero. Every input rule is gated on the user having pressed the input doctor, which is what makes an empty source list a fault instead of a fact. + +Push-to-talk sits inside that last rule. =diag.gather()= reads =ptt.read_state()= into the context, and a muted default source with PTT armed is not a finding. It is the mic doing exactly what the user asked. The wall reports it as an informational row rather than saying nothing, because a user who has forgotten PTT is armed deserves to be told why the mic is silent. + +*** The guard learns direction + +=active_streams()= returns running streams of both directions, and =repair.guard()= refuses the audible remedies whenever any of them is running. That is already slightly wrong: a playback remedy is refused today when the only thing running is a recording. Filtering by direction fixes that and is what the mic remedy needs. + +Unmuting a microphone is not audible. It is a privacy event. If something is capturing when the remedy lands, the user's voice goes out. So the mic-unmute remedy is guarded against *input* streams with the same press-again override the audible remedies use, and the playback remedies are guarded against *output* streams. + +* Failure modes and remedies + +The whole point of a doctor is that every fault it can name has a stated fix, and the reader can see the two together. This is that map: what goes wrong at each probe tier, the verdict the classifier reaches, and the remedy FIX runs. The tiers are the probe ladder, top to bottom, so a fault lower in the table is one the doctor reaches only after the tiers above it answered clean. + +The first four tiers are shared: a broken unit, a dead graph, or a dead Pulse layer takes out both directions at once, and the same verdict serves whichever doctor the user pressed. The kernel tier and the source-side semantic rules are what this spec adds. + +| Tier | What goes wrong | Verdict | Remedy | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 1 units (=systemctl=) | =pipewire= not active, or | =graph-down= | 5: restart =pipewire= (Arm) | +| | the graph will not answer | | | +| | though the unit claims up | | | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 1 units | =wireplumber= not active | =no-session-manager= | 1: restart =wireplumber= | +| | | | (Auto) | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 1 units | =pipewire-pulse= not active | =pulse-down= | 4: restart =pipewire-pulse= | +| | | | (Confirm) | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 2 graph (=pw-dump=) | the graph is up but has no | =no-output-devices= | 1: restart =wireplumber=, | +| | sink | | which re-creates devices | +| | | | (Auto) | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 3 pulse (=pactl=) | the socket accepts and never | =pulse-hung= | 4: restart =pipewire-pulse= | +| | answers | | (Confirm) | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 3 pulse | the socket refuses | =pulse-down= | 4: restart =pipewire-pulse= | +| | | | (Confirm) | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 3 pulse | a probe's own tool is | =tooling-missing= | none: the stack is | +| | missing | | unobserved, not broken | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 4 semantic, output | the default sink names a | =stale-default= | 2: set a present default | +| | device that is absent | | sink (Auto) | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 4 semantic, output | the default sink is muted or | =silenced= | 3: unmute and restore volume | +| | at zero volume | | (Confirm) | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 4 semantic, input | the default source names a | =mic-stale-default= | 7: set a present default | +| | device that is absent | | source (Auto) | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 4 semantic, input | the default source is muted | =mic-silenced= | 8: unmute and restore volume | +| | or at zero, PTT off | | (Confirm) | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 4 semantic, input | the default source is muted, | healthy, informational row | none | +| | PTT armed | | | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 5 kernel (=/proc/asound=) | kernel capture set non- | =mic-unrecognized= | 6: reload profiles, restart | +| | empty, graph has no non- | | =wireplumber= (Confirm) | +| | monitor hardware source | | | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 5 kernel | no capture device is | =no-mic-hardware= | none: a cable, a port, or a | +| | attached at all | | device that is off | +|---------------------------+------------------------------+----------------------------+------------------------------| +| all clean | nothing is wrong | =healthy= | none | +|---------------------------+------------------------------+----------------------------+------------------------------| + +Three entries carry no remedy on purpose, and they are not the same kind of no. =tooling-missing= means the doctor could not observe the stack, =no-mic-hardware= means there is nothing in the stack to fix, and =healthy= means there is nothing wrong. A doctor that offered a button for any of the three would teach the user to distrust the wall. + +Remedies 1 and 6 both restart WirePlumber, and that is deliberate: =no-output-devices= and =mic-unrecognized= are different diagnoses that happen to share a fix. Collapsing them would couple two faults that should read apart, the same call the parent spec made for =pulse-hung= and =pulse-down=. + +* Verdict clusters and remedy classes + +The verdicts and remedies above are the v1 core. Their full shape comes from a failure taxonomy built and saturation-tested after the first draft: [[file:../design/2026-07-10-audio-failure-taxonomy.org][docs/design/2026-07-10-audio-failure-taxonomy.org]], a catalogue of 74 input and 77 output real, user-reported failure modes. A blind resample (108 fresh reports through different sources, none shown the structure) forced zero new clusters, so the taxonomy is representative, not an artifact of sampling. It sorts every failure into nine symptom clusters per direction, and those clusters are what the doctor's verdicts name. + +** The clusters are the verdict structure, and they map to the probe ladder + +The nine input clusters are: (1) no capture device at all, (2) device present but records silence, (3) works but too quiet, (4) works then dies or drops, (5) the server lost a device the hardware has, (6) Bluetooth mic, (7) stack healthy but an app can't hear, (8) hardware/physical, (9) environment/config. Output mirrors this, swapping the mic-specific clusters for speaker-vs-headphone jack routing, HDMI/DisplayPort, and a degraded-audio (crackle/xrun) cluster. + +The clusters are not arbitrary. Seven of the nine are the tiers the doctor's probe ladder already separates (units, graph, pulse, semantic, kernel), and the two it cannot fully see (cluster 7 app-layer, and part of cluster 8 hardware) are exactly the ones that resolve to Guide. So the boundary between what the doctor fixes and what it can only guide falls along the tiers it already probes. The v1 verdicts in the table above are the subset of these clusters the current ladder reaches; the rest are staged below. + +** Remedy classes come from the privilege decision + +Every remedy carries one of four classes, resolved at run time (see the decision "The doctor may use sudo, resolved by context at run time"): Auto, Privileged, Reboot-tail, Guide. The taxonomy tags every entry, so the buildable share is known — a little over half of each direction. The Auto core reduces to five primitives (=set-default=, =set-card-profile=, unmute, restart-services, config-drop-in-plus-restart) applied across roughly fifty diagnoses, so the breadth is in the diagnosis, not the remedies. + +** Three new probes the taxonomy implies + +Read-only and bounded, in the spirit of the kernel tier: + +- *dmesg-pattern hints* (cluster 1). So "no capture hardware" can name the specific cause — firmware-load-fail, NHLT-missing, codec-probe-timeout — instead of a generic verdict, and the Guide can print the right command. +- *the unmute-doesn't-stick signature* (cluster 8). Issue an unmute, re-probe; a mute that will not clear is a hardware switch, not software. This is what tells an Auto (software mute) from a Guide (flip the physical switch). +- *re-probe-after-idle* (cluster 4). The current single-shot probe misses the suspend and autosuspend "works then dies" cases; a second read after an idle window catches them. + +* Alternatives Considered + +** Rebuild push-to-talk so it does not mute the mic + +The idea: keep the source unmuted, and instead unlink its nodes from capturing applications (=pw-link=), or stand up a virtual source that applications connect to and link the real mic into it only while the key is held. + +- Good, because the doctor would see an unmuted mic and need no special case, and because an application's own level meter would keep working. +- Bad, because the microphone stays live at the server and silence becomes a property of graph topology. A crash mid-hold, or a link that fails to tear down, is a hot mic while every indicator reads muted. Source-mute is the one state where the server itself guarantees nothing is captured. +- Bad, because the virtual-source variant requires every application to select the virtual device, which breaks per-application device choice — the thing the panel exists to give. +- Neutral, because the doctor does not need it: =ptt.read_state()= already persists the fact that the mute is deliberate. + +Rejected. The mute is the safety property. + +** One doctor that classifies both directions at once + +- Good, because one press and one verdict. +- Bad, because =classify()= returns exactly one verdict, so a machine with a stale sink default and a muted mic forces the classifier to rank two unrelated faults. Any ranking is a guess about what the user came to fix. +- Bad, because it cannot resolve the absent-microphone ambiguity at all. No press means no assertion, and the doctor is back to shrugging. + +Rejected in favor of one doctor per direction, which supplies the missing intent as a side effect of where the user pressed. + +** Use =arecord -l= for the kernel tier + +- Good, because it is the obvious tool and its output is human-readable. +- Bad, because it is owned by =alsa-utils=, a dependency the audio engine does not otherwise carry, so its absence would have to be modelled as a fourth =tooling-missing= case. +- Bad, because it spawns a process, which means a timeout, which means another way for the bottom tier to fail. +- Neutral, because it reads the same kernel data =/proc/asound= exposes directly. + +Rejected. The bottom tier should be the one thing in the doctor that cannot fail. + +* Review findings [1/10] + +Second review pass, 2026-07-10 (four critical lenses: buildability, technical correctness, adversarial failure-modes, internal consistency). The technical foundations checked out — every load-bearing code and =/proc/asound= claim verified against the real tree. These findings are what the passes surfaced beyond that. The mechanical inconsistencies they also found (a "two new remedies" miscount, two stray OUTPUT/INPUT doctor-key labels) are already fixed in place. + +** TODO Mic-unmute must fail closed on an unreadable graph :blocking: +The stream-active guard abstains and proceeds when =pw-dump= cannot read the graph. That is right for the *output* remedies, whose whole job is repairing a dark graph. It is wrong for the mic-unmute remedy (8), because unmuting is a privacy event, not an availability one: with the graph unreadable the guard cannot see a running capture stream, so FIX would unmute a live mic mid-recording with no second press and no warning. The mic-unmute remedy must fail *closed* on an unreadable graph — refuse, or fall back to =pactl list source-outputs= — never abstain-and-proceed. Every other finding here produces a wrong verdict; this one produces a wrong action. Folds into the open capture-guard decision. (blocking) + +** TODO A non-ALSA microphone classifies as no-mic-hardware :blocking: +A Bluetooth or USB-non-ALSA mic never appears under =/proc/asound/card*=, because bluez runs through PipeWire, not ALSA. The =no-mic-hardware= verdict is gated only on "kernel capture set empty", with no graph clause, so a working BT-headset mic (a real non-monitor source in the graph) reads as "No microphone is attached." The input ladder must consult the graph first: a working non-monitor source in the graph is =healthy= (or routes to the semantic rules) and skips the kernel tier entirely. =no-mic-hardware= fires only when the kernel set is empty *and* the graph has no non-monitor source; =mic-unrecognized= only when the kernel set is non-empty *and* the graph has none. This also resolves the ladder-order contradiction below. Folds into the open input-verdicts decision. (blocking) + +** TODO The input ladder's evaluation order is stated two ways :important: +The "For the implementer" prose lists the kernel-tier rules first (no-mic-hardware, mic-unrecognized), then the semantic rules. The failure table orders the semantic-input rows (tier 4) above the kernel rows (tier 5) and says lower rows are reached only after upper tiers answer clean. Those are opposite orders for the same four rules. The finding above settles it: graph-source presence is the top gate, so define one order and make both the prose and the table match it. + +** TODO The probe does not yet surface source mute, volume, or a monitor flag :important: +=probe_semantic= produces =default_sink_muted= / =default_sink_volume= but no source equivalents, and neither the graph nor the semantic source dicts carry a monitor flag (=pactl._device= strips =device.class=). The =mic-silenced= rule reads source mute/volume the context never provides, and the "non-monitor hardware source" test cannot be computed. Phase 0 must extend =probe_semantic= to surface the source fields and tag sources monitor-vs-hardware, and the =mic-unrecognized= rule must read the =pw-dump= source population (=Audio/Source=, monitor-excluding), not the =pactl include_monitors=True= one — reading the monitor-inclusive list would mean a monitor is always present and the rule never fires. Neither change is scoped by an open decision; both would stall Phase 0/1. + +** TODO The direction must thread through findings() and doctor(), not only classify() :important: +The spec says =classify(ctx)= becomes =classify(ctx, side)= but never that =findings(ctx)= gains =side= too, though the wall shows side-specific rows. =doctor.doctor()= is the sole caller and invokes both =classify= and =findings= twice each (initial and re-probe), plus =final_findings= / =final_verdict=; it must take =side= and thread it to all of them, and =cli.cmd_doctor= must pass the resolved side. Name this in the phase text so it is not left for the implementer to rediscover. + +** TODO Remedy 6 restarts WirePlumber unguarded, mid-call :important: +=mic-unrecognized='s remedy (6, restart WirePlumber) is in neither guard set, so a user on a call can press FIX and reload the device profiles with no guard and no second-press consent. A profile reload can re-select the default sink and drop the call's routing. Decide whether remedy 6 is guarded — against streams of *both* directions, since a WirePlumber restart touches the whole graph — as part of the open capture-guard decision. + +** TODO A stale armed=true PTT state can mask a genuinely muted mic :important: +The safe-degrade argument covers only an unreadable or missing PTT state file (=read_state= returns disarmed). It does not cover =armed=true= left stale — Hyprland restarted and dropped the binds, or the mic was muted by the CONTROLS toggle for an unrelated reason while PTT happened to be armed. =read_state= cannot detect staleness, so the doctor reports "healthy, PTT armed" on a genuinely dead mic, the exact failure this feature exists to fix. At minimum, name the limitation; better, have the informational row say the mic is muted *and* that PTT is armed, so the user is not told the mic is simply fine. + +** TODO Minor build-notes to fold in before Phase 3 :minor: +Three smaller items the passes raised. The =healthy= verdict text is output-worded ("the default output is present and audible") and needs side-aware wording for a clean =MICROPHONE= run. The two keys must clear =doctor_running= on the worker's error path so a raised worker cannot wedge both keys insensitive for the session — the shipped =bg= / =doctor_abort= pattern already does this, so it is a spec-explicitness note, not a code gap. And a USB mic pressed during its ~1s enumeration window (kernel sees the card before WirePlumber creates the source) fires a transient =mic-unrecognized=; self-correcting, worth a line acknowledging the race. + +** TODO Open Decisions still gate implementation :blocking: +The workflow treats unresolved decisions as implementation blockers, because a builder would be accepting product and safety tradeoffs mid-build. Partly addressed as of 2026-07-10: Craig closed the push-to-talk and kernel-tier decisions, and the CLI-flag decision closed with his direction, so the cookie is now =[6/9]=. Three decisions remain open — the direction-aware capture guard, the input verdict and remedy table, and the doctor-key naming and cross-panel style. Close or explicitly risk-accept each before implementation starts. (blocking) + +** DONE Kernel capture devices are not mapped to graph sources :blocking: +CLOSED: [2026-07-10 Fri] +The finding: the kernel names cards by ALSA id and the graph names sources by PipeWire =node.name=, so "compare the kernel capture set against the graph's source list" was undefined, and monitor sources muddied it further. Resolved 2026-07-10 by adopting the reviewer's coarse rule as the v1 definition: =mic-unrecognized= fires when the kernel capture set is non-empty and the graph has zero non-monitor hardware sources. No per-device correlation, no namespace mapping — that whole-mic-lost case is the one this feature exists for, and per-device matching is logged as vNext. The rule now appears in the "For the implementer" kernel-tier note, the failure table, and the input-verdicts decision. + +* Decisions [7/10] + +** DONE A doctor key per direction, on the section headers +CLOSED: [2026-07-10 Fri] +Context: an empty source list is normal on a mic-less desktop and a failure on a machine that should have one. No probe can tell them apart. + +Decision: we will put a doctor key on each of the OUTPUTS and INPUTS engraved section headers, and retire the standalone DOCTOR header and its DIAGNOSE key. Pressing the input doctor asserts that a microphone should exist. + +Consequences: the ambiguity is resolved by the user without a dialog, and the check sits beside the devices it judges. Harder: this supersedes a decision in the parent spec that shipped hours earlier, so the parent's history must say so. The panel also grows a third and fourth console key, and the 400px width has to absorb them. + +** DONE One doctor at a time +CLOSED: [2026-07-10 Fri] +Context: the wall holds one verdict, and a run clears the wall when it begins. + +Decision: we will allow only one doctor run in flight. SPEAKERS must finish before MICROPHONE can be pressed, and both keys go insensitive for the duration. + +Consequences: the wall is never a mix of two runs, and "which verdict is this" never needs asking. Harder: nothing, since the model already clears on begin and locks on =doctor_running=. The keys simply join the lock. + +** DONE An absent microphone is not a fault unless the user says so +CLOSED: [2026-07-10 Fri] +Context: =no-output-devices= is a fault because a machine with no way to play sound is broken. The mirror is false: a machine with no way to record is ordinary. + +Decision: we will not add a source-side =no-input-devices= fault. The input rules fire only under a press of the input doctor, which supplies the assertion that a mic should exist. + +Consequences: the doctor never cries wolf on a mic-less desktop. Harder: the classifier now takes a side parameter, so it is no longer a pure function of the context alone. It stays pure; the side is an input. + +** DONE Does push-to-talk get rebuilt? +CLOSED: [2026-07-10 Fri] +Context: PTT mutes the default source and leaves it muted between holds, so a naive mirror of the sink rules would classify an armed mic as "silenced, FIX unmutes it". Pressing FIX would break PTT and leave a hot mic the user believes is muted. Craig asked whether PTT could avoid muting. + +Decision: we will not rebuild PTT. =diag.gather()= will read =ptt.read_state()= into the context, and an armed-PTT muted mic will be reported as an informational row rather than a finding. + +Consequences: a few lines rather than a rewrite, and the safety property is preserved — source-mute is the only state where the server guarantees no capture. Harder: the doctor now depends on a runtime state file, so a stale or unreadable file must degrade to "not armed" (which =read_state()= already does) and the mic then reads as a genuine finding. That is the safe direction: it over-reports a muted mic rather than under-reporting a hot one. + +Craig ratified 2026-07-10: not rebuilding is fine. + +*** Discussion +The alternatives are recorded above under "Rebuild push-to-talk so it does not mute the mic". The load-bearing argument is that link-based silence fails open. + +** DONE The kernel tier reads =/proc/asound=, not =arecord= +CLOSED: [2026-07-10 Fri] +Context: the doctor needs a layer below PipeWire to tell a lost microphone from an absent one. + +Decision: we will read =/proc/asound/card*/pcm*c= directly. A card exposing a capture PCM has capture hardware. + +Consequences: the bottom tier cannot hang, cannot be absent, and needs no timeout — the only probe in the doctor with that property. Harder: it is Linux-specific and reads a kernel interface rather than a supported API, so a kernel that reorganizes =/proc/asound= would break it silently. The fallback is that an unreadable =/proc/asound= yields "unknown", never "no hardware", so the doctor abstains rather than lying. + +Craig ratified 2026-07-10: all okay. + +** TODO The capture guard, and what it refuses +Context: unmuting a microphone is not audible, but it is a privacy event. =active_streams()= currently ignores stream direction, so a playback remedy is refused today when only a recording is running. + +Decision (proposed): we will make the guard direction-aware. Playback remedies (3, 4, 5) refuse on running *output* streams; the mic-unmute remedy refuses on running *input* streams. Both take the same press-again override, and both abstain on an unreadable graph, as today. + +Consequences: the guard stops over-refusing, and unmuting a mic mid-recording takes deliberate consent. Harder: it changes the behavior of shipped remedies, so the existing guard tests move with it rather than being deleted. + +Owner: Craig. By: before Phase 2 lands. + +** TODO The input verdicts and their remedies +Context: the input side needs its own verdict vocabulary and its own remedy table entries. + +Decision (proposed): four new verdicts, and three new remedies. + +| Verdict | Means | Remedy | Tier | +|---------------------+------------------------------------------+-------------------------------------------+---------| +| =no-mic-hardware= | the kernel sees no capture device | none | — | +|---------------------+------------------------------------------+-------------------------------------------+---------| +| =mic-unrecognized= | kernel capture set non-empty, graph has | 6: reload device profiles (restart | Confirm | +| | no non-monitor hardware source | WirePlumber) | | +|---------------------+------------------------------------------+-------------------------------------------+---------| +| =mic-stale-default= | the default source names a device that | 7: set a present default source | Auto | +| | is absent | | | +|---------------------+------------------------------------------+-------------------------------------------+---------| +| =mic-silenced= | the default source is muted or at zero, | 8: unmute and restore the mic volume | Confirm | +| | and PTT is not armed | | | +|---------------------+------------------------------------------+-------------------------------------------+---------| + +=no-mic-hardware= carries no remedy for the same reason =tooling-missing= carries none: offering a fix for a fault that is not in the stack teaches the user to distrust the wall. + +=mic-unrecognized= uses the coarse set-emptiness rule from the "For the implementer" kernel-tier note, not a per-device match. The kernel names cards by ALSA id and the graph names sources by PipeWire =node.name=; those are different namespaces, and correlating them device-by-device is a vNext refinement. v1 fires =mic-unrecognized= when the kernel capture set is non-empty and the graph has zero non-monitor hardware sources. This resolves the second spec-review finding, which flagged the matching rule as undefined. + +Consequences: the verdict table grows from ten to fourteen, and =classify.findings()= grows a capture-hardware row and a default-input row. Harder: remedy 6 restarts WirePlumber, which remedy 1 already does for a different verdict. Two remedies invoking one action is fine; collapsing them would couple two diagnoses that should stay apart. + +Owner: Craig. By: before Phase 1 lands. + +** DONE The CLI grows explicit =--output= and =--input= flags, output aliased as the default +CLOSED: [2026-07-10 Fri] +Context: =audio doctor= today accepts only =--fix= and =--force=. There is no =--output=, so adding a lone =--input= would read as a special case bolted onto a bare command rather than one of a symmetric pair. + +Decision: we will add both =--output= and =--input= as explicit direction flags, and alias bare =audio doctor= to =audio doctor --output=. Both accept =--fix= and =--force=. The two flags are mutually exclusive; passing both is an error. + +Consequences: the two directions are named symmetrically, and =--input= has a sibling to pair against rather than standing alone. Bare =audio doctor= keeps its current meaning, so nothing that scripts it breaks, and "output is the default" is now a stated alias a reader can see rather than a convention to infer. Harder: =audio doctor --input --fix --force= is a long line, which is the cost of explicitness over =audio doctor input=; the subcommand form was rejected because it would break every existing call. + +Craig's call, 2026-07-10: "alias the command and have =audio doctor --output= be the one that is run by default. That way it's symmetrical, and =audio doctor --input= has a place." + +** TODO Name and style the doctor keys, distinct from the device controls +Context: the CONTROLS section already carries INPUT and OUTPUT keys — the mute toggles that read LIVE / MUTE. A doctor key labelled OUTPUT or INPUT on the section header a few rows up would read as a fourth mute control. Craig flagged the collision and asked for a name and a look that mark the doctor keys as a different function, held consistent across every panel. + +Decision (proposed): the doctor keys are named for what they diagnose — SPEAKERS on the OUTPUTS header, MICROPHONE on the INPUTS. Craig delegated the naming ("I'll let you choose"); these are the choice. Each key carries a distinct diagnostic treatment: a small health glyph (a stethoscope, =nf-md-stethoscope=) ahead of the word, plus a subtly distinct outline, so the key stays inside the dupré aesthetic while reading as diagnostic rather than a device control. The same treatment applies to the doctor keys already shipped in the other panels — net's DOCTOR, maint's CLEAN UP / REVIEW & FIX — so a doctor key looks like a doctor key everywhere. + +Consequences: no word collides with the CONTROLS keys; the two doctor keys carry distinct AT-SPI names the smoke can target; the diagnostic affordance is consistent across the panel family. Harder: it touches every panel's =gui.py= and the shared panel CSS, so the cross-module restyle is its own task rather than part of this build, and it sits beside the already-filed "copy + close on every wall" convergence. The exact glyph and outline are an aesthetic call that wants Craig's eye on a screenshot before it lands — which is why this stays open while the naming is settled. + +Owner: Craig (the visual). By: before Phase 3, where the keys are built. + +** DONE The doctor may use sudo, resolved by context at run time +CLOSED: [2026-07-10 Fri] +Context: the parent spec decided "no sudo anywhere", correct when the feature was purely user-scope PipeWire. The input and output expansion reaches firmware, ALSA saved state, modprobe overrides, and package management, which genuinely need root. Craig also wants the CLI usable as a generic doctor on machines that are not archsetup installs. + +Decision: this supersedes the parent's no-sudo decision. The doctor resolves its privilege at run time from three signals — passwordless sudo available (=sudo -n true=, which succeeds or fails instantly and never hangs), a tty to prompt at, and whether it is the GUI panel. Four remedy classes follow: *Auto* (user-scope, reversible, runs anywhere), *Privileged* (needs sudo — runs silently where passwordless sudo exists, prompts in a CLI with a tty, and degrades to Guide only in a GUI with neither), *Reboot-tail* (the applicable part runs, then the doctor instructs the reboot it cannot complete or verify past), and *Guide* (nothing to run: a physical switch, a BIOS setting, a wait-for-upstream fix). Every archsetup install has passwordless sudo, so on Craig's machines Privileged remedies run; the generic-CLI case prompts. + +Consequences: many failure modes that were guide-only (=alsactl store=, a modprobe override, removing a conflicting package) become applicable remedies on archsetup and on any CLI. Harder, and load-bearing: passwordless sudo is not consequence-free, so every Privileged and Reboot-tail remedy defaults to *Confirm or Arm* tier, never silent Auto — a firmware install or a module reload always takes a deliberate second press even when sudo will not prompt. Auto stays reserved for the user-scope, reversible remedies. This model is not audio-specific: the net, bluetooth, and maint doctors adopt the same run-time privilege resolution and the same Confirm/Arm-default for privileged actions, tracked as its own cross-panel task. + +Craig agreed 2026-07-10 to both the model and the Confirm/Arm-default safety stance, and to making it a cross-panel standard. + +* Implementation phases + +Each phase leaves the tree green and independently useful, as the parent spec's phases did. + +** TODO Phase 0 — the kernel tier, PTT in the context, direction-aware streams +Pure engine, no classifier changes, no UI. =diag.probe_kernel()= reads =/proc/asound=; =diag.gather()= adds =kernel= and =ptt= to the context; =active_streams(graph, direction=None)= filters. =audio diag --json= shows the new context. Fakes: a temp =/proc/asound=-shaped tree via an injectable root, since the real one cannot be faked on PATH. + +** TODO Phase 1 — =classify(ctx, side)= and the input verdicts +Pure, fixture-driven. The shared terminal ladder, then the fork. Pairwise over (kernel capture set × graph sources × default source state × PTT armed). This phase adds the direction flags: =--output= and =--input=, mutually exclusive, with bare =audio doctor= aliased to =--output=. =audio doctor --input= read-only prints findings and a verdict, and =audio doctor= keeps printing exactly what it does today. + +** TODO Phase 2 — the input remedies and the direction-aware guard +Remedies 6, 7, 8, each re-probing its own claim. The guard splits by direction. =audio doctor --input --fix=. The existing guard tests move rather than being deleted. + +** TODO Phase 3 — two section-header doctor keys +Retire the DOCTOR header row and its DIAGNOSE key. OUTPUTS and INPUTS each gain one. Mutual exclusion on =doctor_running=. The wall header names the run. AT-SPI smoke over both keys and the fix path, on fixtures. + +** TODO Phase 4 — flip this spec to IMPLEMENTED +And amend the parent spec's history to record that its DOCTOR-key decision was superseded here. + +** Later phases — the taxonomy expansion (vNext) +v1 (phases 0-4) ships the buildable Auto core and the Guide tail for the clusters the current probe ladder already separates, and flips this spec to IMPLEMENTED. The rest of the taxonomy ([[file:../design/2026-07-10-audio-failure-taxonomy.org][the taxonomy]]) is staged as later phases so v1 is not blocked on them. Each is a small, independent addition; the taxonomy is the backlog and this list is the order: + +- The dmesg-hint probe and the firmware/driver Guide tail (cluster 1). +- The unmute-doesn't-stick signature probe and the hardware-layer Guide (cluster 8). +- Re-probe-after-idle for the suspend and autosuspend cases (cluster 4). +- The Privileged and Reboot-tail remedy machinery: the run-time privilege resolution (=sudo -n= / tty / GUI detection) and the Confirm/Arm-default floor. +- The app-layer Guide tail (clusters 7 and 9): the tail on a healthy verdict that names the likely app or portal cause and prints the fix. + +These grow into their own spec when picked up. + +* Acceptance criteria + +- A machine with no capture hardware, under a press of the input doctor, reports =no-mic-hardware= and offers nothing to press. +- A machine whose kernel lists a capture device the graph has no source for reports =mic-unrecognized=, and FIX reloads the device profiles and re-probes. +- An armed-PTT muted mic classifies as =healthy=, with an informational row saying push-to-talk is armed. +- A muted mic with PTT disarmed classifies as =mic-silenced= and FIX unmutes it. +- FIX on the mic while something is recording is refused, and a second press proceeds. +- Restarting a playback remedy while only a microphone is recording is no longer refused. +- Pressing SPEAKERS while MICROPHONE is running does nothing. +- =audio doctor= with no flag behaves exactly as it does today. + +* Readiness dimensions + +- *Data model & ownership* — the context dict gains =kernel= (generated, per-probe) and =ptt= (read from the runtime state file that =ptt.py= owns; the doctor never writes it). +- *Errors, empty states & failure* — an unreadable =/proc/asound= yields =unknown=, never "no hardware". An unreadable PTT state reads as disarmed, which over-reports a muted mic rather than under-reporting a hot one. Both are the safe direction. +- *Security & privacy* — the mic-unmute remedy is the only privacy-relevant action in the feature, and it is guarded against running capture streams with an explicit second-press override. No credentials, no logs. +- *Observability* — the wall is the observability surface, and it names which doctor ran. The =--json= output carries the full context including the kernel tier. +- *Performance & scale* — the kernel tier is a directory listing. Everything else is unchanged. +- *Reuse & lost opportunities* — =classify.findings()= remains the single row-builder for the CLI and the GUI. =ptt.read_state()= is reused rather than reimplemented. =diag.unit_active= stays the single source of truth for unit state. +- *Architecture fit* — the fork lives in =classify()=, which is pure and already fixture-tested. The GUI change is confined to the section-header rows and the model's key-sensitivity logic. +- *Config surface* — none. N/A. +- *Documentation plan* — the module docstrings carry the design, as in the parent. No user docs; the wall is the documentation. +- *Dev tooling* — =make test= and =make test-panel-audio= cover it. The kernel tier needs an injectable root rather than a PATH fake, which is a new fixture shape for this package. +- *Rollout, compatibility & rollback* — =audio doctor= with no flag is unchanged, so nothing that scripts it breaks. The GUI change is not reversible by config; it is a redesign of a key that shipped the same day. +- *External APIs & deps* — =/proc/asound= layout verified live on ratio, 2026-07-10. No new packages. + +* Risks, rabbit holes, and drawbacks + +The kernel tier reads an interface rather than an API. If a future kernel reorganizes =/proc/asound=, the probe reports =unknown= and the doctor abstains, which is the failure mode we want, but nothing will announce the breakage. A test against a recorded tree pins the shape we expect. + +Remedy 6 restarts WirePlumber to reload device profiles, which is what remedy 1 already does. The temptation is to collapse them. Do not: the two verdicts describe different faults, and a shared remedy is not a shared diagnosis. The parent spec made exactly this call for =pulse-hung= and =pulse-down=. + +The panel is 400px wide and gains two console keys while losing one row. The layout needs eyes on it before Phase 3 closes, and no automated check will catch an ugly one. + +This spec supersedes a decision from a spec that reached IMPLEMENTED the same day. That is a smell worth naming: the DOCTOR-key placement was decided without the input side in view, and one conversation with the user overturned it. The lesson is not that the first decision was careless. It is that the input side should have been in scope from the start, and the parent's Non-Goals never said it was not. + +* Review and iteration history + +** 2026-07-10 Fri @ 18:52:07 -0500 — Craig Jennings — Reviewer +What changed or was recommended: a second review pass across four critical lenses — buildability, technical correctness, adversarial failure-modes, internal consistency. Verified every load-bearing code and =/proc/asound= claim against the real tree; all held. Recorded eight new findings (two blocking: mic-unmute failing open on an unreadable graph, and a non-ALSA mic mis-classifying as no-mic-hardware) and fixed three mechanical inconsistencies in place (a remedy miscount and two stray OUTPUT/INPUT doctor-key labels). +Why: the design was sound at the tier level, but it had two safety and correctness holes that only surface on real hardware (a webcam or Bluetooth mic, an unreadable graph), plus three build gaps that sit outside the open decisions and would stall Phase 0/1. +Artifacts: [[*Review findings][Review findings]]; code read across =~/.dotfiles/audio/src/audio/= (=diag.py=, =classify.py=, =repair.py=, =doctor.py=, =ptt.py=, =pactl.py=); live =/proc/asound= survey on ratio. + +** 2026-07-10 Fri @ 10:55:19 -0500 — Codex — Reviewer +What changed or was recommended: applied the spec-review workflow and recorded two blocking findings: open Decisions still gate implementation, and kernel-capture-to-graph-source matching is undefined. +Why: the current implementation confirms the microphone gap and output-only classifier, but these unresolved choices would force the builder to decide safety and classification behavior during implementation. +Artifacts: [[*Review findings][Review findings]]; current code read in =~/.dotfiles/audio/src/audio/diag.py=, =classify.py=, =repair.py=, =cli.py=; related tracking in [[file:../../todo.org][todo.org]] under "The audio doctor never checks the microphone". + +** 2026-07-10 Fri @ 10:38:12 -0500 — Craig Jennings — Author +What: drafted the spec. Four probe tiers become five, the classifier takes a direction, the panel gets a doctor per direction, and push-to-talk stays as it is. +Why: the doctor calls a stack healthy while the microphone is dead, and the ambiguity of an absent mic cannot be resolved by any probe. +Artifacts: live survey of ratio (=/proc/asound= capture nodes, =_STREAM_CLASSES=, =ptt.read_state()=), and the Chrome incident of the same morning. diff --git a/docs/specs/2026-07-11-bt-doctor-expansion-spec.org b/docs/specs/2026-07-11-bt-doctor-expansion-spec.org new file mode 100644 index 0000000..b255b71 --- /dev/null +++ b/docs/specs/2026-07-11-bt-doctor-expansion-spec.org @@ -0,0 +1,250 @@ +#+TITLE: Bt Doctor Expansion — name the blob, catch the boot-disable +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-11 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* DOING Bt Doctor Expansion +:PROPERTIES: +:ID: 3d4d61c4-e5df-44e9-b8e0-40b31452c3f7 +:END: +- [2026-07-11 Sat @ 02:30 -0500] DOING — decomposed into build tasks (spec-response Phase 6); parent task in =todo.org= bound by =:SPEC_ID:=. Phases 0-1 (the two read-only probes + the firmware-hint Guide verdict) are buildable now and =:solo:=; Phase 2 (the persistent fix) is gated on the shared cross-panel privilege model. +- [2026-07-11 Sat @ 02:00 -0500] READY — third skeptical re-review returned Ready with caveats, no blocking findings; all round-2 resolutions verified against the engine. Caveat accepted: Phase 2 (the persistent fix) depends on the shared cross-panel privilege model, which doesn't exist yet; Phase 0 (both read-only probes) and Phase 1 (the firmware-hint Guide) are buildable today. +- [2026-07-11 Sat @ 01:45 -0500] DRAFT — round-2 review + response. A skeptical re-review caught a real blocker: the AutoEnable default is =true=, not =false= (verified, bluez 5.87), so round 1's "dead every boot on absent config" premise was backwards and would fire a false positive on healthy machines. Corrected — the fault fires only on explicit AutoEnable=false / disabled service / TLP. Also folded three non-blocking corrections (the =code= key is additive-not-existing; the INI-setter is real Phase 2 work; the sequencing caveat now actually in Risks). Findings =[8/8]=, decisions =[3/3]=. Awaiting a third re-review. +- [2026-07-11 Sat @ 01:20 -0500] DRAFT — review incorporated (spec-response). All four findings dispositioned (=[4/4]=), all three decisions accepted and closed (=[3/3]=). The verdict-representation blocker resolved by defining a "verdict" as a step outcome in the existing status model. +- [2026-07-11 Sat @ 00:59:30 -0500] DRAFT — reviewed (spec-review). Stays DRAFT: three decisions open plus one =:blocking:= finding (the bt doctor has no named-verdict layer, so the proposed =no-adapter-firmware=/=powered-off-persistent= verdicts need their representation defined first). Design confirmed against the live engine — the two target gaps (kernel-log firmware hint, boot-persistence read) genuinely do not exist yet. Findings in =* Review findings=. +- [2026-07-11 Sat @ 00:08:41 -0500] DRAFT — drafted. Extends the existing bt doctor (=~/.dotfiles/bluetooth/=, shipped) using the bluetooth half of the failure taxonomy ([[file:../design/2026-07-10-net-bt-failure-taxonomy.org][2026-07-10-net-bt-failure-taxonomy.org]]). Grounded in a read of the live engine, not memory. + +* Metadata + +| Field | Value | +|----------+-----------------------------------------------------------------------------------| +| Status | doing | +|----------+-----------------------------------------------------------------------------------| +| Owner | Craig Jennings | +|----------+-----------------------------------------------------------------------------------| +| Reviewer | Craig Jennings | +|----------+-----------------------------------------------------------------------------------| +| Related | [[file:../design/2026-07-10-net-bt-failure-taxonomy.org][net/bt failure taxonomy]] ; the cross-panel run-time-privilege and copy+close tasks | +|----------+-----------------------------------------------------------------------------------| + +* Summary + +The bt doctor walks a clean chain — adapter → rfkill → bluetooth.service → powered → per-device → audio profile — and applies four safe repairs (unblock, power-on, service-restart, a2dp). The failure taxonomy shows the chain is structurally right but blind in two specific spots the user hits most: when there is no adapter it says "no adapter found" without naming *why* (a missing firmware blob the dmesg log already names), and it never checks whether the adapter is configured to power on at boot, so an =AutoEnable=false= laptop that is dead every login reads as a healthy powered-off adapter. This spec adds those two probes and adopts the cross-panel privilege model. It is an expansion of a working doctor. + +* Problem / Context + +The taxonomy sorted ~55 real bluetooth failure modes into five clusters keyed to the doctor's chain. Read against the live engine (=~/.dotfiles/bluetooth/src/bt/doctor.py=), the doctor already handles cluster 2 well (unblock, service-restart, power-on are its exact auto-fix tiers) and cluster 5 partially (the =a2dp= repair forces the A2DP profile for a card stuck in HSP/HFP or with no sink). Clusters 3 (pairing) and 4 (connection stability) are deliberately light — the doctor treats connecting and pairing as user intents, not health repairs, and that is a correct design choice, not a gap. The two real gaps are at the ends of the chain. + +** "No adapter found" doesn't say why (cluster 1) + +=_adapter_step= reports a missing adapter as "no Bluetooth adapter found (hardware/driver)" and stops. But the taxonomy's cluster 1 is almost entirely *firmware* faults, and the kernel already logged the specific cause: "Direct firmware load for intel/ibt-…sfi failed", "mediatek/BT_RAM_CODE_MT7961… failed", "BCM: Patch …hcd not found", "Reading QCA version information failed". The doctor has that log a =journalctl -k= read away and doesn't consult it. A user who sees "no adapter — hardware/driver" is left to search; a user who sees "the Intel firmware blob ibt-20-1-4.sfi failed to load — update linux-firmware and reboot" has the fix. This is the direct parallel to the audio doctor's cluster-1 dmesg-hint proposal, and the same read-only, bounded shape. + +** A boot-disabled adapter reads as merely powered-off (cluster 2) + +=_powered_step= checks whether the adapter is powered *now* and offers =power-on=. But =power-on= via bluetoothctl doesn't persist: on a machine with =AutoEnable=false= explicitly set (bluez's compiled default is =true= — verified in bluez 5.87's =/etc/bluetooth/main.conf= — so an absent or unset config auto-enables, and the fault is an explicit opt-out, not the missing-config case), or a TLP laptop that disables bluetooth on startup, the adapter is dead again next boot. The doctor fixes the symptom every session and never names the cause. The taxonomy's cluster 2 has a whole sub-family here — AutoEnable explicitly off, service-not-enabled-at-boot, TLP-disables-on-startup, systemd-rfkill-restores-a-stale-block — that all present as "powered off / blocked" and all need a *persistent* fix the doctor doesn't distinguish from a one-shot power-on. + +** Pairing and connection clusters stay light on purpose (clusters 3, 4) + +The doctor doesn't auto-pair or auto-connect, and the destructive re-pair is always user-confirmed. That is right. The one addition worth considering is a *stale-bond signature* — a device that fails to connect with a bond present, repeatedly — so the doctor can *offer* the re-pair with confidence instead of the user guessing. That is a candidate, held as a decision, not a v1 commitment. The rest of clusters 3/4 (physical range, USB3 noise, coexistence, connection parameters) are Guide-only and mostly out of a health doctor's reach. + +* Goals and Non-Goals + +** Goals + +- When there is no adapter, name the specific firmware/driver cause by reading the kernel log, and print the matching fix — instead of a generic "hardware/driver." +- Distinguish a *persistently* disabled adapter (AutoEnable off, service not enabled at boot, TLP-disabled) from a merely powered-off one, and offer the persistent fix rather than a one-shot power-on that dies next boot. +- Adopt the cross-panel run-time privilege model for the new root-needing repairs (editing main.conf's AutoEnable, =systemctl enable=, editing tlp.conf), under the Confirm/Arm-default floor. + +** Non-Goals + +- Auto-pairing or auto-connecting devices. Connecting and pairing are user intents; the doctor's existing stance stands. +- Fixing the firmware itself. The dmesg-hint probe *names* the missing blob and prints the update/reboot instruction; it does not install firmware or rebuild initramfs inside a diagnose run (that is a Reboot-tail Guide the user runs). +- The physical/coexistence connection-stability faults (USB3 noise, range, 2.4GHz coexistence, connection parameters). These are Guide-only and stay out of the health chain. +- Silent privileged action. Editing main.conf, enabling the service, or touching tlp.conf all default to Confirm/Arm, never silent Auto. + +** Scope tiers + +- *v1:* the dmesg firmware-hint probe for the no-adapter case (cluster 1); the boot-enablement probe distinguishing AutoEnable-off / service-not-enabled / TLP-disabled from a plain power-off (cluster 2), with the persistent fix; adoption of the run-time privilege model. +- *Out of scope:* auto-pair/auto-connect; firmware installation inside a run; the physical/coexistence connection faults. +- *vNext:* the stale-bond re-pair-offer signature (cluster 3, pending the decision below); a connection-parameter/coexistence hint tail for a "keeps dropping" verdict (cluster 4); the bluetooth-specific audio-profile expansion beyond the current a2dp repair (codec fallback, default-sink, absolute-volume — several overlap the audio taxonomy and want coordination with the audio doctor). All logged to =todo.org=. + +* Design + +** For the user + +Two verdicts that today under-inform start telling the user the actual cause. + +When there is no adapter, the wall stops at "no adapter" no longer. It names the blob and the fix: "the MediaTek BT firmware (BT_RAM_CODE_MT7961) failed to load — update linux-firmware and reboot," pulled straight from the kernel log the doctor now reads. When the log shows a clean absence (no firmware error, genuinely no controller), it says that instead — the same fail-honest distinction the audio doctor draws between "the server lost a device" and "nothing is attached." + +When the adapter is powered off, the doctor asks a second question: is it *supposed* to be on at boot? If AutoEnable is off, or bluetooth.service isn't enabled, or TLP is disabling it on startup, the verdict names that — "your adapter powers off every boot because AutoEnable is false" — and FIX offers the persistent fix (set AutoEnable, enable the service) rather than a power-on that will be undone by the next reboot. A plain "it's off right now" still gets the quick power-on. + +** For the implementer + +*** How the new "verdicts" are represented + +The bt doctor has no verdict-enum layer like the net doctor's =classify= actions. A step result today carries a fixed key set — =id=, =status= (=pass=/=fail=/=warn=/=info=), =title=, =evidence=, =elapsed_ms=, =safety=, =next_action= (=schema.step=) — and the run rolls the statuses up into an =overall= (=ok=/=warn=/=fail=, =doctor.py:182-183=). So a "verdict" in this spec is not a new =overall= value — it is a distinct step *outcome*: the same =status= with a specific message and evidence, and a =next_action= carrying the fix for the persistent case. The human distinction rides in the existing =evidence= and =next_action= fields — =format_doctor_human= renders =evidence= (=cli.py:94=), so the named blob or persistence cause needs no formatter change. If a stable machine identifier is wanted so =--json= consumers can branch on the outcome without string-matching, that is an *additive* =code= key on =schema.step= — and because the step schema is a locked test contract, adding it ripples into any test asserting exact step shape, so it is called out here rather than assumed. =_adapter_step= and =_powered_step= already own their results; the new probes add branches inside them. Nothing touches the =overall= vocabulary or =AUTO_FIX= tiers — the additions stay inside the existing step-status model rather than inventing a parallel verdict system. + +*** The dmesg firmware-hint probe + +When =_adapter_step= finds no adapter, run a bounded =journalctl -k -b --no-pager= (or =dmesg=) read, scanned for the known firmware-load-failure signatures per vendor (Intel ibt-*.sfi, MediaTek BT_RAM_CODE, Realtek rtl_bt, Broadcom BCM .hcd, Qualcomm QCA version-read). A match sets the =no-adapter-firmware= code on the adapter step, naming the blob and the Reboot-tail Guide (update linux-firmware / symlink the blob / reboot). No match keeps the existing generic no-adapter outcome. The read reuses the engine's existing bounded pattern — =cmd.run(journalctl -u bluetooth -n 1)= already runs for service-log evidence (=doctor.py:84=, 5s timeout) — so the kernel-log read is the same bounded shape, read-only, and runs only in the no-adapter branch, costing nothing on a healthy adapter. + +*** The boot-enablement probe + +When =_powered_step= finds the adapter powered off (or =_rfkill_step= finds a soft-block), consult three persistence signals: bluez =AutoEnable= (parse =/etc/bluetooth/main.conf= [Policy]), =systemctl is-enabled bluetooth=, and whether TLP's =DEVICES_TO_DISABLE_ON_STARTUP= lists bluetooth. The fault fires only on an *explicit* boot-disable: =AutoEnable=false= set in main.conf (bluez's compiled default is =true=, so an absent or unset key means auto-enable-on and is never the fault), a =disabled= bluetooth.service, or a TLP entry. A powered-off adapter with one of those gets a =powered-off-persistent= outcome distinct from the plain =powered-off=; its fix is the persistent one (set AutoEnable / enable the service / edit tlp.conf), Privileged/Confirm. The existing =power-on= stays for the plain case (including the common absent-config machine, which auto-enables by default). + +*** The privilege model + +The new repairs — editing main.conf, =systemctl enable bluetooth=, editing tlp.conf — are root-needing and adopt the cross-panel run-time model: run where passwordless sudo exists, prompt on a tty, default to Confirm/Arm, never silent Auto. Same shared implementation as the net and audio doctors. =priv.py= today exposes exactly one privileged verb, =restart-bluetooth=, via a plain verb→argv dispatch (=priv.py:25-34=); the three new fixes add verbs to it, each a narrowly-scoped operation — set the single =AutoEnable= key, enable one named unit, set the single TLP key — not a general edit-file-as-root. The passwordless-sudo grant widens by three tight verbs, not by a root file-editor, so the Confirm floor is the second guard rather than the only one. One caveat the implementer must not miss: =systemctl enable bluetooth= is a clean argv verb, but the two config edits are not. main.conf is INI — setting =AutoEnable= needs an idempotent setter that creates the [Policy] section when absent and preserves comments (a shipped helper or crudini). tlp.conf is a flat =KEY=VALUE= file — the fix removes =bluetooth= from the =DEVICES_TO_DISABLE_ON_STARTUP= list, a list-edit, not a section-create. Both are real Phase 2 work, not one-line argv verbs. The existing four auto-fix tiers (unblock, power-on, service-restart, a2dp) are unchanged — they are already user-scope or already the doctor's safe tier. + +* Alternatives Considered + +** Print a generic "check firmware" hint without reading the log + +- Good, because it needs no journal read. +- Bad, because "check your firmware" is exactly the uselessly-generic verdict this fixes; the whole value is naming the specific blob the kernel already identified. +- Rejected. The log has the answer; read it. + +** Make power-on always persistent (set AutoEnable on every power-on) + +- Good, because the adapter would then stay on across boots without a separate verdict. +- Bad, because it conflates a deliberate boot-disable (a user or TLP choosing bluetooth-off-by-default for battery) with a fault, and silently overrides a policy the user may have set on purpose. Persistence is a distinct decision that deserves a distinct, confirmed action. +- Rejected in favor of a separate verdict the user confirms. + +** Build the stale-bond re-pair signature into v1 + +- Good, because it would let the doctor offer re-pair with confidence. +- Bad, because re-pair is destructive (removes the bond) and the signature ("fails to connect with a bond present, repeatedly") needs care to not fire on a merely-out-of-range device; getting it wrong offers a destructive fix for a transient condition. +- Held as an open decision, not a v1 commitment. + +* Decisions [3/3] + +** DONE The dmesg firmware-hint probe +Context: cluster 1 is almost all firmware faults the kernel already logs, and the doctor reports a generic "hardware/driver" instead of the named blob. +Decision: we will read the kernel log in the no-adapter branch, match the known per-vendor firmware-load-failure signatures, and set a =no-adapter-firmware= outcome code naming the blob and its Reboot-tail Guide; no match falls back to the generic no-adapter outcome. +Consequences: the most common no-adapter cause becomes self-explaining; harder — a per-vendor signature table to maintain, and the read must stay bounded and only run in the no-adapter branch so it never slows a healthy run. +Resolution: accepted as proposed (the generic "check firmware" alternative was rejected — the whole value is naming the specific blob). Owner: Craig. + +** DONE The boot-enablement probe and the persistent-vs-transient split +Context: =power-on= doesn't persist, so an AutoEnable-off / not-enabled / TLP-disabled adapter is dead every boot and the doctor fixes only the symptom. +Decision: we will add a persistence probe (AutoEnable, service-enabled, TLP) and a =powered-off-persistent= outcome code distinct from =powered-off=, whose fix is the persistent one under the Confirm floor; the plain power-on stays for the transient case. +Consequences: the boot-disable cause is named and fixed once instead of every session; harder — the doctor must not override a *deliberate* boot-disable, so the persistent fix is always confirmed and the outcome names the specific persistence cause rather than blanket-enabling. +Resolution: accepted as proposed (the always-persistent-power-on alternative was rejected — it would silently override a deliberate battery-saving boot-disable). Round-2 correction: the verdict fires only on an *explicit* AutoEnable=false / disabled service / TLP entry — bluez's compiled default is =true= (verified, bluez 5.87), so absent or unset config is auto-enable-on and never the fault. Owner: Craig. + +** DONE Whether the stale-bond re-pair-offer lands in v1 or vNext +Context: a device failing to connect with a bond present, repeatedly, is a re-pair candidate — but re-pair is destructive and the signature can misfire on an out-of-range device. +Decision: defer to vNext. v1 keeps re-pair strictly user-initiated; the signature is designed and validated before the doctor ever *offers* it. +Consequences: no risk of the doctor offering a destructive fix for a transient condition in v1; harder — the "keeps failing to connect" case stays unnamed for now, which is the one cluster-3 gap a user might reasonably expect the doctor to catch. +Resolution: accepted — deferral confirmed. The "keeps failing to connect" gap is logged to vNext. Owner: Craig. + +* Review findings [8/8] + +** DONE The bt doctor has no named-verdict layer, so "emit a verdict named X" is undefined :blocking: +The spec proposes named verdicts throughout — =no-adapter-firmware=, =powered-off-persistent= "distinct from =powered-off=" — mirroring the net doctor's action-identifier vocabulary. But the bt doctor has no such layer. Verified: a step carries a =status= ∈ =pass/fail/warn/info= and the run has an =overall= ∈ =ok/warn/fail= (=doctor.py:182-183=); there are no verdict identifiers as *names*. So "add a =powered-off-persistent= verdict distinct from =powered-off=" had no existing mechanism to attach to. +Disposition: accepted. Added Design "How the new 'verdicts' are represented": a verdict here is a distinct step *outcome* — the same =status= plus a stable machine =code= and a specific message/evidence, set inside =_adapter_step= / =_powered_step=, rendered by =format_doctor_human= and carried in =--json=. Nothing touches the =overall= vocabulary or =AUTO_FIX= tiers. The Decisions and probe descriptions now speak in "outcome code" terms, so the representation is defined before Phase 1. + +** DONE New root repairs expand the NOPASSWD sudoers surface — name it in rollout +=priv.py= today exposes a single privileged verb, =restart-bluetooth= (=priv.py:27-34=), backed by a NOPASSWD sudoers entry. The three new persistent fixes (edit main.conf, =systemctl enable bluetooth=, edit tlp.conf) are each a new root-capable verb. +Disposition: accepted, modified. Rather than just "name the expansion," the resolution constrains each new verb to a narrowly-scoped operation (set the single =AutoEnable= key, enable one named unit, set the single TLP key) — not a general edit-file-as-root — so the passwordless-sudo grant widens by three tight verbs, not a root file-editor. Folded into the privilege-model design and the Rollout dimension. + +** DONE The diagnostic surface is =bt doctor='s JSON, not a =bt diag= subcommand +Phase 0 said "=bt diag --json= (or the equivalent) shows the new signals." Verified: there is no =bt diag= subcommand; =diagnose()= is the report authority (=doctor.py:174=), =doctor()= wraps it (=:207=), JSON is the default output, and =format_doctor_human= (=cli.py:94=) renders the human view. +Disposition: accepted. Phase 0 now points at the real surface (the doctor's JSON report + =format_doctor_human=) and drops the =bt diag= reference. + +** DONE Firmware-hint probe is new but has a bounded precedent — cite it +Phase 0's =journalctl -k= reader is genuinely new (=_adapter_step= reads nothing beyond =btctl.show=, =doctor.py:46-50=), but the engine already runs a bounded =journalctl -u bluetooth -n 1= for service-log evidence (=doctor.py:84=, timeout 5s via =cmd.run=). +Disposition: accepted. The firmware-hint probe design now cites =doctor.py:84= as the bounded precedent the kernel-log read reuses, so the implementer reaches for the same =cmd.run= shape rather than an unbounded call. + +** DONE Round 2 (skeptical review): the AutoEnable default is true, not false :blocking: +Round 1 asserted (twice) that =AutoEnable=false= is "bluez's static default when main.conf is absent," and the whole "dead every boot" premise rested on it. A skeptical re-review read the installed bluez 5.87 =/etc/bluetooth/main.conf= and found the opposite: "Defaults to 'true'." Verified independently (=#AutoEnable=true= is the commented compiled default, bluez 5.87). An implementer trusting the round-1 text would code default=False and report =powered-off-persistent= falsely on every common healthy machine with no/default config — the exact "targets a non-problem" failure. +Disposition: accepted — a real blocker. Corrected the premise everywhere: the fault now fires only on an *explicit* AutoEnable=false / disabled service / TLP entry, never on config absence. Fixed Problem/Context, the boot-enablement probe design, decision 2, and added an acceptance criterion that absent/default config gets the transient power-on. + +** DONE Round 2 (skeptical review): the machine =code= field doesn't exist on the step schema +Round 1's representation subsection spoke of "a stable machine =code=" as if the step result already had one. The skeptical review confirmed =schema.step= returns a fixed key set (=id=, =status=, =title=, =evidence=, =elapsed_ms=, =safety=, =next_action=) with no =code=, and the step schema is a locked test contract. +Disposition: accepted. The subsection now carries the human distinction in the existing =evidence=/=next_action= fields (no formatter change), and names the optional =code= key as an *additive* schema change with the test-contract ripple called out — not assumed to exist. + +** DONE Round 2 (skeptical review): "single-key set" for main.conf/tlp.conf hides real work +Round 1 called the config-edit verbs "narrowly-scoped" without naming the mechanism. The skeptical review noted =systemctl enable= is a clean argv verb but editing an INI key needs an idempotent setter (create the section if absent, preserve comments), which is more than a one-liner. +Disposition: accepted. The privilege-model design now names the INI-setter requirement and marks it Phase 2 work, distinct from the argv-clean service-enable verb. + +** DONE Round 2 (skeptical review): the shared-privilege-code sequencing caveat was claimed but missing +The round-1 review-history entry said the shared-privilege-model sequencing caveat was "already in Risks," but the Risks section did not mention it, and Phase 1 lands "shared cross-panel" code while =priv.py= is bt-local with one verb today — so Phase 1 isn't independently landable until that shared model ships. +Disposition: accepted. Added the sequencing caveat to Risks, naming Phase 0 (the two read-only probes) as the independently-landable slice and noting the AutoEnable correction makes those probes worth landing on their own. + +* Implementation phases + +Each phase leaves the tree green and independently useful, as the existing bt phases did. + +** TODO Phase 0 — the two read-only probes +Pure engine, no repair changes. The dmesg firmware-hint reader (no-adapter branch) and the boot-enablement reader (AutoEnable / service-enabled / TLP), both reporting into the =diagnose()= report dict. That dict feeds both views: the human summary (the default) and =--json= (=cli.py:113-116=); =format_doctor_human= (=cli.py:94=) renders the new evidence. There is no separate =bt diag= subcommand. Fakes: a canned journal buffer with each vendor's signature, and injected main.conf / =systemctl is-enabled= / tlp.conf states. + +** TODO Phase 1 — the firmware-hint verdict (Guide, no privilege dependency) +=diagnose= emits =no-adapter-firmware= (naming the blob, Reboot-tail Guide) when the signature matches. No auto-fix — this verdict is a Guide, so it needs no privilege model and lands independently of the shared cross-panel code. The run-time privilege resolution is a Phase 2 prerequisite, not this phase. + +** TODO Phase 2 — the persistent-power verdict and its fix (gated on the shared privilege model) +=powered-off-persistent= distinct from =powered-off=; the persistent fix (set AutoEnable / enable service / tlp) registers Privileged/Confirm through =priv.py= (which is one verb today), plus the INI/list setters above. =bt doctor --fix= applies it under the Confirm floor; the plain power-on path is unchanged. Hard ordering gate: this phase must not land before the shared cross-panel run-time privilege model exists — shipping a Privileged fix before the Confirm floor would let =--fix= edit root config via passwordless sudo ungated, the exact outcome the model forbids. + +** TODO Phase 3 — flip this spec to IMPLEMENTED +And log the vNext items (stale-bond signature, connection-parameter hints, bt-audio-profile expansion) to =todo.org=. + +* Acceptance criteria + +- [ ] With no adapter and a MediaTek/Intel/Realtek/Broadcom/Qualcomm firmware-load error in the kernel log, the doctor names the specific blob and prints the update-and-reboot Guide. +- [ ] With no adapter and no firmware error in the log, the doctor reports the generic "no controller attached" without inventing a firmware cause. +- [ ] An adapter that is powered off with AutoEnable=false *explicitly set* reports =powered-off-persistent= and FIX offers the persistent fix, not just a one-shot power-on. +- [ ] An adapter powered off with AutoEnable on (a transient off) still gets the quick power-on. +- [ ] An adapter powered off with main.conf absent or AutoEnable unset is treated as auto-enable-on (bluez's compiled default is true) and gets the transient power-on, never =powered-off-persistent=. +- [ ] Every new root-needing repair defaults to Confirm/Arm and never runs silently as Auto. +- [ ] The existing chain (rfkill/service/powered/device/audio) and its four auto-fix tiers classify and repair exactly as today (regression). + +* Readiness dimensions + +- *Data model & ownership* — the diagnose report gains a firmware-hint field (generated from the kernel log) and boot-persistence signals (generated from main.conf / systemctl / tlp.conf). The doctor writes main.conf / tlp.conf / the service enablement only under the Confirm floor. +- *Errors, empty states & failure* — an unreadable journal or main.conf yields "unknown," never a false firmware or persistence verdict. A no-match firmware scan falls back to the existing generic verdict (the safe direction). +- *Security & privacy* — device MACs are already redacted by =redact.py=; the new probes read the kernel log and config files, no secrets. The firmware-signature scan must not surface unrelated journal content. +- *Observability* — the wall names the blob and the persistence cause. The =--json= output carries the new signals. +- *Performance & scale* — the journal read runs only in the no-adapter branch; the persistence read only when powered-off/blocked. Neither touches a healthy run. +- *Reuse & lost opportunities* — reuses the existing chain, =btctl=, =sysio=, =priv.py=, and the shared cross-panel privilege model rather than a bt-local one. =diagnose= stays the single report authority. +- *Architecture fit* — both probes are additive inside existing chain branches (=_adapter_step=, =_powered_step=), not new chain links. Weak point: the firmware-signature table is vendor-specific and will drift as new hardware appears; it degrades to the generic verdict rather than mis-naming, which bounds the risk. +- *Config surface* — none new for the doctor; it *reads* bluez/tlp config and *writes* it only as a confirmed repair. N/A for its own knobs. +- *Documentation plan* — module docstrings, as the package does today. The wall is the user documentation. +- *Dev tooling* — =make test= and the bt panel smoke cover it; the new probes need a canned journal fixture and injected config states, fixture shapes the package can adopt. +- *Rollout, compatibility & rollback* — additive; the existing chain is untouched. The persistent fixes change bluez/tlp config and service enablement, so all are Confirm-tier and reversible by the user. Each new privileged repair adds a narrowly-scoped verb to =priv.py= (single-key sets, one named unit-enable) rather than a general root file-edit, so the passwordless-sudo surface grows by three tight verbs beyond today's single =restart-bluetooth=; the sudoers/priv change ships with the fixes. +- *External APIs & deps* — =journalctl -k=/=dmesg=, =/etc/bluetooth/main.conf=, =systemctl is-enabled=, and =/etc/tlp.conf= layouts are verified against the live system before Phase 0. The per-vendor firmware signatures come from the taxonomy's cluster-1 sources; no new packages. + +* Risks, rabbit holes, and drawbacks + +The firmware-signature table is the maintenance rabbit hole: each vendor logs its failure differently and new hardware adds new strings. The mitigation is that a miss degrades to the existing generic verdict — the doctor is never *worse* than today, only sometimes not-better — so the table can grow incrementally without a correctness cliff. + +The persistent-power fix must not fight a deliberate choice. A user (or TLP on a battery-conscious laptop) may want bluetooth off by default; blanket-enabling AutoEnable would override that silently. The verdict names the specific persistence cause and the fix is always confirmed, so the user sees exactly what would change before it does. + +The bt-audio-profile expansion (vNext) overlaps the audio taxonomy's Bluetooth-mic cluster. When it is picked up, it needs coordination with the audio doctor so the two panels don't both claim the same A2DP/HFP diagnosis with divergent verdicts. Named here so the seam is known. + +The privilege model this spec adopts is shared cross-panel code that does not exist yet — =priv.py= is bt-local today with one verb (=restart-bluetooth=). Phase 1 (which lands the run-time privilege resolution) can only land independently once that shared model ships somewhere; sequence it so whichever panel lands the shared code first, the others depend on it. Until then, Phase 0 (the two read-only probes) is the independently-landable slice, and the AutoEnable-default correction means those probes are worth landing on their own — they add the firmware and boot-persistence *signals* even before any privileged fix exists. + +* Review and iteration history + +** 2026-07-11 Sat @ 00:08:41 -0500 — Craig Jennings — Author +- What: drafted the bt doctor expansion from the bluetooth half of the failure taxonomy. v1 adds the dmesg firmware-hint probe (cluster 1) and the boot-enablement probe (cluster 2); the pairing/connection/audio expansions are staged to vNext. +- Why: the taxonomy showed the doctor's chain is structurally right but blind at its ends — a no-adapter verdict that won't name the firmware blob the kernel already logged, and a power-on that doesn't persist across boots. +- Artifacts: [[file:../design/2026-07-10-net-bt-failure-taxonomy.org][the net/bt failure taxonomy]]; code read across =~/.dotfiles/bluetooth/src/bt/= (=doctor.py=, =repair.py=, =btctl.py=, =sysio.py=, =audio.py=, =priv.py=). + +** 2026-07-11 Sat @ 00:59:30 -0500 — Claude Code (archsetup) — Reviewer +- What: ran spec-review. Rubric =Not ready=. Recorded four findings, one =:blocking:= (no named-verdict layer to attach the proposed verdicts to) and three non-blocking (sudoers-surface expansion, the real diagnostic surface, the bounded firmware-read precedent). The three proposed decisions remain open. +- Why: the design is sound and the code read confirmed both target gaps are real — the engine reads no kernel log for firmware hints and nothing for boot persistence (=main.conf=/=is-enabled=/=tlp.conf= all grep-clean). What holds the rubric is that the spec borrows the net doctor's verdict-naming language, but the bt doctor is step-status-based (=status= pass/fail/warn/info, =overall= ok/warn/fail, =doctor.py:182-183=) with no verdict enum — so how each new "verdict" is represented is undefined. Once that representation is decided and Craig accepts the three decisions, this reaches =Ready with caveats= (shared privilege-model code being the sequencing caveat, already in Risks). +- Artifacts: engine facts verified across =doctor.py= (:25 AUTO_FIX, :46-50 no-adapter branch, :84 bounded journalctl precedent, :100-107 powered step, :174-183 diagnose/overall), =priv.py:27-34= (single =restart-bluetooth= verb), =cli.py:94/173= (formatter + json default), =redact.py:11-20=. Findings in =* Review findings=. + +** 2026-07-11 Sat @ 01:20:00 -0500 — Claude Code (archsetup) — Responder +- What: ran spec-response. Dispositioned all four findings (three accepts, one accept-with-modify: the new privileged verbs are narrowly scoped, not a general root file-editor) and closed all three decisions as accepted. Added Design "How the new 'verdicts' are represented" defining a verdict as a step outcome code in the existing status model; cited the bounded =journalctl= precedent; corrected the Phase 0 diagnostic surface; constrained the sudoers expansion in the privilege-model design and the Rollout dimension. Both =[/]= cookies now read complete. +- Why: convergence toward implementation-ready. The verdict-representation finding was the real blocker — the spec had borrowed net's verdict language onto a step-status engine, so defining "verdict = outcome code" is what makes the two probes implementable without inventing a parallel system. +- Artifacts: findings =[4/4]=, decisions =[3/3]=. Scope expanded only by the tightened privilege-verb obligation, filed in the Rollout dimension, so the readiness rubric holds. Awaiting re-review to flip DRAFT → READY. + +** 2026-07-11 Sat @ 01:45:00 -0500 — Claude Code (archsetup) — Reviewer + Responder (round 2) +- What: a skeptical adversarial re-review returned Not ready with one hard blocker — the AutoEnable default is =true=, not =false= (round 1 had it backwards, verified against bluez 5.87), which would fire =powered-off-persistent= falsely on every healthy default-config machine. Corrected the premise everywhere so the fault fires only on explicit signals. Also fixed three non-blocking issues: the machine =code= is an additive schema change (=schema.step= has no =code= key today), the INI-setter for main.conf/tlp.conf is real Phase 2 work not a one-liner, and the shared-privilege-code sequencing caveat is now actually in Risks (round 1's history claimed it was). Findings now =[8/8]=. +- Why: round 1 rubber-stamped the AutoEnable default without checking the installed bluez. Round 2 verified it directly (=#AutoEnable=true= in =/etc/bluetooth/main.conf=). The correction also strengthens phasing: Phase 0's read-only probes are worth landing on their own, before any privileged fix. +- Artifacts: bluez 5.87 =/etc/bluetooth/main.conf= (AutoEnable default true); =schema.step= (fixed key set, no =code=); =priv.py:25-34= (verb→argv dispatch). Awaiting a third re-review. + +** 2026-07-11 Sat @ 02:00:00 -0500 — Claude Code (archsetup) — Reviewer (round 3) +- What: third skeptical adversarial re-review. Verdict =Ready with caveats=, no blocking findings. Verified all four round-2 resolutions: the AutoEnable premise is now self-consistent (every fault sentence keys on an explicit signal, acceptance criteria cover the absent-config case), the verdict representation is correct against =schema.step= (no =code= key; the distinction rides =evidence=/=next_action=, rendered by =cli.py:98-102= with no formatter change), the INI-setter is concretely named, and Phase 0 is genuinely independently landable. Folded two non-blocking corrections (the human summary is the doctor default not JSON; tlp.conf is a flat list-edit not an INI section) and decoupled Phase 1's Guide-only firmware verdict from the blocked privilege scaffolding. Flipped DRAFT → READY. +- Why: the loop terminates at the rubric. Two skeptical passes plus code re-verification found no remaining blocker. The caveat — Phase 2's persistent fix needs the shared privilege model — is recorded in Risks, Decision 2/the privilege design, and a hard ordering gate on Phase 2; Phases 0 and 1 build today. +- Artifacts: findings =[8/8]=, decisions =[3/3]=. Non-blocking notes folded into wording; no open blocker. diff --git a/docs/specs/2026-07-11-net-doctor-expansion-spec.org b/docs/specs/2026-07-11-net-doctor-expansion-spec.org new file mode 100644 index 0000000..0666dfe --- /dev/null +++ b/docs/specs/2026-07-11-net-doctor-expansion-spec.org @@ -0,0 +1,266 @@ +#+TITLE: Net Doctor Expansion — the clusters the ladder doesn't yet name +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-11 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* DOING Net Doctor Expansion +:PROPERTIES: +:ID: ce29b103-ed9d-4f56-bf8c-9ed8fe680ff3 +:END: +- [2026-07-11 Sat @ 02:30 -0500] DOING — decomposed into build tasks (spec-response Phase 6); parent task in =todo.org= bound by =:SPEC_ID:=. Phase 0 (read-only control-plane probe) is buildable now and =:solo:=; Phases 1-2 are gated on the shared cross-panel privilege model. The systemic connection-name redaction gap is a separate filed task. +- [2026-07-11 Sat @ 02:00 -0500] READY — third skeptical re-review returned Ready with caveats, no blocking findings; all round-2 resolutions verified against the engine. Caveat accepted: Phases 1-2 depend on the shared cross-panel run-time privilege model, which doesn't exist yet, so only Phase 0 (read-only detection) is buildable today. The redaction de-scope (parity + separate systemic task) is the one product call for Craig's eye. +- [2026-07-11 Sat @ 01:45 -0500] DRAFT — round-2 review + response. A skeptical re-review caught two blockers the first round missed (one I introduced): the redaction "copy/--json surface" does not exist, and =rival-manager= as =needs-user-action= could never run its own fix. Both corrected — parity redaction + systemic gap filed separately; all three control-plane verdicts are =fixable=. Auth signal repointed at profile key-mgmt + scan security; masked-NM early-return path named. Findings =[9/9]=, decisions =[3/3]=. Awaiting a third re-review. +- [2026-07-11 Sat @ 01:20 -0500] DRAFT — review incorporated (spec-response). All five findings dispositioned (=[5/5]=), all three decisions accepted and closed (=[3/3]=). Both cookies complete; awaiting a re-review to flip DRAFT → READY. Redaction blocker resolved (later found wrong in round 2). +- [2026-07-11 Sat @ 00:59:30 -0500] DRAFT — reviewed (spec-review). Stays DRAFT: three decisions open plus one =:blocking:= finding (connection names are not redacted today, contra the spec's Security dimension). Design confirmed against the live engine — the two target gaps (=is-enabled= reads, =system-connections= reads) genuinely do not exist yet, so the expansion targets real ground. Findings in =* Review findings=. +- [2026-07-11 Sat @ 00:08:41 -0500] DRAFT — drafted. Extends the existing net doctor (=~/.dotfiles/net/=, shipped) using the network half of the failure taxonomy ([[file:../design/2026-07-10-net-bt-failure-taxonomy.org][2026-07-10-net-bt-failure-taxonomy.org]]). Grounded in a read of the live engine, not memory. + +* Metadata + +| Field | Value | +|----------+-----------------------------------------------------------------------------------| +| Status | doing | +|----------+-----------------------------------------------------------------------------------| +| Owner | Craig Jennings | +|----------+-----------------------------------------------------------------------------------| +| Reviewer | Craig Jennings | +|----------+-----------------------------------------------------------------------------------| +| Related | [[file:../design/2026-07-10-net-bt-failure-taxonomy.org][net/bt failure taxonomy]] ; the cross-panel run-time-privilege and copy+close tasks | +|----------+-----------------------------------------------------------------------------------| + +* Summary + +The net doctor is the most mature of the three panel doctors: its probe ladder already walks link → IP → gateway → route → DNS → egress, and its classifier already names most of the taxonomy's failure clusters with the lightest fix. This spec closes the two clusters it does *not* reach — control-plane conflicts (two network managers fighting over one interface, a masked NetworkManager, a keyfile the daemon silently refuses) and the sharper naming of the terminal auth cluster — and adopts the cross-panel run-time privilege model. It is an expansion of a working doctor, not a rewrite. + +* Problem / Context + +The failure taxonomy sorted ~74 real network failure modes into eight symptom clusters. Read against the live classifier (=~/.dotfiles/net/src/net/classify.py=), the net doctor already reaches six of them: cluster 1 (=rfkill=, =manage-device=), cluster 2 (the DHCP-failed path), cluster 3 (=tunnel-down=, =vpn-policy=), cluster 5 (=resolved-restart=, =dns-test=), and cluster 6 (=portal=, =clock-sync=, =proxy=, and the =upstream-not-local= terminal STOP). Those are =classify='s real action identifiers; a few taxonomy labels (no-hardware, DHCP-failed, DNS-not-resolving) are message text the actions carry, not separate verdicts, and =dns-override= is a doctor =FIX_CHAIN=, not a classifier action. The classifier's terminal-first ordering already refuses to loop repairs against a wrong password, a held portal, or a VPN-owned route. That is a lot of the taxonomy, already built. + +Two clusters fall through. + +** The control plane can be broken while the radio looks fine (cluster 7) + +The taxonomy's largest untouched cluster is NetworkManager itself. When =dhcpcd.service= runs beside NM's internal DHCP client, or =systemd-networkd= and NM both claim one link, or =iwd= and =wpa_supplicant= are both active, the interface flaps or never leases — and every existing probe reads a plausible-looking radio with no verdict that names the fight. NM masked (=systemctl start= returns "Unit is masked") reads as "NetworkManager isn't running" today, which points =nm-restart= at a service that cannot start. A =.nmconnection= keyfile that isn't =600= root-owned is silently skipped by the daemon, so a saved network "just won't connect" with no signal the doctor surfaces. These are distinct root causes with distinct fixes, and no current probe detects any of them. The classifier does carry granular control-plane actions (=reset=, =bounce=, =rfkill=, =nm-restart=), but =nm-restart= is narrowly the dead-service restart, and nothing reads =systemctl is-enabled= or the =system-connections= keyfiles — the reads that would surface a masked NM, a rival manager, or a bad keyfile. The gap is missing detection, not one verdict overloaded across three faults. + +** The auth cluster is named too coarsely (cluster 4) + +=classify= detects an auth failure from NM state 120 / GENERAL.REASON and returns =needs-user-action= — correct, and correctly terminal. But the taxonomy shows the auth cluster is not one failure: a pure-WPA3/SAE association failure, a hidden SSID never probed, an enterprise cert mismatch, and a wrong regulatory domain are different faults with different next actions, and some (SAE key-mgmt, =wifi.hidden yes=) are one-line profile fixes rather than "re-enter the password." The doctor collapses them all to one message. + +** The flaky/drops cluster needs signal the one-shot doctor doesn't collect (cluster 8) + +Powersave disconnects, roaming stalls, USB autosuspend, no-reconnect-after-resume, firmware crashloops — these are intermittent, and a single-shot "why am I offline right now" probe cannot see them. Naming them needs event-log correlation the doctor doesn't do. This cluster is real but out of v1; it is named here so the boundary is explicit. + +* Goals and Non-Goals + +** Goals + +- Add a control-plane cluster: a probe that detects a second network/DHCP manager active alongside NetworkManager, a masked/failed NM distinct from a merely-stopped one, and a keyfile-permission fault, each with its own verdict and the lightest fix. +- Sharpen the auth verdict: extract the specific auth cluster cause (SAE, hidden SSID, enterprise cert, regdom) so =needs-user-action= names the real next step, and apply the one-line profile fix where one exists. +- Adopt the cross-panel run-time privilege model (Auto / Privileged / Reboot-tail / Guide, resolved from =sudo -n= + tty + GUI), so control-plane repairs that need root (=systemctl disable dhcpcd=, =chmod= a keyfile, =systemctl unmask=) run under the same Confirm/Arm-default floor the audio doctor defined. + +** Non-Goals + +- Rebuilding the existing ladder. Clusters 1/2/3/5/6 stay exactly as they classify today; this spec only adds where the taxonomy shows a gap. +- The flaky/drops cluster (cluster 8). Naming intermittent faults needs event-log correlation that a one-shot doctor doesn't do. Logged to =todo.org=, not built here. +- Turning the doctor into a NetworkManager profile editor. The auth-cluster fixes are limited to the one-line profile settings that get a stuck association online (SAE key-mgmt, hidden flag); it will not manage certificates, enterprise identities, or credential entry — those stay Guide. +- Silent privileged action. Every Privileged/Reboot-tail control-plane repair defaults to Confirm/Arm, never silent Auto — same stance as the audio spec. + +** Scope tiers + +- *v1:* the control-plane conflict probe and its verdicts (rival-manager, NM-masked, keyfile-perms); sharper auth-cluster reason extraction plus the SAE/hidden one-line fixes; adoption of the run-time privilege model for the new root-needing repairs. +- *Out of scope:* the flaky/drops cluster; certificate/enterprise credential management; any change to the six clusters already classified. +- *vNext:* event-log correlation for cluster 8 (powersave/roam/suspend/autosuspend drop signatures); a DoT/DNSSEC-specific verdict distinguishing "the venue resolver mangles DNSSEC" from the generic DNS-not-resolving; per-profile autoconnect/duplicate-profile hygiene. All logged to =todo.org=. + +* Design + +** For the user + +Nothing changes for the six clusters that already work. What changes is that two failure shapes that today produce a wrong or vague verdict start naming themselves. + +When a second manager is fighting NetworkManager for the link, the wall says so by name — "dhcpcd is running alongside NetworkManager and they're fighting over the interface" — and offers the fix (stop the rival) rather than bouncing a connection that will flap again the moment the other daemon re-grabs it. When NM is masked, the verdict distinguishes "masked — it can't start until unmasked" from "stopped," so FIX unmasks rather than uselessly restarting. When a saved network silently won't load because its keyfile is world-readable, the doctor names the permission fault instead of leaving the user to wonder why a known-good network never activates. + +When association fails on auth, the wall stops saying only "authentication failed" and names which auth: a WPA3-only network the profile isn't set for, a hidden SSID that needs the hidden flag, an enterprise network missing its CA cert. The first two carry a one-press fix; the rest tell the user the specific thing to supply. + +** For the implementer + +*** The control-plane probe + +A new read-only probe tier, beside the existing ones, answering three questions the current ladder never asks: + +1. *Is a rival manager active?* Check whether =dhcpcd.service=, =systemd-networkd.service=, or a standalone =iwd.service= is active while NetworkManager is also active and owns (or wants to own) the link. This is a =systemctl is-active= read plus NM's backend setting — bounded, no hang. A rival that is active is the verdict; the fix is =systemctl disable --now <rival>= (Privileged), never a connection bounce. +2. *Is NM masked or failed, as distinct from stopped?* =systemctl is-enabled NetworkManager= returns =masked=; the unit's =ActiveState=/=Result= distinguishes a crash-loop from a clean stop. A masked NM gets an =unmask= verdict (Privileged), not the existing =nm-restart=. +3. *Does the active profile's keyfile have the wrong permissions?* For the selected connection, stat its =/etc/NetworkManager/system-connections/*.nmconnection=; a non-=600= or non-root-owned file is the silent-skip fault. Fix: =chmod 600= + =chown root= (Privileged). + +The probe runs before the existing "NetworkManager isn't running" rule, because a masked NM and a rival-manager fight are both more specific than "not running" and would otherwise be mis-verdicted by it. One ordering subtlety verified against the engine: a fully-down NM makes =nmcli= raise and =diagnose= early-returns with only the service and link steps (=diag.py:503-511=), so the masked/failed check has to run inside that early-return path — otherwise a masked NM short-circuits before the new probe and never reaches its verdict. + +*** The classifier gains control-plane verdicts + +=classify= adds, in specificity order ahead of the generic =nm-restart= rule: =rival-manager= (fixable → Privileged disable-rival), =nm-masked= (fixable → unmask), =keyfile-perms= (fixable → chmod/chown). All three are =fixable= — the doctor only applies a repair when the outcome is =fixable= (=doctor.py:181=), so a terminal =needs-user-action= would never run its fix — and each is Privileged, so =net doctor --fix= still gates it on the Confirm floor rather than acting silently. Each carries evidence naming the specific rival/unit/file. These are additive; the existing rules below them are untouched. + +*** The auth verdict takes a reason + +The specific auth cause is not in =auth_failed_reason= — that REASON/journal string (=doctor.py:32-56=) only marks *that* auth failed, not whether it was SAE, a hidden SSID, or an enterprise cert. The distinction is read from signals the engine already has: the active profile's key-mgmt (=manage.py:36-47=, which already detects WPA3/SAE incompatibility) and the scanned network's SECURITY flags (=nmcli.py:164=). From those, =gather_context= derives a small classifier: SAE/PMF, hidden-SSID, enterprise-cert, or generic-PSK. (regdom has no engine signal — grep-clean — so it stays Guide.) The =needs-user-action= message is keyed off it. For SAE and hidden-SSID — the two with a deterministic one-line profile fix — the verdict becomes =fixable= with a Privileged/Auto profile-modify action (=key-mgmt sae= + PMF, or =wifi.hidden yes=) rather than terminal. The rest stay =needs-user-action= with a sharpened message. + +*** The privilege model + +The new repairs (=systemctl disable <rival>=, =unmask=, =chmod=/=chown= a keyfile, profile-modify) are the net doctor's first root-needing doctor repairs beyond the ones already in =priv.py=. They adopt the cross-panel run-time resolution the audio spec defined: Privileged remedies run silently where passwordless sudo exists (every archsetup install), prompt on a CLI with a tty, and default to Confirm/Arm — never silent Auto. This is the same standard, not a net-specific one; the shared implementation is the tracked cross-panel task. Concretely, the three fixes register through =priv.py='s =VERBS= table and =repair.py='s =ACTIONS= registry (=disable-rival=, =unmask-nm=, =chmod-keyfile=) — the existing dispatch path, not a new one — and each verb is a narrowly-scoped command (disable one named unit, unmask NetworkManager, chmod/chown one keyfile), never a general run-as-root, so the passwordless-sudo grant stays tight. + +*** Redaction of the new evidence + +The new verdicts surface a connection name (=rival-manager=, =keyfile-perms=) and a =/etc/NetworkManager/system-connections/*.nmconnection= basename. The redaction reality, verified against the engine: SSID redaction exists only in the event log (=redact_event=, gated on =redact_ssid=, default off); the copyable report scrubs MAC/IP only (=scrub_text=, =redact.py=), and =--json= is a raw =json.dumps(out)= with no redaction at all (=cli.py=). Connection names already appear in the clear in the link-step evidence (=diag.py:103=, e.g. "wlan0 connected (HomeNetwork)") and in =--json= today. So there is no copy-vs-wall redaction surface to piggyback on, and the new verdicts add no new leak class — a connection name already shows for the link step. v1 keeps parity: the new evidence is redacted exactly as the existing link-step evidence is (MAC/IP via =scrub_text=; connection name in the clear). The systemic gap — connection names and SSIDs leaking into the copyable report and =--json= across every step, gated behind a default-off toggle — predates this spec and spans the whole engine, so it is filed as its own task rather than half-solved for two new verdicts. The keyfile probe reads permissions, not secrets; the auth-reason extraction reads NM's reason string, not the PSK. + +* Alternatives Considered + +** Fold the control-plane faults into the existing =nm-restart= verdict + +- Good, because it is zero new classifier surface. +- Bad, because =nm-restart= is the wrong fix for all three: restarting a masked NM fails, restarting NM does not stop a rival =dhcpcd=, and it does nothing for a bad keyfile. A shared verdict would send the doctor's one fix at three faults it can't fix. +- Rejected. Different root causes with different fixes are different verdicts — the same principle the audio spec used for =pulse-hung= vs =pulse-down=. + +** Make the doctor a full profile editor for the auth cluster + +- Good, because it could fix more auth failures automatically. +- Bad, because credential and certificate entry is a genuine user decision, not a repair — the doctor cannot invent an enterprise CA or a password. Auto-editing profiles beyond the two deterministic one-liners risks writing a wrong setting the user then has to unwind. +- Rejected in favor of fixing only SAE key-mgmt and the hidden flag, and guiding the rest. + +** Build the flaky/drops cluster now with a synthetic re-probe + +- Good, because it would catch powersave/autosuspend "works then dies" cases. +- Bad, because a one-shot doctor invoked when the user is already offline has no drop history to read; catching intermittent faults needs the panel's event log correlated over time, which is a separate probe surface. +- Rejected for v1; logged as vNext. + +* Decisions [3/3] + +** DONE The control-plane probe and its three verdicts +Context: cluster 7 is the taxonomy's largest untouched cluster, and no current probe detects a masked NM, a rival manager, or a bad keyfile. +Decision: we will add a read-only control-plane probe (rival-manager active-check, NM masked-vs-failed-vs-stopped, active-profile keyfile permissions) and three verdicts ahead of the generic =nm-restart= rule, each with the lightest specific fix. +Consequences: the doctor names the fight instead of bouncing a link that will re-flap; harder — three new verdicts and a probe that reads =systemctl= state plus a stat, and the ordering has to sit ahead of the existing not-running rule without disturbing it. +Resolution: accepted as proposed (the fold-into-nm-restart alternative was rejected — that fix cannot address any of the three faults). Owner: Craig. + +** DONE How far the auth-cluster fix goes +Context: the auth cluster is terminal today; some members (SAE, hidden SSID) have deterministic one-line profile fixes, others (enterprise cert, credential) do not. +Decision: we will extract the specific auth reason and make only SAE-key-mgmt and hidden-flag =fixable=; everything else stays =needs-user-action= with a sharpened, cause-named message. Both profile-modify fixes are Confirm-tier (persisted state). +Consequences: two more auth failures self-heal; harder — the doctor now writes to a connection profile, which is a heavier action than a bounce, and the boundary between "fix" and "guide" inside one cluster has to be defended so it doesn't creep into credential management (the Non-Goal is the guardrail). +Resolution: accepted as proposed. Owner: Craig. + +** DONE Adopt the run-time privilege model as the cross-panel standard +Context: the new control-plane repairs need root; the audio spec already defined the four-class run-time model and made it a cross-panel standard. +Decision: we will adopt it verbatim — Privileged repairs run where passwordless sudo exists, prompt on a tty, default to Confirm/Arm, never silent Auto — sharing the implementation with the other panels rather than reimplementing it. +Consequences: the net doctor's root repairs are consistent with audio/bt/maint; harder — it couples this spec to the shared privilege-model task, so the ordering across panels has to be settled (which panel lands the shared code). That sequencing caveat is recorded in Risks. +Resolution: accepted — this adopts an already-decided cross-panel standard, not a new choice. Owner: Craig. + +* Review findings [9/9] + +** DONE Connection names are not redacted today, but the spec assumes they are :blocking: +The Security & privacy dimension stated "SSIDs and connection names are already redacted by =redact.py=." Verified against the live engine: =redact.py= redacts SSID, MAC, IP, secret-keys, and =portal_url= only (=redact.py:9-53=) — there is no connection-name redaction. The new =rival-manager= and =keyfile-perms= verdicts surface a connection name and a =/etc/NetworkManager/system-connections/*.nmconnection= path (the file basename is the connection name) into the wall and the =--json= output. +Disposition: accepted, modified in one detail. Rather than redact everywhere (which would blank the network name on the user's own screen), v1 redacts the connection name and keyfile basename at the same copy/=--json= surface the existing SSID redaction covers — the shareable text is scrubbed, the on-screen wall still names the network. Folded into Design "Redaction of the new evidence," the Security & privacy dimension, Phase 1, and a new acceptance criterion. + +** DONE "One =nm-restart= verdict covers the whole control plane" overstates current scope +Problem/Context and the first Alternative rested the motivation on the claim that the doctor "currently has one verdict (=nm-restart=) covering the whole control plane." Verified: =nm-restart= is one narrow action for a dead NetworkManager *service* only (=classify.py:96-97=, =repair.py:705=); the classifier already carries granular actions (=reset=, =bounce=, =rfkill=, =manage-device=, =resolved-restart=…). The real, verified gap is that *no* probe detects a rival manager, a masked NM, or a bad keyfile (=is-enabled= and =system-connections= reads are grep-clean). +Disposition: accepted. Reworded the cluster-7 paragraph to rest on the missing detections; the first Alternative already argues against reusing =nm-restart= as a fix, which stays valid. + +** DONE "=systemctl is-enabled= already used by the engine" is inaccurate +External APIs & deps said =systemctl is-active/is-enabled= are "already used by the engine." Verified: only =is-active= is used (=cmd.py:27=); =is-enabled= has zero hits, and =system-connections= is not read today either. +Disposition: accepted. Corrected the dimension to say =is-active= and =nmcli= are used today, =is-enabled= and the keyfile reads/=stat= are new bounded calls. + +** DONE Problem/Context lists message text as if it were classifier verdicts +The six-cluster evidence listed =no-hardware=, =DHCP-failed=, =DNS-not-resolving=, =dns-override= as classifier verdicts. Verified: the first three are message text, and =dns-override= is a doctor =FIX_CHAIN= (=doctor.py:24=), not a =classify= action. +Disposition: accepted. Rewrote the listing to cite =classify='s real action identifiers and note which taxonomy labels are message text. + +** DONE Name the =priv.py= / =repair.py= integration point for the new privileged repairs +The spec said it reuses =priv.py= but the phase plan did not name the concrete integration: privileged repairs dispatch through =priv.py='s =VERBS= table (=priv.py:150=) and =repair.py='s =ACTIONS= registry (=repair.py:695=), not a new path. +Disposition: accepted, extended. Phase 1 and the privilege-model design now name =disable-rival=/=unmask-nm=/=chmod-keyfile= as =VERBS= + =ACTIONS= entries, and add that each verb is a narrowly-scoped command (not a general run-as-root) so the passwordless-sudo grant stays tight. + +** DONE Round 2 (skeptical review): the redaction "copy/--json surface" does not exist :blocking: +The round-1 disposition claimed v1 would redact at "the same copy/=--json= surface the existing SSID redaction covers." A second skeptical review traced every redaction call site and found no such surface: SSID redaction lives only in =redact_event= (event log, gated on =redact_ssid=, default off), =--json= is a raw =json.dumps(out)= with no redaction, and the copyable report scrubs MAC/IP only (=scrub_text=). Connection names already appear in the clear in the link-step evidence (=diag.py:103=) today. So the round-1 resolution invented a surface that isn't there. +Disposition: accepted — the round-1 fix was wrong and is corrected. v1 keeps parity with existing link-step behavior (the new verdicts leak no more than the link step already does); the systemic connection-name/SSID redaction gap across the report and =--json= is filed as a separate task. Rewrote Design "Redaction of the new evidence," the Security dimension, Phase 1, and the acceptance criterion. + +** DONE Round 2 (skeptical review): rival-manager can't satisfy its own FIX criterion :blocking: +Round 1 marked =rival-manager= =needs-user-action= (terminal) while the acceptance criterion required FIX to stop the rival. But the doctor only applies a repair when the outcome is =fixable= (=doctor.py:181=), so a terminal =rival-manager= would never run =disable-rival=. +Disposition: accepted. =rival-manager= is now =fixable= (Privileged/Confirm), consistent with =nm-masked= and =keyfile-perms=; the Confirm floor still gates it. Corrected the classifier design and added an acceptance criterion that all three control-plane verdicts are =fixable=. + +** DONE Round 2 (skeptical review): auth-cluster classifier pointed at the wrong signal +Round 1 said =auth_failed_reason= would carry the SAE/hidden/enterprise distinction. The skeptical review confirmed that REASON/journal string only marks *that* auth failed; the actual discriminating data lives in the profile key-mgmt (=manage.py:36-47=, already detects SAE incompatibility) and the scanned SECURITY flags (=nmcli.py:164=). +Disposition: accepted. Phase 2 / the auth-verdict design now reads those signals instead of =auth_failed_reason=; regdom stays Guide (no engine signal). No new data collection is forced. + +** DONE Round 2 (skeptical review): masked-NM must be reached in the NM-down early-return +When NM is fully down, =nmcli= raises and =diagnose= early-returns with only the service and link steps (=diag.py:503-511=), so a masked NM would short-circuit before the control-plane probe and never reach the new verdict. +Disposition: accepted. The control-plane probe design now states the masked/failed check must run inside that early-return path. + +* Implementation phases + +Each phase leaves the tree green and independently useful, as the existing net phases did. + +** TODO Phase 0 — the control-plane probe (read-only) +Pure engine, no classifier changes. A probe module that reports rival-manager state, NM masked/failed/stopped, and active-profile keyfile permissions into the diag context. =net diag --json= shows the new signals. Fakes: injected =systemctl is-active/is-enabled= results and a temp system-connections tree. + +** TODO Phase 1 — control-plane verdicts + the privilege model +=classify= gains =rival-manager=, =nm-masked=, =keyfile-perms= (all =fixable=), ordered ahead of the generic not-running rule, with the masked/failed check reachable in the NM-down early-return path. The run-time privilege resolution lands (shared with the cross-panel task) and the three new fixes register as Privileged/Confirm through =priv.py='s =VERBS= table and =repair.py='s =ACTIONS= registry (=disable-rival=, =unmask-nm=, =chmod-keyfile=), each a narrowly-scoped verb. The new evidence keeps parity with existing redaction (MAC/IP via =scrub_text=); the systemic connection-name gap is a separate task, not this phase. =net doctor= names them; =net doctor --fix= applies them under the Confirm floor. Hard ordering gate: this phase must not land before the shared run-time privilege model exists — =priv.py= today is a bare =VERBS=+sudo dispatcher with no Confirm/Arm resolution (=priv.py:150=) and =_attempt= runs repairs ungated, so shipping the Privileged =fixable= verdicts first would let =net doctor --fix= silently disable =dhcpcd= via passwordless sudo, the exact outcome the model forbids. Phase 0 (read-only detection) has no such dependency and lands first; if the fault-naming is wanted before the privilege model, the verdicts can ship detection-only (no =--fix= action) as an interim slice. + +** TODO Phase 2 — the sharpened auth verdict +The auth-reason classifier; SAE and hidden-SSID become =fixable= profile-modify repairs; the rest get cause-named =needs-user-action= messages. Pairwise over (reason × profile-state). + +** TODO Phase 3 — flip this spec to IMPLEMENTED +And log the vNext items (flaky/drops cluster, DoT/DNSSEC verdict, profile hygiene) to =todo.org=. + +* Acceptance criteria + +- [ ] With =dhcpcd.service= active alongside NetworkManager, the doctor reports =rival-manager= naming dhcpcd, and FIX stops it rather than bouncing the connection. +- [ ] A masked NetworkManager reports =nm-masked= (distinct from stopped), and FIX unmasks it. +- [ ] A non-600 keyfile for the active profile reports =keyfile-perms=, and FIX corrects the permissions. +- [ ] A WPA3-only association failure reports the SAE cause and (with --fix) sets the profile's key-mgmt, rather than saying only "authentication failed." +- [ ] An enterprise-cert auth failure stays =needs-user-action= but names the missing CA cert. +- [ ] Every new root-needing repair defaults to Confirm/Arm and never runs silently as Auto. +- [ ] =rival-manager=, =nm-masked=, and =keyfile-perms= are all =fixable= outcomes, so =net doctor --fix= actually runs their repairs (a terminal outcome would be skipped by =doctor.py:181=). +- [ ] The =rival-manager= and =keyfile-perms= verdicts expose the connection name no more than the existing link-step evidence does (MAC/IP scrubbed, connection name in the clear — parity, not a new leak). The systemic connection-name/=--json= redaction gap is tracked as a separate task. +- [ ] The six existing clusters classify exactly as they do today (regression). + +* Readiness dimensions + +- *Data model & ownership* — the diag context gains control-plane signals (generated per-probe) and a richer =auth_failed_reason= (generated from GENERAL.REASON + journal, as today). The doctor never writes NM config except the two auth profile-modifies and the keyfile-perms fix, all under the Confirm floor. +- *Errors, empty states & failure* — an unreadable =systemctl=/=stat= yields "unknown," never a false rival/masked/perms verdict (the safe direction). Partial reads degrade to the existing behavior. +- *Security & privacy* — verified: SSID redaction exists only in the event log (=redact_event=, gated on =redact_ssid=, default off); the copyable report scrubs MAC/IP only (=scrub_text=) and =--json= is raw. Connection names already appear in the clear in the link-step evidence and =--json= today, so the new =rival-manager=/=keyfile-perms= verdicts add no new leak class — they reach parity with existing behavior. The systemic connection-name/SSID redaction gap across the report and =--json= is pre-existing and filed as a separate task. The keyfile probe reads permissions, not secrets; the auth-reason extraction reads NM's reason string, not the PSK. +- *Observability* — the wall names the specific rival/unit/file. =--json= carries the new context. +- *Performance & scale* — three =systemctl= reads and a stat; negligible beside the existing probe cost. +- *Reuse & lost opportunities* — reuses =gather_context='s existing auth-reason extraction, =priv.py=, and the shared cross-panel privilege model rather than a net-local one. =classify= stays the single verdict authority. +- *Architecture fit* — the new probe is additive beside the existing tiers; the classifier additions sit ahead of the generic not-running rule and leave the rest untouched. Weak point: ordering — the new verdicts must precede =nm-restart= or a masked/rival case mis-classifies. +- *Config surface* — none new. N/A. +- *Documentation plan* — module docstrings, as the package does today. The wall is the user documentation. +- *Dev tooling* — =make test= and the net panel smoke cover it; the control-plane probe needs injected =systemctl= state, a fixture shape the package already uses elsewhere. +- *Rollout, compatibility & rollback* — additive; =net doctor= with no new fault behaves as today. The auth profile-modify and keyfile-perms fixes change persisted NM state, so both are Confirm-tier and reversible by the user. +- *External APIs & deps* — =systemctl is-active= and =nmcli= are already used by the engine; =systemctl is-enabled= and reads/=stat= under =/etc/NetworkManager/system-connections= are new calls (bounded with an explicit timeout, like every existing probe). No new packages. The exact rival-manager set (dhcpcd/networkd/iwd) is verified against the live system before Phase 0. + +* Risks, rabbit holes, and drawbacks + +The rival-manager check can false-positive if a rival service is active but not actually contending for the same interface (e.g. =systemd-networkd= managing a container bridge while NM owns wifi). The probe must scope the conflict to the link the doctor is diagnosing, not merely "is networkd running" — otherwise it cries wolf on a legitimate split. This is the main correctness rabbit hole and wants a test with a bridge-only networkd. + +The auth-cluster boundary between "fix" and "guide" is a slope. SAE and the hidden flag are safe because they are deterministic and reversible; the temptation is to add "just one more" auto-fix until the doctor is editing enterprise profiles it shouldn't. The Non-Goal is the guardrail; hold it. + +Coupling to the shared privilege-model task means this spec can't fully land until that model exists somewhere. Sequence it: whichever panel lands the shared code first, the others depend on it. + +* Review and iteration history + +** 2026-07-11 Sat @ 00:08:41 -0500 — Craig Jennings — Author +- What: drafted the net doctor expansion from the network half of the failure taxonomy. v1 adds the control-plane cluster (rival-manager, NM-masked, keyfile-perms) and sharpens the auth verdict; the flaky/drops cluster is staged to vNext. +- Why: the taxonomy showed the net doctor already reaches six of eight clusters, and the two it misses (control plane, auth naming) are where its verdicts are wrong or vague today. +- Artifacts: [[file:../design/2026-07-10-net-bt-failure-taxonomy.org][the net/bt failure taxonomy]]; code read across =~/.dotfiles/net/src/net/= (=classify.py=, =diag.py=, =doctor.py=, =repair.py=, =priv.py=). + +** 2026-07-11 Sat @ 00:59:30 -0500 — Claude Code (archsetup) — Reviewer +- What: ran spec-review. Rubric =Not ready=. Recorded five findings, one =:blocking:= (connection-name redaction assumed but absent) and four non-blocking accuracy/integration corrections. The three proposed decisions remain open. +- Why: the design is sound and the code read confirmed both target gaps are real (=is-enabled= and =system-connections= reads do not exist in the engine today), so the expansion is well-grounded. What holds the rubric is the privacy claim the spec states as already-true (=redact.py= redacts SSID/MAC/IP/secret-keys/portal_url only — not connection names, =redact.py:9-53=) and the still-open decisions. Once the redaction finding is dispositioned and Craig accepts the three decisions, this reaches =Ready with caveats= (the cross-panel shared privilege-model code is the remaining sequencing caveat, already recorded in Risks). +- Artifacts: engine facts verified across =classify.py= (:96-97, :55-62), =diag.py= (:518-537, bounded probes), =doctor.py= (:112-116 auth-reason), =cmd.py:27= (=is-active= only), =priv.py:150=/=repair.py:695= (dispatch registry), =redact.py:9-53=. Findings in =* Review findings=. + +** 2026-07-11 Sat @ 01:20:00 -0500 — Claude Code (archsetup) — Responder +- What: ran spec-response. Dispositioned all five findings (four accepts, one accept-with-modify: the redaction scrubs at the copy/=--json= surface, not the on-screen wall) and closed all three decisions as accepted. Folded the redaction requirement into Design, the Security dimension, Phase 1, and a new acceptance criterion; reworded the =nm-restart=/control-plane motivation and the six-cluster evidence onto the verified facts; named the =VERBS=/=ACTIONS= dispatch integration and tightened the new verbs to narrowly-scoped commands. Both =[/]= cookies now read complete. +- Why: convergence toward implementation-ready. The redaction modify avoids blanking the network name on the user's own screen while still scrubbing the shareable text — a connection is usually named after its SSID, so leaving it unredacted leaks the already-redacted SSID by a side channel. +- Artifacts: findings =[5/5]=, decisions =[3/3]=. Scope expanded only by the redaction obligation, which is filed as an acceptance criterion and a Phase 1 deliverable, so the readiness rubric holds. Awaiting re-review to flip DRAFT → READY. + +** 2026-07-11 Sat @ 01:45:00 -0500 — Claude Code (archsetup) — Reviewer + Responder (round 2) +- What: a skeptical adversarial re-review returned Not ready with two blockers, both in the parts round 1 claimed to resolve. (1) The redaction "copy/--json surface" I asserted in round 1 does not exist — SSID redaction is event-log-only, =--json= is raw, the copyable report scrubs MAC/IP only. (2) =rival-manager= marked =needs-user-action= could never run its fix, since the doctor only repairs =fixable= outcomes (=doctor.py:181=). Fixed both, plus two non-blocking corrections (auth signal repointed at =manage.py:36-47= + =nmcli.py:164=; masked-NM early-return path). Findings now =[9/9]=. +- Why: the first round introduced a factual error by asserting a redaction surface without verifying =redact.py='s structure. Round 2 verified every claim against the engine before writing. The redaction resolution is now a de-scope: v1 keeps parity with the pre-existing link-step behavior and the systemic redaction gap is a separate task — a scope call worth Craig's eye. +- Artifacts: engine re-verified at =doctor.py:181= (fix gate), =cli.py= (raw =--json=), =report.py= / =redact.py= (MAC-IP-only scrub), =diag.py:103= (link evidence), =diag.py:503-511= (NM-down early return), =manage.py:36-47= / =nmcli.py:164= (auth signals). Awaiting a third re-review. + +** 2026-07-11 Sat @ 02:00:00 -0500 — Claude Code (archsetup) — Reviewer (round 3) +- What: third skeptical adversarial re-review. Verdict =Ready with caveats=, no blocking findings. Verified all five round-2 resolutions against the engine: redaction parity is coherent and buildable (no criterion assumes unbuilt redaction), rival-manager as =fixable= preserves terminal-first ordering with no loop (=doctor.py:184-195=), the auth signals (=manage.py:46= SAE, =nmcli.py:159-176= hidden/SECURITY) distinguish all three cases, and the masked-NM check is addable in the early-return path (=diag.py:503-511=). Flipped DRAFT → READY. +- Why: the loop terminates at the rubric, not at exhaustion. Two independent skeptical passes plus a code re-verification found no remaining blocker. The one named caveat — Phase 1 needs the shared privilege model or =--fix= runs ungated (=priv.py:150=, =_attempt= ungated) — is honestly recorded in Risks, Decision 3, and now a hard ordering gate on Phase 1; Phase 0 is fully buildable now. +- Artifacts: findings =[9/9]=, decisions =[3/3]=. Non-blocking: keyfile-perms on an inactive profile names a connection the disconnected link step wouldn't — same data class, covered by the separate redaction task. |
