Homey app extending com.melcloud with automatic cooling adjustment based
on an outdoor temperature source. ESM only, Node >= 22.19. It talks to
the MELCloud devices exclusively through the local Homey API (homey-api)
— device behavior is fixed in com.melcloud (sibling repo with its own
CLAUDE.md), never worked around here.
This app has no npm dependency on the MELCloud stack — its real dependency is WIRE-LEVEL, against whatever com.melcloud version is installed alongside, and it is one-directional (com.melcloud never calls back). The consumed surface, exhaustively:
-
The app ids themselves carry the original submissions' typo, and it is LOAD-BEARING: this app is
com.mecloud.extensionand addresses the main app ascom.mecloud(MELCLOUD_APP_ID,app.mts). A platform id cannot change without orphaning every install, so the store URLs keep the typo too (https://homey.app/a/com.mecloud,https://homey.app/a/com.mecloud.extension). Never "fix" the missinglanywhere — manifest, code or docs links (a README "fix" once turned all three store links into 404s; reverted, 2026-08). -
GET /devices/groupson com.melcloud's app API — the building grouping. The contract is DEGRADE, never fail: an absent route (older com.melcloud, app not installed) or an off-shape payload reads as "no grouping" (to-device-groups.mtssanitizes; the settings page falls back to one flat group). Never assume the route exists. -
The MELCloud devices themselves through
homey-api(HomeyAPIV3Local): driver ids (melcloud,home-melcloud), capability ids (target_temperature,thermostat_mode,measure_temperature*), anddevice.data.idas the join key between Homey devices and/devices/groupsentries — Classic serializes a NUMBER as string, Home a GUID, which is why joins go throughtoJoinKey(group-devices.mts) and nothing else; #1229 was exactly a join done another way. -
Per-device failures are reported and skipped (
#listenToDevice), so one renamed capability cannot take the whole adjustment down.
Changing any of these on the com.melcloud side is a cross-repo change: check this app the way byte-identical kernels are checked. The exact-pin rule of the npm consumers has no equivalent here — the wire tolerates version skew by design, which is why every consumed shape is sanitized at entry.
Run the FULL suite before any push — CI runs all of it:
npm run format/npm run format:fix— prettier (eslint does NOT cover formatting).npm run lint/npm run lint:fix— ESLint (also lints CSS, HTML, JSON, YAML and Markdown via the language plugins).npm run typecheck— the native TypeScript 7 compiler, reached by its explicit path,node ./node_modules/@typescript/native/bin/tsc(buildspells out the same path). Keep the path explicit: the native package ships no.binshim, and thetscandtsc6shims that do exist come from the compat package@typescript/typescript6and run TypeScript 6 — a baretscwould silently typecheck on the wrong compiler.npm test/npm run test:coverage— vitest; backend coverage is at 100% (branches included), keep it there.settings/is browser glue and excluded.npm run build— esbuild bundle (scripts/bundle.mts) +tscemit, BOTH into.homeybuild. The Homey CLI runsnpm run buildwhen it detects TypeScript — but only AFTER its pre-process copy into.homeybuild, so the source tree stays sources-only and everything the package needs must be emitted there: tsc does it viaoutDir, andbundle.mtsemits the settings bundles there too (its former source-tree outfiles landed too late to be copied — the com.melcloud #1404 root cause: store installs 404'd the bundles). The CLI's own build invocation is therefore sufficient for install, run, validate and publish alike; a standalone suite run (no.homeybuildpage copy) still proves the bundles compile.- Cache-busting
?v=— a PACKAGE-TIME transform:bundle.mtsstamps every local asset reference of the.homeybuildpage copy with a content hash (?v=<hash>), so phone webviews (which cache assets across app versions) refetch an asset exactly when its bytes change. The committed source HTML carries NO stamps — never hand-add a?v=there, and nothing needs re-committing when a settings source changes (the old re-stamp-and-commit dance is gone). Stamps exist only in the packaged app, within attribute/import reference contexts (href="/src=") — matched WHEREVER they appear, HTML comments included: a commented reference to a missing file fails the packaging pass, and one to a real file would be stamped by the builder yet invisible to the page-side DOM query, splitting the two identities into an endless refetch handshake. Delete a dead reference, never comment it out. npm run homey:validate— Homey validation at publish level; may rewrite files (locales), re-stage if it does.npm run homey:start—homey app run --remotefor on-device testing.
Check real exit codes; never pipe a check's output through tail/grep
to judge success.
app.mts— discovers the MELCloud AC devices (ATA_DRIVER_IDSmatches BOTH dialects: Classicmelcloudand MELCloud Homehome-melcloud; the app idcom.mecloudis a historical typo) and the temperature sensors, debounces device events, and owns the per-deviceMELCloudListeners plus the sharedOutdoorSourceregistry.- Device grouping — com.melcloud exposes
GET /devices/groups([{ deviceIds, name }], one entry per MELCloud building, both dialects, sorted by name). The extension declares thehomey:app:com.mecloudpermission and calls the endpoint throughthis.homey.api.getApiApp('com.mecloud')when (re)loading devices. Any failure or off-shape payload (com.melcloud missing or too old) reads as "no grouping" (null, sanitized bylib/to-device-groups.mts) and the settings fall back to one row per device. The join key isString(device.data.id)— the MELCloud id com.melcloud writes at pairing (Classic numeric DeviceID, Home uuid); same-name buildings across dialects merge into one group and unmatched devices trail in an unnamed group (lib/group-devices.mts). The settings UI renders ONE select per building and fans the pick out to every device of the group before the PUT; storage stays per device (outdoorSources), listeners unchanged. listeners/— instance-based. EachMELCloudListeneris bound to anOutdoorSource(per-device setting):CapabilityOutdoorSource(a "deviceId:capabilityId" path watched through a capability instance) or the sharedWeatherOutdoorSourcedefault. Sources hold their cooling subscribers: watching starts with the firstattach(single-flight — concurrent attaches await one start) and stops with the lastdetach. melcloud.mts only imports the source as a type — no runtime cycle.settings/index.mts— browser-side settings UI, bundled by esbuild intosettings/index.jsas a CLASSIC IIFE (format: 'iife',globalName: MELCloudWebview), loaded via<script defer src>— NOT an ES module (mirrors com.melcloud). Only the JS module loader fails:import()/<script type=module>stall on a COLD webview open against Homey's local origin (the #1404 spinner), while classic resource fetches — the stylesheet, a classic<script src>— load cold. The HTML declares the docs' canonical globalfunction onHomeyReady(homey)inline (it must exist at parse time), which pollsglobalThis.MELCloudWebviewand calls itsstart(homey).defer(as in com.melcloud) is the right fit for an app bundle that reads the DOM — ordered, after<body>parses, before DOMContentLoaded — and here it is doubly required: this entry does DOM lookups at module top level, whichdefermakes safe. The poll's 10 s timeout still ends the overlay if the script failed to load. Init work is separately time-bounded (10 s) withhomey.ready()in afinally;startis non-throwing by construction (failure alerts go throughfireAndForget).scripts/bundle.mtsstamps every local asset reference — only inside an attribute/import context, never a comment — with a content hash (?v=): phone webviews cache assets across app versions. Never load the bundle as a STATIC<script type=module>: it stalls the whole boot on a cold open (shipped and reverted in com.melcloud, proven on-device there). Dynamicimport()is merely unnecessary, not broken — its supposed Android fetch failures were com.melcloud #1404's missing-bundle 404s — but do not churn the loading mechanism without new on-device evidence: classicdefercarries the bounded boot plus beacon. Phone webviews also cache the HTML ITSELF across app versions (proven on com.melcloud), so shipped bundle filenames are a COMPAT CONTRACT:scripts/bundle.mtsbuilds the entry twice —index.js(IIFE) for the current HTML,index.mjs(ESM) for every cached ESM-era HTML, which is why the entry keepsexport const start. Never rename or drop a shipped bundle filename; add alongside. A second cache layer covers the HTML itself (phone webviews cache the page across app versions, force-close included): each bundle carries a freshness handshake — the page's identity is the document-order join of its?v=stamps (a CSS-only ship moves it too),GET /webview-hashesserves the live hashes (a manifestbundle.mtsemits into the packaged app, read by@olivierzal/homey-kit/node;api.mtspasses the manifest URL explicitly — the kit's default resolves against its own module, which lives innode_modules), and a mismatch triggers ONE refetch of the document through a never-cached address (?fresh=<identity>— a bare reload can be re-served the same stale document from the HTTP cache; sessionStorage guard,watchWebviewFreshnessfrom@olivierzal/homey-kit/webview), whose fresh stamps pull the fresh assets; a mismatch that survives its refetch is reported toPOST /boot-error. The guarantee lives in the BOOT check, and which surface needs it was measured on device (2026-08-07): the web-app settings page is destroyed and REMOUNTED when the app restarts, and mobile widgets reload too — both are fresh for free. Only the mobile settings page survives an app restart, so it alone never boots again; that is why the watcher re-checks on RETURN TO THE FOREGROUND (visibilitychange), the trigger that covers it. The app also emits awebview_hashes_changedrealtime event at its own boot and the page subscribes to it, but it guarantees NOTHING on its own: it fires at the end of the app'sonInit, i.e. exactly when the restart has just disconnected every open page, so its audience is absent by construction (measured: an open mobile page produced no request and no breadcrumb). Never fold the visibility trigger into it. Every failure path stays open: an unstamped page, an absent route or denied storage must never take a working webview down. When the bundle still fails to boot, theonHomeyReadypoll's timeout beacon POSTs theuserAgentplus afetchprobe of the bundle to/boot-error(app.error) before degrading, distinguishing a fetch failure from a parse-or-runtime crash (pre-es2020 engines). Webview runtime-API floor: es2023 array methods are accepted (toSorted/toReversedship today), but nothing newer — no iterator helpers (.entries().map(), 2025-era): esbuild lowers syntax only. Settings pages and widgets do NOT style the same way: settings follow the Homey Style Library (homey-form-*/homey-button-*; in ahomey-form-groupthe control is a SIBLING after its label — see custom-views/html-and-css-styling in the Homey docs), while widgets get injected CSS variables and their own class set. Do not copy markup across the two, nor from com.melcloud's settings (which nest controls inside labels).homey-api-override.d.ts— ambient module declaration for the homey-api surface actually used;homey-override.d.tstypes the app settings.lib/homey.mtsre-exports the runtime-providedhomeySDK (the scoped eslint carve-out forimport-x/no-extraneous-dependencieslives there, not inline).
.homeycompose/is the SOURCE forapp.jsonandlocales/*.json; commit the CLI-generated outputs verbatim (no trailing newline).- App-API surface conventions (aligned on com.melcloud): paths are
kebab-case REST,
get*for GET — exceptis*for a boolean GET —,update*for PUT, a business verb for POST (logWebviewBooton/boot-error);fetch*in the webview is reserved for transport calls (load*reads the settings store). The auto-adjustment path is/cooling/auto-adjustment(the snake_case legacy alias was dropped by decision, 2026-07 — a cached pre-rename bundle now alerts on Apply until it refreshes). The com.melcloud grouping isGET /devices/groupsonly; an older com.melcloud reads as "no grouping" (the sanitizer's degradation path).@olivierzal/homey-kit/settingsis the settings page's transport (error-first-callback SDK). The surface is test-pinned in two halves, one file each — extend BOTH when touching a route:tests/unit/api-contract.test.tspins manifest ids ↔ handlers both ways plus the handlers' function type;tests/unit/api-route-guards.test.tspins the call sites (every settings path literal must match a declared route). Both are now the kit's table-driven kernels: each file holds this app's table and the factory call, nothing else. - Dirty-gating:
createDirtyGate(@olivierzal/homey-kit/webview) is the ONE primitive behind the Update/Refresh pair — never re-derive its invariant at a call site. The gate also freezes the gated fieldsets while a request is in flight (containerdisabled+aria-busy, so a control's own domaindisabledsurvives the thaw): every success path rewrites the fields, so a mid-flight edit would be silently clobbered — pass every region the arming source reads throughfieldsetElements. Arming comes from exactly ONE source, exclusive by type: baseline mode (serialize, a pure snapshot — never a request-body builder) or predicate mode (isActionable, no baseline at all;markSavedthen only re-evaluates) — this app's single pair is baseline-mode. Disabled greying stylesbutton:disabledgenerically, never a per-class list. The kit's own suite locks the behavior — a change to the gate is a kit release, adopted here by an exact-pin bump. - Home ATA devices (
home-melcloud) do NOT exposemeasure_temperature.outdoor; only Classic ATA devices do. The default outdoor-source selection and the sensor list must never assume it. - Both ATA drivers share
thermostat_modevalues (incl.cool) and the 10–31 °Ctarget_temperaturerange; the setpoint ceiling is read fromcapabilitiesObjat runtime (31 °C fallback). - The comfort setpoint and the debt are ONE record, not two. It is the
previousmember of theadjustmentsentry (below): what the device held when the app engaged is both the floor the calculation uses and the value owed back, so a separatethresholdsmap was a second store for the same number, kept in step by hand — exactly the kind of pair that drifts when one side's sanitizer drops an entry. The key is gone; installs carrying it just stop reading it. A missing entry means ABSENT, never a stand-in value: nothing is written andlog.noThresholdis reported (it used to send 0 °C — the placeholder reaching the unit as a real command). A missing outdoor reading is treated the same way: no efficiency floor rather than one computed from 0, and#getTargetTemperaturereturnsnullwhen neither floor is known. A setpoint the app cannot READ is refused the same way: the device is left unadjusted rather than written into a debt that could never be repaid (Number()used to turn that absence into 0, or into a NaN that JSON stored asnulland the sanitizer then dropped). - Restoring a setpoint is EVENT-INDEPENDENT, and that is the whole
design. The
adjustmentssetting is a ledger of what this app owes each device —{ previous, written }, what it found and what it put there — written on every successful write and cleared only once the device has taken its value back. It has exactly two writers, named for their intent:recordComfort(the value found, then any the user chooses) andrecordWrite(what actually reached the device); each keeps the other member. The capability listeners remain the fast path (leaving cooling settles immediately), but correctness lives in#reconcileAdjustments, which runs on every restart (boot, device add/remove, settings apply) and re-judges every outstanding debt against the LIVEthermostat_mode. That is what survives the four ways a listener never sees a device leave cooling: a realtime reconnect (homey-api wires noonReconnect, so missed events are never replayed), a crash or reboot with nounload, the extension being disabled, and a device opted out of adjustment — a deviceDISABLED_SOURCEexcludes fromautoAdjustCoolingmust still get back what was taken from it. Two refusals are deliberate: a setpoint that no longer holdswrittencarries a decision made after ours and stands (log.kept), and an unreadable one settles nothing, leaving the debt for a later pass rather than commanding blind. Never make the restore depend on the threshold map, on a live listener, or on having witnessed the transition. - Restarts are SERIALIZED (
#queueRestart):autoAdjustCoolingis a critical section — tear down, settle, rebuild — and two overlapping runs (a device event landing on a settings apply) raced for the same device slots, leaving a listener installed by the losing run alive but unreachable, still writing setpoints nothing could settle. The listener registry is keyed by device id for the same reason: an array cleared by length reset cannot express ownership. - Independent per-device work goes through
settleAll(lib/settle-all.mts), notPromise.all: the aggregate must attempt every branch and report each failure on its own.Promise.allwould abandon the restart at the first rejection — skipping the very reconciliation meant to repair it — and would surface one reason while hiding the others. KeepPromise.allonly where the caller genuinely cannot continue without every branch. - Outdoor sources are per device (
outdoorSourcessetting: null/absent = Homey weather,'none'= the device is not adjusted at all); the legacy globalcapabilityPathis migrated to every known AC device once, then unset. - Both per-device maps are reached ONLY through the app's accessor pairs
(
outdoorSources,thresholds): the key name and its sanitizer live together, the getter hands back a sanitized fresh copy, and no caller can write past the contract its reader assumes. - Entries for devices Homey no longer knows are deliberately NOT pruned.
They are inert —
#inheritedSourcematches on the live grouping, and Homey ids are UUIDs, so a stale entry can neither be resurrected nor influence an inheritance. Pruning was designed and refused: the only place to run it is after#loadDevices, which empties the device list before its network call, so any hiccup (com.melcloud stopped, a failed read) would wipe every source and threshold the user configured — and a wrongly-pruned source does not self-heal, the device re-seeds as a newcomer straight toDISABLED_SOURCE. Map hygiene does not justify a destructive operation on user configuration. - Grouping joins on the MELCLOUD id (
device.data.id), never the Homey id:/devices/groupsspeaks MELCloud ids while every settings map is keyed by Homey id.lib/group-devices.mtsexportstoJoinKeyas the single home for that conversion — confusing the two spaces silently broke building inheritance for every newcomer, and test fixtures must give devices amelcloudIddistinct from theiridor they cannot catch it. - The Homey weather (home-screen temperature) is served by the LOCAL
weather manager. Route it through the connected homey-api session's
generic
call({method, path: '/api/manager/weather/weather'}): the app-sidehomey.api.getrejects withMissing Session, and homey-api ships no weather manager wrapper (absent from its local specification). ReadtemperatureCelsius, nottemperature(unit-dependent); poll it (no push events), readings are sanitized bylib/to-temperature.mts(anything non-finite reads as null, never 0/NaN).
- What
@typescript-eslint/naming-conventioncannot see is convention too: booleans read as questions even untyped (isX/hasX), handlers as verbs; a name states what the thing IS, never its history. Test files are named after the unit under test (<module>.test.ts); shared test helpers keep their family's names — apps sayassertDefinedandmock(overrides)where the libraries saydefinedandmock(value?): two test families, deliberately not unified. - Static markup and styles live in
.html/.cssfiles. TS builds DOM only when the content is programmatic (computed values, per-item nodes), viacreateElement— neverinnerHTML(no-unsafe-dom-htmlenforces it). Inline style writes are reserved for values CSS cannot express; anything static belongs in the stylesheet, following the CSS/HTML lint rules' spirit even where no rule captures it. - The webview runtime floor (es2023: no
Object.groupBy/Map.groupBy, no iterator helpers, novregex flag) is DERIVED, not precautionary: the Homey mobile app requires iOS 16.4 or later (App Store, read 2026-08-11) and a Homey app only ever gets the system WebKit, so the worst legitimate engine is iOS 16.4's — es2023-complete, short of every es2024 gain (Object.groupByandPromise.withResolversneed Safari 17.4, thevflag 17). es2024 becomes derivable when that App Store minimum reaches 17.4; Android never binds the floor, its System WebView being evergreen. - The floor is enforced by a scoped lint block over
settings/— the tsconfig cannot express two runtimes in one project. Atsconfig.webview.jsonfloor was probed and refused on com.melcloud (2026-08-06): tsc checks the import CLOSURE, which crosses into node-side code — the same shape exists here (settings/imports sharedlib/andtypes/modules). - TWO floors coexist, on UNRELATED engines — never let one move the
other. On the webview side the danger is APIs, because esbuild
lowers syntax but NEVER polyfills (
Object.groupBy, iterator helpers…); thevflag joins them because esbuild defers it instead of lowering it — under the bundler's es2020 target the literal ships as anew RegExpcall, so an escapee fails at RUNTIME, inside the feature that runs it, not at parse. Narrower blast radius, same ban. The node-side floor is the Homey's own Node, and it is held by the manifest'scompatibilitydeclaration, not by a check. - A floor is declared from WHERE THE CODE RUNS, never from what a
dependency happens to require.
compatibility: ">=12.9.0"is Athom's own documented Node 22 boundary ("as of Homey v12.9.0, all Homey platforms run apps on Node.js v22") and already covers theenginesof every shipped dependency. Raising it cannot express more than it already does: the two firmware lines are numbered independently, so one semver range cannot say "has Node 22" across both — and on Homey Pro (2016-2019) the Node 22 firmware is still only a release candidate, so a raise would cut off that whole stable install base rather than a few laggards. - Node-side runtime APIs above es2022 are therefore LEGITIMATE:
toSorted/toReversed(Node 20),Object.groupBy(Node 21) andPromise.withResolvers(Node 22) all predate the declared engine, and@olivierzal/homey-kitalready callstoSortedinside the boot path. Never rewrite one away for an engine the manifest does not claim —pushToUI'stoReversedandgroup-devices.mts'stoSortedstay exactly as they are. The same holds for syntax:files.mtsreads its JSON through import attributes, the statically analysable form, and node-side regexes take the family'svflag — only the webview globs step down tou, and that step-down is the scoped block's job, never a second overlay.
The shared tooling lives in @olivierzal/configs (exact pin): the
eslint homeyApp preset (plugins are the package's dependencies — no
plugin devDeps here; the webview floor rides its webviewFloorFiles
glob), the prettier config ("prettier" key in package.json, no local
file) and the tsconfig/app base (outDir stays local — paths in an
extended tsconfig resolve against the base file inside node_modules).
The overlay keeps ONLY per-repo verdicts: the lint ignores and the
**/*.d.ts block around homey-api-override.d.ts. Naming comes whole
from the family core, strict by default — properties are camelCase and
the only departures are the preset's scoped escapes (capability-shaped
keys for the platform, quoted keys, the __ sentinel). Re-tightening it
locally is not worth what it costs: the rule's options array replaces
rather than merges, so a partial override silently drops every entry it
omits, and a full copy drifts from the core it duplicates. Do not
re-declare family policy locally — a rule evaluation or version bump
happens in configs, adoption is a reviewed pin bump. The
ci/claude/dependabot/dependency-review/pr-title/zizmor workflows are
stubs calling the family reusables in OlivierZal/configs, pinned
@<sha> # vX.Y.Z; dependency vulnerabilities are GitHub's own —
Dependabot alerts scan continuously and carry the named, reasoned
dismissals (an exception lives on the advisory, so it cannot outlive
it, and this repo's parseuri ReDoS is dismissed there), while
dependency-review judges what a PR introduces;
publish.yml and validate.yml stay local (no reusable exists).
.npmrc (scope registry + NODE_AUTH_TOKEN auth) is load-bearing:
the configs devDependency lives on GitHub Packages, where even reads
need auth.
@olivierzal/homey-kit (exact pin, a PRODUCTION dependency — the
manifest reader runs on the device) owns what used to be copied across
the three apps: the dirty gate and the freshness handshake
(/webview), the settings transport (/settings), the manifest reader
(/node), fireAndForget/getErrorMessage
(root) and the two test kernels (/testing). A change to any of them
is a kit release adopted here by a pin bump — never a local edit, and
never a re-derivation.
What stays local, by measurement rather than omission:
- The webview
fireAndForgetinsettings/index.mts: it surfaces in the dev tools, it does not log through a logger instance. The kit's node-side seam takes(promise, logger, message)and is what everyapp.mts/listeners/**site uses. lib/errors.mts(NotFoundError): unlike the two sibling apps, this one forcessuper('notFound')andapi.mtsthrows it with NO argument, becausesettings/index.mtsmatches on that exact message (message === 'notFound') to tell "no MELCloud device paired yet" from a real failure. Swapping in the kit's class — whose message is whatever the caller passes — would silently break that branch, and no test would see it. Extend the kit class or keep this one; never replace it blind.- The
ManagerSettingsaugmentation: the local block is STRICTER than the kit's generic (no(key: string) => unknownoverload) and carriesunset, which the generic lacks. Adopting it would loosen this app. homey-api-override.d.ts, and thelib/helpers no sibling shares.
api.mts passes the manifest URL to getWebviewHashes explicitly: the
kit's default resolves ../webview-hashes.json against its own module,
which sits in node_modules — only the caller knows where the bundler
stamped it. Dropping that argument silently disables the freshness
handshake (the reader fails open with an empty map).
- Code adapts to the rules, never the reverse. Never add a disable —
not inline, not through config options or ignore regexes: refactor
until the rule passes. One counterweight: when every compliant shape
reads worse than the violation (a rule-pair conflict, a
protocol-imposed form), the documented disable IS the honest form.
Current irreducibles: the fire-and-forget disable (once, in
lib/fire-and-forget.mts— the settings page wraps it for its defaultonError) and the TS9019 isolatedDeclarations carve-out inlib/homey.mts. - Naming is stricter than com.melcloud: properties are camelCase-only
in app code. The tests block relaxes it (documented in the config)
because test doubles mirror external contracts: capability ids
(snake_case, dotted), device ids (hyphenated), module export names
(PascalCase) and Homey's
__translation method. - Ambient
*.d.tsfiles have a scoped carve-out (script parse, namespace-merged classes) — also documented in the config. - A config-level
'off'with a one-line reason is not a disable: it is the triage ledger for opt-in rules that were evaluated and refused (tool-ownership overlap, platform floor, absent domain). Disables suppress an adopted rule; ledger entries record a verdict — re-evaluate one when its stated reason expires (target bump, new tooling). - Zero-warning policy: every enabled rule is at
error. - Test doubles are SYNC where the real API is async (the caller's
awaithandles both):mockImplementation(async …)without an await tripsrequire-await, and non-async promise-returning arrows trippromise-function-async— type thevi.fnas value-returning instead. useDefineForClassFieldswipes fields assigned bysuper(): a subclass re-declaring an Error option (likecause) must usedeclare, not a field initializer.- All-type exports hoist the keyword (
export type { A, B }); mixed exports keep inlinetypespecifiers, mirroring the inline-type-imports style. No shipped rule enforces the export side (consistent-type-exportstolerates inline specifiers once present;import-x/consistent-type-specifier-stylecovers imports only): the convention is maintained by hand, in review — a bespokeno-restricted-syntaxselector for it was removed by decision (2026-07-28).
-
Companion docs are part of a change's definition of done: whenever a PR changes behavior, API surface, requirements or process, the same PR updates the affected companion files (README.md, CONTRIBUTING.md, SECURITY.md, CLAUDE.md) — never a later sweep; the 2026-08 README audit caught exactly the drift this prevents (a shipped Home ATW driver absent from its README, a stale
Resultkind list). -
Every substantive wave ends with a cleanup pass over its own diff — residue (history comments, orphaned helpers, stale doc claims), simplification, and the factoring the change just made possible. Features land first, the sweep runs second, so the sweep covers them; a wave is not done until that pass has run.
-
Design phases (on Olivier's call, start and end): iterate on
design/*branches with dev-installs only — no PR merges, no tags, no releases, no App Store publishes until he lifts the pause. -
mainis protected (PRs only, squash merges); CI must be green. -
The PR title IS the commit that lands:
squash_merge_commit_titleisPR_TITLE, so the title is the single source (under the formerCOMMIT_OR_PR_TITLE, a one-commit PR silently took its commit subject instead). It must follow Conventional Commits, which the requiredPR titlecheck enforces (.github/workflows/pr-title.yml, byte-identical in the five repos) — default type set, no scope allowlist, and nosubjectPattern: subjects legitimately open on a proper noun. Dependabot's prefixes are pinned tobuild(deps)/build(deps-dev)rather than inferred, which is what had it land a different style in each repo. The subject casing stays inferred and cannot be pinned:commit-messageaccepts onlyprefix,prefix-developmentandinclude, so Dependabot keeps matching each repo's own history (Bump undiciin one,bump temporal-polyfillin another). Left alone by decision (2026-08): a Dependabot commit subject is not a contract, the PR title is — and thePR titlecheck already holds that one. -
After every push, monitor the triggered pipelines to completion — the PR checks after a push, the publish run after a release tag — and act on the outcome: rerun transient infra failures (a SonarCloud 504 is not a finding), fix real ones. Work is not done while its pipeline is red or unwatched.
-
Copilot reviews every PR, and every review thread (Copilot or human) must end RESOLVED: with a code change when the point holds, or with a reasoned reply when it does not — verify claims against sources before acting either way. Resolve the thread once settled; none left dangling.
-
SonarCloud must be spotless for a PR to merge — and the quality gate passing is necessary, NOT sufficient: the free-tier gate tolerates 3 % duplication on new code, lets code smells through, and cannot be customized, so the real bar is ours, held in review. That bar is zero on BOTH windows — new code and overall alike: zero open issues of every kind (bugs, code smells, vulnerabilities) across the whole project, 0 % duplicated lines across the whole codebase, and 100 % coverage (within the exclusions
sonar-project.propertiesdeclares). A Sonar finding is handled like a lint error — the code adapts, or the divergence is settled as a documented verdict — never merged over. -
Homey App Store releases: write the user-facing changelog entry into
.homeychangelog.jsonunder the NEW version key (all 13 locales — the com.melcloud set), bumpversionin.homeycompose/app.json, alignpackage.jsonvianpm version X.Y.Z --no-git-tag-version, runhomey:validateto regenerateapp.json, and land it all through a PR. Then tagvX.Y.Zand publish a GitHub release:publish.ymlfires on release-published (environmenthomey,HOMEY_PAT) and pushes to the App Store. Fallback when the secret is stale (The access token provided is invalid):homey app publishfrom an authenticated CLI — answer NO to the version prompt (a yes bumps and rewrites app.json) and let the changelog come from.homeychangelog.json. -
Store submissions: a rejected version number cannot be resubmitted — bump the patch version.
-
Sonar: the CI upload step self-arms on the
SONAR_TOKENsecret (a job-level env gate — the secrets context is not valid in stepif). Adding the secret requires disabling automatic analysis on sonarcloud.io first (the two modes conflict).