Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ See `docs/architecture.md` for system design. This file contains only rules and
- **Robust to any input.** A running device tolerates any sequence of UI actions or API calls: add, delete, replace, or reconfigure any module in any order, at any grid size, and it keeps running. Degraded or idle is acceptable; crashed is not. This robustness is a defining strongpoint of projectMM, and it's guarded by the test framework, not by hope: a discovered crash drives a new test that pins the fix (see the Hard Rule). Out of scope: power loss, malformed OTA, brown-out, and other physical/electrical faults the firmware can't intercept; this principle is about what the software accepts as input.
- **No reboot to apply a configuration change.** Every setting takes effect live, on the next render tick — change a pin map, a strand length, an output protocol, a mic pin or rate, anything, on a running device and it just works. There is no init-once-at-boot step, and no *config* change requires a restart, which sets projectMM apart from most LED-controller firmware (where a pin or protocol change means a reboot). Like robustness, this is a defining strongpoint, and it falls out of the architecture for free rather than being hand-built per module: any control whose change reshapes derived state routes through the generic `onBuildState()` rebuild sweep, so drivers, the audio peripheral, effects, layouts, modifiers and network I/O all inherit it. When adding a feature, don't reach for a reboot/restart to apply config; make the change live. Full mechanism + rationale: [architecture.md § Live reconfiguration](docs/architecture.md#live-reconfiguration-every-change-applies-without-a-reboot). The one exception is what you'd expect: a *firmware* OTA flash swaps the binary and needs the usual power cycle — that's not a configuration change, and (like power loss and brown-out) it's the same physical-fault boundary the robustness principle draws.
- **Domain-neutral core.** Separate core infrastructure from the light domain as much as practical. When mixing is necessary, use domain-neutral naming so the code stays open to future separation.
- **Present tense only.** Code, comments, and documentation describe the system as it is now. No changelogs, no roadmaps. History lives in git commits. This bans not just future-tense ("will be", "planned") but **absence-narration**: phrases like "no longer", "anymore", "formerly", "used to", "X was removed", or "there's no longer a Y" describe a *change from a past state* a present-tense reader never saw — state what *is*, not what stopped being. (The test: "there is no MCLK pin" is a present-tense *property* — keep it; "there's no SET_DEVICE_MODEL RPC anymore" narrates a removal — cut it, just describe the path that exists.) Exceptions: `docs/backlog/` (forward-looking) and `docs/history/` (backward-looking) — and `decisions.md` lessons, which legitimately contrast before/after because the contrast *is* the lesson.
- **Present tense only.** Code, comments, and documentation describe the system as it is now. No changelogs, no roadmaps. History lives in git commits. This bans not just future-tense ("will be", "planned") but **absence-narration**: phrases like "no longer", "anymore", "formerly", "used to", "X was removed", or "there's no longer a Y" describe a *change from a past state* a present-tense reader never saw — state what *is*, not what stopped being. (The test: "there is no MCLK pin" is a present-tense *property* — keep it; "there's no SET_DEVICE_MODEL RPC anymore" narrates a removal — cut it, just describe the path that exists.) Exceptions: `docs/backlog/` (forward-looking) and `docs/history/` (backward-looking) — and `docs/adr/` + `lessons.md`, which legitimately contrast before/after because the contrast *is* the record (an ADR's Context is the situation that forced the decision; a lesson's before-state is what makes it a lesson).

## Hard Rules

