From cb6c15d06592beb5e1dc04b8fdbe02168482f6f4 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 14 Jul 2026 07:14:16 -0400 Subject: [PATCH 1/2] ci: build and test on a self-hosted macOS runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No CI existed. GitHub bills macOS runners at a 10x minute multiplier on private repos, so one 5-8 minute build+test burns 50-80 billed minutes — the free allowance (2,000 min/month = 200 macOS minutes) covers roughly three runs a month. Untenable for a repo under active development. Self-hosted is safe HERE because this repo is private. Do not copy this workflow to a public repo: a fork's pull_request can execute arbitrary code on the runner. That caveat applies to link-devices. The suite itself takes 0.7 seconds; all the cost is compiling and booting a simulator, both of which a warm persistent machine keeps around. Notes: the runner service does not inherit a login shell's PATH, so /opt/homebrew/bin is added explicitly or xcodegen is not found. The .xcodeproj is gitignored and regenerated from project.yml on every run. --- .github/workflows/ci.yml | 79 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a9a0ca6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +# Runs on a SELF-HOSTED macOS runner, not a GitHub-hosted one. +# +# Why: GitHub bills macOS runners at a 10x minute multiplier on private repos, so a +# single 5-8 minute build+test burns 50-80 billed minutes. The free allowance +# (2,000 min/month = 200 macOS minutes) would cover roughly three runs. Untenable +# for a repo under active development. +# +# A self-hosted runner is safe HERE specifically because this repo is private. Do NOT +# copy this to a public repo — a fork's pull_request can execute arbitrary code on the +# runner. (That caveat applies to link-devices, which is public.) +# +# The suite itself takes 0.7 seconds. All the cost is compiling and booting a +# simulator, both of which a warm persistent machine keeps around — so incremental +# runs land in well under a minute. + +on: + pull_request: + paths: + - 'Sources/**' + - 'Tests/**' + - 'project.yml' + - '.github/workflows/ci.yml' + push: + branches: [main] + paths: + - 'Sources/**' + - 'Tests/**' + - 'project.yml' + - '.github/workflows/ci.yml' + workflow_dispatch: + +# Pushing twice in a minute shouldn't queue two builds on a single machine. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: [self-hosted, macOS] + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - name: Environment + run: | + # The runner service does not inherit a login shell's PATH, so Homebrew + # binaries (xcodegen) are not on it by default. + echo "/opt/homebrew/bin" >> "$GITHUB_PATH" + + - name: Versions + run: | + xcodebuild -version + /opt/homebrew/bin/xcodegen --version + + # project.yml is the source of truth; the .xcodeproj is gitignored and must be + # regenerated. This also regenerates Info.plist — see project.yml's comment. + - name: Generate Xcode project + run: xcodegen generate + + - name: Test + run: | + set -o pipefail + xcodebuild \ + -project KitchenSyncApp.xcodeproj \ + -scheme KitchenSyncApp \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -resultBundlePath TestResults.xcresult \ + test + + - name: Upload results on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: xcresult + path: TestResults.xcresult + retention-days: 7 From 2eefb76b9251a87e247a26c8b9dbe1d11d0dfb24 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 14 Jul 2026 07:39:08 -0400 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20project=20CLAUDE.md=20=E2=80=94=20g?= =?UTF-8?q?stack=20+=20the=20rules=20that=20aren't=20guessable=20from=20th?= =?UTF-8?q?e=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gstack: /browse for all web browsing, never mcp__claude-in-chrome__* directly. The KitchenSync rules worth encoding are the ones a new agent (or human) will get WRONG by reading the code alone: - Live vs reboot IS the KsLiveEdit case list, and it is not guessable from field names: metro_accent is live, metronome ENABLE is not; led enable IS live, follow_beat is not. Names the three structural defenses and says not to weaken them. - Transport is quantized. Never predict armed->running client-side. - XcodeGen REGENERATES Info.plist from project.yml, so hand-edits to it vanish. And NSLocalNetworkUsageDescription / NSBonjourServices are load-bearing: without them iOS silently kills Bonjour and .local resolution with no useful error. - TDD is the rule; network tests run the real client against a stubbed wire. - The known gap: nothing has ever spoken to real hardware. --- CLAUDE.md | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b76c3ac --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,100 @@ +# gstack + +Use the `/browse` skill from gstack for all web browsing. Never use `mcp__claude-in-chrome__*` tools directly. + +Available gstack skills: +/office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review, /design-consultation, /design-shotgun, /design-html, /review, /ship, /land-and-deploy, /canary, /benchmark, /browse, /connect-chrome, /qa, /qa-only, /design-review, /setup-browser-cookies, /setup-deploy, /setup-gbrain, /retro, /investigate, /document-release, /document-generate, /codex, /cso, /autoplan, /plan-devex-review, /devex-review, /careful, /freeze, /guard, /unfreeze, /gstack-upgrade, /learn + +# KitchenSync iOS + +Companion app for the KitchenSync/X32Link fleet in [`link-devices`](https://github.com/ericdahl-dev/link-devices). +Plain HTTP + Bonjour against the protocol `KitchenSync/main/ks_web.cpp` already serves. + +## The firmware is the source of truth + +Field names, value grammars, and option strings are copied from the firmware, not invented. +Where the Swift and the firmware disagree, **the firmware is right and the Swift is the bug.** +Each Swift type's doc comment names the C symbol it mirrors — keep that up. + +When you need a protocol fact, read it in `link-devices`: +`KitchenSync/main/ks_web.cpp` (routes), `ks_config.c` (form grammar), `ks_config_json.c` +(`/config.json`), `ks_status.c` (`/status`), `wifi_link.c` (mDNS). The web UI's CSS lives in +`X32Link/ui_chrome.c`, which `ks_web.cpp` includes as `%CSS%`. + +## Live vs reboot IS the `KsLiveEdit` case list + +This is the single most important rule in the codebase, and it is **not guessable from field +names.** + +- `POST /live` — partial patch, applied instantly, no reboot. Restricted to `KsLiveEdit`'s + closed case set. +- `POST /save` — the full form. Persists to NVS and **reboots the device**: it drops out of the + Link session and all clock output stops for several seconds. Mid-set, that is destructive. + +The trap: `metro_accent`, `metro_vol` and `metro_voice` are live, but metronome **enable** is +not. `led` enable *is* live, but `follow_beat` enable is not. (The ES8311 codec and I2S only come +up at boot.) On the device's own web page those toggles are the identical component forty pixels +apart, with nothing on screen distinguishing them. + +Three defenses, and **do not weaken any of them**: + +1. Every control on `DeviceDetailView` routes through a `KsLiveEdit` case, so that screen + *cannot* reboot the device — there is no case to hand a reboot-only field. +2. Reboot-required settings exist only in `DeviceSettingsSheet`, behind a consequence band and a + confirmation that names the device. +3. `LiveRebootPartitionTests` asserts every key `saveFormFields` emits is either live-editable or + declared in `KsConfig.rebootRequiredFormKeys` — exactly one, never both, never neither. Add a + field without classifying it and CI fails. + +Passwords are write-only. `/config.json` never returns one; blank means "keep current". +`WifiCredentialEdit` is keyed by slot `id`, **never array position** — get that wrong and you +silently overwrite the wrong saved network. + +## Transport is quantized + +Play **arms** and fires on the next bar line. Stop is immediate. The device computes +`TransportLaunchState` (stopped / armed / running) and reports it in `/status.launch[N]` on every +poll. + +**Never predict the armed→running transition client-side.** No local timers, no optimistic state. +The device already computes it; guessing is how you show a running output that never started. + +The armed state blinks at beat rate (`60/bpm` — the firmware's own `flashBeat` cadence) because +it is the only evidence a tap registered and it may persist for most of a bar. Hard-edged, not a +soft `repeatForever` throb: a throb reads as "loading", and armed is a cocked hammer. + +## Build + +`project.yml` is the source of truth. **XcodeGen *regenerates* `Sources/KitchenSyncApp/Info.plist` +on every run — hand-edits to that file are silently overwritten.** Plist keys go under +`info.properties` in `project.yml`. The `.xcodeproj` is gitignored; regenerate, don't hand-edit. + +Two Info.plist keys are load-bearing and easy to lose: `NSLocalNetworkUsageDescription` and +`NSBonjourServices`. Without them iOS silently blocks Bonjour browsing *and* `.local` resolution +— discovery finds nothing, every request fails, and there is no useful error to go on. + +```sh +xcodegen generate +xcodebuild -project KitchenSyncApp.xcodeproj -scheme KitchenSyncApp \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test +``` + +## Tests + +**TDD is the rule here: tests first, never backfilled.** + +Network tests run the **real** `KitchenSyncClient` against `StubURLProtocol` — mocking the client +itself would only assert that our fake calls our fake. Build fixtures from the firmware's actual +`snprintf` / `ks_config_set()` output, not from plausible-looking JSON. + +Logic that decides something (state→appearance mapping, tempo source, transport summary) belongs +on a model as a pure function where it can be asserted — not inside a `View`. + +CI runs on a **self-hosted macOS runner** (GitHub bills hosted macOS at 10× on private repos). + +## Known gap + +**Nothing has ever spoken to a real device.** Every network test runs against a stub; +`KitchenSyncDiscovery` has never seen a live Bonjour responder. Use `link-devices/tools/linkcli` +to get a unit on the bench. Treat the protocol layer as verified against a *reading* of the +firmware, not against the firmware.