Add the menubar-pet design !24
1 file changed, +213 −0
docs/superpowers/specs/2026-09-04-menubar-pet-design.md added +213
| @@ -0,0 +1,213 @@ | ||
| 1 | # menubar-pet design | |
| 2 | ||
| 3 | Date: 2026-09-04 | |
| 4 | Status: approved, not yet implemented | |
| 5 | Baseline: v1.1.0 + `888f0e5` | |
| 6 | ||
| 7 | The third face, and the first macOS-native one. A menu-bar pet with one life: | |
| 8 | it ages, neglect kills it, and death is permanent for that pet. | |
| 9 | ||
| 10 | ## Why this shape | |
| 11 | ||
| 12 | The two existing faces are stateless functions of the current snapshot. This | |
| 13 | one is not: permadeath needs durable state that outlives the 7-day history | |
| 14 | retention, a reinstall, and a reboot. That is the whole reason it warrants a | |
| 15 | design rather than another renderer in the existing shape. | |
| 16 | ||
| 17 | ## The mechanic | |
| 18 | ||
| 19 | Neglect kills it. "Showing up" is defined precisely, since the whole mechanic | |
| 20 | turns on it: a history row for `KeysPerMin` with a value above zero, or for | |
| 21 | `CommitsToday` with a value above zero, in any repository signald was watching | |
| 22 | at the time. Both are already persisted with timestamps. | |
| 23 | ||
| 24 | `SessionSeconds` deliberately does not count — a shell sitting open is not | |
| 25 | attention, and counting it would mean a forgotten terminal keeps the pet alive | |
| 26 | indefinitely. Hardware signals do not count either: a sleeping machine still | |
| 27 | reports a battery percentage. | |
| 28 | ||
| 29 | | stage | quiet for | | |
| 30 | |---|---| | |
| 31 | | content | < 1 day | | |
| 32 | | restless | 1-2 days | | |
| 33 | | hungry | 2-3.5 days | | |
| 34 | | sick | 3.5-5 days | | |
| 35 | | dying | 5-7 days | | |
| 36 | | dead | >= 7 days | | |
| 37 | ||
| 38 | Thresholds are constants, so the pace is one edit. | |
| 39 | ||
| 40 | **Everything derives from timestamps; nothing accumulates.** Death occurs at | |
| 41 | `last_activity + 7 days`, not when the app noticed. The answer is identical | |
| 42 | whether you look an hour or a month later, which makes sleep, reboots and | |
| 43 | daemon restarts irrelevant by construction. | |
| 44 | ||
| 45 | **A new pet is born on the next activity after a death, never automatically.** | |
| 46 | Automatic rebirth would turn a fortnight away into a chain of pets born, never | |
| 47 | fed, and dead — a cemetery full of lives that did not happen. Requiring | |
| 48 | activity means you return to one grave and a fresh pet. | |
| 49 | ||
| 50 | **Names** come from a small fixed word list, chosen deterministically from the | |
| 51 | birth timestamp. A cemetery of "Mochi, 9 days" means something; one of | |
| 52 | "generation 3" does not. | |
| 53 | ||
| 54 | **The cemetery is unbounded, deliberately.** A pet dies at most every ~8 days: | |
| 55 | about 45 a year, a few dozen bytes each, under 100 KB in twenty years. The | |
| 56 | spool was capped because nothing consumed it. Here the point is remembering, | |
| 57 | and bounded remembering is forgetting on a delay. | |
| 58 | ||
| 59 | ## Architecture | |
| 60 | ||
| 61 | The app polls a Rust one-shot. Three options were considered: | |
| 62 | ||
| 63 | - **Swift decodes the wire directly.** Rejected: a second implementation of the | |
| 64 | decode path, including v5 skip semantics. The wire is the privacy boundary, | |
| 65 | and the stated reason for one bus was one boundary to defend rather than | |
| 66 | five. | |
| 67 | - **A streaming Rust sidecar.** Rejected: long-lived child-process supervision | |
| 68 | inside a GUI app, which is the exact class of bug v1.0 was spent fixing in | |
| 69 | the daemon. | |
| 70 | - **Swift polls a Rust one-shot.** Chosen. | |
| 71 | ||
| 72 | The decline plays out over days, so a 5-second poll is already far faster than | |
| 73 | the mechanic needs. It keeps one wire decoder, in the language that has the | |
| 74 | test suite and the privacy gate, and puts the interesting logic — aging, death, | |
| 75 | the cemetery — where it can be unit-tested. | |
| 76 | ||
| 77 | ### Components | |
| 78 | ||
| 79 | **`crates/pet-life/`** — library plus a thin binary. The library owns aging, | |
| 80 | stages, death, the cemetery and persistence. The binary prints state as JSON | |
| 81 | and exits. | |
| 82 | ||
| 83 | **`menubar-pet`** — a Swift app: an `NSStatusItem`, a menu, and a timer. No | |
| 84 | wire decoding, no socket, no subprocess supervision. | |
| 85 | ||
| 86 | ### The JSON contract | |
| 87 | ||
| 88 | ```json | |
| 89 | { "generation": 3, "name": "Mochi", "stage": "hungry", | |
| 90 | "age_days": 4.2, "quiet_days": 2.1, "alive": true, | |
| 91 | "cemetery": [ { "name": "Bean", "generation": 2, "lived_days": 9.4 } ] } | |
| 92 | ``` | |
| 93 | ||
| 94 | Hand-rolled rather than `serde`. The shape is fixed and every value is a | |
| 95 | number, a boolean, a stage name or a name from a curated list — no user input, | |
| 96 | so no escaping hazard. The README makes a point of `rusqlite` being the single | |
| 97 | dependency. If hand-rolling turns awkward, `serde` is the fallback and the | |
| 98 | change should say so rather than contorting around it. | |
| 99 | ||
| 100 | Pinned by a test asserting a known state serialises to exact bytes, the same | |
| 101 | discipline as the canonical wire frame. That has already caught two | |
| 102 | cross-language mismatches in this project, and this is a second language | |
| 103 | boundary. | |
| 104 | ||
| 105 | ### Where activity comes from | |
| 106 | ||
| 107 | Not from what the app observes while running. If the app were closed for a | |
| 108 | week the pet would starve while you worked the whole time. | |
| 109 | ||
| 110 | `pet-life` reads signald's SQLite history for the most recent keystroke or | |
| 111 | commit activity, and separately persists the latest activity it has ever seen. | |
| 112 | The effective value is the later of the two. That survives the app being | |
| 113 | closed, the machine sleeping, and the daemon restarting — all normal, none of | |
| 114 | which should kill a pet. | |
| 115 | ||
| 116 | History retention defaults to 7 days and the neglect window is also 7, so | |
| 117 | beyond that the table holds no activity rows. By then the pet is dead | |
| 118 | regardless, so the ambiguity never affects a live pet, and the persisted value | |
| 119 | covers the rest. | |
| 120 | ||
| 121 | Access is via new `History::open_read_only` and `History::last_activity_ms` on | |
| 122 | signald rather than a second copy of the schema. Read-only matters: the normal | |
| 123 | `open` prunes and migrates, and a menu-bar app polling every five seconds must | |
| 124 | not mutate the daemon's store. | |
| 125 | ||
| 126 | ### Persistence | |
| 127 | ||
| 128 | `$XDG_DATA_HOME/ambient-companions/pet.json`, else | |
| 129 | `~/.local/share/ambient-companions/pet.json`. | |
| 130 | ||
| 131 | Deliberately **not** under the daemon's state directory. That path derives from | |
| 132 | the socket, which follows `$XDG_RUNTIME_DIR` when set — a tmpfs on systems that | |
| 133 | set it, wiped every reboot. It is unset on macOS today, so putting the cemetery | |
| 134 | there would work by luck. Data, not runtime. | |
| 135 | ||
| 136 | Writes are temp-file-plus-rename, so a crash mid-write cannot corrupt the | |
| 137 | graveyard. If two instances run, worst case is last-writer-wins losing one | |
| 138 | update, costing a few seconds of `last_seen`. | |
| 139 | ||
| 140 | ## The app | |
| 141 | ||
| 142 | **Build.** SwiftPM cannot emit a `.app`, so the executable is a normal SwiftPM | |
| 143 | target and a script assembles the bundle around it: `Contents/MacOS` and an | |
| 144 | `Info.plist` with `LSUIElement` set, so there is no Dock icon and no window. | |
| 145 | No `.xcodeproj` — `swift build` stays the primary path, which keeps it | |
| 146 | buildable from the formula and the pre-push hook. | |
| 147 | ||
| 148 | **Runtime.** An `NSStatusItem` showing the face, and a timer polling `pet-life` | |
| 149 | every 5 seconds. The menu carries name, stage and age, the cemetery below a | |
| 150 | separator, and Quit. It resolves `pet-life` next to its own binary first, then | |
| 151 | `PATH` — the order `signald` uses for `macos-collector`. | |
| 152 | ||
| 153 | If `pet-life` is missing or fails, the status item shows a neutral glyph and | |
| 154 | the menu says the daemon is not reachable. A face that cannot read the bus says | |
| 155 | so rather than showing a stale pet, which is the rule the terminal pet follows. | |
| 156 | ||
| 157 | **Distribution: extend the existing formula, no cask.** Casks want a | |
| 158 | downloadable signed archive, meaning release artifacts, signing and | |
| 159 | notarization — a great deal of machinery for a personal tool. The formula | |
| 160 | already builds from source, so it builds the bundle and installs it into the | |
| 161 | prefix alongside the binaries. | |
| 162 | ||
| 163 | Building locally also sidesteps the unsigned-app problem: Gatekeeper | |
| 164 | quarantines downloaded apps, not ones compiled on the machine. | |
| 165 | ||
| 166 | **Launch at login** is documented via System Settings -> Login Items, not | |
| 167 | another LaunchAgent plist. signald needs a plist because it is a background | |
| 168 | daemon that must run without a session; a menu-bar app is session-scoped, and | |
| 169 | Login Items is the mechanism people already know. | |
| 170 | ||
| 171 | **Not building:** preferences, a settings window, death notifications, or a | |
| 172 | dock-icon mode. The pet is one glyph and a menu. | |
| 173 | ||
| 174 | ## Testing | |
| 175 | ||
| 176 | `pet-life` is pure functions over timestamps with `now_ms` injected, so tests | |
| 177 | are deterministic and instant — no sleeping, no clock mocking. | |
| 178 | ||
| 179 | - Every stage boundary. | |
| 180 | - Death derived at `last_activity + 7d`, identical whenever observed. | |
| 181 | - A month's absence yields exactly one grave, not a chain. | |
| 182 | - Rebirth requires activity. | |
| 183 | - Name determinism. | |
| 184 | - Cemetery append across generations. | |
| 185 | - A crash mid-write leaves the previous file intact. | |
| 186 | - The JSON contract, pinned to exact bytes. | |
| 187 | - `last_activity_ms` against an in-memory history fixture, including the case | |
| 188 | where retention has pruned everything. | |
| 189 | ||
| 190 | The Swift side stays untested: it is a status item, a timer and a menu, and the | |
| 191 | logic lives in Rust. What is checked is that the bundle assembles — the `.app` | |
| 192 | exists and its `Info.plist` carries `LSUIElement`. Claiming UI coverage that | |
| 193 | does not exist would be worse than saying this plainly. | |
| 194 | ||
| 195 | ## Documentation | |
| 196 | ||
| 197 | - README: the faces diagram gains a third built face; a phase-plan entry; | |
| 198 | workspace layout; launch instructions; the Paths table gains `pet.json` with | |
| 199 | a note on why it is not under the runtime directory. | |
| 200 | - CHANGELOG: an Unreleased entry. | |
| 201 | ||
| 202 | ## Delivery | |
| 203 | ||
| 204 | A stack, each MR targeting the one below: | |
| 205 | ||
| 206 | 1. `History::open_read_only` and `last_activity_ms` on signald. | |
| 207 | 2. `crates/pet-life/`: the mechanic, persistence, the cemetery, the JSON. | |
| 208 | 3. The Swift app and its bundle script. | |
| 209 | 4. Formula, README and CHANGELOG. | |
| 210 | ||
| 211 | Then a minor release, since this adds a face and a binary without changing the | |
| 212 | wire contract. `SCHEMA_VERSION` stays 5: the pet reads existing signals and | |
| 213 | adds none. | |