Expand Down Expand Up @@ -56,7 +56,7 @@ The design rationale for each rule below lives in [docs/architecture.md](docs/ar

Then check the recommendation against [§ Principles](#principles) (minimalism, data over objects, concrete first) and propose it as a question, not a fait accompli. The product owner picks; the agent implements only what was picked. If the picked option turns out to need a follow-up change (e.g. an updated naming convention to make the new layout consistent), surface that *before* starting the move so it's a single coherent refactor, not three round-trips.

**Plan before implementing.** Use `/plan` mode before every feature. Review plans for: unnecessary files, inheritance where structs suffice, modifications outside the relevant directory. Reject and regenerate bad plans. **Save every approved plan** to `docs/history/plans/` named `Plan-YYYYMMDD - <title>.md` (ISO-8601 date order so the directory sorts chronologically, e.g. `Plan-20260620 - Improv-as-REST.md` for 2026-06-20), as the first implementation step. The plan is the design record that complements `decisions.md` (the lesson record): the plan says what we set out to build and why; decisions.md captures what we learned doing it. **These saved plans are a reference archive for the product owner — agents WRITE a plan when creating one, but do NOT read the existing plan files for context unless the product owner explicitly points to one** (they're under the "Never automatically" rule below alongside the rest of `docs/history/`). Plans are **kept, not pruned** — they are the permanent design-intent record. When a plan's design ships (or doesn't), mark its outcome in the filename with a trailing parenthetical — `… (shipped).md` once it lands, `… (attempted, abandoned).md` if it was tried and dropped — so the directory shows at a glance what's done; an unmarked plan is still in flight. The **one exception** to "kept, not pruned": a multi-phase effort's per-phase plans may be **consolidated into a single `… (shipped).md` record** once the *whole* effort lands, provided that record preserves the design-intent arc (what each phase set out to do + the outcome, including any dead-ends). This isn't losing design intent — it's the same subtraction the rest of the process rewards, applied to a set of plans whose story is now one story. Consolidate only *shipped/settled* phases, never in-flight ones.
**Plan before implementing.** Use `/plan` mode before every feature. Review plans for: unnecessary files, inheritance where structs suffice, modifications outside the relevant directory. Reject and regenerate bad plans. **Save every approved plan** to `docs/history/plans/` named `Plan-YYYYMMDD - <title>.md` (ISO-8601 date order so the directory sorts chronologically, e.g. `Plan-20260620 - Improv-as-REST.md` for 2026-06-20), as the first implementation step. The plan is the design record that complements `lessons.md` (the lesson record): the plan says what we set out to build and why; lessons.md captures what we learned doing it. **These saved plans are a reference archive for the product owner — agents WRITE a plan when creating one, but do NOT read the existing plan files for context unless the product owner explicitly points to one** (they're under the "Never automatically" rule below alongside the rest of `docs/history/`). Plans are **kept, not pruned** — they are the permanent design-intent record. When a plan's design ships (or doesn't), mark its outcome in the filename with a trailing parenthetical — `… (shipped).md` once it lands, `… (attempted, abandoned).md` if it was tried and dropped — so the directory shows at a glance what's done; an unmarked plan is still in flight. The **one exception** to "kept, not pruned": a multi-phase effort's per-phase plans may be **consolidated into a single `… (shipped).md` record** once the *whole* effort lands, provided that record preserves the design-intent arc (what each phase set out to do + the outcome, including any dead-ends). This isn't losing design intent — it's the same subtraction the rest of the process rewards, applied to a set of plans whose story is now one story. Consolidate only *shipped/settled* phases, never in-flight ones.

**Use `uv` for every Python invocation.** Never type `python` or `python3` directly; always go through `uv run` (e.g. `uv run moondeck/build/build_desktop.py`, `uv run python -c "…"`). This applies to shell commands, CMake `add_custom_command` / `execute_process`, documentation examples, and anything that shells out. In CMake, resolve `find_program(UV_EXECUTABLE NAMES uv REQUIRED HINTS "$ENV{USERPROFILE}/.local/bin" "$ENV{HOME}/.local/bin")` once and use `${UV_EXECUTABLE} run python …` thereafter. Reason: uv manages the project venv and is the project standard ([moondeck/MoonDeck.md](moondeck/MoonDeck.md)); bare `python3` isn't on PATH on Windows (and macOS Python Launcher pops a Store prompt). If you catch yourself about to type `python`, stop and prefix with `uv run`.

Expand Down Expand Up @@ -153,7 +153,7 @@ The "this is now trunk" moment. Where the wider hygiene checks live, because onc

1. All commit gates passed on every commit in the PR.
2. PR feedback addressed (CodeRabbit + human review).
3. **Carry forward lessons**: if the branch produced a hard-won lesson, a proven pattern, or a non-obvious decision worth keeping, note it in `docs/history/decisions.md` as part of the branch's commits, so the lesson lands in `main` with the code that proved it. Do this on the branch before the merge commit.
3. **Carry forward lessons**: if the branch produced something worth keeping, record it in the right home as part of the branch's commits, so it lands in `main` with the code that proved it (do this on the branch before the merge commit). A **debugging lesson or gotcha** (a bug, its cause, the fix) goes in `docs/history/lessons.md`. A genuine **architectural decision** (chose approach A over B/C) is a new immutable ADR in `docs/adr/` (`NNNN-<title>.md`, Nygard format; supersede, never edit). A lesson that has hardened into a **general rule** goes to CLAUDE.md or `coding-standards.md`, not a history file.
4. **Documentation sync**: every new module / control / API endpoint has matching docs (`docs/moonmodules/*.md`, `docs/testing.md`, `docs/architecture*.md`).
5. **Reviewer agent**: trigger this **first** so it runs while the other checks (docs sync, carry-forward lessons, conditional gates) proceed in parallel. Opus reviewer over the **whole branch diff** (`git diff main...HEAD`). Scope: domain boundary, **common patterns first** (flag any new convention (naming scheme, file shape, build flag, control mechanism, UI affordance) that isn't recognisable from a widely-used project / framework / canonical resource; bespoke choices must carry a stated reason at the introduction site, see the principle in § Principles), **unnecessary abstractions** (no-op / pass-through wrappers that only rename or re-namespace an existing function, single-call-site indirection that would read clearer inlined, names that obscure where the real code lives), **duplicated patterns** (same logic in multiple places that belongs in a base class or shared function), hot-path violations, spec conformance, bloat, platform boundary. Architectural drift is more visible across N commits than across one: "three commits each added a wrapper" reads as a pattern that one commit hides. Findings either get fixed in additional branch commits before merge, or are accepted with a one-line reason in the PR description. CodeRabbit complements this: CodeRabbit handles line-level bugs in the PR; the Reviewer agent handles architectural drift.
6. **PR title and description**: review and update if the work done differs from what the PR title/description says. The description is the permanent record of what landed and why; it should reflect the actual diff, not the original intent.
Expand All @@ -179,7 +179,7 @@ The "end users will use this" moment. Per-release criteria are defined by the pr

5. **Changelog / release notes**: drafted in the GitHub release body. Skip only for unreleased pre-1.0 tags.
6. **Cross-platform smoke**: run scenarios on every supported platform (today: PC + ESP32; later: + Teensy, RPi), if the release claims new platform support or the version bumps a major or minor.
7. **Principles audit**: sweep `docs/` (except `docs/backlog/` and `docs/history/`) and `src/` for forward-looking language ("roadmap", "will be", "planned", "in the future", "currently lacks", `TODO`, `FIXME`) and other violations of § Principles. Acceptable hits carry a one-line justification; the rest get rewritten present-tense or moved to `docs/backlog/` / `docs/history/`. The reviewer agent can run this end-to-end. Skip only for releases where the diff against the previous tag is doc-empty.
7. **Principles audit**: sweep `docs/` (except `docs/backlog/`, `docs/history/`, and `docs/adr/`) and `src/` for forward-looking language ("roadmap", "will be", "planned", "in the future", "currently lacks", `TODO`, `FIXME`) and other violations of § Principles. Acceptable hits carry a one-line justification; the rest get rewritten present-tense or moved to `docs/backlog/` / `docs/history/`. The reviewer agent can run this end-to-end. Skip only for releases where the diff against the previous tag is doc-empty.

What the agent reads:
- Always: `CLAUDE.md`, `architecture.md`
Expand All @@ -201,9 +201,12 @@ docs/
backlog-core.md ← to-build list, core / infrastructure domain (+ UI)
backlog-light.md ← to-build list, light domain (drivers, effects, preview, sensors)
backlog-mixed.md ← to-build list, items spanning both domains
adr/ ← architecture decision records (Nygard format; immutable, superseded-not-edited)
README.md ← ADR index
NNNN-<title>.md ← one decision each (Status / Context / Decision / Consequences)
history/ ← backward-looking: accumulated wisdom
README.md ← index: what's here + cross-repo trends + digest prompt
decisions.md ← actions, lessons, proven patterns
lessons.md ← debugging lessons + gotchas (bug → cause → fix; pruned as absorbed)
plans/ ← approved feature plans (Plan-YYYYMMDD - <title>.md; PO reference, agents don't auto-read)
*-inventory.md ← prior-project surveys (v1, v2, moonlight)
<repo>.md ← friend-repo monthly activity digests (FastLED, WLED, …)
Expand Down
18 changes: 18 additions & 0 deletions docs/adr/0001-persistence-pod-memcpy-not-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# 1. Persist POD module state with memcpy, not JSON

Status: Accepted

## Context

Plan-09 attempted ~1700 LOC of JSON-based persistence for module state. It was fully abandoned. The premise "persistence is JSON" was assumed without justification: neither human-readability nor manual editability were real requirements. The JSON design spawned ~15 helpers (`rebuildControls`, `LoadAllFn`, `applyNode`, `serializeNode`, `cleanupTmpLeafCb_`, and more), which was the system signalling the design was too elaborate for the job. It forced a Scheduler reorder (3→5 phases) that bred secondary bugs (a duplicate-children bug, a MAC→deviceName guard, multiple "device shows nothing" failures), and needed five defensive null guards that masked an allocate-new-before-free fragmentation invariant.

## Decision

Persist POD module state with a single `memcpy(file, this + sizeof(MoonModule), classSize - sizeof(MoonModule))`, loading it back before any module's `setup()` / `onBuildControls()` by memcpy into member memory directly. Plan-10 took this path and shipped.

## Consequences

- Save and restore are one line each; no serializer/deserializer helper sprawl.
- Loading before `setup()` means no Scheduler phase reorder and none of its secondary bugs.
- POD-only: state is a flat memory image, so there is no schema-versioned migration across a struct-layout change (a future need would be its own decision, not a reason to pay for JSON now).
- The lesson that generalised: question a format premise before building to it; suspicious helper proliferation is a design smell; fix an invariant, do not paper it with per-call-site guards.
Comment on lines +1 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Rewrite this ADR in present tense.

The context/decision prose reads like a changelog ("attempted", "was fully abandoned", "took this path and shipped"), which violates the repo rule for markdown docs outside docs/backlog/ and docs/history/. Keep the same decision content, but phrase it as current guidance.

As per coding guidelines, **/*.h,hpp,cpp,cc,mm,m,md: write code, comments, and documentation in the present tense only; avoid changelog-style or absence-narration phrasing, except in docs/backlog/ and docs/history/.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/adr/0001-persistence-pod-memcpy-not-json.md` around lines 1 - 18,
Rewrite the ADR text in present tense throughout, keeping the same decision and
technical meaning but removing changelog-style phrasing such as “attempted,”
“was fully abandoned,” and “took this path and shipped.” Update the prose in the
Context, Decision, and Consequences sections of
0001-persistence-pod-memcpy-not-json.md so it reads as current guidance, and
keep the wording aligned with the document’s policy language for markdown docs
outside docs/backlog/ and docs/history/.

Source: Coding guidelines

23 changes: 23 additions & 0 deletions docs/adr/0002-adaptive-memory-degradation-cascade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# 2. Adaptive allocation with a degradation cascade

Status: Accepted

## Context

The light pipeline runs on devices from a no-PSRAM ESP32 (~180 KB free internal heap) to a PSRAM-rich P4. A fixed buffer scheme either wastes memory on small installs or fails to fit large ones. The pipeline has intermediate buffers (mapping LUT, driver output buffer) that a 1:1 unshuffled layout does not need at all.

## Decision

Allocate intermediate buffers on demand, only when the pipeline actually needs them, and degrade under memory pressure rather than fail:

- The **mapping LUT** is built only when modifiers exist and the layout is not a plain grid and heap remains after reserving `HEAP_RESERVE` (32 KB) for stack/HTTP/WiFi.
- The **driver output buffer** is built only when a LUT is actually allocated.
- When memory is insufficient, degrade in order: full pipeline → skip the output buffer (map inline) → skip the LUT (forced 1:1) → reduce layer dimensions (halve to a floor of 8×8).

Each level is observable (`degraded()`, `lutSkipped()`, `outputBufferSkipped()`). Every allocation is predict-then-measure: predict from dimensions + channels + modifiers, compare the heap delta, and flag >5% variance as a leak.

## Consequences

- A 1:1 unshuffled layout allocates zero intermediate buffers; ArtNet reads the layer buffer directly. Maximum LED count at minimum memory.
- The device stays running under pressure (degraded is acceptable, crashed is not), instead of failing an allocation outright.
- The mechanism and buffer-type detail live in [architecture.md § Memory strategy](../architecture.md#memory-strategy); this ADR records the decision to make allocation adaptive rather than fixed.
17 changes: 17 additions & 0 deletions docs/adr/0003-layer-buffer-persists-frame-to-frame.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 3. The layer buffer persists frame-to-frame

Status: Accepted

## Context

An early design cleared the render buffer before every effect frame, on the reasoning "the buffer is the effect's to fill, every time." This silently broke every persistence effect: a scroll reads the prior column and shifts it (reading a wiped buffer, only the fresh pixel survived); a trail calls `fadeToBlackBy` to decay the previous frame (fading zeroes never forms a trail); Game-of-Life reads its prior cell state (gone). The symptom that surfaced it: FreqMatrix lit only one row, and ~13 effects' `fade` controls did nothing.

## Decision

The render buffer is **not** cleared each frame. `Layer::loop()` leaves the previous frame's pixels in place, zeroed once on allocation/resize, then persistent, matching the FastLED / WLED / MoonLight convention (their `leds[]` / segment / VirtualLayer buffers all persist). Each effect owns its background inside its own `loop()`: a full-grid effect overwrites every pixel, a trail calls `fadeToBlackBy`, a sparse effect that wants a clean frame calls `draw::fill` itself. There is no per-effect "persist" flag (a flag would be bespoke). `fadeToBlackBy` is a Layer operation collected once per frame: effects register an amount, the Layer keeps the MIN across them and applies one pass at the next frame's start.

## Consequences

- Persistence effects (scroll, trail, Game-of-Life) work; multiple effects on one layer deliberately interact through the shared persistent buffer.
- N fading effects cost one buffer pass, not N, and never fade each other's fresh pixels.
- A state-advancing effect (Game-of-Life) separates when the simulation steps (gated on `bpm`) from when it paints (frame rate); its state lives in the persistent buffer. `unit_Layer_persistence` and `unit_GameOfLifeEffect` pin it.
Comment on lines +1 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Rewrite this ADR in present tense.

The context text uses retrospective phrasing ("cleared", "broke", "surfaced it"), which conflicts with the repo rule for markdown docs outside docs/backlog/ and docs/history/. Keep the same behavior description, but phrase it as current guidance.

As per coding guidelines, **/*.h,hpp,cpp,cc,mm,m,md: write code, comments, and documentation in the present tense only; avoid changelog-style or absence-narration phrasing, except in docs/backlog/ and docs/history/.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/adr/0003-layer-buffer-persists-frame-to-frame.md` around lines 1 - 17,
The ADR in this document uses retrospective wording throughout the Context and
Decision sections, so rewrite the affected prose in present tense while keeping
the same technical meaning. Update the narrative around Layer::loop(),
fadeToBlackBy, and the persistence behavior so it reads as current guidance
rather than history, and avoid phrases like “cleared,” “broke,” or “surfaced” in
past-tense form.

Source: Coding guidelines

Loading
Loading