Compare commits

...
276 Commits
Author SHA1 Message Date
Matysh 24e48935c3 Release v1.79.0 candidate: reconcile mirrored main workflow history
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (interaction) (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric-backdrop) (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric-stage3) (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (led-strips) (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Canceled after 0s
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Merge the previously mirrored #716 workflow callers. Both already match dev
byte-for-byte; the candidate Git tree is unchanged. This makes the stable
promotion a fast-forward without rewriting main or dev history.

Release: v1.79.0
Issue: #716
Issue: #780
Issue: #788
User-Visible: no
2026-10-03 22:26:34 +03:00
Matysh 6b0d7d66dd Release v1.79.0 candidate
Promote published v1.79.0-beta.7 without feature-source changes.
Aggregate stable highlights since v1.78.0 and rebuild versioned assets.

Release: v1.79.0
Issue: #780
Issue: #788
User-Visible: yes
2026-10-03 22:24:42 +03:00
Matysh 0588fa8f0a Release v1.79.0-beta.7 candidate
Release: v1.79.0-beta.7
Issue: #788
User-Visible: yes
2026-10-03 20:12:10 +03:00
claude[bot] 019cfa5c5a docs: accept derived artifacts on dev for v1.79.0-beta.7
Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697).
Прогон: https://github.com/Matysh/houseplan-card/actions/runs/37139464966
Скриншоты документации: отпечаток исходников; изменённые кадры: нет.

Issue: #697
User-Visible: no
2026-10-03 17:11:26 +00:00
Matysh 5fff48f7b1 docs(led): record full regression acceptance and self-review
Issue: #788
User-Visible: no
2026-10-03 19:38:27 +03:00
Matysh a74a50d331 test(led): align mutation witness after occluder filtering
Keep the executable shared-masonry proof without adding new text anchors
against the monolith. Update the unchanged circle-event mutation oracle.

Issue: #788
User-Visible: no
2026-10-03 19:21:46 +03:00
Matysh 416d2fae71 perf(led): avoid redundant tube and barrier calculations
Preserve exact light fields while culling provably distant occluders,
reusing physical tube paths across camera updates and excluding LED emitter
data from aligned architectural fingerprints. Cover output equivalence,
in-place invalidation, resize tags and hyphenated benchmark chunk hashes.

Issue: #788
User-Visible: yes
2026-10-03 19:18:49 +03:00
Matysh 08bc58cc0e chore(golden): accept corrected LED end-cap feather
Reviewed Linux Validate 37134548622 on c142e4f7. The right free cap now
fades through the valid wall-circle crescent instead of cutting it away.
One declared frame changed; 199 baselines retained, 101 exact witnesses.
Private household screenshots were not uploaded or committed.

Issue: #788
User-Visible: no
Release: v1.79.0-beta.7
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/37134548622
2026-10-03 18:58:09 +03:00
Matysh c142e4f7b5 fix(led): preserve strip corners and feather the complete light field
Keep real end and corner emitters, robust decimal joins and wall-circle
sweep events. Render one positive-winding compound visibility clip so
Chromium cannot cancel or cut away overlapping light regions.

Add independent pixel oracles for glow falloff and wall-following tubes.
Replace the lossy fan-count limit with explicit cached-path bounds while
retaining the original timing and warm-cycle heap-growth limits.

Issue: #788
User-Visible: yes
2026-10-03 18:46:35 +03:00
Matysh ad10a5b099 chore(release): prepare v1.79.0-beta.6
Build the beta candidate, date both changelogs, refresh release notes and tighten release ratchets.

Release: v1.79.0-beta.6
Issue: #786
Issue: #787
User-Visible: yes
2026-10-03 17:58:43 +03:00
Matysh 76310fc981 docs(screenshots): обновить fingerprint после #787 2026-10-03 17:39:20 +03:00
Matysh 0f466743c3 chore(golden): эталон прямоугольной LED-ленты (#787)
Issue: #787
User-Visible: no
Release: v1.79.0-beta.6
Baseline-Reviewed-Local: sha256:44eb42f580fc9f973509af68f47565b4cad2a9a6200e445e728f6198e785ef98
2026-10-03 17:26:56 +03:00
Matysh 35ef231191 fix(led): сохранить прямоугольник через проёмы (#787)
Issue: #787
User-Visible: yes
2026-10-03 17:17:50 +03:00
Matysh fab28af26f docs(screenshots): обновить fingerprint после #786 2026-10-03 17:00:16 +03:00
Matysh a54429d4a3 chore(golden): эталон гладких торцов LED-ленты (#786)
Issue: #786
User-Visible: no
Release: v1.79.0-beta.6
Baseline-Reviewed-Local: sha256:1c7933e47079b24064413013a999ed10376a0aad05f8c8f57f1c683bb64c6ae7
2026-10-03 16:51:39 +03:00
Matysh 9598768167 fix(led): сгладить торцы поля свечения (#786)
Issue: #786
User-Visible: yes
2026-10-03 16:41:10 +03:00
Matysh aa1105468b chore(release): prepare v1.79.0-beta.5
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Build the beta candidate, date both changelogs, refresh release notes and tighten release ratchets.

Release: v1.79.0-beta.5
Issue: #785
User-Visible: yes
2026-10-03 14:24:42 +03:00
claude[bot] 42d1a8a9dc docs: accept derived artifacts on dev for v1.79.0-beta.5
Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697).
Прогон: https://github.com/Matysh/houseplan-card/actions/runs/37119340533
Скриншоты документации: отпечаток исходников; изменённые кадры: нет.

Issue: #697
User-Visible: no
2026-10-03 11:22:39 +00:00
Matysh 8b88a6f606 chore(golden): accept LED strip continuity scenes (#785)
Issue: #785
User-Visible: no
Release: v1.79.0-beta.5
Baseline-Reviewed-Local: sha256:c30cae622673c0e45a45de8a63d12e8c0f7cc5cc1ff5bf147450849a7158e35e
2026-10-03 14:12:46 +03:00
Matysh 4c7e9815f3 fix(led): сохранить непрерывный Glow и толщину ленты (#785)
Issue: #785
User-Visible: yes
2026-10-03 14:00:14 +03:00
Matysh 1deb29e163 chore(release): prepare v1.79.0-beta.4
Build the beta candidate, date both changelogs, refresh release notes and tighten release ratchets.

Release: v1.79.0-beta.4
Issue: #783
Issue: #784
User-Visible: yes
2026-10-03 13:23:36 +03:00
Matysh 3048ac9195 test(led): wait for the no-Glow fade to finish (#784)
The state and room-setting updates are separate renders. Assert the stable no-Glow contract after the legitimate 500 ms leaving phase instead of racing it on fast CI runners.

Release: v1.79.0-beta.4

Issue: #784
User-Visible: no
2026-10-03 11:15:38 +03:00
Matysh 654cb9d404 docs(screenshots): refresh source fingerprint for #784
All 11 documentation frames remain pixel-identical after the LED strip runtime changes; only the source fingerprint is refreshed.

Issue: #784

User-Visible: no

Release: v1.79.0-beta.4
2026-10-03 11:01:35 +03:00
Matysh 8dbfe5e6b2 test(golden): accept polished LED strip glow (#784)
Reviewed all three changed WSL frames: the 30 cm field is continuous at
straight cuts and corners in 2D light/dark and 2.5D scenes.

Issue: #784
User-Visible: no
Release: v1.79.0-beta.4
Baseline-Reviewed-Local: sha256:cf474aa1ff676d2262f1c9e442114e7df0e91867c69c78bdc82c923a48355650
2026-10-03 10:54:55 +03:00
Matysh 706eb02d70 fix(led): polish glow and interactions (#784)
Issue: #784
User-Visible: yes
2026-10-03 10:47:28 +03:00
Matysh 4962de1d5c fix(ci): parse beta-derived docs CSV lists (#783)
Keep repeated --expect-change flags compatible while accepting the comma-separated workflow input and ignoring whitespace, empty values, and duplicates.

Issue: #783
User-Visible: no
2026-10-03 10:04:12 +03:00
Matysh 31b4ddda55 Release v1.79.0-beta.3 candidate
Publish the three integrated S8 issues since beta.2. Synchronize all seven version sources, rebuild committed frontend assets, tighten ratchets to measured facts, and update release metadata for LED strips. Canonical documentation screenshots were reviewed from Linux CI and accepted in the preceding derived commit.

Local checks: build/typecheck, bundle policy, bundle budgets, monolith checks and release-contract tests passed. Native-Windows unit run passed 3517 tests; two GNU-bash tests and one TypeScript diagnostic-shape test remain Linux-only. Full exact-SHA Validate is mandatory before publication.

Release: v1.79.0-beta.3
Issue: #765
Issue: #780
Issue: #781
User-Visible: yes
2026-10-03 09:40:14 +03:00
Matysh 072fb108cf docs: accept derived artifacts on dev for v1.79.0-beta.3
Производные артефакты беты приняты вручную по резервному пути релиз-менеджера (PROCESS.md §8, §11.4, #697). Каноническая съёмка: https://github.com/Matysh/houseplan-card/actions/runs/37102465043. Все 11 кадров просмотрены; изменения #780 и тотальный байтовый сдвиг Chromium 151 приняты с записанным --no-witnesses. Golden полного Validate 37102870712 зелёный и эталонов не меняет.

Issue: #697
User-Visible: no
2026-10-03 09:31:48 +03:00
Matysh 24671d4358 perf: снизить стоимость маски LED-лент (#780)
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Issue: #780
User-Visible: no
2026-10-03 00:10:28 +03:00
Matysh 6607a34973 perf: упростить SVG-поле LED-лент (#780)
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Issue: #780
User-Visible: no
2026-10-02 23:38:35 +03:00
Matysh e9e6eba5d1 docs(review): yellow code review #780 r2
Exact-SHA performance keeps the result yellow: the 50x50 LED profile
exceeds warm-ready and camera Long Task budgets on all seven samples.

Issue: #780
User-Visible: no
2026-10-02 22:59:42 +03:00
Codex a20ef2b9b0 chore(monolith): raise bundleBytes for the r1 LED fixes (#780)
bundleBytes 2 667 879 → 2 670 566 (+2 687, over the 2 000 band): the value
badge of a strip on the card and on the static card, the visibility gate
before import(), the release of a card's LED caches on disconnect and the
cache statistics of the lazy runtime/field chunks (r1 M1, M4, M5). The
badge method moves below `_renderDevice`, outside the vacuum section the
isometric contract reads. The review index is rebuilt after the rebase.

Issue: #780
User-Visible: no
2026-10-02 20:58:11 +03:00
Codex e447364636 perf(led): the led-strips-v1 profile proves caches, disconnect and a late import (#780)
r1 M5: the runtime and field chunks release a card's frame and field cache
on disconnect (ledRelease from disconnectedCallback), never cache for a
disconnected card, and a chunk that lands after disconnect renders nothing
(the card's LED hook is connected-only). The profile now reports the three
caches of the shown space separately (shapes ≤ 50, visibility ≤ 50, retained
per-emitter fans ≤ 2500) through the runtime's ledStats, asserts 0 retained
entries and 0 live LED timers/frames/observers after every disconnect,
judges the Long Tasks of a 100-step camera series as well as the interaction
profile's camera scenario, runs one extra cold mount whose runtime response
is held while the card is removed, and enforces ≥ 7 samples after ≥ 1
warm-up — also on reports merged from parts (--warmup-only, --merge).

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
Codex b40e8ff58d fix(led): badge at the anchor, room_id wins, chain stays in its space, hidden loads nothing (#780)
r1 of the code review:
- M1: a strip keeps its marker's value badge, passive, at the half-length
  anchor on the card and on the static card (no icon core, pulse or slot).
- M2: one room resolver for the strip's Glow — an explicit valid room_id of
  the marker wins over the anchor room; a stale one falls back (stripRoom).
- M3: the chain remembers the space it is drawn in; a space switch finishes
  it there, never in the space shown next, and opens no picker over it.
- M4: visibility is decided before import(): a hidden marker or an
  HA-disabled device loads no LED chunk (ledVisible, also checks the stored
  marker so a just-hidden one does not slip through a stale device list).
- M6: the static card is a full light_pools × live_states browser matrix.

Unit tests for M2/M3, smokes for M1/M3/M4/M6, five registered mutants.

Issue: #780
User-Visible: yes
2026-10-02 20:54:50 +03:00
Codex ef3e7a5440 fix(config): an ordinary save without led_strips keeps the stored shapes (#780)
r1 H1: a previous frontend sends every space without the unknown field and
the ordinary write path replaced the document with it, erasing every LED
strip. The shared ordinary-writer helper now copies the stored shapes of a
space the payload omits (config/set and Optimize); an explicit list from a
new client still deletes, a removed space takes its strips, and a link to a
marker the same write deleted unbinds by the usual rule. config/set reports
the normalisation counters after the preservation.

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
MatyshandCodex 403bcca680 docs(reviews): independent code review of LED strips (#780)
Manual review requested after the automated reviewer stopped before a verdict.
Material 2c5b59aa: red, one High and six Medium findings.

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
Codex 91b57ae60e test(golden): accept the LED strip scenes and the Devices toolbar shift (#780)
Accepted from the golden-images artifact of the full Validate on 63e751ad,
every frame reviewed side by side:
- new: led-strip-design-reference-off-light, led-strip-design-reference-on-light,
  lighting-led-strip-glow-dark, led-strip-off-light, iso-led-strip-dark (the
  designer's four strips on #868D94; compared with Led-On/Led-Off in
  docs/design/led-strips/ACCEPTANCE.md);
- changed: geometry-devices-editor-dark, device-inbox-narrow-ru-dark,
  device-dialog-desktop-en/de, toggle-entity-dialog-desktop-en/mobile-ru (the
  «LED strip» tool after «Add»), geometry-decor-editor-dark,
  furniture-categories-light, room-discard-dialog-mobile-ru,
  support-phone-success-dark-en, support-phone-validation-light-ru — icons
  the stale demo icon map lacked are drawn now.
demo/srv/assets/icons.js is the output of demo/gen_icons.mjs again (restores
63e751ad; the 4283cb13 trim is dropped so the frames match the accepted run).

Issue: #780
User-Visible: no
Release: v1.79.0-beta.3
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/36999711281
2026-10-02 20:54:50 +03:00
Codex 3d2c5ab3ee fix(demo): add only the LED tool icons to the demo icon map (#780)
A full regeneration of demo/srv/assets/icons.js also picked up 29 icons other
tasks reference and dropped one, shifting unrelated golden frames. Keep the
committed map and add exactly mdi:led-strip-variant and mdi:link-variant(-off).

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
Codex 5d3f29c249 fix(card): static «Show as LED strip» in the device dialog; demo icons (#780)
The full Validate on a6c4001c found two things:
- smoke_help_affordance: the device dialog shifted when the LED tool chunk
  arrived after it opened (the representation section appeared late). A
  device without a strip now gets the static section at once (label in the
  base dictionary); the press loads the tool, leaves the dialog through its
  own guard and starts drawing for the same marker. A strip's device still
  loads the tool with the dialog. The dialog alone no longer loads anything.
- smoke_lazy_admin_locale counted nine namespace chunks (stale since the
  `tools` namespace); it now reads NAMESPACE_LOCALE_CHUNKS and accepts the
  editor's own `tools-de`.
- demo/srv/assets/icons.js regenerated: the demo/golden ha-icon stub lacked
  mdi:led-strip-variant and mdi:link-variant(-off).

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
Codex ec9d968c8a test(smoke): the Devices toolbar has the LED strip tool after Add (#780)
Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
Codex 8915e49a58 test(smoke): LED strip smokes enter through __hpTest and the DOM (#780)
setServerConfig/setLayout/setMode/openMarkerDialog/close and the tray's own
«Device settings» + Delete + confirm instead of private card writes; config
writes pass through the fixture so revisions stay consistent.

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
Codex d515c42177 feat: LED strips — editor notes, docs, demo, smokes, golden scenes, profile (#780)
Stage 5 of #780.

- Import summary: «Strips left unbound after import: {n}» from the backend
  `unbound_led_strips` count; «Optimize plans» reports strips passing
  through walls per space and edits none (AC16).
- Linear field for long strips (ТЗ §13.2): pieces of at most the radius
  along the polyline, emitters thinned to r/4, each piece clipped to the
  visibility fans of its own emitters as separate clipPath children (no
  boolean pass per piece), one floor clip for the whole layer, no fan at
  all where nothing blocks within the radius; a grid index of body faces
  and boxed inside tests; unchanged fields skip re-diffing. 50×50 on the
  large house: first stable frame ~1.4 s, warm space ~1.1 s locally.
- led-strips-v1 profile: demo/benchmark_led_strips.mjs with the derived
  large-house fixture (10×5, 50×50, none), absolute limits of the ТЗ table
  in demo/performance/budgets-led-strips.json, exact counters (zero
  recomputes on HA ticks/camera/colour, ≤50 cache entries, no growth over
  20 cycles); added to the full performance workflow.
- Bundle: LAZY_LED_GZIP_CEILING 10 KiB, LAZY_LED_EDITOR_GZIP_CEILING 11 KiB
  (measured + 10 %, rounded up); overlaps with the initial and editor
  graphs refused; the lazy editor graph stays inside its ceiling.
- Smokes smoke_led_strip_draw/bind/glow, linked in smoke-links; 13 mutants
  in the registry (7 browser guards in the inventory); config field registry
  entry `spaces[].led_strips`.
- Golden: five new scenes on the `golden-led` space of the visual fixture
  (`ledStrips` option, the designer's four strips on #868D94), matrix v71.
- Docs: LIGHT, DEVICE-PRESENTATION, USER-GUIDE (en/ru), UX-MODES,
  ARCHITECTURE, ISOMETRIC, CONFIG-COMPATIBILITY, TOUCH-SUPPORT, demo/stand
  README, performance README; docs/design/led-strips with the unchanged
  designer archive, two paired frames and ACCEPTANCE.md; both changelogs.

Issue: #780
User-Visible: yes
2026-10-02 20:54:50 +03:00
Codex ade3078d53 chore(monolith): raise hostRefs and bundleBytes for the LED strips (#780)
hostRefs 4924 → 5050 (+126): the new lazy LED editor reads card state through
its own structural port `LedEditorHost` (src/led-strip-editor.ts) — not the
HouseplanEditorHostPort — the same way the stairs editor does. bundleBytes
2 607 854 → 2 664 132: the three new LED chunks (runtime, field, editor),
their ru/de/fr dictionaries and the shared geometry chunk. Re-measured at the
end of the task.

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
Codex d21fba11d6 feat(card): the LED strip tool in the Devices editor (#780)
Stage 4 of #780 (ТЗ §4–§5). «LED strip» next to «Add» draws a chain with
clean clicks only (pan, pinch, a second finger, cancel and the synthetic
click after navigation add nothing), snaps to the grid and to physical wall
faces / zero-wall axes, stops at the first face of masonry, partitions,
columns and windows (doors, gates and passages are cut by geometry), Shift
gives 45°. Ctrl+Z removes the chain's own point first, Esc finishes, a
double click or a click on the first point (≥3 vertices) closes; fewer than
two distinct points write nothing. A finished strip opens the device picker
(lights first, taken markers explained, «New device…» binds in that
dialog's own write, «Later» keeps unbound geometry).

A selected strip shows its vertex handles (a drag re-checks both neighbours
and the whole path) and a new Devices tray branch: device settings,
bind/change, unbind, show as icon, delete. The device dialog gets the
representation section: show as strip (restores a hidden shape at once or
draws one for this marker, behind the dialog's own save/discard guard),
show as icon, unbind/delete for a hidden shape. Every geometry or
representation change is one optimistic write and one LED command of the
device history; Undo/Redo restores only its own strip record and refuses
when a newer change sits on it. A rebinding renames the link in the marker
save, a deleted marker leaves an unbound strip, a bound marker may not move
to another space. Plan/Background show strips as a passive translucent mark.

The tool, its `led` dictionary (en static, ru/de/fr lazy) and placement
geometry are a new lazy `led-strip-editor` chunk (9.5 KB gzip), loaded only
on the tool, an editable strip in the shown space or a strip device's
dialog. The initial graph gets the loader and delegation only
(src/led-strip-card.ts); the lazy editor graph +122 B, inside its ceiling.
Mutant anchors follow the moved code (marker dialog guard, static LED layer).

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
claude[bot]andCodex ed238059e1 refactor(card): the LED linear field is its own lazy chunk (#780)
ТЗ §13.1: the static card with light_pools:false must not load the linear
field. led-strip-field.ts (2.1 KB gzip) is a dynamic import of the LED
runtime (5.0 KB gzip), loaded only when a strip is on in a Glow room with
a light scene, with the same fingerprint handshake and hashed-URL retry
(manifest role led-field, retry token counted).

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
claude[bot]andCodex 8237bbc1cc feat(card): LED strips in the 2.5D View (#780)
Stage 3c of #780 (ТЗ §7). In the volumetric View the stripe is raised like
a device tile: body lifted by ISO_TILE.lift (0.075 D), the edge swept
ISO_TILE.depth (0.1 D) below it in isoEdgeColor, an inert blurred floor
shadow from isoTileShadow for the current theme and floor; D takes the
shared ISO_ICON_SCALE. The field stays on the floor plane, the hit path
moves with the raised body, everything is in plan units so zoom cannot
detach it. Editors and the static card keep Flat.

Issue: #780
User-Visible: no
2026-10-02 20:54:49 +03:00
claude[bot]andCodex 05d62f053a feat(card): LED strips on the static space card (#780)
Stage 3b of #780 (ТЗ §8). houseplan-space-card draws a represented marker
as a passive strip through the same lazy runtime: no icon, no auto slot, no
round pool, no hit path, focus or actions. The stripe uses the drawn wall
geometry for the face offset, so light_pools:false builds no barriers,
visibility or timers; the linear field appears only with light_pools:true
and Glow; with live_states:false the stripe is neutral. Strip points vote
in the content frame. Probed in the demo: plain card — one passive stripe
with the source-colour core, no field, no hit path; light_pools — the field.

Issue: #780
User-Visible: no
2026-10-02 20:54:49 +03:00
claude[bot]andCodex 3a1cd7b1ad feat(card): LED strips in View — lazy chunk, stripe, linear light (#780)
Stage 3a of #780.

- src/led-strip-gate.ts is the only initial-graph foothold (ТЗ §13.1): which
  markers a space shows as a strip (active and bound), the half-length
  anchor that replaces their icon position, and one page-wide load of the
  lazy led-strip-runtime chunk (fingerprint handshake, a hashed-URL retry on
  the next explicit entry, never in a loop).
- The card: a represented marker takes no auto slot, draws no icon, casts no
  round pool and is placed at the anchor; two call sites render the stripe
  layer and the linear field from the chunk. The space model carries the
  stored strips untouched; scaling, validation and geometry are in the chunk.
- src/led-strip-runtime.ts: the stripe is two strokes of one derived path
  (outline #383838 t, core t/2, round joins and caps, t = 0.08/0.12 D),
  white off, white core + field on with Glow, source-colour core without
  Glow, grey dashed unavailable without a field. The hit path takes the
  card's own device handlers (one action path) with radius max(22 px, t/2)
  and the nearest stripe as owner. The linear field is the exact distance
  field with the shared falloff: opaque grey bands of a luminance mask per
  piece, pieces joined by lighten (the maximum), each piece clipped to what
  its own emitters see, so a hidden part never lights through another part's
  visibility; buried emitters emit nothing; a failed clip is dark. A bounded
  per-space cache (50) counts geometry rebuilds.
- The initial View graph had 501 B of headroom. The furniture library copy
  (furn.*, 104 keys × 4 languages, editor-only) moves into a new lazy `tools`
  namespace (#627 mechanism) that the editor runtime awaits; the initial
  View graph is 298 686 B gzip with the LED gate in it (−1 879 B vs the base).

Tests: test/led-strip-runtime.test.mjs (6: representation, gate anchor =
geometry anchor, falloff, states and radius, per-piece clipping and buried
strips, bounded cache), bundle and i18n fixtures for the LED chunk and the
fourth namespace.

Issue: #780
User-Visible: no
2026-10-02 20:54:49 +03:00
claude[bot]andCodex fb3071d3c1 feat(card): pure LED strip geometry and the space model field (#780)
Stage 2 of #780. src/led-strip-geometry.ts (pure, not imported by the
initial graph): the anchor at half the polyline length; stripPieces and
visibleStripPath — a segment lying on a thick body face within
epsilonGeom is shifted t/2 into free floor, free floor and zero-wall axes
stay at 0, a face→floor transition is a connector without gap; emitter
samples epsilon outward on a face and none inside a body; placement that
stops at the first face and lets a strip touch and slide along it, a
vertex drag clamped on its path and both neighbours; the screen hit owner
with radius max(22 px, t/2) and a stable-id tie.

SpaceModel gains optional led_strips (render units, data only, no geometry
import in space-geometry.ts).

Tests: test/led-strip-geometry.test.mjs (10).

Issue: #780
User-Visible: no
2026-10-02 20:54:49 +03:00
claude[bot]andCodex 870237f3fb feat(backend): LED strips are stored, normalised and transferred (#780)
Stage 1 of #780 — the data model. A space carries an optional
`led_strips: [{id, points, marker, active?}]` (custom_components/houseplan/
led_strips.py, pure, strict mypy).

- Type schema inside SPACE_SCHEMA: 2–50 finite numeric points (no strings,
  booleans, NaN or off-canvas values), ≤50 strips per space including hidden
  shapes, strict boolean `active`, marker = non-empty string or null.
- A config-level step after coordinate canonicalisation judges the shape
  (two distinct points, non-zero length, a closed strip needs three distinct
  vertices, a hidden shape needs a marker, unique ids per space) and the links
  in the order the spec fixes: duplicates are rejected before any
  normalisation (two links to the same missing id still conflict); a link to
  a marker that is not live becomes an unbound strip (marker null, active
  true, id and points kept), so a client that does not know strips can delete
  a bound marker without its save failing; a live marker with an empty space
  adopts the strip's space; a non-empty foreign space rejects the write.
- config/set answers with `led_strips: {unbound, space_adopted}` when the
  write was normalised, so a new client re-reads; old clients ignore it.
- Space import remaps links through the marker id map; a skipped or
  virtualised duplicate leaves the strip unbound; a coinciding old id never
  binds. Plan-only export keeps geometry and nulls every link. Import details
  report `unbound_led_strips`, computed by the server, never read from the file.
- Coordinates get JSON-noise cleanup only, like stairs (face contacts are
  off-lattice), in both canonicalisers with a shared fixture case.
- Support package: counters only (total/unbound/hidden), no coordinates or ids.

Tests: tests_backend/test_led_strips.py (41, pure), test_ha_import_export
(6 cases: full round trip with a hidden shape, orphan count, remap against a
coinciding id, skip and virtual duplicates, plan-only), test_ha_websocket
(old client deletes a bound marker → save stands, counters, foreign space
rejects without a new revision). Full backend with the HA harness: 969 passed.
Mutating the shape check, the duplicate check, the orphan normalisation or the
space adoption each turns the pure suite red.

Issue: #780
User-Visible: no
2026-10-02 20:54:49 +03:00
Codexandclaude[bot] 9fa5efbdac docs(reviews): manual code review of the pending-round witness (#781)
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Manual review requested by the owner after the automated reviewer failed.
Material e931a949: green, no High/Medium findings, one non-blocking Low.

Issue: #781
User-Visible: no
2026-10-02 19:57:08 +03:00
Matyshandclaude[bot] 73b0cfff5b test(process): поймать оживление прошлого запроса (#781)
Issue: #781
User-Visible: no
2026-10-02 19:57:08 +03:00
claude[bot] 13d8df5554 docs: review document for #780
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Issue: #780
User-Visible: no
2026-10-02 10:47:04 +03:00
claude[bot] 9e1a2f4a07 docs(reviews): rebuild the review index after rebase onto dev 2026-10-02 10:18:38 +03:00
Matyshandclaude[bot] 16069d1dcc docs(reviews): independent code review of pipeline snapshot (#765)
Manual review requested by the owner after the automated reviewer failed.
Material af09d36d: green, no High/Medium findings, one non-blocking Low.

Issue: #765
User-Visible: no
2026-10-02 10:18:29 +03:00
claude[bot] 23f822475b fix(process): prepare and the usage step run pipeline scripts from one pinned dev snapshot (#765)
The body of _process.yml is read from dev (@dev, #623). After "Перейти на
ветку задачи" the working copy of job prepare is the task branch, and a
show/ship branch with a clean merge is not rebased before review: its
scripts/ may lag dev or be replaced. #749 fixed job integrate; prepare
still ran four control scripts from the material — the issue-body digest
for the anchor, --reuse of a green verdict (#499), validate-gate (#510)
and the spec-change check (#517). The material decided its own admission:
a branch whose review-doc-guard.mjs prints reuse=true merges without the
model. model_review took model-usage.mjs from the material too: a lagging
branch has none, and the publication silently wrote reason=missing.

Now prepare extracts one snapshot right after setup-node, before the
branch switch: `git rev-parse origin/dev` once and `git archive <sha>
scripts .github/workflows/validate.yml`, so the git fetch of the track and
rebase steps cannot mix versions. Every repo script of the job runs from
it via TOOLS — the track step and the rebase guard lose their own
extractions. The SHA goes out as job output tools_sha; the usage step of
model_review archives the same commit inside itself, so a snapshot failure
is a failure of the reporting step (continue-on-error), not of the stage.
The model session runs on that runner, so the usage line stays untrusted
input parsed strictly (#556, #737).

withMaterialAnchors is idempotent: a repeated call drops the separator the
previous call wrote instead of piling up `---` lines.

Tests: test/process-prepare-tools.test.mjs — the job contract (no step
calls scripts/ from the working copy, one pinned archive before the branch
switch, tools_sha reaches model_review) and the steps as they are, on real
bash and git: a branch behind dev without model-usage.mjs and with a
substituted review-doc-guard.mjs; the anchor digest, reuse and the spec
check come from dev, the usage line is data even after dev moved. Red on
the old workflow: all four. Harnesses of process-track, rebase-generated,
review-doc-guard and model-usage take the prepare snapshot. Three registry
mutants (reuse from the material, usage from moving dev, separators).

Canon: PROCESS.md §10.4 «Скрипты конвейера — из dev» covers prepare and
the usage step; «Расход модели» names it a pipeline step, not the reviewer's.

Issue: #765
User-Visible: no
2026-10-02 10:18:29 +03:00
claude[bot] ceee249210 docs: review document for #780
Issue: #780
User-Visible: no
2026-10-02 10:09:11 +03:00
Matysh d0c13bc555 Release v1.79.0-beta.2 candidate
Publish the 58 integrated S8 issues since beta.1. Synchronize versions,
rebuild committed frontend assets, refresh ratchets to measured facts,
and update release metadata. All 11 documentation screenshots were
accepted as pixel-identical in WSL, without replacing PNG bytes.

Owner-authorized manual ship review covers all 13 ship issues; High 0.
Local checks: build/typecheck, 3510 unit tests passed (30 skipped),
20 selected browser smokes, Stage 3 dense observable contract.
Full exact-SHA Validate remains mandatory before publication.

Release: v1.79.0-beta.2
Issue: #694
Issue: #703
Issue: #704
Issue: #705
Issue: #707
Issue: #708
Issue: #712
Issue: #714
Issue: #715
Issue: #716
Issue: #717
Issue: #718
Issue: #719
Issue: #720
Issue: #721
Issue: #722
Issue: #723
Issue: #724
Issue: #725
Issue: #726
Issue: #727
Issue: #728
Issue: #729
Issue: #730
Issue: #731
Issue: #732
Issue: #733
Issue: #734
Issue: #735
Issue: #736
Issue: #737
Issue: #738
Issue: #739
Issue: #740
Issue: #741
Issue: #742
Issue: #743
Issue: #744
Issue: #745
Issue: #746
Issue: #747
Issue: #748
Issue: #749
Issue: #750
Issue: #751
Issue: #752
Issue: #753
Issue: #754
Issue: #755
Issue: #756
Issue: #757
Issue: #758
Issue: #759
Issue: #760
Issue: #761
Issue: #762
Issue: #772
Issue: #775
User-Visible: yes
2026-10-02 07:55:09 +03:00
Matysh 11e9eb10df docs: manual ship review for v1.79.0-beta.2
Owner-authorized fallback after the review model stopped before reading code. All 13 ship issues reviewed; High 0, Medium 0, Low 0.
2026-10-02 07:54:50 +03:00
claude[bot] 044f09a6a5 docs(reviews): индекс после сдвига каталога (#744)
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Issue: #744
User-Visible: no
2026-10-01 23:05:23 +00:00
claude[bot] 92b89d9b80 docs: review document for #744
Issue: #744
User-Visible: no
2026-10-01 23:05:20 +00:00
Claude 2bcde13158 chore(monolith): raise the bundleBytes baseline for #744 (#744)
After the rebase onto dev (#762) the raw dist total is 2 607 854 B against
the 2 605 172 B baseline, +2 682 B, over the 2 000 B band. The growth is the
task's own and deliberate: the floor-geometry key module and its call sites
(src/floor-geometry-key.ts, houseplan-card.ts, clean-floor.ts) plus the
wrapper of the new lazy space-editor chunk that keeps the initial View graph
under its absolute budget (initial View 300 572 B gzip, headroom 494 B).
No other metric moved past its band (portMembers 347 -> 349, band 5).

Issue: #744
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-02 01:01:38 +03:00
Claude 7bbab4b61a docs(reviews): индекс после сдвига каталога (#744)
Issue: #744
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-02 00:56:24 +03:00
claude[bot]andClaude 9882046b86 docs: review document for #744
Issue: #744
User-Visible: no
2026-10-02 00:56:09 +03:00
Claude a86be350b1 perf(bundle): load the space-card GUI editor lazily to keep #744 under the initial View budget (#744)
The floor-geometry key of #744 (+~143 B gzip) put the initial View graph at
301 097 B gzip, 31 B over the absolute 301 066 B wall in
scripts/bundle-budget.mjs. The comment over that wall says the next growth
is paid by moving code into lazy graphs, not by raising the budget.

`src/space-card.ts` imported its Lovelace GUI config editor
(`src/space-editor.ts`, the `houseplan-space-card-editor` element)
statically, so every View paid for a form only the dashboard editor opens.
`getConfigElement()` now imports it on demand, exactly as
`houseplan-card.getConfigElement()` already does for `./editor`; Home
Assistant awaits the returned promise. The editor's dependencies (lit, i18n,
space-geometry) stay in the shared chunk, so the new `space-editor-*.js`
chunk holds only the element itself (~1.1 KB gzip).

Measured: initial View 301 097 -> 300 489 B gzip (-608 B), headroom to the
301 066 B budget 577 B. The beta ceiling (300 142 B, band 2 000 B above it,
#699) and the budget are unchanged; lazy editor/onboarding graphs are not
touched.

Issue: #744
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-02 00:56:09 +03:00
claude[bot]andClaude 3d68a2fe23 docs(reviews): индекс после сдвига каталога (#744)
Issue: #744
User-Visible: no
2026-10-02 00:56:09 +03:00
claude[bot]andClaude 2d7a1c9788 docs: review document for #744
Issue: #744
User-Visible: no
2026-10-02 00:56:04 +03:00
claude[bot]andClaude bb9aaefe2a docs: review document for #744
Issue: #744
User-Visible: no
2026-10-02 00:56:04 +03:00
Claude 6ed9aa1652 test(floor): judge both frames of a held resize drag (#744)
smoke_floor_geometry_cache failed in Validate run 36875756451 on one check,
ac2cPreviewWallStandsWhereAFreshCardDrawsIt, with every area check green.
The check read the moving wall only from the live layer (data-kind
"preview"). A host render during a held drag ends that layer: updated()
commits it, and nothing repaints it until the next accepted move. The
settled scene then draws the preview record itself, and its walls equal
those of a fresh card on that record. In the smoke, the "Room updated"
toast from the AC1 rename expires 3.5 s after the save, about when the drag
starts: locally the rename-to-drag gap is 3.47-3.6 s. On a faster runner
the expiry landed after the last move, so the check found no live path. A
light toggled in Home Assistant mid-drag gives the same red.

The drag now starts once the toast is gone, so the live frame normally
judges the live layer. If a host render still lands, the check judges the
settled union the same way; a stale union has none of the moved faces. A
new check takes the settled frame on purpose: a state change during the
held drag, then the union path and the areas must equal the fresh card's.
The live strips never pass through the floor-geometry caches, so the old
check stayed green with a floor key that ignores the preview. The new
check fails on that key, and a failure prints a diagnostic line naming the
judged layer and whether the settled union is the stored one.

Issue: #744
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-02 00:56:04 +03:00
Claude f5b20e07f6 perf(floor): key the floor-geometry caches by the floor's content (#744)
The physical bodies, the wall union pool, the inner room contours and the
clean floor carried the global config epoch in their keys. Every edit of
any floor bumps it, so after one edit every other floor was cold again:
in large-house the first visit to an untouched floor rebuilt its wall
union and paid ~0.7 s flat / ~0.65 s 2.5D instead of ~40-55 ms.

A floor's geometry reads only its own config record (spaceModels) and
constants, so the key is now a content fingerprint of that record
(src/floor-geometry-key.ts), remembered per epoch and per record object.
The geometry also reads the current floor's config next to the model it
is given; when those records differ the key covers both. The live resize
preview is its own record, so preview frames get their own key; the
editor runtime seeds the pool and re-keys the bodies through the same
reader. The stairs editor no longer clears the clean floors of every
floor: the stairs are part of the floor's record.

The #735 switch-cycle guard now also sees the union pool and the inner
contours (optional members of the large-house card contract, so an older
comparison bundle reads 0). smoke_floor_geometry_cache proves the warm other floor and the
invalidation against an independent card (multi-floor push with shared
walls, a stair, a resize preview and its cancel); two mutants guard it.

Issue: #744
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-02 00:56:04 +03:00
claude[bot] c4cb0649ae docs(reviews): индекс после сдвига каталога (#762)
Issue: #762
User-Visible: no
2026-10-01 21:00:48 +00:00
claude[bot] baf0136d9a docs: review document for #762
Issue: #762
User-Visible: no
2026-10-01 21:00:48 +00:00
Matyshandclaude[bot] 6149bcf866 refactor: keep room warm-draft construction in the lazy editor (#762)
Issue: #762
User-Visible: no
2026-10-01 21:00:48 +00:00
Matyshandclaude[bot] e2d38d4e16 test: await warm navigation request before checking its authority (#762)
Issue: #762
User-Visible: no
2026-10-01 21:00:48 +00:00
Matyshandclaude[bot] f2936813af fix: keep warm editor adoption subordinate to user navigation (#762)
Issue: #762
User-Visible: yes
2026-10-01 21:00:48 +00:00
claude[bot] a05458b6f3 docs: review document for #662
Issue: #662
User-Visible: no
2026-10-01 20:52:49 +00:00
claude[bot] 5021c48446 docs: review document for #662
Issue: #662
User-Visible: no
2026-10-01 20:33:15 +00:00
claude[bot] 8a3bf71ef1 docs(reviews): индекс после сдвига каталога (#740)
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Issue: #740
User-Visible: no
2026-10-01 18:38:03 +00:00
claude[bot] 97d40ea710 docs: review document for #740
Issue: #740
User-Visible: no
2026-10-01 18:38:00 +00:00
claude[bot] 8e4f417f51 docs: review document for #740
Issue: #740
User-Visible: no
2026-10-01 18:27:53 +00:00
Claudeandclaude[bot] b8f662cdf1 docs(screenshots): source fingerprint for #740 on the current dev — all frames pixel-identical
Validate on the conveyor's rebase eb439e81 failed preflight: the branch
changes visual sources (stairs), so the screenshot check is strict on it,
and the fingerprint was stale against the moved dev (#739, #742, #759).
The branch is rebased onto b84465e5 (inventory counts merged with #742:
lifecycle 89, total 204/200) and `docs:accept --identical` re-captured
all 11 frames: pixel-identical, only the source fingerprint moves.

Issue: #740
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 18:27:53 +00:00
Claudeandclaude[bot] d709ea110d perf(stairs): draw all treads of a stair as one path (#740)
A floor with stairs pays for them on every switch to it: the stair layer
is emptied on other floors, so Lit recreates every symbol on each return
and the browser lays out and paints it again. With 250 stairs (the
large-house fixture, the per-floor limit) that was 2,875 SVG elements and
about 40 ms per entry locally; each stair carried 3-7 separate tread lines
with four bound coordinates each.

The treads of one stair are now a single <path class="hp-stair-tread">
with one `M a L b` subpath per tread, in geometry order and with the
numbers the lines carried. Treads of one stair never overlap (straight:
parallel, >= 20 cm apart; spiral: inner ends >= 6.7 cm apart at the
3.6 cm stroke), so the path paints the same pixels at any opacity. The
outline points and the tread data are built once per cached geometry
object (cachedStairMarkup, weak keys), not on every render. The View and
plan-editor layers share the strings; outline, hit polygon, trapezoid,
arrow, attributes and handlers are unchanged. Floor 1 of the fixture
drops from 4,210 to 2,960 elements.

Witnesses: the unit test for the path data and its cache, and the
smoke_stairs markup checks in View and in the plan editor, are red on
dev. The stairs-view-tread-lines mutant restores the View lines.

Issue: #740
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 18:27:53 +00:00
claude[bot] 9f695d8a5d docs: review document for #762
Issue: #762
User-Visible: no
2026-10-01 17:53:24 +00:00
claude[bot] 0606a3664a docs(reviews): индекс после сдвига каталога (#748)
Issue: #748
User-Visible: no
2026-10-01 16:49:55 +00:00
claude[bot] ae3db2bacd docs: review document for #748
Issue: #748
User-Visible: no
2026-10-01 16:49:55 +00:00
Claudeandclaude[bot] c9bf16dec3 docs(reviews): индекс после сдвига каталога (#748)
Issue: #748
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 16:49:55 +00:00
claude[bot] 94bd932153 docs: review document for #748
Issue: #748
User-Visible: no
2026-10-01 16:49:55 +00:00
claude[bot] 134a7029b4 docs: review document for #748
Issue: #748
User-Visible: no
2026-10-01 16:49:55 +00:00
Claudeandclaude[bot] cf7a9cc4fc fix(process): reconcile canon and hints with the pipeline after #707–#730 (#748)
Five places still described the pipeline as it was before code that is
already in dev:

- the S3 hint of the task packet told the author to push the branch, while
  the spec lives in the issue body (§2.3, #517) and nothing is pushed
  before S5 (§11.8);
- process-gate printed «FAIL п.9 Gates: light» for a trailer nobody writes
  or reads, while §10.2 item 9 is the unimplemented release:prerelease
  verdict check. The check is removed; a contract test ties every RULES key
  to an implemented item of §10.2 and every finding number to a RULES key;
- §10.4 item 4 demanded a heredoc in run:, while #723/#730 and their tests
  demand the opposite: commit messages echo line by line into a file,
  comment and summary texts come from code;
- the ship merge comment, AUTHOR.md, REVIEWER.md and AGENTS.md named only
  the pre-beta document, though since #727 the night reads ship code first;
- the nightly publication committed «docs: ship review for nightly …
  перед бетой» with the beta step's Issue: #696. It now has its own
  subject (the document name), body and Issue: #727; the beta message is
  unchanged.

The browser-guard inventory note still said growth above 200 fails
mutation-gate --check; since #699 it is a guideline and --check warns. Its
counts now match the inventory: 205, lifecycle 90.

Issue: #748
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 16:49:55 +00:00
claude[bot] 146da62b0d docs(reviews): индекс после сдвига каталога (#775)
Issue: #775
User-Visible: no
2026-10-01 16:44:29 +00:00
claude[bot] cdfc6e59dc docs: review document for #775
Issue: #775
User-Visible: no
2026-10-01 16:44:28 +00:00
Matyshandclaude[bot] 09fb02cf15 fix(process): bind Validate resume to the current pending round
Issue: #775
User-Visible: no
2026-10-01 16:44:28 +00:00
claude[bot] dd86bf955a docs(reviews): индекс после сдвига каталога (#772)
Issue: #772
User-Visible: no
2026-10-01 16:20:19 +00:00
claude[bot] 97f38ecaae docs: review document for #772
Issue: #772
User-Visible: no
2026-10-01 16:20:14 +00:00
Matysh 50b163c60c fix(ci): close smoke selection and persisted-type risk blind spots
Issue: #772
User-Visible: no
2026-10-01 19:05:09 +03:00
claude[bot] 1f15ee143e docs(reviews): индекс после сдвига каталога (#743)
Issue: #743
User-Visible: no
2026-10-01 15:41:48 +00:00
claude[bot] 0d989b79e2 docs(reviews): индекс после сдвига каталога (#743)
Issue: #743
User-Visible: no
2026-10-01 15:41:48 +00:00
claude[bot] a6076e585e docs: review document for #743
Issue: #743
User-Visible: no
2026-10-01 15:41:48 +00:00
claude[bot] a2578e6f14 docs: review document for #743
Issue: #743
User-Visible: no
2026-10-01 15:41:48 +00:00
Claudeandclaude[bot] b76e0d38ef test(perf): the backdrop budget follows the 2.5D ceiling of #747 (#743)
#747 landed while #743 was in flight and set the switchCycleMs hardMaxMs
of the 2.5D family to 1550. The backdrop budget is the historical isometric
budget under its own profile id (#743 test), so after the rebase its 8000
became the only difference; it now carries 1550 like the rest of the family.

Issue: #743
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 15:41:48 +00:00
Claudeandclaude[bot] b18d366e8c test(perf): 2.5D backdrop twin profile judges one update pass per floor switch (#743)
No Full Performance profile walked the backdrop (imagePlan) path: every
large-house fixture has plan_url null. So the #739 K1 double render -- a
warm 2.5D floor switch with a backdrop cleared the ready paper, inserted
the veil, probed the card background and rendered a second time -- was
invisible to CI by time and structurally; a temporary probe found it.

large-house-isometric-backdrop-v1 is the twin of large-house-isometric-v1
with the shipped f1.svg under a URL of its own on every floor
(plan_aspect 1, room geometry unchanged). The variant is derived in the
runner as plan-snap's is, so demo/fixtures and the bundle fingerprint do
not change. A first stable frame without the backdrop image fails the
sample. After the switchCycle window and its #735 guard, before forced
GC and outside every timed window, a probe makes six warm switches and
counts performUpdate passes until updateComplete resolves true: the K1
second pass starts after the first updateComplete resolves, so a count
taken right after the first await reads one on both sides.

evaluate.mjs rejects a candidate of this profile unless every switch took
one pass, or when the probe is missing; the base is reported, not judged
(v1.78.0 and dev before #739 take two). The budget is a copy of the
historical isometric budget under the new profile id. performance.yml
gains the isometric-backdrop matrix entry with exact-SHA comparison.

Witness: a tree with #739 reverted reads perSwitch 2 in every sample and
benchmark:compare against the branch report throws on the pass count;
v1.78.0 reads 2, the branch reads 1.

Issue: #743
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 15:41:48 +00:00
claude[bot] 134e3e79da docs(reviews): индекс после сдвига каталога (#749)
Issue: #749
User-Visible: no
2026-10-01 15:35:16 +00:00
claude[bot] 30508e1a5c docs: review document for #749
Issue: #749
User-Visible: no
2026-10-01 15:35:16 +00:00
claude[bot] 3c58ad1e73 docs(reviews): индекс после сдвига каталога (#749)
Issue: #749
User-Visible: no
2026-10-01 15:35:16 +00:00
claude[bot] f4ec9d995b docs: review document for #749
Issue: #749
User-Visible: no
2026-10-01 15:35:16 +00:00
Claudeandclaude[bot] ff4e096858 fix(process): integrate runs every pipeline script from one dev snapshot (#749)
The body of _process.yml is read from dev (@dev, #623), so the flags and
formats it passes to scripts are dev's. After "Опубликовать документ ревью"
the working copy of job integrate is the task branch, and a show/ship branch
with a clean merge is not rebased before review: its scripts/ may lag dev by
days. review-doc-guard.mjs silently ignores unknown flags (the anchor lost
#726 route and #737 usage), and a stale merge-candidate.mjs merges the old
way. Only two calls (#723 push refusal, #726 route) were taken from dev, each
with its own extraction, and on ship/reuse the remaining ones ran dev's
version anyway: the script version depended on the path.

Now one step right after setup-node extracts
`git archive origin/dev scripts .github/workflows/validate.yml` into
$RUNNER_TEMP/dev-tools and every repo script of the job runs from there via
TOOLS (review-result-gate, review-doc-guard, reviews-index, merge-candidate,
process-track route, status-label). validate.yml is part of the snapshot
because workflow-jobs.mjs reads it relative to itself; without it ci-proof
answers `failed (#622)` and every code merge would return to S6. The working
copy stays the material: git, the document and paths are judged there.

PROCESS.md §10.4 gets the paragraph "Скрипты конвейера — из dev": the
model_review exception, merges of pipeline changes judged by dev's version,
and compatible edits of the Validate proof contract.

Tests: test/process-integrate-tools.test.mjs is the job contract (no step
calls scripts/ from the working copy, every call goes through the snapshot,
one archive from origin/dev with validate.yml, and the step as is yields a
directory where ci-proof resolves the job contract); publish-push-refusal
runs the publish step and the #413 step on real bash with a task branch whose
review-doc-guard.mjs exits 7 (red with the old call). Existing harnesses take
the snapshot step before the publish and decide steps; the #706 mutant anchor
follows the status-label call.

Issue: #749
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 15:35:16 +00:00
claude[bot] 5c0fe8e79f docs(reviews): индекс после сдвига каталога (#752)
Issue: #752
User-Visible: no
2026-10-01 15:34:54 +00:00
claude[bot] bf0bc1f4a6 docs: review document for #752
Issue: #752
User-Visible: no
2026-10-01 15:34:50 +00:00
claude[bot]andClaude 04084581c0 docs(reviews): индекс после сдвига каталога (#752)
Issue: #752
User-Visible: no
2026-10-01 18:21:52 +03:00
claude[bot]andClaude 341c9fcfce docs: review document for #752
Issue: #752
User-Visible: no
2026-10-01 18:21:31 +03:00
Claude b7dbc21c42 fix(process-metrics): task volume, merge return reasons and spec drafts (#752)
The weekly report disagreed with its own definitions (#728) and with the
pipeline texts that appeared after it (#705, #723, #729).

Volume. A task's volume counted documentation, so the archive move of #682
weighed 333 060 lines and #680/#681 thousands, all landing in the "> 1000"
bucket and shifting the cohort medians; and every commit with a Release:
trailer was dropped, including the task's own golden acceptance and test
re-pinning commits. Volume is now the +/- of class A and B files only.
Only beta commits are left out: the beta or release candidate (Release:
trailer plus the candidate subject or a changed bundle, bundle-policy.mjs,
drops every Release: commit, so it is not reused. A task whose commits
touch only docs/reviews/** has no volume and is no longer read as
infrastructure: the pipeline writes those documents, not the task.

Return reasons. Every non-merge outcome of merge-candidate.mjs fell to
"unknown". merge-candidate.mjs now exports a sign for the heading of each
outcome comment (OUTCOME_SIGNS; the step-failure text moved into
commentFor as 'error', byte for byte), and a test on the templates
themselves holds every case to its own sign. The report maps merge-stage
outcomes after a green verdict to "merge" and a push refused while
rebasing before review to the new "push-refused" reason.

Spec drafts. A new section after "По трекам" counts the S4-spec-review
epochs on the ask track for tasks whose first S5-ready falls in the
window, the epochs with a "Черновик:" comment (§7.2) and whether the draft
went to S5 or was thrown at S3, Spec-Draft: commits in dev for the window,
and S5 -> S7 per track for tasks with and without a draft, "мало данных"
under three. The snapshot also reads timelines of tasks in S5-S7.

Three #728 assertions pinned the old behaviour (a Release: fixture without
the candidate subject, and the rebase workflow refusal read as unknown);
they now expect the new definitions.

Issue: #752
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 18:21:30 +03:00
claude[bot] 14ea266112 docs(reviews): индекс после сдвига каталога (#746)
Issue: #746
User-Visible: no
2026-10-01 14:55:10 +00:00
claude[bot] 255677556f docs: review document for #746
Issue: #746
User-Visible: no
2026-10-01 14:55:10 +00:00
Claudeandclaude[bot] 953f15b967 fix(plan): write every room state's transparency as colour plus opacity (#746)
`.room { transition: 0.12s }` interpolates a room's colour and its
fill-opacity / stroke-opacity independently, and the visible opacity is their
product. The states without a fill kept their alpha in the colour with the
default opacity 1 (.overlay transparent, .yard rgba 0.14, .outlined rgba
0.06 / 0.55, .picked rgba 0.25), while .styled writes an opaque colour plus
fill-opacity: var(--room-fill-op). On a change between the two on the same
node one half rose while the other fell, and mid-way the room was darker than
at either end. Opening the space settings on a floor with no fill (the dialog
shows "no fill" as its own colour at alpha 0) flashed every room grey for
~0.1 s, 0 -> 0.241 -> 0; cancelling the dialog after a preview, entering and
leaving the plan editor briefly darkened the fill (0.18 -> 0.317 -> 0.06).

Every .room state now writes an opaque colour plus *-opacity, and
transparent only together with a zero opacity. The transition itself,
.styled and the --room-* variables are unchanged; the space card takes the
same styles. The resting paint is the same: the witness records each state's
colour and visible opacity as dev drew them, and screenshots of seven resting
states (View without fill, with fill and borders, plan editor, room picked for
a merge, yard with and without borders, yard in the plan editor) are
pixel-identical to dev outside the plan editor's tool hint, whose text shifts
by a sub-pixel between runs on dev too.

Witness: new demo/smoke_room_fill_transitions.mjs, deterministic. A
MutationObserver pauses the room's transitions at their first frame right
after Lit commits, and the smoke seeks them through 0..120 ms in 15 ms steps.
Red on dev: fill overshoot 0.241 / 0.125 / 0.137 / 0.134 on the four paths,
stroke 0.241 / 0.242 on the first two. A real colour change of the same room
still runs a fill transition (catches `transition: none`).

A card-mod rule that sets only `fill` on an unfilled room now meets
fill-opacity 0; CHANGELOG and STYLING-HOOKS say to set the opacity with the
colour (the values are generated, STYLING-HOOKS §3.3).

Issue: #746
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 14:55:10 +00:00
claude[bot] c86e4bdd31 docs(reviews): индекс после сдвига каталога (#761)
Issue: #761
User-Visible: no
2026-10-01 14:35:55 +00:00
claude[bot] 24ea768609 docs(reviews): индекс после сдвига каталога (#761)
Issue: #761
User-Visible: no
2026-10-01 14:35:55 +00:00
claude[bot] 30dcc5b2cb docs: review document for #761
Issue: #761
User-Visible: no
2026-10-01 14:35:55 +00:00
Claudeandclaude[bot] a22f6f5411 fix(process-metrics): count model tokens only for the report window (#761)
tokenUsage summed the usage line of every review document in HEAD: the
report had no window, and every week repeated the whole history.

A document now enters a week's tokens when the commit that added it to
dev falls in [since, until]. fetchSnapshot reads the committer date from
git log -M --diff-filter=AR over docs/reviews and legacy/reviews:
an add sets the date, a rename (the #682 archive move) carries it to the
new path instead of adding the document again. The "missing" count of
#737 (hp:usage-none) follows the same window. ship findings keep reading
every SHIP-REVIEW document; only the token sum is windowed. Without the
date map (a unit over ready documents) there is no window, as before.

Proof is a temporary git repository with dated commits: a document
outside the window, one inside, an hp:usage-none pair on both sides and
an archive move inside the window; only the inside documents count.

Issue: #761
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 14:35:55 +00:00
claude[bot] aa910c31a3 docs(reviews): индекс после сдвига каталога (#751)
Issue: #751
User-Visible: no
2026-10-01 14:31:06 +00:00
claude[bot] 72975e7514 docs: review document for #751
Issue: #751
User-Visible: no
2026-10-01 14:31:06 +00:00
Claudeandclaude[bot] 25001ef7ab fix(ci): pipefail before every | tee, API base from GITHUB_API_URL (#751)
No workflow sets `shell:`, and GitHub runs such a step as `bash -e {0}`,
without pipefail: the exit code of `… | tee` is tee's, and a failing left
side passed silently. Three steps were unprotected:
- _process-resume.yml: an exception of process-resume.mjs (gh, API) left the
  step green and the resume event was lost until process-reconcile;
- release-review.yml: a failed `prepare` went on with an incomplete
  GITHUB_OUTPUT and proceed=true;
- validate.yml: a failed `classify-changes.mjs --heavy` left `heavy` empty,
  heavy jobs were skipped and job `changes` stayed green.
Each gets `set -o pipefail` as the first line of `run` (validate.yml's step
becomes a block), following #727 and #472. test/workflow-pipefail.test.mjs
walks every .github/workflows/*.yml: a `| tee` line in `run` must follow
`set -[a-z]*o pipefail` or the step must have `shell: bash`; on the old tree
it names exactly the three places, and the _process-resume and validate
steps run on real bash under `bash -e` with a failing node.

ci-proof.mjs exports githubApiBase(env) (GITHUB_API_URL or
https://api.github.com, no trailing slash); githubCandidateTree,
loadGithubProofContext and release-gate's workflowRunsUrl take `apiBase`
with that default instead of the hardcoded host. night-red.mjs passes the
base directly and drops the fetch wrapper that rewrote the prefix. On
github.com the runner's GITHUB_API_URL is the same host, so behaviour there
does not change; archive_download_url stays as the API returned it.

The `mode` input for ship-review is out of scope (thin file in main, #716).
Thin files are not touched: _process-resume.yml is a body, validate.yml and
release-review.yml are not thin.

Issue: #751
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 14:31:06 +00:00
claude[bot] d93bcf2628 docs(reviews): индекс после сдвига каталога (#750)
Issue: #750
User-Visible: no
2026-10-01 14:27:08 +00:00
claude[bot] 09bf6a95ec docs: review document for #750
Issue: #750
User-Visible: no
2026-10-01 14:27:08 +00:00
Claudeandclaude[bot] de3e1f00e1 fix(process): free the reviewer prompt budget, rules stay in REVIEWER.md (#750)
The code-review prompt sat at exactly 1 400 of its 1 400 words (#634), so
any new line turned the budget test red, and #707/#726 already had to route
their notes through job outputs. Part of the text was dead or a retelling
of the reviewer digest, which #634 says the prompt must not repeat:

- the mutants fragment of the track line: since #709 `mutants` is always
  false, so «прогнаны Validate» was unreachable;
- «Отсутствие мутантов по диффу — не находка» in the show line: a rule of
  every track, already in REVIEWER.md «Трек show» and §10.4;
- three retellings — the repeated round, the gate scope and the severity
  paragraph — now one-line references to the REVIEWER.md sections. The ban
  on a separate issue for an in-scope Medium stays in the prompt: the model
  files issues itself, and that mistake is expensive.

What only the prompt said moves into REVIEWER.md with links to the canon:
the spec delta is the diff of the issue body, a doubt about locality means
a full review with a stated reason, the three smoke-select answers
(docs/TESTING.md), and geometry without invariants in the report is an
unrun gate.

The prompt is now 1 130 words; the 1 400 threshold stays, the difference
is headroom. A new test ties every «docs/process/REVIEWER.md, «X»»
reference in the prompt to an existing `## X` section.

Issue: #750
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 14:27:08 +00:00
claude[bot] 653d94ef09 docs(reviews): индекс после сдвига каталога (#753)
Issue: #753
User-Visible: no
2026-10-01 14:03:19 +00:00
claude[bot] 475f391cf9 docs: review document for #753
Issue: #753
User-Visible: no
2026-10-01 14:03:19 +00:00
claude[bot] 3078962b64 docs(reviews): индекс после сдвига каталога (#753)
Issue: #753
User-Visible: no
2026-10-01 14:03:19 +00:00
claude[bot] 6abb589f0c docs: review document for #753
Issue: #753
User-Visible: no
2026-10-01 14:03:19 +00:00
Claudeandclaude[bot] 4aa6c85f98 fix(process): the #562 infra entry no longer covers an issue in S3/S4 (#753)
Rule 8 skipped the status check for any range without class A files, so a
task in S3-spec or S4-spec-review could push a branch of tests, demo or
scripts with only a warning. §11.8 forbids exactly that: before S5 neither
class A commits nor the branch itself is pushed, because the spec-review
step takes the freshest origin/issue/<NN>-* as its material and lays the
SPEC-REVIEW document there, putting code in front of a spec reviewer who
must not read it (§2.4).

The #562 entry was written for a task before its first S status. The
decision is now made per issue in checkIssueStatuses: the status stays
optional only when the range is infrastructural and the issue carries
neither S3-spec nor S4-spec-review. Such an issue gets a rule 8 refusal
naming its status and §11.8; the range-wide #562 warning is still printed.
No status, S1-new/S2-analysis (reviewer-filed infra issues) and S5-S8 keep
their old outcome; closed, blocked and fail-closed checks are untouched;
rule 10 is still called only for ranges with class A.

PROCESS.md §10.2 gets the one-sentence exception, the mutation registry a
mutant that drops the S3/S4 condition.

Issue: #753
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 14:03:19 +00:00
claude[bot] 9deed2a3c3 docs(reviews): индекс после сдвига каталога (#756)
Issue: #756
User-Visible: no
2026-10-01 13:56:37 +00:00
claude[bot] 80b63b7ee8 docs: review document for #756
Issue: #756
User-Visible: no
2026-10-01 13:56:37 +00:00
claude[bot] 024583bd22 docs: review document for #756
Issue: #756
User-Visible: no
2026-10-01 13:56:37 +00:00
Claudeandclaude[bot] f0e1c0b8cc fix(warm): revive the editor draft when write access arrives late (#756)
A same-route remount that cannot edit yet - hass arrives after the
element is inserted (the demo's own order), or a non-admin waits for the
server's can_write - keeps the editor in _pendingNavMode and enters it
later through _resumePendingNavMode -> _setMode. The draft revival was
wired only into the immediate warm adoption (_requestMode(..., adopt)):
on the pending path the draft was lost, _warmRevivePending stayed up for
the instance's life, _warmSnapshot stopped writing dlg, and the next
remount brought back the predecessor's stale draft.

The adoption tail (draft revival once under a held refit, then the
settled stage as the refit baseline) is shared by both paths: the
sequencing lives in src/warm-mode-adoption.ts, the refit bookkeeping in
the card's _holdWarmRefit/_releaseWarmRefit. The pending mode still enters through _setMode, the transition
authority smoke_nav_persist holds it to; resumeWarmMode then settles the
revival. _setMode ends the passive boot grace and refits the camera to
a header measured before the editor chrome rendered, so a camera the
pending window left untouched is put back and held exactly as an
immediate adoption holds it; a camera that has already moved on (View
refit, the user's pan, another space) is left to the ordinary refit. A
mode that did not commit, an explicit mode command in the pending window
and a route departure settle the revival too: no outcome leaves
_warmRevivePending up. The core file gives back 4 lines.

smoke_warm_dialogs gains section H through the UI: the three late-write
orders keep mode, draft, dirty baseline and a frame-by-frame identical
viewport; the chain carries this instance's draft, not the predecessor's;
a space switch in the pending window eats the draft. 14 checks are red
on dev. The mutant warm-pending-mode-leaves-revive-waiting is guarded by
the smoke; docs/WARM-REMOUNT.md §2 describes the pending editor.

Issue: #756
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 13:56:37 +00:00
Claudeandclaude[bot] 86a301f618 fix(ci): the screenshot fingerprint is only a warning on a task branch (#760)
The pipeline dispatches a full Validate for a `ci:golden` task, and
`screenshotsGateMode` read `full=true` as strict on any ref. Between betas
the source fingerprint on dev is legitimately stale (#479: re-captured for
the beta candidate), so every visual task failed preflight on the
conveyor's material until its author re-ran `docs:accept --identical` on
the current dev - #718 and #740 both did, and two visual tasks in a row
could not merge without it. On a task branch the mode is now `warn` even
with `full=true`; dev, main, PRs, the schedule and Release: candidates stay
strict.

Issue: #760
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 13:51:39 +00:00
claude[bot] 1abd65a2be docs(reviews): индекс после сдвига каталога (#747)
Issue: #747
User-Visible: no
2026-10-01 13:42:35 +00:00
claude[bot] 62a2e3b05b docs: review document for #747
Issue: #747
User-Visible: no
2026-10-01 13:42:35 +00:00
Claudeandclaude[bot] fa18ca81c9 test(perf): pull the switchCycleMs ceilings down to the warmed level (#747)
Since #735 switchCycleMs times the warmed twelve-switch cycle, and the
absolute ceilings of 7000 ms (flat) and 8000 ms (2.5D) sat 7.6-10.2
times above the level. performance_smoke judges only these ceilings, so
between full runs the warm floor switch that #694/#725 just sped up was
guarded only against a several-fold collapse.

Series: every Full Performance run after #735, both sides (the base is
measured by the candidate runner, so it is warm too), 7 samples each -
36821241343 (#735, base 76558bf2), 36838891001 (#740), 36838952536
(#742) and 36839009721 (#739), the last three against dev 7ff2b5ae.

  flat  large-house-v1 / plan-snap / interaction   666.9-812.7 ms
  2.5D  large-house-isometric / stage3-dense       766.5-1333.9 ms

The 2.5D maximum is the dense pair of 36838952536, whose base on the
same runner read 1249.7 ms against 849.9-982.4 ms elsewhere: runner
noise the series is meant to contain. No 3-sample performance_smoke
median is in the series yet; those profiles join Validate only on a
src/** diff.

Rule (as #692, #675 falls in the same band): one number per family, the
first multiple of 50 ms at or above 1.15 x M and no higher than 1.2 x M,
M being the family's maximum median: flat 1.15 x 812.7 = 934.6 -> 950
(+16.9 %), 2.5D 1.15 x 1333.9 = 1534.0 -> 1550 (+16.2 %). One number
per family keeps the smoke = full (#473 AC4), plan-snap/interaction
"every original ceiling" and dense = historical (#160) contracts; the
price is wider headroom for the faster profiles. The base-relative
comparison of the full workflow (0.35 / 0.2, 250 ms) is unchanged and
stays the detector for smaller growth.

The new test pins both families: one ceiling in every file of a family,
every point of the series passes the smoke budget with --absolute-only,
the ceiling follows the rule and stays inside [1.15, 1.2] x M, doubling
the level fails, and the full profiles' ratio and noise allowance are
unchanged. 7000 left in any flat file or a ceiling under 1.15 x M reds
it. The README records the series and the reasoning.

Issue: #747
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 13:42:35 +00:00
claude[bot] ffa13c6628 docs(reviews): индекс после сдвига каталога (#745)
Issue: #745
User-Visible: no
2026-10-01 12:30:46 +00:00
claude[bot] 5e1b508215 docs: review document for #745
Issue: #745
User-Visible: no
2026-10-01 12:30:45 +00:00
claude[bot] 88702eedc6 docs(reviews): индекс после сдвига каталога (#745)
Issue: #745
User-Visible: no
2026-10-01 12:30:45 +00:00
claude[bot] d4eea609cf docs: review document for #745
Issue: #745
User-Visible: no
2026-10-01 12:30:45 +00:00
Claudeandclaude[bot] dc77852e0b fix(space-card): key room shapes by space and id so a config change never repaints a stranger (#745)
The space card drew its rooms with a bare map(), so Lit reused room nodes by
position, and `.room { transition: 0.12s }` (planStyles is part of this card's
styles too) drew a node's fill and stroke in from whichever room held it
before. Two paths change the room set in the same DOM: a new `space` in
setConfig of the same element (the card editor's preview), and a config event
from any device that inserts, removes, reorders or re-zones a room of the
shown space. Filled rooms faded out and back in for ~0.12 s, unfilled ones
briefly darkened in a filled room's place.

The list is now keyed(space.id, repeat(rooms, (r, i) => r.id || i, ...)),
the same shape as the full card after #742: the outer key handles the space
change, the inner one keeps a node bound to its room inside a space. An
id-less room keys by its numeric index, which never equals a string id. The
transition itself stays: it smooths a real fill change on the same room. The
#742 note in plan.styles.ts now names the space card as well.

Witness: a new section of smoke_space_card. The config is delivered by a
server push (__hpTest.setServerConfig), the event the card subscribes to.
Red on dev: node r1 reused for g1 with fill/fill-opacity transitions and a
first-frame fill of rgba(0, 0, 0, 0) / 0; a room inserted first shifts all
four nodes and replays fill transitions. A real custom_fill change still runs
a fill transition on the same node (catches `transition: none`). One mutant:
inner key replaced by map(), guarded by AC2 (checked by hand: red).

Issue: #745
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 12:30:45 +00:00
claude[bot] f10c5b4374 docs(reviews): индекс после сдвига каталога (#754)
Issue: #754
User-Visible: no
2026-10-01 12:24:59 +00:00
claude[bot] 9e64fc80ee docs: review document for #754
Issue: #754
User-Visible: no
2026-10-01 12:24:59 +00:00
Claudeandclaude[bot] d8e09cd1a0 test(harness): close three smoke-select and fixture blind spots (#754)
Three independent blind spots in the test harness.

1. smoke-select read symbols only from changed lines of a --unified=0
   diff. An edit to the arguments of a multi-line call names nothing:
   #741 (d5bdfde9) changed only the arguments of
   runtime.resolveIsoOverlayFitEnvelope({ on the line above, and the
   selection answered "unproven" plus the visual minimum, although the
   callee is registered in smoke-links for smoke_iso_flat_parity and
   smoke_isometric_contract - the two smokes the #741 author ran by hand.
   The selection diff now carries CALL_CONTEXT_LINES = 3 lines of
   context; for each changed line parseDiff looks for the nearest
   unclosed "(" above it within the hunk, walking through a literal
   argument ({ or [ after "(", "," or "["), stopping at ";" on depth zero
   or any other unclosed brace. A callee from the symbol table joins
   symbols and the new callees field and is marked "(вызов)" in the
   report. Context lines never give direct symbols. task-packet takes a
   separate context diff for selectSmokes; change-risk keeps --unified=0.
   Over the last 80 src commits of dev: 16 commits gain a callee, 2 move
   from unproven to a proven link (#741, #724 5f8e8ca7), +15 smokes in
   total, at most 4 per commit, none lost.

2. The #732 dead-field check judged only scene-builder calls. The four
   resolveIsoOverlayFitEnvelope({...}) literals in iso-scene-render tests
   went straight into the test-build function, so stageSize: null (the
   field #741 removed) stayed green. They now go through overlayFit typed
   with OverlayFitFixture (keys of IsoOverlayFitEnvelopeInput); the check
   judges overlayFit/resolveIsoOverlayFitEnvelope calls like the scene
   builders, and its probe asserts that OverlayFitFixture rejects
   stageSize, so the type resolved to the real input and not to any.

3. smoke_backdrop's mode() called the private _setMode and slept 220 ms.
   It now enters a mode through __hpTest.setMode and waits for the end of
   the transition by the same markers as section 6b (#715): one page
   helper used by both. Oracles and the 59 check names are unchanged.

Witnesses: d5bdfde9 selects both iso smokes with no "unproven"; the same
fixture without context lines is unproven again; attribution disabled
reds both AC1 units. stageSize: null in an overlayFit call reds the first
#732 test; a direct resolveIsoOverlayFitEnvelope({...}) reds the third.
smoke_backdrop is green normally and with animation frames slowed to 60
and 150 ms; a stage animation that never ends fails with a named error.

Issue: #754
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 12:24:59 +00:00
claude[bot] da8edb1f0e docs(reviews): индекс после сдвига каталога (#755)
Issue: #755
User-Visible: no
2026-10-01 12:21:13 +00:00
claude[bot] 1265036d2a docs: review document for #755
Issue: #755
User-Visible: no
2026-10-01 12:21:13 +00:00
Claudeandclaude[bot] a7b02db609 fix(process): import and type-only lines no longer raise risk (#755)
The risk table of #707 judged every non-comment line of a class A file as
code. On the history since 15.09 that raised #741 from ship to show for a
removed interface member that never reaches JS, and put false classes on
#624 (removed imports), #693/#694 (stairs-view is rendering, not geometry)
and #725 (the config fingerprint memo is not the config schema).

- Lines of module syntax and TypeScript types give no risk, like comments:
  `import …`, `export … from …`, `export type …`, the head of `interface X`
  or `type X =`, and the lines inside such a block (indented, plus the
  closing line). The block state per side of a change block starts from the
  hunk context git writes after `@@ … @@` and follows every unindented line
  of the block, so a member under `@@ … @@ export interface X {` and a whole
  interface added in one hunk are judged alike. Only `.ts`, and not in the
  `migration` area: there the types are the config contract (#588, #649).
- `stairs*` is narrowed to the stairs model (`stairs`, `stairs-box`,
  `stairs-editor-model`); `config-*` to writing and adopting the config
  (`config-adoption`, `config-store`, `config-reload-authority`,
  `config-write-conflict`).
- A replaced line is one piece of evidence: a removed line whose counterpart
  in the same change block hits the same class is folded into it instead of
  printing `path:N (удалена)` next to `path:N`.

classifyRisk stays a pure function over the diff text. On the history the
raising classes change for #741 (none), #693 (visual only), #694 (no
geometry), #725 (perf only) and #624 (no devices/perf); migration on #588,
#612, #649 and #661 stays. PROCESS.md §5 names the new exemption.

Issue: #755
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 12:21:13 +00:00
claude[bot] 8070357266 docs(reviews): индекс после сдвига каталога (#737)
Issue: #737
User-Visible: no
2026-10-01 12:18:13 +00:00
claude[bot] 2095a40f41 docs: review document for #737
Issue: #737
User-Visible: no
2026-10-01 12:18:13 +00:00
Claudeandclaude[bot] 9c4a45a6b2 test(pre-push): the hook fixture carries model-usage.mjs (#737)
Validate on the conveyor's rebase d1954183 was red on one unit: the real
pre-push hook test (#633 AC1) copies every module the hook runs into a
temporary repo, and since #729 process-gate pulls in review-doc-guard,
which now imports scripts/model-usage.mjs. The copy lacked it, so the hook
died on ERR_MODULE_NOT_FOUND instead of reporting a red gate:small. The
module joins HOOK_FILES next to the #729 ones.

Issue: #737
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 12:18:13 +00:00
Claudeandclaude[bot] f5a6b47b39 feat(process): the pipeline records model usage in the review document's machine block (#737)
The weekly process metrics weigh tracks and the nightly ship review in the
order quality, speed, tokens (#707), but the third axis had no source: the
claude-code-action step hides usage from the Actions log on purpose, nothing
read its execution_file, and the #728 reader printed "no data" every week.

scripts/model-usage.mjs is the single module that builds and parses the line:
`<!-- hp:usage input_tokens=N output_tokens=N cache_creation_input_tokens=N
cache_read_input_tokens=N num_turns=N -->` (sums over every model in the last
`result` message, `result.usage` when modelUsage is absent) or
`<!-- hp:usage-none reason=<code> -->`. Only the result message is read; the
rest of the file holds tool results, and no byte of it is printed.

A new step right after Review in both model_review jobs (always(),
continue-on-error) hands the line out as the job output `usage`. Usage is a
reporting figure like the stage duration, so it travels as a job output and
not through the sealed artifact: REQUIRED_FILES and the #556 gate are
unchanged. Publication treats the line as untrusted input and writes the
normalized form as the last line of the anchor block (review-doc-guard
--anchor --usage=) or right after the SHIP-REVIEW block; empty becomes
reason=missing, anything off-format reason=invalid.

The #728 reader now takes the line only from the machine block: a reviewer
quoting the previous round in prose no longer doubles its usage, and
"no data" is counted as missing, never as zero. PROCESS.md §10.4 documents
the source, the format and why it is a job output.

Issue: #737
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 12:18:13 +00:00
claude[bot] 707cb18f7e docs(reviews): индекс после сдвига каталога (#757)
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Issue: #757
User-Visible: no
2026-10-01 12:12:15 +00:00
claude[bot] 929044b932 docs: review document for #757
Issue: #757
User-Visible: no
2026-10-01 12:12:15 +00:00
Claudeandclaude[bot] 4091d83af3 fix(editor): a render no longer restarts a failed runtime load (#757)
The lazy-runtime contract (#353) says a non-terminal failure waits for
the next explicit intent and that there are no background retries. But
_renderBody calls ensure() on every repaint while a surface waits for
the editor or onboarding runtime, and to the loader that call was
indistinguishable from an intent. Surfaces the core opens without the
runtime - the kiosk size dialog after a 3 s hold, the floor import
wizard on an empty plan, a dialog a warm remount revives - therefore
turned one failure into a loop: the loader's own state change, the
toast and its expiry, every hass tick repainted, started a new cycle
and showed a new toast every ~3.5 s. A wall tablet whose old hashed
chunks answer 404 after an integration update sat in that loop forever.

EditorRuntimeLoader.ensure takes an intent: the render calls it as
'reconcile'. A reconcile starts the first cycle a surface needs, but
after a non-terminal failure it returns false without loading until an
explicit ensure() - a tab, an opener, _requestMode, "Add space" - has
started a new cycle. Explicit calls, the terminal fingerprint failure,
ready and an in-flight cycle behave as before, for every loader
instance. The card's render lines stay line-neutral.

smoke_lazy_editor_chunk gains the three surfaces offline through their
real paths (a 3 s touch hold on a kiosk card, an empty plan pushed by
the server, General settings revived by a remount): one cycle, one
notice and an idle loader over 8 s, then the Plan tab and "Add space"
heal. On dev: 4 requests / 3 notices, 6 / 2 and 3 / 2. The loader unit
test pins reconcile versus intent; the mutant
render-reconcile-restarts-editor-runtime-cycle is guarded by the smoke.

Issue: #757
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 12:12:15 +00:00
Claude b84465e541 test(smoke): footer-width waits for the locale gate and its own dialog (#759)
smoke_dialog_footer_width switched the language by assigning
card._config and then measured the first hp-dialog in the tree. Since
#627 the main catalog for de/fr and the editor's settings/support/topology
dictionaries for ru/de/fr are lazy chunks; while one is in flight the
language gate keeps the previous frame (inert, aria-busy) and the dialog
is not rendered. The old wait only covered de and only the main catalog
(card._t('btn.save') === 'Speichern'), so under load the first dialog
after a switch (opening in ru/de) was read from the held frame and four
checks went red on a zero row.

Both page.evaluate blocks now wait by condition, like
smoke_dialog_polish_603 (#712): first for the gate's own markers (no
aria-busy, lang equals the requested language), then for
hp-dialog[data-kind=<kind>] to have its .dialog-action-footer laid out,
with a 5 s deadline and a named error. The measurement reads the dialog
of the requested kind instead of the first hp-dialog. Checks, names and
thresholds are unchanged (same 36 names under HP_SMOKE_CHECKS=1).

Runs on the branch: 10/10 sequential, 12/12 in 6 rounds of two parallel
copies (dev: 9/12 red under the same load); green with ru/de chunks
delayed 400 ms and 1500 ms and with only the editor dictionaries delayed
150 ms. Sabotage still bites: opening --hp-dialog-wide-width 560px reds
opening_*_medium_shell, physical footer buttons min-width 170px red
physical_*_three_actions_one_row and _positive_localization_headroom,
and an opening dialog that never renders fails with a named error.

Issue: #759
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 14:12:57 +03:00
claude[bot] 7ce3f6629f docs(reviews): индекс после сдвига каталога (#742)
Issue: #742
User-Visible: no
2026-10-01 10:13:50 +00:00
claude[bot] 33a787a151 docs: review document for #742
Issue: #742
User-Visible: no
2026-10-01 10:13:50 +00:00
Claudeandclaude[bot] a0f72280ee fix(plan): key room shapes by space and id so a floor switch never repaints a stranger (#742)
The flat room list was a bare map(), so Lit reused room nodes by position.
On a floor switch the previous floor's room node became the new floor's room
and `.room { transition: 0.12s }` drew its fill and stroke in from the old
computed values: one paper-white frame, then two or three frames darker than
the final fill (alpha rises while fill-opacity falls), then the fill. Between
two filled floors the fill bled in from the other floor's colour.

The list is now keyed(space.id, repeat(rooms, (r, i) => r.id || i, ...)), the
shape #534 settled on for openings and markers. The outer key handles the
floor switch, including room ids repeated on two floors (ids are unique only
within a space); the inner key keeps a node bound to its room inside a space,
where inserts, merges, splits and the editor filter shift positions. An
id-less room keys by its numeric index, which never equals a string id. The
transition itself stays: it smooths hover and a real fill change on the same
floor.

Witness: a new section of smoke_space_switch_transitions, before physicalize
(after it the first room changes template branch and the node is recreated
anyway). Red on dev: five transitions on g1, node r1 reused for g1, computed
fill rgba(0, 0, 0, 0) / 1; room nodes swapped by an insert; same-id case
reuses r1. A real custom_fill change still runs a fill transition (catches
`transition: none`). Two mutants: inner key removed, outer key removed.

Issue: #742
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 10:13:50 +00:00
claude[bot] 88985d2e4c docs(reviews): индекс после сдвига каталога (#739)
Issue: #739
User-Visible: no
2026-10-01 10:06:38 +00:00
claude[bot] c629c9c238 docs: review document for #739
Issue: #739
User-Visible: no
2026-10-01 10:06:38 +00:00
Claudeandclaude[bot] 9324d81b5f perf(iso): keep the theme paper across floor switches in 2.5D (#739)
In 2.5D with a backdrop image every floor switch rendered the card twice
before the first frame. The paper key of the first-frame state (#654)
held the space id, so each switch cleared the ready paper: the first
render inserted the loading veil, updated() probed the computed card
background with a temporary span and asked for a second full update,
which removed the veil again. The colour itself never changed: it is the
theme card background, and no space sets those variables. Locally this
second pass was about 50 ms per warm switch on the large house.

The paper under a backdrop is now resolved once per theme identity
(dark mode, default and dark default theme, theme) and card mode. The
state keeps the resolved paper of the current theme and mode beside the
current paper, so a floor with a backdrop is ready in prepare() when that
paper is known -- also after a drawn floor in between -- and the switch
renders once: no veil, no probe, no second update. A drawn plan keeps its
white paper without the DOM. Any change of the theme identity or the
mode, also one made in Flat or in an editor, drops the kept paper, so the
first backdrop floor after load, a theme change and a trip to an editor
take the #654 path unchanged. isoPaperContext still takes the floor; it
deliberately leaves it out of the identity.

Witnesses: the #739 unit test is red on dev at "a floor switch shows no
veil" and on a key-only variant (space dropped, no theme cache) at
"drawn -> backdrop keeps the known theme paper"; the new
smoke_iso_floor_switch is red on dev (2 updates, 1 colour probe and a
veil insertion in every click task). The iso-paper-resolved-per-floor
mutant puts the floor back into the theme identity.

Issue: #739
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 10:06:38 +00:00
claude[bot] 37238f3990 docs: review document for #744
Issue: #744
User-Visible: no
2026-10-01 10:04:12 +00:00
Claudeandclaude[bot] 5a49c8c32d test(moon): the static-sky AC15 test waits for the moon chunk itself (#758)
The scheduled mutation gate runs the #718 guard alone
(`--test-name-pattern="#718"`), and there the AC15 test was red on clean
code, so three shards failed with "guard red without a mutant". The test
was synchronous and relied on `#661 C7`, earlier in the file, having
loaded the lazy moon chunk; until the chunk arrives `moonLayer` returns
`nothing` by design (#661 C7). The test now awaits the chunk through
`withMoon` before its checks. The guard command is green alone (6/6) and
the whole file stays green (20/20).

Issue: #758
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 09:05:19 +00:00
claude[bot] 8b7725acb1 docs(reviews): индекс после сдвига каталога (#729)
Issue: #729
User-Visible: no
2026-10-01 08:57:25 +00:00
claude[bot] c84c430b2e docs: review document for #729
Issue: #729
User-Visible: no
2026-10-01 08:57:19 +00:00
Claude 0e44f8a69f feat(process): a track:ask draft may be written during the spec review (#729)
On track:ask every spec review round is 10-45 minutes of waiting, and
rule #1 kept the author idle for all of it. Rule 10 (#738) judges a
class A commit by its author date, so code written in the
S4-spec-review epoch was always refused: nothing told a draft written
against the reviewed text from a violation. The owner allowed changing
rule #1 for this (decision 2026-10-01).

A draft commit carries `Spec-Draft: sha256:<issueBodyDigest(body)>`,
the hash the pipeline already writes as "Тело issue" into the review
document anchor. Rule 10 accepts a class A commit written in S4 only
when its S4 epoch (a repeated S4 does not restart it) was closed by
S5-ready, the track at the author date was ask, and the trailer equals
the body of the green, High 0 SPEC-REVIEW added inside that epoch, read
from the range head or origin/dev. The first failing check is the one
finding: trailer format, track, how the epoch ended, the missing
document with a `git fetch origin dev` hint, or both hashes and the
document name. A trailer on a commit written in an allowed epoch is a
warn. Without the document reader rule 10 is exactly #738; main always
passes one, and it reads git only when the range holds a draft.

The task packet tells S4 on ask that a local draft is allowed while the
branch stays closed, prints the trailer line, and in S5/S6 names the
green spec review, whether the body changed since, and the --report
check before push. SPEC-REVIEW documents are a separate input
(specDocs) from the branch and origin/dev, so the previous verdict and
the AC witness keep their source.

PROCESS.md gets §11.8 and the points that refer to it (§1, §2.4-2.6,
§3 item 1, §7.2, §9, §10.2 item 10, §12); AUTHOR.md, REVIEWER.md and
AGENTS.md follow, with three new key rules in process-digests. The
pre-push hook fixture copies the modules process-gate now imports.

Issue: #729
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 11:47:01 +03:00
claude[bot] 7ff2b5aeaa docs(reviews): индекс после сдвига каталога (#731)
Issue: #731
User-Visible: no
2026-10-01 06:47:05 +00:00
claude[bot] 4097132374 docs(reviews): индекс после сдвига каталога (#731)
Issue: #731
User-Visible: no
2026-10-01 06:47:05 +00:00
claude[bot] a729b5000d docs: review document for #731
Issue: #731
User-Visible: no
2026-10-01 06:47:05 +00:00
Claudeandclaude[bot] 36ebabaaec fix(moon): the status line after a warm revive of General settings (#731)
#718 K7 takes the moon status once per opening of General settings,
outside the draft. A warm remount revives the open dialog on a new card
instance, but `_warmReviveDialog` restored only the draft: the new
instance had no opening of its own, so the "Now: ..." line never came
back.

A revive is an opening too. The `settings` branch now asks for the
status the way `_openSettingsDialog` does - through the lazy editor
runtime (`_openMoonStatus` -> `openMoonStatus`): at once when the
runtime is there (an editor revives after `_requestMode(..., adopt)`
has installed it), after it loads in View; once per revive and only
while that revived dialog is still open. The snapshot of now,
`hass.config` and `sun.sun` is the revive's own, nothing of the dead
instance's opening is carried over, the draft key and the dirty flag do
not change. The View graph gets no static moon-status import; other
dialog kinds never ask for the moon chunk.

demo/smoke_moon_status.mjs gains the revive scenarios - View, the plan
editor, a revive while the chunk is still loading, a space-dialog
revive that must not load the chunk; the first three are red on dev.
test/moon-settings.test.mjs executes the revive as a new opening; the
wiring itself is proven by the smoke, not by reading the monolith as
text (#624). docs/SUN.md and docs/WARM-REMOUNT.md say a revive is an
opening; scripts/smoke-links.mjs links the two new symbols to the smoke.

Issue: #731
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 06:47:05 +00:00
claude[bot] de117d5042 docs(reviews): индекс после сдвига каталога (#735)
Issue: #735
User-Visible: no
2026-10-01 06:41:26 +00:00
claude[bot] 6372c21eec docs: review document for #735
Issue: #735
User-Visible: no
2026-10-01 06:41:26 +00:00
Claudeandclaude[bot] 5e31816881 test(perf): time switchCycle warm and fail on a floor build inside it (#735)
Every large-house sample mounts a new card that has visited only floors 1
and 2 before the twelve-switch cycle, so the cycle's second step was always
the first visit to floor 3: 20 new clean-floor entries, and in 2.5D one Iso
geometry entry plus one structural build. In large-house-interaction-v1 the
editor series also moves the config epoch that keys the clean-floor cache,
so floor 1 was cold as well. That one cold step was about half of
switchCycleMs, which the README and the cycle comment describe as warmed
navigation, and a 35% warm regression drowned in it.

The runner now visits every fixture floor once in cycle order after the
settings dialog closes, outside every timed and Long Task window, and
returns to floor 2, so the cycle still starts with 2 -> 1. A guard snapshots
the hot caches and the 2.5D structural build counter around the window and
fails the sample when anything grew. Caches an older base lacks read as 0
and its null counter is not judged, so a v1.78.0 base still passes.

Budgets, hardMaxMs, metric names, the report schema, profiles and the
workflow are unchanged. Base and candidate are both measured by the
candidate runner, so the comparison is unaffected; the absolute
switchCycleMs level steps down, which the README now explains. A unit
anchor pins the warm-up position and the guard message.

Issue: #735
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 06:41:26 +00:00
claude[bot] b417ec4c9e docs(reviews): индекс после сдвига каталога (#741)
Issue: #741
User-Visible: no
2026-10-01 05:59:54 +00:00
claude[bot] 15f8da8563 docs(reviews): индекс после сдвига каталога (#741)
Issue: #741
User-Visible: no
2026-10-01 05:59:54 +00:00
claude[bot] d87f834448 docs: review document for #741
Issue: #741
User-Visible: no
2026-10-01 05:59:54 +00:00
Claudeandclaude[bot] d5bdfde919 refactor(iso): drop the unused stageSize from the overlay fit input (#741)
Since #713 the overlay fit envelope reserves no nudge budget, and since
#725 _isoScene passes `stageSize: null` while resolveIsoOverlayFitEnvelope
never reads the field. The room focus still built a { width, height }
object from the stage for nothing. The optional field is removed from
IsoOverlayFitEnvelopeInput together with both call-site arguments.

The #725 AC3 unit compared bounds with stageSize null and 1000x500, which
is now meaningless; it checks instead that the fit bounds follow only
scene.frame and the tiles: the same bounds for every stage aspect, a moved
frame moves them, an enclosing frame is returned as is.

Issue: #741
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 05:59:54 +00:00
claude[bot] f559b3fe19 docs(reviews): индекс после сдвига каталога (#736)
Issue: #736
User-Visible: no
2026-10-01 05:56:05 +00:00
claude[bot] 90ea1cab84 docs(reviews): индекс после сдвига каталога (#736)
Issue: #736
User-Visible: no
2026-10-01 05:56:05 +00:00
claude[bot] 9debce3394 docs: review document for #736
Issue: #736
User-Visible: no
2026-10-01 05:56:05 +00:00
Claudeandclaude[bot] 17b7b0ad73 feat(process): a red night comments on the tasks merged since the last green one (#736)
A red nightly Validate was a signal "to the author of the latest dev
commits" that nobody received: the red run was visible only in Actions,
and nobody computed who the author was.

- scripts/night-red.mjs: acts only on conclusion=failure of the red run
  (cancelled, timed_out and the rest are a summary line). The last green
  night is the newest of the last 50 Validate workflow_dispatch runs on
  dev that completed successfully, was created before the red run, sits
  on an ancestor of the red SHA and has a green ci-proof under the
  release policy, so a light green run (stale) never counts. Suspects
  are the Issue: trailers of `git rev-list --no-merges G..R` commits that
  touch a class A/B file and carry no Release: trailer: docs-only and
  beta-candidate commits do not count, a branch merged by a merge commit
  brings its second-parent commits, a commit without a trailer is a
  "no task" summary line, an empty range means a likely flake. One
  comment per task names both runs, up to ten of its commits and the
  failed jobs, says "suspect, not guilty" and ends with the marker
  hp:night-red green=<G> red=<R> commits=<all sha12>. No comment goes to
  a closed task or to a task whose marker with the same green already
  lists all its current range commits: one comment per series of red
  nights until the task commits again; a green night starts a new series.
  Failures become a ::warning:: and a summary line, exit code 0.
- _nightly.yml: dispatch also outputs run_id; a new job night_red runs
  after it only when dispatch failed with a known run, continue-on-error,
  permissions actions: read and contents: read (the union with the other
  jobs is unchanged, thin files in main are untouched), checks out dev
  with full history without blobs, reads Actions with github.token and
  writes issues with HP_PROCESS_TOKEN. The header names the addressee.
- PROCESS.md §10.4: the "Красная ночь" paragraph next to the nightly
  ship review.

Tests run the scripted rules on real git in temporary repositories with
real ci-proof fixtures, and the workflow step on real bash with a local
Actions API server and a fake gh.

Issue: #736
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 05:56:05 +00:00
claude[bot] 7cb90e379b docs(reviews): индекс после сдвига каталога (#738)
Issue: #738
User-Visible: no
2026-10-01 05:52:18 +00:00
claude[bot] df8482c702 docs: review document for #738
Issue: #738
User-Visible: no
2026-10-01 05:52:18 +00:00
Claudeandclaude[bot] 82fbad67d5 fix(process): rule 10 judges the status at the commit's author date (#738)
Rule 10 compared a class A commit's authorDate with the FIRST time the
issue reached S5-ready. After a return S5+ -> S3/S4 (the #726 reclassify
route or a manual return) code written in S3/S4 and pushed after the new
S5 passed both rules: rule 8 saw the current S5/S6, rule 10 saw the old
S5 from before the return.

checkCommitEraStatuses now builds status epochs from the labeled events:
a label from `allowed` opens the "may touch code" epoch, S1-new..
S4-spec-review close it, every other label (blocked, track:*, review-4,
S8-merged under --no-merged) changes nothing. The status at authorDate is
the last status event at or before it; a pre-ready status (or no status
event at all) is a rule 10 fail. Before the first readiness the old text
stays; after a return the finding names the status, the return time and
the next readiness or "not reached yet". Commits written before the
return stay legitimate. The timeline runner, the warn without timeline
or without `allowed` events and the commit selection are unchanged.

PROCESS.md §10.2 gets item 10 describing the epochs.

Issue: #738
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 05:52:18 +00:00
claude[bot] bc59917ea4 docs(reviews): индекс после сдвига каталога (#718)
Issue: #718
User-Visible: no
2026-10-01 05:38:09 +00:00
claude[bot] c39a8fe88e docs: review document for #718
Issue: #718
User-Visible: no
2026-10-01 05:38:04 +00:00
Claudeandclaude[bot] b0751497d4 test(moon): judge the editor after the mode transition; fresh screenshot fingerprint (#718)
Validate on the conveyor's rebase 98d98e83 was red twice over:

- smoke_moon_static ac5_noMoonInTheEditor sampled the plan editor two
  frames after `setMode('plan')`, while the View -> editor transition (#101)
  was still running and the sky layer was legitimately fading out. Locally
  2 of 3 runs red on 98d98e83. The check now waits for the transition to
  end (no `_modeTransitionBusy`, no `mode-transition` class) and turns red
  if it never does; 5 of 5 runs green.
- the branch changes visual sources, so the screenshot check is strict on
  it, and #725 moved the sources under the fingerprint. `docs:accept
  --identical`: all 11 frames pixel-identical, only the fingerprint moves.

Issue: #718
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 05:25:21 +00:00
Claudeandclaude[bot] c2ca6a55d2 docs(screenshots): source fingerprint for #718 — all frames pixel-identical
`npm run docs:accept -- --identical` (no prior docs:capture): the local
capture of all 11 documentation frames decoded pixel-identical to the
committed PNGs, so only the source fingerprint moves. None of the frames
shows the General settings dialog (the new moon status line) or a static
background at night (the new moon sky layer); the PNGs stay byte-for-byte.

Issue: #718
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 05:25:21 +00:00
Claudeandclaude[bot] 1c49544831 test(golden): accept the 2 reviewed static-background moon frames (#718)
The golden-images artifact of Validate run 36790529482 (16616f09, Linux,
Chromium 151.0.7922.34) reports exactly the two new #718 scenes as
missing-baseline and every other scene as passed:

- static-bg-moon-gibbous-white-light: a waxing gibbous moon on the plain
  white static background, top-left behind the plan.
- static-bg-moon-crescent-south-dark: the southern-hemisphere crescent on
  the dark static background, top-left behind the plan.

Both frames were reviewed: the moon sits behind the plan in the top-left
corner on either background, as the spec asks. The other 190 baselines are
kept byte-for-byte; 145 environment witnesses matched.

Issue: #718
User-Visible: no
Release: v1.79.0-beta.2
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/36790529482
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 05:25:21 +00:00
Claudeandclaude[bot] b998b0b34a feat(moon): the moon with any background, and its status in General settings (#718)
The owner decided on 30.09 that the moon is not part of the "Follow the Sun"
environment but a switch of its own: with a static background (global or a
space's own) the card showed no moon even with the switch on, and the switch
said nothing about why the moon was missing right now.

With a static background there is no environment, so the moon stands in its
own layer, `.hp-moon-sky`: the first child of `.stage` / `.hp-static-stage`,
the whole scene, no z-index, filter or will-change, under the plan by DOM
order, fading with the #101 View weight. Inside is the very #661 element, so
place, size, art and fades are unchanged, and a background switch moves it to
its new parent in the same render without a flicker. The phase comes from the
same `resolveDayCycle`, computed only while the moon is on and on View; without
`sun.sun` both cards keep their 30 s clock ticker and re-render only when the
phase changes (the environment is still compared by its whole fingerprint).

General settings get a second caption line under the moon switch
(`data-moon-status`): one snapshot per opening, judged by the lazy chunk as if
the switch were on, first reason wins (no home, day, below 3°, under 3 %),
numbers rounded and clamped below the threshold they missed. `moonStatus`
decides "shown" with the same `moonShownAt` as the element. It lives in a
WeakMap beside the draft, so it never makes the dialog dirty; a closed
opening's result is dropped. The dialog loads the chunk through the gate's
loader (`withMoon`), now shared by every caller while a load is in flight, so
there is still one fingerprint check and one retry token.

Bundle (same build, against origin/dev): initial View 300 072 -> 300 248 B gzip
(+176 B, under the 500 B of the spec; budget and ceiling not raised); lazy
editor 238 558 -> 238 991 B (+433 B, the line and English strings); lazy moon
11 385 -> 11 712 B (+327 B, layer CSS and status). `src/moon.ts` stays out of
the initial and the editor graph; bundle-budget now refuses an editor/moon
overlap. Monolith metrics: hostRefs 4 885 -> 4 888 — the three `host.` reads of
`src/editors/moon-status.ts` (hass, `_settingsDialog`, requestUpdate) through
its own three-member interface, not the editor port; the other five metrics
are unchanged. houseplan-editor-runtime.ts grows by two lines (import, call).

Tests: AC9/AC10/AC15 and the sky layer in test/moon.test.mjs (the #661
"static -> nothing" check inverted), AC14 and the opening lifecycle in
test/moon-settings.test.mjs, smokes demo/smoke_moon_static.mjs (AC1-AC6; AC1
and AC3 were red on dev) and demo/smoke_moon_status.mjs (AC11/AC12), AC7 in
smoke_daycycle_layer_budget. Golden: two new scenes
(static-bg-moon-gibbous-white-light, static-bg-moon-crescent-south-dark,
matrix v70), the harness checks the moon's parent by background and waits for
the status line in the General settings frames. Four new mutants; the clock
ticker one is a browser guard (201 at the guideline of 200).

Issue: #718
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 05:25:21 +00:00
claude[bot] 712d41b6d6 docs: review document for #729
Issue: #729
User-Visible: no
2026-10-01 05:23:15 +00:00
claude[bot] 76558bf2a0 docs: review document for #742
Issue: #742
User-Visible: no
2026-10-01 05:14:49 +00:00
claude[bot] 0621d53a49 docs: review document for #740
Issue: #740
User-Visible: no
2026-10-01 05:11:55 +00:00
claude[bot] bb25110dc4 docs: review document for #739
Issue: #739
User-Visible: no
2026-10-01 05:08:55 +00:00
claude[bot] f3cfb93e94 docs: review document for #737
Issue: #737
User-Visible: no
2026-10-01 05:07:59 +00:00
claude[bot] b27ae06ef8 docs(reviews): индекс после сдвига каталога (#730)
Issue: #730
User-Visible: no
2026-10-01 05:03:39 +00:00
claude[bot] 7126fd8a02 docs: review document for #730
Issue: #730
User-Visible: no
2026-10-01 05:03:35 +00:00
claude[bot]andClaude 02dce77c98 docs(reviews): индекс после сдвига каталога (#730)
Issue: #730
User-Visible: no
2026-10-01 07:48:42 +03:00
claude[bot]andClaude 53bb3bf453 docs: review document for #730
Issue: #730
User-Visible: no
2026-10-01 07:48:37 +03:00
claude[bot]andClaude 0ab685e380 docs: review document for #730
Issue: #730
User-Visible: no
2026-10-01 07:48:37 +03:00
Claude 4eb712be0e fix(process): a workflow-permission refusal tells the author to rebase (#730)
Review r1 (Medium): refusalSummary said "повтор и ребейз не помогут" for every
non-stale outcome, and since AC2 the rebase guard's summary carries it too.
For a workflow-permission refusal a rebase and push by the author is exactly
the way out (PROCESS.md §10.4). That outcome now says so; other GitHub
refusals keep the old sentence.

Issue: #730
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 07:48:37 +03:00
claude[bot]andClaude 8e42f4e972 docs: review document for #730
Issue: #730
User-Visible: no
2026-10-01 07:48:37 +03:00
Claude 562313944f fix(process): ship review and beta-derived pushes tell a GitHub refusal from a moved dev (#730)
After #705 and #723 two more workflow bodies still treated every failed
push as a moved dev: the SHIP-REVIEW publication (_ship-review.yml) retried
three times with "dev went ahead", and the derived-artifacts bot commit
(_beta-derived.yml) told the release manager to rerun the workflow. A
refusal by GitHub itself - a token without the workflow right, a branch
rule, a hook - is cured by neither, and neither step said what GitHub
answered.

Both pushes now keep stderr and hand it to the #705 classifier through the
same CLI (merge-candidate.mjs --push-refusal). A stale lease keeps the old
behaviour: another attempt for the ship review, the rerun advice for the
derived artifacts. Any other outcome stops the step at once: the log gets
the git answer and the step summary gets the reason and the git answer
without secrets (--summary, refusalSummary with the new ship-review and
beta-derived labels). The classifier comes from dev, as for the other steps
of these bodies: both jobs check out dev, and the ship review resets to
origin/dev before every attempt. The ship review commit message is built
line by line into a file instead of a heredoc, as in #723. The thin callers
ship-review.yml and beta-derived.yml are untouched.

The rebase guard in _process.yml also writes the refusal reason to its step
summary now (--summary, label "rebase"); a stale lease writes none.

test/publish-push-refusal.test.mjs runs both steps as they are with real
bash and real git in temporary repositories (moved dev = a real neighbour
push, GitHub refusal = recorded stderr with a token, a credential URL and
an Authorization header); on the old bodies 10 of its 12 new tests fail.
The #705 execution tests of the rebase guard in rebase-generated.test.mjs
now also read the step summary. PROCESS.md names the two steps next to the

Issue: #730
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 07:48:37 +03:00
claude[bot] 3b9f25ea65 docs(reviews): индекс после сдвига каталога (#727)
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Issue: #727
User-Visible: no
2026-10-01 03:25:24 +00:00
claude[bot] cf8b8f0307 docs: review document for #727
Issue: #727
User-Visible: no
2026-10-01 03:25:24 +00:00
claude[bot] d5bca5dee5 docs(reviews): индекс после сдвига каталога (#727)
Issue: #727
User-Visible: no
2026-10-01 03:25:24 +00:00
claude[bot] 61d98158ce docs: review document for #727
Issue: #727
User-Visible: no
2026-10-01 03:25:24 +00:00
Claudeandclaude[bot] e58d7d06f8 feat(process): nightly ship batch review, reused by the beta gate by patch set (#727)
Ship tasks merge without a model review and their code was first read by
the batch review right before a beta: one session over the whole range,
ten to forty-five minutes on the release path, days after the merge. The
gate also knew a single document (SHIP-REVIEW-<tag>.md) and covered tasks
by number only, so a commit that landed after the review under the same
trailer still counted as read.

- scripts/ship-review.mjs: the patch set of a task is the sorted
  `git patch-id --stable` of its range commits, without `Release:`
  commits (the beta candidate carries every Issue: of the line) and
  commits touching only docs/reviews/**; the diff options are explicit
  so a local git config cannot change it. shipCoverage rates every ship
  task from the documents of the same base (candidate and origin/dev,
  latest publication wins): clean, high, stale, none; documents without
  `patches` cover by number. `tag=nightly` is a reserved mode: the
  candidate is required, the document is
  SHIP-REVIEW-<base>-dev-<sha12>.md, only none/stale tasks are read and
  nothing runs when nothing is uncovered. The beta reads the same delta
  (force=true reads everything, as before); the brief names what the
  night already read. The gate refuses none/stale with the command and
  keeps the High refusal with force=true; all clean passes without a tag
  document. The machine block gains `mode` and `patches` at its end.
  comment-high writes one line per task of a nightly document with High,
  once per document (hp:ship-review-high).
- _ship-review.yml: prepare refuses nightly without a candidate before
  defaulting to the dev tip, computes the document from base and SHA and
  no longer reads a prepare failure behind `| tee` as "no ship tasks";
  publish takes mode and patches from prepare, never from the model
  result; a new step comments High at night with HP_PROCESS_TOKEN.
- _nightly.yml: the Validate run SHA is a separate step output before
  the wait; a new job dispatches ship-review.yml -f tag=nightly on it
  whatever Validate's outcome, waits only for the run to appear and
  never colours the night. Thin files in main are unchanged.
- reviews-index/reviews-archive: the nightly name is a ship document
  with nightly: true; a beta base archives with its line, a stable base
  with the nearest archived line newer than the base, or stays.
- PROCESS.md §11.7, §10.4 and REVIEWER.md describe the nightly mode,
  patch set, coverage and beta delta; the digest test pins the key rule.

Tests run the prepare, publish and comment steps and the nightly steps
on real bash with real git in temporary repositories; only push
transport and gh are faked.

Issue: #727
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 03:25:24 +00:00
claude[bot] af339fda3b docs(reviews): индекс после сдвига каталога (#725)
Issue: #725
User-Visible: no
2026-10-01 03:17:40 +00:00
claude[bot] 011e351249 docs: review document for #725
Issue: #725
User-Visible: no
2026-10-01 03:17:40 +00:00
Claudeandclaude[bot] 235f60e693 perf(card): a floor switch stops forcing layout and re-walking the config (#725)
Profiling #694 found three costs on every View pass, paid even with the
summary panel hidden.

The summary panel read the safe-area probe's computed style in layout(),
which the card reaches up to five times per render (renderControls,
menuItems, renderPanel twice, the clock check), and its updated()
measured the stage, probe and kiosk buttons after every DOM commit. The
insets now live in the measured state: measureLayout is the only method
that reads style or layout, and updated() calls it only when an input of
the measurement changed (probe, kiosk buttons or stage element, title,
language, mode, kiosk, kiosk scale, narrow, HA theme), after connect()
or an identity change, on visibility, once after document.fonts.ready,
and from resized() as before. A floor switch or an HA tick no longer
measures.

The _model getter rebuilt the config fingerprint (a walk over every
space and room with JSON.stringify of room settings) on each of its
dozens of reads per render. ConfigFingerprintPass remembers the whole
cache key (epoch and fingerprint) from the start of willUpdate() to the
end of render() while the epoch, the config object and its spaces array
are unchanged. Remembering only the fingerprint and concatenating the key
on every read was tried first: in 2.5D on the large house the switch cycle
measured slower than without any memo, and CPU profiles showed several
times more garbage collection on load and on the first visit of a floor;
one remembered key per pass has neither. Outside the pass (handlers, updated(),
timers) every read still builds the key, so an in-place edit without an
epoch bump stays visible (HP-1454-04). No write to the fingerprinted
fields is reachable from willUpdate() or render().

_isoScene read the stage box during render only to feed an aspect into
the overlay fit, whose frame has not depended on the aspect since #713.
It now uses the frame's own aspect and passes stageSize: null.

render-layout-read.mjs now also judges _isoScene and the whole summary
runtime except measureLayout, forbids layout property reads
(clientWidth, offsetTop, ...) besides the two calls, and reports every
violation. Two registered mutants restore the old reads.

No visible change: panel caps, side, offsets and kiosk clearance are
computed from the same values; the 2.5D frame is the same.

Issue: #725
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 03:17:40 +00:00
claude[bot] 8fac66e1c3 docs(reviews): индекс после сдвига каталога (#726)
Issue: #726
User-Visible: no
2026-10-01 03:06:49 +00:00
claude[bot] 530eb0090d docs: review document for #726
Issue: #726
User-Visible: no
2026-10-01 03:06:49 +00:00
Claudeandclaude[bot] d327ec3d93 feat(process): a failed show verdict re-routes to ask without a fresh budget (#726)
A non-green show verdict that found "something to decide" went down the same
path as "fix the code": S6 with a limit of 2. Promoting the task to track:ask
was left to the agent's memory, with no named criterion and no trace, and the
exhausted budget only surfaced on the next S7 - after a fix nobody would read.

The structured verdict now carries `route` (fix | reclassify) and an optional
`criterion` (one of the six show criteria of PROCESS.md section 5). The trust
boundary reads a missing route as fix, rejects one outside the dictionary and
rejects reclassify on a green verdict. `reviewRoute` in process-track.mjs is
the single decision: on a code review of an unconfirmed show it moves the task
to track:ask and S3-spec; on an owner-confirmed show it adds `blocked` and asks
the owner; anywhere else reclassify degrades to fix with a note. The verdict
that spends the last cycle sets review-4 at once; the stage budget is shared
across tracks, so promotion changes the limit (4), not the count.

The "Решение по вердикту" step makes one `process-track.mjs route` call (from
dev, like the track step) and only executes its output: comment from a file,
labels from add/remove lists, status via status-label.mjs as before. The track
step also emits `confirmed` and a `route_note` for the review prompt; the
review document anchor gains a route tail that the old reader still parses;
wait-verdict reports the two new pipeline comments. The guard's own
spent >= limit check stays as the safety net.

Issue: #726
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 03:06:49 +00:00
claude[bot] 56be779ca7 docs(reviews): индекс после сдвига каталога (#728)
Issue: #728
User-Visible: no
2026-10-01 03:03:28 +00:00
claude[bot] 5e430d9ede docs: review document for #728
Issue: #728
User-Visible: no
2026-10-01 03:03:28 +00:00
Claudeandclaude[bot] 0cc7c60e8b feat(process): process metrics by track — segments, returns, ship findings, job minutes (#728)
The weekly report could not say whether the tracks of #695/#696 paid off:
it read only the first S4/S5/S7/S8 placements of closed issues, no track,
no waiting, no reason for a return, and the CLI never passed jobs, so the
"Job-минуты" line never printed. The owner decides on these numbers, so the
definitions are spelled out in the report headers and anything unknown is
printed as such.

scripts/process-metrics.mjs (pure functions over the snapshot):
- K1 trackAt/trackPath: track at a moment from the labels set before it,
  resolved by process-track.mjs (labelTrack) — one rule with the pipeline;
  infra = no class A file in the issue's commits (Release: commits aside).
  The issue's track is the one at its first S8-merged.
- K2 issueSegments: queue/spec/work/review/rework/blocked from the first
  status label to the first S8, summing to lead; blocked is taken out of
  the segment under it; S7 over S7 is neither a return nor a new segment.
- K3 returnSignal/returnReason: S7 -> S6/S3 and S4 -> S3 returns, reason
  from the last comment with a sign between the review placement and the
  return. merge and the "not run" family come from PIPELINE_EVENTS, the
  verdicts from verdictDeclaration with the issue's own document; the two
  continuations have no pipeline constant, so NOT_RUN_VALIDATE_RE and
  NOT_RUN_CONFLICT_RE are exported copies held by a contract test on the
  _process.yml templates. Anything else is unknown; hp:route (#726)
  gives reclassify/owner-question when present.
- K4 shipFindings: High/Medium/Low of SHIP-REVIEW-*.md (docs/reviews and
  legacy/reviews) by the anchor block, summed per issue; the track table
  counts each document once.
- K5 stageMinutes: jobs of process and Validate runs (skipped runs aside,
  at most 600, "усечено: N из M" beyond), stages by job name, per track at
  run time, Validate per event; unavailable jobs are "нет данных", not 0.
  jobMinutes gets the same data and prints again.
- K6 tokenUsage: "Токены: нет данных (…)" until the pipeline records usage
  (issue F); the hp:usage line format is provisional.
- K7 compareCohorts: issues with the first S8 within 28 days before and
  after 2026-09-28 (--compare, --compare-days), cohort = track x volume
  bucket (<=30/31-200/201-1000/>1000 lines of Issue-trailer commits without
  Release:, class D and docs/reviews/**); n < 3 on a side is "мало данных".
- fetchSnapshot: issues state=all since the earliest window (the old
  selection is still "closed in the window"), timelines up to 10 pages
  (beyond: "таймлайн усечён"), jobs, ship and usage review docs, git log
  --numstat of origin/dev.

_process-metrics.yml: full history (fetch-depth: 0) for K1/K7 and a 30
minute ceiling. The thin process-metrics.yml is unchanged. PROCESS.md §5
points at the report. Old sections and their tests are unchanged.

Issue: #728
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 03:03:28 +00:00
Claude b3dd9444b5 test(daycycle): judge pinch frames, not the first frames of the recording (#734)
presentedFramesHaveNoWhiteTile in smoke_daycycle_layer_budget turned red now
and then on frames that are not pinch frames: the first one or two frames of
the screencast sometimes show the room before its fill (white paper, 0.997
near-white), before any pinch move, and the next frames are light grey. The
check judged every recorded frame, so a stale opening frame failed a gesture
that painted correctly.

The check now judges pinch frames only. Not judged: a frame whose swap
time (screencast metadata) is earlier than the first pinch move, and a frame
before the first one that shows the room filled. The guard keeps its power:
a white tile during the pinch comes after a filled frame and stays red; a
room white from the recording start through the whole gesture leaves no
judged frame inside the gesture, which fails both the frame count and the
white-tile check (an empty set no longer passes `every`). The frame count
counts judged frames inside the gesture, not every recorded frame.

Why the fill appears later is out of scope.

Issue: #734
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 05:33:49 +03:00
claude[bot] 52dc08a0e5 docs(reviews): индекс после сдвига каталога (#694)
Issue: #694
User-Visible: no
2026-10-01 00:22:26 +00:00
claude[bot] 78acf2c6ba docs(reviews): индекс после сдвига каталога (#694)
Issue: #694
User-Visible: no
2026-10-01 00:22:26 +00:00
claude[bot] dcb8f80de2 docs: review document for #694
Issue: #694
User-Visible: no
2026-10-01 00:22:26 +00:00
Claudeandclaude[bot] 6f17b6cd4c perf(card): a floor switch stops re-querying the same subtrees (#694)
A floor switch replaces the whole stage, so the card's pointer-hover
MutationObserver receives hundreds of records whose targets are the same
few containers. Each record re-ran `matches` and a `.devlayer` subtree
`querySelector` on its target, and kept doing so after the device layer
had already been found. The batch logic moves to `deviceLayerMutated` in
device-hit-owner.ts: a node is checked at most once per batch, the first
hit ends the checks, and every added node still goes through
`_syncPointerHoverSubtree` in record order. The card shrinks by 12 lines.

The View stair layer read the card's `_model` getter once more for every
navigable stair; the getter rebuilds the config fingerprint on each read.
`renderLayer` now reads it once.

`languageRenderGate` wrote `lang` on the host on every render. It now
writes it only when the value differs (language switch, English fallback,
a foreign value); an unchanged value is left alone.

No behaviour changes: DOM, tooltips and pixels are the same. Unit tests
count subtree queries per node, `_model` reads per render and `lang`
writes; one mutant per change restores the old behaviour.

Issue: #694
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 00:22:26 +00:00
claude[bot] 153fa7a680 docs: review document for #728
Issue: #728
User-Visible: no
2026-10-01 00:19:58 +00:00
claude[bot] fc835cb97d docs: review document for #727
Issue: #727
User-Visible: no
2026-10-01 00:18:39 +00:00
claude[bot] 499c0860df docs(reviews): индекс после сдвига каталога (#707)
Issue: #707
User-Visible: no
2026-10-01 00:15:55 +00:00
claude[bot] 8df4ef710c docs: review document for #707
Issue: #707
User-Visible: no
2026-10-01 00:15:51 +00:00
Claudeandclaude[bot] 1d51beade1 feat(process): risk by changed hunks decides ship and informs show (#707)
The ship limits count lines and files but not what was touched: a
12-line pointerdown handler passed them like a typo and merged unread.
The track rule also lived twice - the guard computed the cycle limit in
bash while process-track.mjs computed the track, and the two disagreed
on multiple track labels. The packet still told authors to rebase
show/ship branches that merge cleanly.

- scripts/change-risk.mjs: one pure classifier over `git diff -U0` from
  the merge base. Class A lines only; comments, blank lines and pure
  renames give no risk; deletions do. Area and token rules per class
  (geometry, touch, migration, devices, perf, ux, visual render/ui),
  evidence as path:line, five per class.
- process-track.mjs: owner confirmation is a comment line
  "Трек: <x> — решение владельца" by the repo owner (latest wins, only
  for the current track); several track labels read as the strictest
  with a warning; cycleLimit, guardLimit and rebaseBeforeReview are the
  single source. `stage` makes the whole S7 track decision in one call:
  ship with risk and no confirmation is raised to show with evidence,
  a confirmed ship keeps merging without the model and records the risk
  for the batch review; show/ask get a risk note for the reviewer.
- _process.yml: the guard asks process-track.mjs for the limit and keeps
  no track logic; the track step calls the script once and only
  executes its raise flag and comment file; risk_note reaches the
  Review prompt, ship_risk reaches the hp:ship-merge comment (marker
  line unchanged).
- task-packet.mjs: track basis, limit and rebase policy; next step
  without the stale rebase line; risk with its consequence per track;
  required checks with reasons (ci:golden only on render risk);
  changelog and visual evidence - from the same exports.
- ship-review.mjs: the batch brief prints the risk line of a ship merge.
- Canon: PROCESS.md §5, §5.1, §10.4, §11.7, both digests, AGENTS.md.
- Registry anchors that watched the moved code are moved, not dropped.

Issue: #707
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 00:03:35 +00:00
claude[bot] a49f7095ce docs: review document for #728
Issue: #728
User-Visible: no
2026-09-30 23:34:30 +00:00
claude[bot] 3847baa5a0 docs: review document for #727
Issue: #727
User-Visible: no
2026-09-30 23:33:52 +00:00
claude[bot] 5ce83feffa docs: review document for #726
Issue: #726
User-Visible: no
2026-09-30 23:33:09 +00:00
claude[bot] fd082ad460 docs: review document for #725
Issue: #725
User-Visible: no
2026-09-30 23:31:13 +00:00
claude[bot] 85598638c1 docs(reviews): индекс после сдвига каталога (#732)
Issue: #732
User-Visible: no
2026-09-30 23:26:46 +00:00
claude[bot] 0f7ec02352 docs: review document for #732
Issue: #732
User-Visible: no
2026-09-30 23:26:46 +00:00
Claudeandclaude[bot] 715735d753 refactor(iso): remove the empty overlay renderers and dead fixture fields (#732)
After #714 and #724 the 2.5D overlays still carried stubs:

- renderIsoOverlayGrounds and renderIsoRaisedOverlays returned an empty SVG
  on every frame. They go with IsoFramePresentation.grounds/raised and the two
  bindings in the card. The iso-overlays-svg element itself stays, now empty:
  it is the inert camera-viewBox layer the contract and live-touch smokes
  measure screen-facing HTML against, so the 2.5D DOM keeps its elements.
- IsoOverlayRenderEntry.groundRadius was computed for every device, room label
  and lock and read only by the snapshot comparison that compared it.

The overlay test fixtures passed view, referenceView, stageSize and layers
(and one test selectedDeviceId), which IsoOverlaySceneInput does not have, and
asserted that changing them keeps the placement - a claim the signature makes
by itself. Those fields are gone from every fixture. The zoom/resize asserts of
"Stage 4 reuses pure overlay placements" and "#713 AC3" (renamed to what it
still checks) and the "#570 supersedes #473 W1" selection test go; the #724
AC2 test now zooms the way production does, through the live frame of
resolveIsoScene, and checks that the structural geometry and so the overlay
scene are reused. The #713 K8 fixture no longer passes stageSize, which
resolveIsoOverlayFitEnvelope does not read.

test/iso-overlay-fixture-types.test.mjs typechecks the overlay test files with
the TypeScript compiler: their fixture types (OverlaySceneFixture,
OverlayEntryFixture) are the keys of the production types with deliberately
loose values, so a partial fixture is fine and a field the type lacks is an
excess-property error. Three checks: no excess property in the fixture files;
a probe shows the fixture types resolve to the real inputs and reject view,
referenceView, stageSize, layers, selectedDeviceId and groundRadius; every
call of the scene builder gets its argument through a checked type (a literal
in overlayScene or a declaration of the fixture type). Each check is red when
a dead field is put back into a declared fixture, an override literal or an
entry, when a literal goes straight into the builder, when a fixture loses its
annotation, and when groundRadius returns to the entry type.

isometric-contract now asserts that nothing renders into the overlay surface
and that the removed renderers and groundRadius stay gone. No mutant is
anchored on the removed code; mutation-gate --check is unchanged (3 warnings).
The 19 2.5D golden scenes pass in capture on the accepted baselines.

Issue: #732
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 23:26:46 +00:00
Claudeandclaude[bot] 40607aa37f fix(scripts): isMainModule compares real paths of argv and module (#733)
process.argv[1] keeps the path as typed, so a script started through a
symlink (or from a symlinked directory) still carries the link path there,
while Node builds import.meta.url of the main module from the real path.
The two never matched, and every CLI guarded by isMainModule silently did
nothing and exited 0. Both sides are now resolved with realpathSync before
the pathToFileURL comparison; a path that does not exist is compared as is,
without throwing, exactly as before.

The unit test writes a CLI and a module it imports into a temporary
directory, launches the CLI directly, through a directory link (a junction
on Windows, no admin rights needed) and through a file symlink (skipped on
EPERM), and checks that only the launched script runs its main. It is red
on the previous implementation.

Issue: #733
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 23:19:47 +00:00
claude[bot] 34423a6236 docs(reviews): индекс после сдвига каталога (#724)
Issue: #724
User-Visible: no
2026-09-30 22:39:24 +00:00
claude[bot] 295d61f377 docs: review document for #724
Issue: #724
User-Visible: no
2026-09-30 22:39:24 +00:00
Claudeandclaude[bot] 5f8e8ca7e8 refactor(iso): drop the 2.5D overlay data nothing reads (#724)
After #714 the 2.5D overlay scene still carried what decides nothing:

- src/iso-overlays.ts: IsoOverlayPlacement loses tether and grounding (always
  invisible) and raisedScene (always equal to visualScene); IsoOverlayOwner
  loses area; IsoOverlayPlacementInput loses hovered, focused, selected and
  filtersSupported, which the resolver ignored. IsoWallSilhouette and
  tetherGeometry go with them.
- src/iso-scene-render.ts: the structural scene no longer projects wall
  silhouettes (isoWallSilhouettesOf and IsoSceneCacheEntry.wallSilhouettes)
  that served only as a cache key. The placement and render-scene caches are
  keyed by the wall geometry the scene is drawn with (IsoOverlaySceneInput.
  structure = scene.geometry): the structural LRU hands out the same object
  across zoom, stage resize and HA state, and a new one after any wall, room
  or opening edit. The resolveCollisions flag and its fit/live cache slots
  are gone: since #713 both held equal placements, and 2.5D renders only in
  View, where the fit probe and the live frame ask with the same devices, so
  they now read one snapshot.
- src/houseplan-card.ts: the fit call passes no flag; the overlay scene gets
  structural.geometry. data-hp-iso-nudged stays the constant "false" read by
  the golden requireOneRise preflight, the live-touch smoke and the benchmark.

Tests: iso-overlays pins the placement fields; iso-scene-render builds the
structure with buildIsoWallGeometry, the #714 zoom/resize and #711 state tests
stay, fit and live are asserted to share one snapshot, and two #724 AC2 tests
run the production path (createIsoStructuralSource -> resolveIsoScene ->
buildIsoOverlayRenderScene): a thicker wall with the same room rebuilds the
scene (red with a key without walls, e.g. keyed by the room rows), and a room
edit that moves the owner gives the new owner (red with a constant key). The
silhouette-construction test goes with the construction.

Mutants: #473 W2 (iso-placement-cache-survives-silhouette-change, id kept for
history) now keys the placement cache by a constant instead of input.structure
and its guard also runs the #724 AC2 tests; W6 patches the new structure line;
the W5 description no longer speaks of a nudge. The isometric-contract regex
checks the new key instead of the silhouette construction. docs/ISOMETRIC.md
names the key.

Live 2.5D output is unchanged: the 21 isometric golden scenes pass on the
accepted baselines.

Issue: #724
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 22:39:24 +00:00
claude[bot] 108427dc77 docs(reviews): индекс после сдвига каталога (#723)
Issue: #723
User-Visible: no
2026-09-30 22:26:44 +00:00
claude[bot] 3e7f7d3936 docs: review document for #723
Issue: #723
User-Visible: no
2026-09-30 22:26:44 +00:00
Claudeandclaude[bot] 0d85807157 fix(process): publish steps tell a GitHub push refusal from a moved branch (#723)
Two steps publish a commit and treated every failed push as a moved branch:
the release review job (release-review.yml) retried three times with "dev
went ahead", and the review document step (_process.yml) rebased and pushed
again. A refusal by GitHub itself - a token without the workflow right, a
branch rule, a hook - cannot be cured by a retry or a rebase, and the step
never said what GitHub answered.

Both pushes now keep stderr and hand it to the #705 classifier through the
same CLI the rebase guard uses (merge-candidate.mjs --push-refusal). Only a
stale lease (rejected / fetch first / stale info) keeps the old retry or
rebase. Any other outcome stops the step at once, without retries: the log
gets the git answer and the step summary gets the reason and the git answer,
both passed through redactSecrets (token, credential URL, Authorization).
The review document step takes the classifier from dev, as the rebase guard
does: a task branch behind dev may not carry it.

The summary text is written by the new --summary option (refusalSummary),
not by a multi-line string in run:, and both commit messages are now built
line by line into a file instead of a heredoc (PROCESS.md §10.4 item 4).
release-review.yml is dispatch-only and is not mirrored to main. PROCESS.md
names the rule next to the rebase guard; the #638 trailer witness in
test/release-review.test.mjs follows the line-by-line message.

test/publish-push-refusal.test.mjs runs both steps as they are with real
bash and real git in temporary repositories; only the push transport is
replaced: a moved branch is a real neighbour push, a GitHub refusal is a
recorded stderr carrying a token, a credential URL and an Authorization
header. On the old steps 9 of its 11 tests fail.

Issue: #723
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 22:26:44 +00:00
claude[bot] 5cd9d0419a docs: review document for #707
Issue: #707
User-Visible: no
2026-09-30 22:21:03 +00:00
Claudeandclaude[bot] e00f62dca0 test(summary): #509 AC3 counts masonry passes instead of milliseconds (#721)
The large-house AC3 witness asserted an absolute 2.5 s budget for one
floor's clean-floor total. On a loaded 2-CPU machine the healthy path
took 2.7-4.3 s and the test went red while the code was fine.

The property #509 AC3 protects is structural: the summary panel builds
the space's wall masonry once and hands it to innerContourForRoom
(shared.roomGeom / shared.multiWallNodes); without it the masonry is
rebuilt for every room. Every masonry build walks the contours of all
rooms, so the test now counts reads of room.poly and compares the
floor total against one explicit spaceWallGeometry pass of the same
floor in the same run. Healthy code costs ~1.3 passes; the registered
mutant summary-area-recomputes-walls-per-room costs 21.3 and is red,
and so are the half-regressions that drop only one of the two shared
arguments (4.6 and 18.0 passes).

The count is deterministic, so machine load no longer matters.

Issue: #721
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 22:16:19 +00:00
Claudeandclaude[bot] bbcc88caf1 test(i18n): the second-card locale check counts locale requests only (#722)
secondCardReusesPageLocale compared the count of all page requests before
and after the second card mounted. The only extra request is that card's own
plan image: under page.route the browser HTTP cache is off, so it is fetched
again, and whether it lands before the read is a race. The German locale file
itself is loaded exactly once per page. The check now counts requests for the
locale chunk only and still fails if the second card fetches it again.

Issue: #722
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 21:08:23 +00:00
claude[bot] 66ac7d23ec docs: review document for #707
Issue: #707
User-Visible: no
2026-09-30 21:07:49 +00:00
claude[bot] 2d735c56f9 docs(reviews): индекс после сдвига каталога (#716)
Issue: #716
User-Visible: no
2026-09-30 21:01:10 +00:00
claude[bot] 48b001c646 docs: review document for #716
Issue: #716
User-Visible: no
2026-09-30 21:01:10 +00:00
claude[bot] dccbb80890 docs: review document for #716
Issue: #716
User-Visible: no
2026-09-30 21:01:10 +00:00
Claudeandclaude[bot] d11ad9c1c2 ci: register ship-review and beta-derived as thin callers in main (#716)
`workflow_dispatch` runs the file from the chosen ref, but GitHub lists a
workflow and accepts a dispatch (button, `gh workflow run`, API) only when
its file exists on the default branch. `ship-review.yml` (#696) and
`beta-derived.yml` (#697) lived only in `dev`, so neither could be started
at all, and the comment "the file runs from `--ref dev`, no mirror in
`main` needed" was wrong. Both beta steps are needed before the next
promotion would bring them to `main`.

They now follow the #623 layout instead of a full copy in `main`: a thin
caller (trigger, dispatch inputs, run-name, permission ceiling, concurrency)
calls `_ship-review.yml` / `_beta-derived.yml` at `@dev` with
`secrets: inherit`. A full copy would either need a mirror on every edit or
drift silently, and a dispatch from `main` (the button's default) would run
the stale copy; the thin caller runs the dev body from any ref. The caller
ceiling is the union of the body jobs' permissions (#556): ship-review
`contents: read` + `issues: read`, beta-derived `contents: read` +
`actions: read`; writes to `dev` stay with HP_PROCESS_TOKEN as before.

`workflow_sync` in validate.yml now compares eight files, and
test/default-branch-workflows.test.mjs lists the two dispatch-only files
explicitly with the reason checked (only `workflow_dispatch`). Workflow
tests and the #697 provenance mutant read the bodies. PROCESS.md §10.4,
§8 and §11.7 say how these are run and that a new thin file is mirrored
into `main` before it is merged into `dev`.

Issue: #716
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 21:01:10 +00:00
claude[bot] 7188db8187 docs: review document for #694
Issue: #694
User-Visible: no
2026-09-30 20:59:45 +00:00
claude[bot] a5a73d1511 docs(reviews): индекс после сдвига каталога (#714)
Issue: #714
User-Visible: no
2026-09-30 20:36:03 +00:00
claude[bot] a312897527 docs(reviews): индекс после сдвига каталога (#714)
Issue: #714
User-Visible: no
2026-09-30 20:36:03 +00:00
claude[bot] 7ac24a03a0 docs: review document for #714
Issue: #714
User-Visible: no
2026-09-30 20:36:03 +00:00
Claudeandclaude[bot] 7574518575 refactor(iso): remove the dead #651 overlay placement search (#714)
Since #713 every raised device tile and lock badge is its floor anchor lifted
by one shared wall-top rise and room names stay on the floor, so the live
scene no longer called the #651 search. What was left of it only cost code,
build time and review attention:

- src/iso-overlays.ts: resolveIsoOverlayRigidGroups, resolveIsoOverlayCollisions
  with their boundary-candidate machinery, the nudge search in
  resolveIsoOverlayPlacement (the vector to the room safe point, the near-wall
  test, the zoom hint), the safe point itself, the nudge/nearWall/cleared/capped
  and status/reason fields, and ISO_OVERLAY_MAX_NUDGE_CSS_PX /
  ISO_OVERLAY_SAFETY_GAP_CSS_PX.
- src/iso-scene-render.ts: the zoom reuse fast path and the CSS-pixel scale it
  compared; a placement now depends only on anchor, owner, footprint and rise,
  so zoom and stage resize reuse it by signature. residualPairs is gone and the
  memo key is called layoutSignature.
- src/houseplan-card.ts: the overlay scene no longer receives the view, the
  reference view or the stage rect it only fed to that scale;
  data-hp-iso-nudged stays as the constant "false" that the golden
  requireOneRise preflight, the live-touch smoke and the Stage 4 benchmark read.

The #585/#651 unit tests and the seven mutants that guarded only the removed
code are deleted; kept tests drop their nudge assertions, and a stage resize is
now pinned as a non-layout event. docs/ISOMETRIC.md keeps #651 as history only.
Live 2.5D output is unchanged: the 21 isometric golden scenes pass on the
accepted baselines.

Issue: #714
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 20:36:03 +00:00
claude[bot] 12bdc0be68 docs(reviews): индекс после сдвига каталога (#704)
Issue: #704
User-Visible: no
2026-09-30 20:30:34 +00:00
claude[bot] 8b7628285a docs: review document for #704
Issue: #704
User-Visible: no
2026-09-30 20:30:34 +00:00
Claudeandclaude[bot] 37b1cbf74b fix(release): let the stable-line review run when release.yml queues it (#704)
release.yml dispatches release-review.yml with GITHUB_TOKEN, so the run is
started by github-actions[bot], and claude-code-action refused it: "Workflow
initiated by non-human actor: github-actions (type: Bot). Add bot to
allowed_bots list" (v1.78.0: release run 36468444979, review 36468505112).
The release went out and nobody learned that the review never ran.

The review step now allows exactly github-actions[bot]. At the pinned SHA
(9cdae7f0) the action compares allowed_bots entries and the actor
case-insensitively with the `[bot]` suffix stripped, so this entry matches
GITHUB_ACTOR; any other bot is still refused, and a human dispatch never
consults the list.

independent-review no longer stops at the dispatch: it looks the run up by
workflow, branch dev, event, time and run-name "Release review <tag>" for
up to three minutes and writes the link and status to the step summary.
A run that did not appear or did not start is a warning; the release is
not blocked.

Neither file is executed from main, so no mirror is needed (§10.4).

Issue: #704
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 20:30:34 +00:00
claude[bot] 40b226405d docs: review document for #718
Issue: #718
User-Visible: no
2026-09-30 20:30:04 +00:00
claude[bot] 2f1be1a86a docs: review document for #694
Issue: #694
User-Visible: no
2026-09-30 20:28:41 +00:00
claude[bot] 24cb6d113a docs(reviews): индекс после сдвига каталога (#705)
Issue: #705
User-Visible: no
2026-09-30 20:25:08 +00:00
claude[bot] 6e2c75afc9 docs: review document for #705
Issue: #705
User-Visible: no
2026-09-30 20:25:08 +00:00
Claudeandclaude[bot] e5c217111c fix(process): a GitHub push refusal is not a stale lease (#705)
merge-candidate treated any push stderr containing "rejected" as a stale
lease. A `! [remote rejected]` from GitHub itself - in #700 the rebased
candidate changed .github/workflows/ and the conveyor token has no workflow
permission (runs 36484993494, 36487044060) - became "the branch moved after
the reviewed material (#312)", and the stderr was never printed, so the
author was sent to look for a commit that did not exist.

classifyPushRefusal now tells three outcomes apart: a stale lease
(`[rejected] (stale info)`, `fetch first`, a server-side lock race) keeps
the old behaviour; GitHub's workflow refusal (PAT, OAuth App, GitHub App,
bot and integration wordings) and any other `[remote rejected]` get their
own outcome, S6-in-progress and a comment naming the reason. The workflow
comment says what to do: the author rebases and pushes, or the owner grants
the permission. The git answer goes to the log and the comment with tokens
and credential URLs cut out; the merge-step failure comment is redacted too.

The rebase guard in _process.yml parses its push refusal with the same code
(`merge-candidate.mjs --push-refusal`): a stale lease is the old error, a
workflow refusal returns the task to S6 without review like a conflict, and
material/reuse/gate skip the rebase that never reached the branch.

Mutant push-refusal-kinds-glued restores the old regex; guard: #705 AC1.

Issue: #705
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 20:25:08 +00:00
Claudeandclaude[bot] 84ed38e3d5 test(perf): the Stage 4 dense contract requires no nudged overlay (#719)
The isometric-stage3-dense-v1 runner still demanded at least one bounded
#651 nudge. Since #713 every raised device tile and lock badge is lifted by
the one shared wall-top rise and carries data-hp-iso-nudged="false", so the
Full Performance profile failed its input contract before any timing.

The contract is inverted: a single nudged raised root now fails the sample,
matching the golden requireOneRise preflight. The performance README states
the current contract, and the #570 runner-contract unit pins the new failure
text.

Issue: #719
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 20:17:15 +00:00
Claude 2022e183a5 ci: mirror thin workflow callers in main after #716
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Кнопку и запуск workflow_dispatch GitHub даёт только workflow, чей файл
лежит в ветке по умолчанию: ship-review.yml (#696) и beta-derived.yml (#697)
жили только в dev и не запускались вовсе. Два тонких вызывающих файла —
кнопка, входы, run-name, потолок прав и concurrency; тела `_ship-review.yml`
и `_beta-derived.yml` вызываются `@dev`. Файлы байт-в-байт равны ветке
задачи issue/716-register-dispatch-workflows@5451542e. В dev она сливается
после этого коммита: в обратном порядке preflight workflow_sync на dev
нашёл бы файлы, которых нет в main.

Issue: #716
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 21:40:31 +03:00
claude[bot] 3e38de07b3 docs(reviews): индекс после сдвига каталога (#703)
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Issue: #703
User-Visible: no
2026-09-30 18:27:54 +00:00
claude[bot] aee0a5825b docs: review document for #703
Issue: #703
User-Visible: no
2026-09-30 18:27:54 +00:00
Claudeandclaude[bot] 3d99f261ff fix(ci): fetch release tags where the range base reads them (#703)
Review r1 (High): actions/checkout passes `git fetch --no-tags` unless
`fetch-tags: true`, even with fetch-depth 0, so releaseTaggedShas() was always
empty in CI and a candidate outside the 100-run API window fell back to
event.before instead of the last release tag. Preflight and changes now fetch
tags; the workflow contract pins the option. The AC2 dev-push case now uses its
own input (dev runs only, an older `before`) instead of repeating the main call
(review r1, Low).

Issue: #703
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 18:27:54 +00:00
claude[bot] 204e728097 docs: review document for #703
Issue: #703
User-Visible: no
2026-09-30 18:27:54 +00:00
Claudeandclaude[bot] 1557475af3 fix(ci): stable promotion judges nothing already judged on dev (#703)
Validate on a push to main took the range base from main's own runs only,
and skipped HEAD: the nearest judged ancestor was the previous stable, so the
whole beta line was re-judged by today's rules (run 36468413524: 55 smoke
private writes made before #629). Preflight on main used event.before, the
same old-main..candidate.

The range base now reads Validate runs of both integration branches,
counts published release tags as judged material, and accepts HEAD itself
when it already has a successful run (or a tag). A promoted SHA gets an
empty range and the dev verdict; a failed HEAD is re-judged over the same
range; a hotfix on main is judged from the candidate.

Issue: #703
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 18:27:54 +00:00
Claudeandclaude[bot] 445a9a0cdf test(rebase): temp repositories run no background maintenance (#717)
The fixture repositories are removed with rmSync in each test's finally,
and that cleanup sometimes failed with ENOTEMPTY on work/.git/objects.
commit, fetch, rebase and the receiving side of push all start
`git maintenance run --auto` / `git gc --auto`; recent git (2.47+)
detaches auto maintenance by default, and a detached run creates
objects/maintenance.lock after the command has returned, i.e. while
rmSync is already walking the tree.

The environment the test already uses for core.autocrlf now also sets
maintenance.auto=false and gc.auto=0 for the working clones. The bare
origin gets receive.autogc=false, maintenance.auto=false and gc.auto=0
in its own config, since git drops GIT_CONFIG_* for the local transport's
receive-pack. The cleanup keeps rmSync in every finally (temp-dir hygiene
rule) with maxRetries/retryDelay, so a file that still appears under it
is retried instead of failing the test. No assertion changed.

Issue: #717
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 18:24:39 +00:00
Claudeandclaude[bot] 07d90be28f test(dialog): measure the room toggle after its knob stops sliding (#712)
The second flake of smoke_dialog_polish_603: the knob slides with
`transition: left .15s` and the probe waited a fixed 220 ms, 70 ms of slack
that load ate (rightGap 3.05 and 5.6 instead of 2 in 2 of 20 loaded runs).
The probe now waits until the toggle and its pseudo-elements have no running
animation. The geometry oracle and its negative probe are unchanged.

Issue: #712
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 18:21:21 +00:00
Claudeandclaude[bot] f56342479b test(dialog): wait for the room dialog by condition, not by two frames (#712)
smoke_dialog_polish_603 switches the card language on every step and then
opened the room dialog with a private _openRoomEdit call followed by
updateComplete and two frames. de and fr (and the editor's settings
dictionaries) are lazy chunks: until they arrive the card's language gate
keeps the previous frame, inert and aria-busy, so the dialog is not in the
tree yet. When the chunk took longer than two frames (CI, 1 of 2 runs)
the next line read querySelector of null. Delaying the de/fr chunks by
400 ms in the harness reproduces the TypeError every time.

The step now waits until the new language is painted (no aria-busy, lang
equals the requested code), enters Plan with __hpTest.setMode, opens the
dialog with __hpTest.openRoomEdit (the real gear; the facade waits for
[data-kind="room"]) and waits until the dialog's basics card is laid out.
The covered private calls _setMode and _openRoomEdit are gone; every
check and its oracle is unchanged.

Issue: #712
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 18:21:21 +00:00
Claudeandclaude[bot] bcff8c7157 test(stairs): take the AC5 reference after the previous write is adopted (#708)
untouchedDialogSaveKeepsSizes compared the stair before and after an
untouched dialog Save byte for byte, but took the reference while the
previous frame gesture's debounced save (500 ms) was still pending. That
write adopts the canonical record it sends, so under load it landed
between the two reads: the reference had x: 0.21699999999999997, the
read after Save had x: 0.217, and the check went red although the dialog
wrote nothing.

The smoke now waits for the card's own "config writes idle" condition
(no debounced save pending, no write in flight; read-only) before taking
the reference, so both sides are the same canonical stair. The exact JSON
comparison, the >1 m field and the no-history check are unchanged; a
reference that never goes idle within 5 s fails the check instead of
racing.

Issue: #708
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 18:17:50 +00:00
Claude a4af5a2008 test(backdrop): probe editor backgrounds after the mode transition (#715)
The 6b probes entered each mode with a private _setMode call and read the
stage and paper colours 220 ms later. The mode transition interpolates
exactly those colours (inline stage background, --hp-mode-paper under
.stage.mode-transition) for its 220 ms plus a measurement frame, and it is
driven by animation frames, so under load the probe caught an
intermediate colour and plan_editor_stage_white_with_backdrop /
plan_editor_paper_white_with_backdrop went red. Slowing frames to 60 ms
reproduces both every time; three parallel copies of the smoke fail 16 of
18 runs.

Each probe now enters its mode through __hpTest.setMode and waits until
the transition has ended by the card's own markers: the stage carries
mode-<mode> and no longer mode-transition, and neither the stage nor the
paper has a running animation. The same wait precedes the View probe.
The colour assertions are unchanged; a plan stage forced to a non-white
background still fails the check.

Issue: #715
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 20:52:28 +03:00
Claude 9e1cc91277 perf(budget): the 2.5D view toggle is a reported figure, not a gate (#720)
Owner decision of 2026-09-30 in #694. Switching Flat <-> 2.5D is a one-off
General settings change, and since #649 the runner measures a full config
reload for the candidate against a per-device projection flip for v1.77.0,
which alone explains most of 73.8 -> 195.7 ms. The scene build stays gated by
modelReady, firstStableRender and spaceSwitch, a UI freeze by the single
long-task ceiling; the runner still reports viewToggleMs.

Issue: #720
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 19:42:52 +03:00
500 changed files with 50774 additions and 9674 deletions
+228
View File
@@ -0,0 +1,228 @@
name: "Бета: производные артефакты на dev · тело (#716)"
# #697, PROCESS.md §8 и §11.4: ветки задач не коммитят ни отпечаток и кадры
# скриншотов документации, ни эталоны golden. Их обновляет один коммит бота на
# `dev` перед кандидатом беты; коммит проверяет релиз-менеджер.
#
# - Скриншоты: съёмка на `dev` тем же каноническим способом, что
# `docs-screenshots.yml`, и приёмка `docs:accept --reviewed`. Кадры, которые
# менять не собирались, обязаны совпасть байт-в-байт (свидетели среды);
# изменившийся кадр принимается, только если назван в `docs_expect_change`.
# Ничего не изменилось — обновляется один отпечаток исходников.
# - Golden: артефакт `golden-images` полного Validate на `dev`
# (`golden_run`) и `golden:accept --reviewed` с объявленными сценами. Коммит
# несёт `Release:` и `Baseline-Reviewed:` — провенанс, который требует
# validate-commit-provenance.mjs.
#
# Необъявленная разница — отказ с перечнем кадров, артефакт съёмки остаётся
# для просмотра: решение, какие кадры сдвинулись законно, принимает человек.
on:
# #716: тело вызывается тонким файлом `beta-derived.yml` по ссылке `@dev`;
# кнопка, входы ручного запуска, run-name и concurrency живут там. Без
# файла в ветке по умолчанию GitHub не даёт запустить workflow_dispatch вовсе.
workflow_call:
inputs:
tag:
description: "Beta tag these artifacts are for, for example v1.79.0-beta.1"
required: true
type: string
docs_expect_change:
description: "Doc scenario ids expected to change, comma-separated (empty = none)"
required: false
type: string
default: ""
golden_run:
description: "Validate run id on dev whose golden-images artifact holds the shifted frames (empty = skip golden)"
required: false
type: string
default: ""
golden_expect_change:
description: "Golden scenes expected to change, comma-separated"
required: false
type: string
default: ""
golden_expect_new:
description: "New golden scenes, comma-separated"
required: false
type: string
default: ""
permissions:
contents: read
# Concurrency уровня workflow — у вызывающего `beta-derived.yml` (#716).
jobs:
accept:
name: "Отпечаток, кадры и эталоны — одним коммитом в dev"
runs-on: ubuntu-24.04
timeout-minutes: 40
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: dev
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
cache: npm
- run: npm ci
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install pinned Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install chromium
- name: Build the bundle the screenshots must come from
run: npm run build
# Тот же закреплённый упаковщик, что у docs-screenshots.yml: другой
# переписал бы все кадры без единого изменённого пикселя.
- name: Установить oxipng
env:
OXIPNG_VERSION: 10.2.0
OXIPNG_SHA256: b33f84c73d42cb592bea5d84c431030b1e97784817693380dfcec7d9575f871e
run: |
set -euo pipefail
asset="oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
curl -fsSL -o "$asset" \
"https://github.com/oxipng/oxipng/releases/download/v${OXIPNG_VERSION}/${asset}"
echo "${OXIPNG_SHA256} ${asset}" | sha256sum -c -
mkdir -p "$HOME/.local/bin"
tar -xzf "$asset" --strip-components=1 -C "$HOME/.local/bin" \
"oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu/oxipng"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
"$HOME/.local/bin/oxipng" --version
- name: "Съёмка воспроизводима между прогонами (#410, #422)"
run: node scripts/capture-determinism.mjs
- name: Кадры документации — съёмка и приёмка
id: docs
env:
EXPECT: ${{ inputs.docs_expect_change }}
run: |
set -euo pipefail
node demo/docs/capture.mjs --stability=3
cand="$RUNNER_TEMP/docs-candidate"
rm -rf "$cand" && mkdir -p "$cand"
cp docs/images/*.png docs/images/screenshots.json "$cand/"
# Приёмка сравнивает кандидата с ЗАКОММИЧЕННЫМИ кадрами: рабочая
# копия возвращается к dev, и заменить файлы может только она.
git checkout -- docs/images
git clean -fdq -- docs/images
args=(--reviewed "--from=$cand")
if [ -n "$EXPECT" ]; then args+=("--expect-change=$EXPECT"); fi
node scripts/docs-accept.mjs "${args[@]}"
if git diff --quiet -- docs/images; then changed=false; else changed=true; fi
echo "changed=$changed" >> "$GITHUB_OUTPUT"
git diff --stat -- docs/images
- name: Сохранить кандидата скриншотов для просмотра
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: docs-candidate-${{ github.run_id }}
path: ${{ runner.temp }}/docs-candidate
if-no-files-found: ignore
retention-days: 7
- name: Эталоны golden из прогона Validate
id: golden
if: inputs.golden_run != ''
env:
GH_TOKEN: ${{ github.token }}
RUN: ${{ inputs.golden_run }}
EXPECT_CHANGE: ${{ inputs.golden_expect_change }}
EXPECT_NEW: ${{ inputs.golden_expect_new }}
run: |
set -euo pipefail
case "$RUN" in ''|*[!0-9]*) echo "::error::golden_run — числовой id прогона Validate"; exit 1 ;; esac
meta=$(gh api "repos/${{ github.repository }}/actions/runs/$RUN" \
--jq '[.path, .head_branch, .status, .html_url] | @tsv')
IFS=$'\t' read -r path branch status url <<< "$meta"
# Провенанс эталонов — завершённый Validate на dev: другой workflow
# или ветка задачи доказательством для кандидата беты не являются.
if [ "$path" != ".github/workflows/validate.yml" ] || [ "$branch" != "dev" ] || [ "$status" != "completed" ]; then
echo "::error::прогон $RUN — не завершённый Validate на dev ($path, $branch, $status)"
exit 1
fi
from="$RUNNER_TEMP/golden-candidate"
rm -rf "$from" && mkdir -p "$from"
gh run download "$RUN" --repo "${{ github.repository }}" -n golden-images -D "$from"
# Отчёт обязан быть снят с этого же дерева: accept.mjs сверяет его
# отпечаток с исходниками и откажет, если dev ушёл вперёд.
args=(--reviewed "--from=$from")
if [ -n "$EXPECT_CHANGE" ]; then args+=("--expect-change=$EXPECT_CHANGE"); fi
if [ -n "$EXPECT_NEW" ]; then args+=("--expect-new=$EXPECT_NEW"); fi
node scripts/golden-accept.mjs "${args[@]}"
if git diff --quiet -- demo/golden/baselines; then changed=false; else changed=true; fi
{ echo "changed=$changed"; echo "url=$url"; } >> "$GITHUB_OUTPUT"
git diff --stat -- demo/golden/baselines
- name: Коммит в dev
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
TAG: ${{ inputs.tag }}
DOCS_CHANGED: ${{ steps.docs.outputs.changed }}
DOCS_EXPECT: ${{ inputs.docs_expect_change }}
GOLDEN_CHANGED: ${{ steps.golden.outputs.changed }}
GOLDEN_URL: ${{ steps.golden.outputs.url }}
GOLDEN_EXPECT_CHANGE: ${{ inputs.golden_expect_change }}
GOLDEN_EXPECT_NEW: ${{ inputs.golden_expect_new }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# Хук pre-push гоняет process-gate; gate:small здесь не нужен —
# продуктового кода коммит не несёт.
HP_PREPUSH_GATE: "0"
run: |
set -euo pipefail
git add -- docs/images demo/golden/baselines
if git diff --cached --quiet; then
echo "::notice::отпечаток, кадры и эталоны на dev уже свежие — коммитить нечего"
exit 0
fi
msg="$RUNNER_TEMP/message.txt"
{
echo "docs: accept derived artifacts on dev for $TAG"
echo ""
echo "Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697)."
echo "Прогон: $RUN_URL"
if [ "$DOCS_CHANGED" = "true" ]; then
echo "Скриншоты документации: отпечаток исходников; изменённые кадры: ${DOCS_EXPECT:-нет}."
fi
if [ "$GOLDEN_CHANGED" = "true" ]; then
echo "Golden: изменённые сцены: ${GOLDEN_EXPECT_CHANGE:-нет}; новые: ${GOLDEN_EXPECT_NEW:-нет}."
fi
echo ""
if [ "$GOLDEN_CHANGED" = "true" ]; then
echo "Release: $TAG"
echo "Baseline-Reviewed: $GOLDEN_URL"
fi
echo "Issue: #697"
echo "User-Visible: no"
} > "$msg"
git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
commit -q -F "$msg"
git show --stat --format='%H %s' HEAD | head -40
# #730: сдвигом dev считается только устаревший lease — отказ
# разбирает код слияния (merge-candidate.mjs --push-refusal, #705).
# Скрипт — из dev: рабочая копия и есть dev. Сдвиг — прежний совет
# перезапустить; отказ GitHub (право на workflow, правило ветки, хук)
# перезапуск не лечит: причина и ответ git без токена — в журнале и в
# сводке шага.
push_err="$RUNNER_TEMP/beta-derived-push.stderr"
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev 2> "$push_err"; then
kind=$(node scripts/merge-candidate.mjs --push-refusal="$push_err" --ref=dev \
--stage=beta-derived --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
if [ "$kind" = "stale" ]; then
echo "::error::dev ушёл вперёд за время съёмки — запустить workflow заново: отпечаток судит дерево, а оно уже другое"
else
echo "::error::push производных артефактов в dev отклонён ($kind) — это не сдвиг dev, перезапуск не поможет; причина и ответ git — выше и в сводке шага"
fi
exit 1
fi
echo "### Производные артефакты $TAG" >> "$GITHUB_STEP_SUMMARY"
echo "Коммит \`$(git rev-parse --short HEAD)\` в dev — проверить перед кандидатом беты." >> "$GITHUB_STEP_SUMMARY"
+118 -2
View File
@@ -12,12 +12,19 @@
#
# Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт:
# гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и
# там полный набор идёт заново.
# там полный набор идёт заново. Адресат сигнала (#736) — задачи, чьи коммиты
# классов A/B вошли в dev после последней зелёной ночи: job `night_red` пишет
# каждой один комментарий (`scripts/night-red.mjs`, PROCESS.md §10.4).
#
# Сигнал обязан быть настоящим (#492 §7): до этой задачи job завершалась
# успехом в момент постановки Validate в очередь, и красный полный прогон не
# делал ночной workflow красным. Теперь job находит запущенный прогон и ждёт
# его: успешный dispatch — не успешная проверка.
#
# #727: после Validate при любом его исходе ночь запускает пакетное ревью ship
# (`ship-review.yml -f tag=nightly`) на SHA прогона Validate: оно читает код, а
# не прогон. Ship-задача узнаёт о дефектах в ночь после слияния, а бета читает
# только то, что ночь не прочла (PROCESS.md §11.7). Цвет ночи — цвет Validate.
name: "Ночной полный прогон dev · тело (#623)"
on:
@@ -34,6 +41,11 @@ jobs:
name: "Запустить Validate на dev с полным набором и дождаться результата"
runs-on: ubuntu-24.04
timeout-minutes: 90
outputs:
# #727: SHA прогона Validate — отдельным шагом до ожидания, красный Validate его не теряет.
head_sha: ${{ steps.validate.outputs.head_sha }}
# #736: прогон Validate для разбора красной ночи — тем же шагом, до ожидания.
run_id: ${{ steps.validate.outputs.run_id }}
steps:
# #658: при плане 02:30 UTC ночь фактически стартовала в 07:42–08:05 и
# кончалась в рабочее время владельца. Сдвиг старта больше часа — видимое
@@ -55,7 +67,9 @@ jobs:
if [ "$lag" -gt 60 ]; then
echo "::warning::ночной прогон стартовал на $lag мин позже расписания ($SCHEDULE UTC) — очередь расписаний GitHub (#658)"
fi
- env:
- name: "Запустить Validate и найти его прогон"
id: validate
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
@@ -80,5 +94,107 @@ jobs:
url="${{ github.server_url }}/$REPO/actions/runs/$run_id"
echo "дочерний прогон: $url"
echo "- Validate(dev, full=true): $url" >> "$GITHUB_STEP_SUMMARY"
head_sha=$(gh run view "$run_id" --repo "$REPO" --json headSha --jq .headSha)
echo "run_id=$run_id" >> "$GITHUB_OUTPUT"
echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"
- name: "Дождаться Validate"
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
RUN_ID: ${{ steps.validate.outputs.run_id }}
run: |
set -euo pipefail
run_id="$RUN_ID"
# Ждём завершения; красный дочерний прогон — красный ночной.
gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30
# #727 К6: ночное пакетное ревью ship на SHA прогона Validate — при любом его
# исходе. Dispatch тонкого `ship-review.yml` (`github.token` порождает прогон,
# как у Validate; входы и права тонких файлов уже в main). Job ждёт только
# появления прогона, не его конца; не появился — предупреждение, не красная
# ночь: цвет ночи — цвет Validate.
ship_review:
name: "Запустить ночное пакетное ревью ship на SHA прогона Validate"
needs: dispatch
if: always()
continue-on-error: true
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: "Запустить ship-ревью и дождаться появления прогона"
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
CANDIDATE: ${{ needs.dispatch.outputs.head_sha }}
run: |
set -euo pipefail
if [ -z "$CANDIDATE" ]; then
echo "::warning::нет SHA прогона Validate — ночное ship-ревью не запущено"
echo "- Ship-ревью ночью: не запущено — нет SHA прогона Validate" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
since=$(date -u +%FT%TZ)
if ! gh workflow run ship-review.yml --ref dev -f tag=nightly -f candidate="$CANDIDATE" --repo "$REPO"; then
echo "::warning::ship-ревью ночью не запущено: dispatch отклонён — ночь не красится"
echo "- Ship-ревью ночью: dispatch отклонён" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
run_id=""
for _ in $(seq 1 18); do
sleep 10
run_id=$(gh run list --repo "$REPO" --workflow ship-review.yml --branch dev \
--event workflow_dispatch --json databaseId,createdAt --limit 5 \
--jq "[.[] | select(.createdAt >= \"$since\")] | sort_by(.createdAt) | last | .databaseId // empty")
[ -n "$run_id" ] && break
done
if [ -z "$run_id" ]; then
echo "::warning::прогон ship-ревью не появился за 3 минуты — ночь не красится, её цвет — цвет Validate"
echo "- Ship-ревью ночью (\`$CANDIDATE\`): прогон не появился за 3 минуты" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
echo "- Ship-ревью ночью (\`$CANDIDATE\`): ${{ github.server_url }}/$REPO/actions/runs/$run_id" >> "$GITHUB_STEP_SUMMARY"
# #736: красная ночь — один комментарий в каждую задачу, чьи коммиты классов
# A/B вошли в dev после последней зелёной ночи (`scripts/night-red.mjs`,
# PROCESS.md §10.4). Только при красном dispatch с известным прогоном;
# исход прогона (failure, а не cancelled/timed_out) скрипт читает сам.
# Actions — `github.token`, issue — `HP_PROCESS_TOKEN`: `issues: write` у
# ночи нет, и потолок тонкого `nightly.yml` в main не меняется. Сбой —
# предупреждение, не красная ночь сверх Validate.
night_red:
name: "Красная ночь: комментарий в задачи, слитые после последней зелёной"
needs: dispatch
if: always() && needs.dispatch.result == 'failure' && needs.dispatch.outputs.run_id != ''
continue-on-error: true
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
actions: read
contents: read
steps:
# Нужны коммиты и деревья, не блобы; скрипт и его импорты — без зависимостей, npm ci не нужен.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: dev
fetch-depth: 0
filter: blob:none
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
- name: "Комментарий в задачи диапазона от последней зелёной ночи до красной"
env:
ACTIONS_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
REPO: ${{ github.repository }}
RED_RUN: ${{ needs.dispatch.outputs.run_id }}
run: |
set -euo pipefail
# Сбои API, токена и git скрипт сам сводит к предупреждению; упавший
# скрипт — тоже предупреждение: цвет ночи — цвет Validate.
if ! node scripts/night-red.mjs --repo="$REPO" --red-run="$RED_RUN"; then
echo "::warning::красная ночь (#736): скрипт упал — комментарии не написаны; цвет ночи — цвет Validate"
echo "- Красная ночь (#736): скрипт упал — комментарии не написаны" >> "$GITHUB_STEP_SUMMARY"
fi
+10 -3
View File
@@ -5,6 +5,12 @@ name: "Метрики процесса · тело (#623)"
# идёт в step summary и артефакт; в issue и репозиторий ничего не пишется.
# Цифры аудита 22.09 (1,57 раунда код-ревью, S7 ≈ 36 мин/раунд, 226 Validate
# за неделю) были собраны руками за час — теперь они стоят один запуск.
#
# #728: эффект процесса по трекам — отрезки, возвраты с причинами, находки
# ship, job-минуты по стадиям и сравнение до/после 28.09. Прогон дороже:
# jobs до 600 прогонов и таймлайны задач окна сравнения (≈ 600–800 запросов),
# поэтому потолок 30 минут; трек инфраструктуры и объём задачи читаются из
# истории git — нужна полная история dev.
on:
# #623: тело вызывается тонким файлом `process-metrics.yml` из ветки по умолчанию
@@ -26,14 +32,15 @@ jobs:
metrics:
name: "Снимок недели: issue, раунды, прогоны"
runs-on: ubuntu-24.04
timeout-minutes: 15
timeout-minutes: 30
steps:
# Код — из dev, как у reconcile: расписание читается из main, а исполняется
# версия, которую проверил CI.
# версия, которую проверил CI. Полная история (#728): коммиты задачи по
# трейлеру `Issue: #NN` дают признак инфраструктуры и объём для сравнения.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: dev
fetch-depth: 1
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
+6 -1
View File
@@ -54,7 +54,12 @@ jobs:
SHA: ${{ github.event.workflow_run.head_sha }}
EVENT: ${{ github.event.workflow_run.event }}
STATUS: ${{ github.event.workflow_run.status }}
VALIDATE_RUN_ID: ${{ github.event.workflow_run.id }}
# #751: без pipefail код конвейера — код tee, и исключение скрипта (gh,
# API) проходило зелёным шагом: событие возобновления терялось до
# прохода process-reconcile.
run: |
set -o pipefail
node scripts/process-resume.mjs \
--repo="$REPO" --branch="$BRANCH" --sha="$SHA" \
--event="$EVENT" --status="$STATUS" --apply=true | tee -a "$GITHUB_STEP_SUMMARY"
--event="$EVENT" --status="$STATUS" --run-id="$VALIDATE_RUN_ID" --apply=true | tee -a "$GITHUB_STEP_SUMMARY"
+347 -164
View File
@@ -82,19 +82,14 @@ jobs:
# очереди concurrency, пока владелец снял метку или поставил blocked —
# снимок события об этом не знает, а исполнять отозванный запрос нельзя.
current=$(gh issue view "$NUM" --repo "$REPO" --json labels --jq '.labels[].name')
labels=$(printf '%s\n' "$current" | paste -sd, -)
has() { printf '%s\n' "$current" | grep -qx -- "$1"; }
BLOCKED=false; EXHAUSTED=false; SMALL=false; TRIVIAL=false
BLOCKED=false; EXHAUSTED=false
has blocked && BLOCKED=true
has review-4 && EXHAUSTED=true
# Треки #695 (PROCESS §5): `track:show` и `track:ship` — лимит 2, как у
# прежних `small`/`trivial`; `track:ask` — 4. Цену захода по треку
# (мутанты, ребейз, модель) решает prepare (#696).
has small && SMALL=true
has trivial && TRIVIAL=true
has track:show && SMALL=true
has track:ship && SMALL=true
# Явный `track:ask` главнее прежних меток: метка владельца окончательна.
if has track:ask; then SMALL=false; TRIVIAL=false; fi
# Пути диффа ветки против dev — для признака инфраструктуры (§1, §5.1);
# без ветки или при отказе compare API список пуст.
changed=$(mktemp)
# Этап определяется первым: от него зависит, какие вердикты считать.
stage=""; marker=""
@@ -111,10 +106,6 @@ jobs:
stage=""
fi
# Лимит циклов: 4 на `track:ask`, 2 на `track:show`/`track:ship` (PROCESS.md §4, §5).
limit=4
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
# Считаются ДВЕ РАЗНЫЕ величины, и это не педантизм (#227).
#
# `attempt` — сколько раз ревью уже отработало на этом этапе. Он нужен
@@ -223,27 +214,28 @@ jobs:
fi
echo "ветка материала: ${branch:-нет, читался dev}"
# §5.1 (r1 #695): инфраструктурная задача (§1) без трековой метки
# идёт как `track:show` — лимит 2. Признак механический, как в §1:
# в диффе ветки против dev ни одного файла класса A. Guard историю
# не клонирует, поэтому пути — из compare API; 300 файлов — потолок
# ответа, и полный список тогда не доказан: остаётся лимит 4.
if [ -n "$branch" ] && ! has track:ship && ! has track:show && ! has track:ask \
&& ! has small && ! has trivial; then
changed=$(mktemp)
if gh api "repos/$REPO/compare/dev...$branch" --jq '.files[].filename' > "$changed" 2>/dev/null \
&& node --input-type=module -e '
import { readFileSync } from "node:fs";
import { classify } from "./scripts/process-gate.mjs";
const files = readFileSync(process.argv[1], "utf8").split("\n").filter(Boolean);
process.exit(files.length > 0 && files.length < 300 && files.every((f) => classify(f) !== "A") ? 0 : 1);
' "$changed"; then
limit=2
echo "инфраструктурная задача без трековой метки — трек show (§5.1), лимит 2"
fi
# Guard историю не клонирует, поэтому пути — из compare API. Отказ
# API — пустой список: инфраструктура не доказана (fail-closed).
if [ -n "$branch" ]; then
gh api "repos/$REPO/compare/dev...$branch" --jq '.files[].filename' > "$changed" 2>/dev/null \
|| : > "$changed"
fi
fi
# Трек и лимит циклов (PROCESS.md §4, §5, §5.1; #707) решает
# process-track.mjs — та же функция, что у шага трека в prepare и у
# пакета задачи. Своей логики трека здесь нет: две копии правила уже
# расходились (#707 п.2). Ответ compare на 300 файлов и больше полного
# списка не доказывает — скрипт оставляет `ask`/4. Скрипт не
# отработал — лимит 4, как без доказанного трека: guard обязан
# продолжить работу, а не встать.
limit=4
track_out=$(node scripts/process-track.mjs limit --labels="$labels" --files="$changed") \
|| echo "::warning::process-track.mjs limit не отработал — лимит по умолчанию 4"
printf '%s\n' "$track_out"
new_limit=$(printf '%s\n' "$track_out" | sed -n 's/^limit=//p')
case "$new_limit" in ''|*[!0-9]*) : ;; *) limit="$new_limit" ;; esac
# Отказ обязан быть виден в issue, а не только в логе прогона.
# Ревьюшная метка обещает работу; если конвейер её не начал и промолчал,
# задача стоит в этом статусе бесконечно и никто об этом не узнаёт.
@@ -303,7 +295,7 @@ jobs:
echo "cycle=$attempt" >> "$GITHUB_OUTPUT"
echo "spent=$spent" >> "$GITHUB_OUTPUT"
echo "limit=$limit" >> "$GITHUB_OUTPUT"
echo "labels=$(printf '%s\n' "$current" | paste -sd, -)" >> "$GITHUB_OUTPUT"
echo "labels=$labels" >> "$GITHUB_OUTPUT"
prepare:
name: "Ревью: материал и deterministic gates"
@@ -339,10 +331,21 @@ jobs:
spec_body_doc: ${{ steps.spec_body.outputs.doc }}
spec_body_recorded: ${{ steps.spec_body.outputs.recorded }}
duration_seconds: ${{ steps.duration.outputs.seconds }}
# #765: SHA снимка скриптов конвейера — одна версия на заход: model_review
# снимает расход скриптом из того же коммита dev.
tools_sha: ${{ steps.tools.outputs.sha }}
track: ${{ steps.track.outputs.track }}
mutants: ${{ steps.track.outputs.mutants }}
full: ${{ steps.track.outputs.full }}
ship: ${{ steps.track.outputs.ship }}
# #707: риск по изменённым участкам — заметка ревьюеру и строка риска
# для комментария слияния ship (пакетное ревью, §11.7).
risk_note: ${{ steps.track.outputs.risk_note }}
ship_risk: ${{ steps.track.outputs.ship_risk }}
# #726: подтверждение трека владельцем — решению по вердикту; заметка
# маршрута (`route`/`criterion`) — в промпт Review.
confirmed: ${{ steps.track.outputs.confirmed }}
route_note: ${{ steps.track.outputs.route_note }}
steps:
- name: Начать измерение стадии
id: clock
@@ -383,6 +386,29 @@ jobs:
node-version: 22
cache: npm
# #765 (продолжение #749): тело читается из dev (@dev, #623), значит, и
# управляющие скрипты подготовки — из dev. Рабочая копия после «Перейти на
# ветку задачи» — материал: ветка show/ship с чистым слиянием до ревью не
# ребейзится (§10.4) и может нести scripts/, отставшие на дни или
# подменённые. Раньше якорь тела issue, reuse (#499), Validate-gate (#510)
# и проверку ТЗ (#517) исполняли скрипты ветки — материал сам участвовал в
# решении о своём допуске к ревью. Снимок один на заход: SHA закрепляется
# здесь, до любого git fetch трека и ребейза, и выходом `tools_sha` уходит
# в model_review. validate.yml в снимке — по той же причине, что в #749:
# ci-proof.mjs (validate-gate) читает его по пути от себя. Git, дифф и
# документы по-прежнему судятся в рабочей копии — снимок только исполняет.
- name: Скрипты конвейера — из dev (#765)
id: tools
run: |
set -euo pipefail
git fetch -q origin dev
sha=$(git rev-parse origin/dev)
tools="$RUNNER_TEMP/dev-tools"
rm -rf "$tools" && mkdir -p "$tools"
git archive "$sha" scripts .github/workflows/validate.yml | tar -x -C "$tools"
{ echo "dir=$tools"; echo "sha=$sha"; } >> "$GITHUB_OUTPUT"
echo "скрипты конвейера: dev ${sha:0:12}"
# Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся
# в issue/<NN>-slug. Если ветка запушена — переключаемся на неё, иначе
# ревьюер прочтёт dev и не найдёт того, что должен оценивать.
@@ -415,12 +441,17 @@ jobs:
# Трек решает цену захода (PROCESS.md §5, §10.4, #696): мутантов в
# разработке нет ни на одном треке (#709); `ship` сливается без модели,
# если правка укладывается в механические рамки. Рамки судят дифф от
# merge-base с dev — работу задачи, а не то, что принёс dev, — поэтому шаг
# стоит до ребейза. Выход за рамки повышает трек до `show` здесь же:
# повысить трек вправе любой, понизить — только владелец (§5). Скрипт
# берётся из dev, как помощник ребейза: show/ship до ревью не ребейзятся,
# и ветка, отставшая от dev, его может ещё не нести.
# если правка укладывается в механические рамки и не несёт риска по
# изменённым участкам (#707) без подтверждения владельца. Рамки и риск
# судят дифф от merge-base с dev — работу задачи, а не то, что принёс
# dev, — поэтому шаг стоит до ребейза. Повышение ship → show — здесь же:
# повысить трек вправе любой, понизить — только владелец (§5). Всё решает
# ОДИН вызов process-track.mjs по меткам, диффу и комментариям; bash только
# исполняет: пишет комментарий из файла скрипта и меняет метки по его
# флагу. Выходы (`track`, `mutants`, `full`, `ship`, `risk_note`,
# `ship_risk`) скрипт пишет в GITHUB_OUTPUT сам. Скрипт — из снимка dev
# (#765): show/ship до ревью не ребейзятся, и ветка, отставшая от dev, его
# может ещё не нести.
- name: "Трек задачи и рамки ship (#696)"
id: track
env:
@@ -429,42 +460,26 @@ jobs:
STAGE: ${{ needs.guard.outputs.stage }}
LABELS: ${{ needs.guard.outputs.labels }}
BRANCH: ${{ steps.branch.outputs.name }}
OWNER: ${{ github.repository_owner }}
TOOLS: ${{ steps.tools.outputs.dir }}
run: |
git fetch -q origin dev
tools="$RUNNER_TEMP/track-tools"
rm -rf "$tools" && mkdir -p "$tools"
git archive origin/dev scripts | tar -x -C "$tools"
head=HEAD
if [ -z "$BRANCH" ]; then head=origin/dev; fi
out=$(node "$tools/scripts/process-track.mjs" resolve --labels="$LABELS" --base=origin/dev --head="$head")
# Комментарии — для строки владельца «Трек: <x> — решение владельца»
# (#707). Не прочитаны — пустой файл: происхождение не установлено.
comments="$RUNNER_TEMP/track-comments.json"
gh issue view "$NUM" --repo "${{ github.repository }}" --json comments > "$comments" 2>/dev/null \
|| : > "$comments"
out=$(node "$TOOLS/scripts/process-track.mjs" stage --stage="$STAGE" --labels="$LABELS" \
--branch="$BRANCH" --base=origin/dev --head=HEAD --comments="$comments" --owner="$OWNER" \
--out="$RUNNER_TEMP/track" \
--run-url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}")
printf '%s\n' "$out"
track=$(printf '%s\n' "$out" | sed -n 's/^track=//p')
mutants=$(printf '%s\n' "$out" | sed -n 's/^mutants=//p')
full=$(printf '%s\n' "$out" | sed -n 's/^full=//p')
ship=false
if [ "$track" = "ship" ] && [ "$STAGE" = "code" ] && [ -n "$BRANCH" ]; then
limits=$(node "$tools/scripts/process-track.mjs" ship-limits --base=origin/dev --head=HEAD)
printf '%s\n' "$limits"
if printf '%s\n' "$limits" | grep -qx 'ship=true'; then
ship=true
else
violations=$(printf '%s\n' "$limits" | sed -n 's/^violations=//p')
track=show
cat > /tmp/ship.md <<EOF
**Трек повышен: \`track:ship\` → \`track:show\`.** Правка выходит за механические рамки ship (PROCESS.md §5): $violations.
Слияние без ревью модели для неё закрыто. Этот заход идёт по треку show: лёгкий Validate и ревью модели «корректность и AC». Понизить трек обратно может только владелец.
[Прогон](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
EOF
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/ship.md
gh issue edit "$NUM" --repo "${{ github.repository }}" \
--add-label track:show --remove-label track:ship
echo "track:ship -> track:show: $violations"
fi
if printf '%s\n' "$out" | grep -qx 'raise=true'; then
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file "$RUNNER_TEMP/track/raise.md"
gh issue edit "$NUM" --repo "${{ github.repository }}" \
--add-label track:show --remove-label track:ship
echo "track:ship -> track:show"
fi
{ echo "track=$track"; echo "mutants=$mutants"; echo "full=${full:-false}"; echo "ship=$ship"; } >> "$GITHUB_OUTPUT"
echo "- трек **$track** · мутанты по диффу: $mutants · полный набор: ${full:-false} · слияние без модели: $ship" >> "$GITHUB_STEP_SUMMARY"
# Ревьюер обязан смотреть тот же код, который уедет в dev (#257). Раньше
# ревью шло по ветке как есть, а слияние делало ребейз — проверенный SHA и
@@ -488,6 +503,7 @@ jobs:
BRANCH: ${{ steps.branch.outputs.name }}
NUM: ${{ github.event.issue.number }}
TRACK: ${{ steps.track.outputs.track }}
TOOLS: ${{ steps.tools.outputs.dir }}
# rebase, в отличие от commit, не принимает -c user.*: он запускает
# свои процессы и требует личность в окружении, иначе падает с
# «unable to auto-detect email address».
@@ -524,13 +540,10 @@ jobs:
# сдвиге dev. Помощник ребейза пересобирает индекс по каталогу, если
# ВСЕ конфликты остановки — индекс, и отказывает (с abort) на любом
# другом. Список конфликтов он снимает ДО abort (#364) и печатает в
# stdout по строке. Помощник берётся из dev, а не из ветки: ветка,
# отставшая от dev, его ещё не несёт.
tools="$RUNNER_TEMP/rebase-tools"
rm -rf "$tools" && mkdir -p "$tools"
git archive origin/dev scripts | tar -x -C "$tools"
# stdout по строке. Помощник — из снимка dev (#765), а не из ветки:
# ветка, отставшая от dev, его ещё не несёт.
code=0
files=$(node "$tools/scripts/rebase-generated.mjs" --onto=origin/dev) || code=$?
files=$(node "$TOOLS/scripts/rebase-generated.mjs" --onto=origin/dev) || code=$?
# Код 3 — отказ с перечнем (ребейз отменён); любой другой ненулевой —
# сбой самого помощника, конфликтом ветки он не выдаётся.
if [ "$code" -ne 0 ] && [ "$code" -ne 3 ]; then
@@ -560,11 +573,38 @@ jobs:
# fetch и push автор мог запушить коммит, и слепой --force потерял бы
# его молча. Расхождение lease — падение прогона, а не предупреждение:
# ревью пошло бы по коду, которого на ветке уже нет.
#
# #705: отказ разбирает тот же код, что у слияния кандидата
# (merge-candidate.mjs --push-refusal): ответ git — в журнал без
# токена, исход — одним словом. Отказ GitHub по праву на workflow —
# не сдвинутая ветка: задача возвращается автору без ревью с
# комментарием, как при конфликте. Прочий отказ GitHub — ошибка
# прогона со своей причиной. #730: причина и ответ git без токена —
# ещё и в сводке шага (--summary; устаревший lease её не пишет).
push_err="$RUNNER_TEMP/rebase-push.stderr"
if ! git push -q --force-with-lease="refs/heads/$BRANCH:$before" \
"https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:refs/heads/$BRANCH"; then
echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора"
exit 1
"HEAD:refs/heads/$BRANCH" 2> "$push_err"; then
refusal="$RUNNER_TEMP/push-refusal.md"
kind=$(node "$TOOLS/scripts/merge-candidate.mjs" --push-refusal="$push_err" \
--branch="$BRANCH" --candidate="$(git rev-parse HEAD)" --stage=rebase \
--run-url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
--comment="$refusal" --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
case "$kind" in
stale)
echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора"
exit 1
;;
workflow)
{ echo 'refused=workflow'; echo "refusal_comment=$refusal"; } >> "$GITHUB_OUTPUT"
echo "::warning::GitHub не принял push ребейза $BRANCH: кандидат меняет workflow-файл, у токена конвейера нет права на workflow — ревью не запускается"
exit 0
;;
*)
echo "::error::GitHub отклонил push ребейза $BRANCH ($kind) — это не изменение ветки автором; ответ git выше"
exit 1
;;
esac
fi
# #539: ссылка на стороне GitHub доезжает не мгновенно, а гейт ниже
# просит `workflow_dispatch` ПО ИМЕНИ ВЕТКИ — SHA туда передать
@@ -598,11 +638,12 @@ jobs:
# это заметить и отказаться, а не молча увезти в dev непроверенный код.
- name: Зафиксировать SHA материала ревью
id: material
if: steps.rebase.outputs.conflict != 'true'
if: steps.rebase.outputs.conflict != 'true' && steps.rebase.outputs.refused == ''
env:
NUM: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
TOOLS: ${{ steps.tools.outputs.dir }}
run: |
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
@@ -628,7 +669,8 @@ jobs:
body=$(mktemp)
if gh issue view "$NUM" --repo "$REPO" --json body --jq .body > "$body"; then
digest=$(node -e '
import("./scripts/review-doc-guard.mjs").then(async (m) => {
const { pathToFileURL } = require("node:url");
import(pathToFileURL(`${process.env.TOOLS}/scripts/review-doc-guard.mjs`).href).then(async (m) => {
const { readFileSync } = await import("node:fs");
process.stdout.write(m.issueBodyDigest(readFileSync(process.argv[1], "utf8")));
});
@@ -651,15 +693,16 @@ jobs:
# теле issue, которого в дереве нет.
- name: "Зелёный вердикт прошлого захода применим без ревью (#499)"
id: reuse
if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code'
if: steps.rebase.outputs.conflict != 'true' && steps.rebase.outputs.refused == '' && needs.guard.outputs.stage == 'code'
env:
NUM: ${{ github.event.issue.number }}
# Правка ТЗ между раундами обязана отменять повторное применение
# зелёного вердикта: иначе вызов модели пропускается и находку
# «ТЗ менялось» некому напечатать (#517 AC6).
ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
TOOLS: ${{ steps.tools.outputs.dir }}
run: |
out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \
out=$(node "$TOOLS/scripts/review-doc-guard.mjs" --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \
--issue-body="${ISSUE_BODY}")
printf '%s\n' "$out"
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"
@@ -702,6 +745,23 @@ jobs:
--add-label S6-in-progress --remove-label S7-code-review
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
# #705: GitHub не принял push приведённой ветки — кандидат меняет
# workflow-файл, а у токена конвейера нет права на workflow. Ветку никто
# не двигал, и ревью по неопубликованному ребейзу шло бы по коду, которого
# на ветке нет. Возврат автору без ревью, как при конфликте: цикл не
# израсходован, метка меняется. Текст комментария собрал разбор отказа.
- name: "Push ребейза отклонён по праву на workflow — вернуть автору без ревью (#705)"
if: steps.rebase.outputs.refused == 'workflow'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
NUM: ${{ github.event.issue.number }}
COMMENT: ${{ steps.rebase.outputs.refusal_comment }}
run: |
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file "$COMMENT"
gh issue edit "$NUM" --repo "${{ github.repository }}" \
--add-label S6-in-progress --remove-label S7-code-review
echo "S7-code-review -> S6-in-progress (ревью не запускалось: push отклонён по праву на workflow)"
# До ревью конвейер запускает Validate на материале и ждёт его — всегда
# лёгкий: мутантов в разработке нет (#709), весь реестр — ночью. Лёгкому годится и
# завершённый push-прогон на том же SHA: dispatch повторил бы его. Красный или
@@ -712,7 +772,7 @@ jobs:
# слияние само дожидается Validate на кандидате.
- name: Validate на материале
id: gate
if: steps.rebase.outputs.conflict != 'true'
if: steps.rebase.outputs.conflict != 'true' && steps.rebase.outputs.refused == ''
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
STAGE: ${{ needs.guard.outputs.stage }}
@@ -722,6 +782,7 @@ jobs:
MUTANTS: ${{ steps.track.outputs.mutants }}
# #697: `ci:full`/`ci:golden` — полный набор на материале.
FULL: ${{ steps.track.outputs.full }}
TOOLS: ${{ steps.tools.outputs.dir }}
run: |
if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then
echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}"
@@ -735,7 +796,7 @@ jobs:
# S7), страховка — process-reconcile. Зелёный или красный завершённый
# прогон гейт и без ожидания возвращает сразу.
set +e
node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait \
node "$TOOLS/scripts/validate-gate.mjs" --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait \
--mutants="${MUTANTS:-false}" --full="${FULL:-false}"
code=$?
set -e
@@ -864,13 +925,15 @@ jobs:
env:
NUM: ${{ github.event.issue.number }}
DIGEST: ${{ steps.material.outputs.issue_body }}
TOOLS: ${{ steps.tools.outputs.dir }}
run: |
if [ -z "$DIGEST" ]; then
echo "хеша тела нет — сравнивать не с чем"
exit 0
fi
out=$(node -e '
import("./scripts/review-doc-guard.mjs").then(async (m) => {
const { pathToFileURL } = require("node:url");
import(pathToFileURL(`${process.env.TOOLS}/scripts/review-doc-guard.mjs`).href).then(async (m) => {
const { execFileSync } = await import("node:child_process");
const [num, digest] = process.argv.slice(1);
const git = (args) => { try { return execFileSync("git", args, { encoding: "utf8" }); } catch { return ""; } };
@@ -978,6 +1041,8 @@ jobs:
timeout-minutes: 45
outputs:
duration_seconds: ${{ steps.duration.outputs.seconds }}
# #737: строка расхода сессии модели (scripts/model-usage.mjs).
usage: ${{ steps.usage.outputs.line }}
steps:
- name: Начать измерение стадии
id: clock
@@ -1133,12 +1198,16 @@ jobs:
ничего не вернул на правки и цикла не образует (#227).
Номер захода нужен для имени документа — два документа с
одинаковым номером затёрли бы друг друга.
Трек: ${{ needs.prepare.outputs.track || 'ask' }} (PROCESS.md §5) · мутанты по диффу на материале: ${{ needs.prepare.outputs.mutants == 'false' && 'не запрашивались' || 'прогнаны Validate' }}
Трек: ${{ needs.prepare.outputs.track || 'ask' }} (PROCESS.md §5)
${{ needs.prepare.outputs.track == 'show' && needs.guard.outputs.stage == 'code' && 'Трек show: объём и серьёзность находок — docs/process/REVIEWER.md, «Трек show» (#696). Отсутствие мутантов по диффу — не находка.' || '' }}
${{ needs.prepare.outputs.track == 'show' && needs.guard.outputs.stage == 'code' && 'Трек show: объём и серьёзность находок — docs/process/REVIEWER.md, «Трек show» (#696).' || '' }}
${{ needs.prepare.outputs.rebase_note }}
${{ needs.prepare.outputs.risk_note }}
${{ needs.prepare.outputs.route_note }}
${{ needs.prepare.outputs.spec_body_changed == 'true' && format('ТЗ в теле issue менялось после зелёного ревью ТЗ ({0}, записанный хеш {1}). GitHub хранит правки тела без diff — дельту показать нельзя, поэтому AC сверяются с ТЕКУЩИМ текстом целиком, а не по дельте, и находка называется в вердикте (#517).', needs.prepare.outputs.spec_body_doc, needs.prepare.outputs.spec_body_recorded) || '' }}
Правила ревью в этом промпте не повторяются (#634): их канон —
@@ -1171,17 +1240,8 @@ jobs:
FILTERING.md, STAIRS.md, RADAR.md, PDF-EXPORT.md,
STYLING-HOOKS.md.
**Если цикл не первый — объём разбора по дельте, а не заново**
(PROCESS.md §2.10, issue #214): найди вердикт и материал
предыдущего раунда (блок «Материал раунда» его документа), объяви
дельту `git diff <тот SHA>..HEAD` (для spec — дифф тела issue или
файла ТЗ), по каждой находке покажи, чем именно она закрыта —
строка кода или текста, а не заявление автора, — и заново проверяй
только AC, чьё доказательство дельта задевает. Разбор остаётся
ПОЛНЫМ, если дельта не локальна: ребейз на ушедший вперёд dev,
смена контракта, новая подсистема, объём сопоставим с задачей.
Сомневаешься — разбирай полностью и скажи почему. Сокращается
объём РАЗБОРА, а не строгость.
Цикл не первый — разбор по дельте (PROCESS.md §2.10):
docs/process/REVIEWER.md, «Повторный раунд».
Для этапа spec: ТЗ живёт в теле issue (#517) — читай его, а не файл.
Файлы docs/specs/<NN>-*.md — архив ТЗ до 2026-09-10: если такой файл
@@ -1224,22 +1284,8 @@ jobs:
`npm run build` со сверкой трёх копий бандла. Свежесть скриншотов
документации — не гейт задачи (#697): её обновляет бот на dev.
${{ needs.guard.outputs.stage == 'spec' && 'Зависимости и Chromium на этапе spec не ставились (#696).' || (needs.prepare.outputs.track == 'show' && 'Зависимости установлены (`npm ci` не нужен); Chromium — только если тело issue называет смоук (#696).' || 'Зависимости уже установлены workflow, Chromium тоже — `npm ci` выполнять не нужно.') }}
По диффу и AC: браузерные смоки — названные в AC
плюс вывод `node scripts/smoke-select.mjs --base <base> --head <head>`,
приложенный к комментарию с решением по каждой строке: прогнал либо
не прогнал и почему. Три вида ответа инструмента разные: «прямое
совпадение», «зарегистрированная связь», «НЕОПРЕДЕЛЁННОСТЬ» — связь
не доказана, и это не разрешение ничего не прогонять; слабые связи —
повод посмотреть, а не обязанность прогонять;
`npm run golden:verify` при метке `ci:golden`;
`python -m pytest tests_backend -q` при правке
`custom_components/**/*.py`; инварианты модели
`npm run invariants -- --config <экспорт>` при правке геометрии или
ссылок на неё (#254) — задача меняет геометрию, а инварианты в
отчёте не названы, это непрогнанный гейт, а не мелочь;
performance-профили, если названы в AC. Дисциплина «тест должен
уметь падать» не отменяется, но применяется к тем тестам, которые
ты прогонял.
По диффу и AC — смоки, golden, pytest, инварианты, performance:
docs/process/REVIEWER.md, «Объём гейтов».
**В комментарии обязателен перечень: какие гейты прогнал, какие нет и
почему.** Раздел «чего не проверял» в документе ревью — не
@@ -1247,13 +1293,9 @@ jobs:
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
Серьёзность: High блокирует; Medium В СКОУПЕ задачи чинится в ней
же — без High это жёлтый вердикт и возврат автору, отдельный issue
НЕ заводится (#202); Low либо правится, либо снимается с записью.
Жёлтый вердикт допустим и при полностью выполненных AC, если
изменение не решает заявленный сценарий или ухудшает смежный.
Продуктовое рассуждение расширяет вопросы, но не отменяет AC и не
даёт права менять скоуп.
Серьёзность и вердикт — docs/process/REVIEWER.md, «Находки и вердикт»:
Medium В СКОУПЕ — жёлтый вердикт и возврат автору, отдельный issue
НЕ заводится (#202).
Только Medium-находку ВНЕ скоупа задачи (попутный дефект соседнего
поведения, который в этой ветке чинить нельзя) заведи отдельным
@@ -1290,7 +1332,43 @@ jobs:
claude_args: |
--max-turns 150
--allowedTools Read,Write,Grep,Glob,Bash,mcp__github__add_issue_comment,mcp__github__issue_write,mcp__github__issue_read
--json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["green","yellow","red"]},"high":{"type":"integer"},"medium":{"type":"integer"},"summary":{"type":"string"}},"required":["verdict","high","medium","summary"]}'
--json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["green","yellow","red"]},"high":{"type":"integer"},"medium":{"type":"integer"},"summary":{"type":"string"},"route":{"type":"string","enum":["fix","reclassify"]},"criterion":{"type":"string"}},"required":["verdict","high","medium","summary","route"]}'
# #737: расход сессии — отчётная величина, как длительность стадии: по
# нему ничего не решается, поэтому он идёт выходом job, а не через
# запечатанный artifact (#556). Публикация разбирает строку строго. Сам
# execution_file — все сообщения сессии, с результатами инструментов —
# не выгружается и не печатается: скрипт берёт из него только `result`.
# `always()` — расход упавшей сессии хотя бы виден в сводке.
#
# #765: рабочая копия здесь — материал ревью. На отставшей ветке show/ship
# model-usage.mjs нет, и публикация молча писала `reason=missing`; ветка
# могла и подменить скрипт. Скрипт берётся из того же коммита dev, что и
# снимок подготовки (`tools_sha`). Снимок извлекается здесь, а не отдельным
# шагом до Review: его сбой — сбой отчётного шага (continue-on-error), а не
# всей стадии. От сессии модели, исполнявшейся на этом раннере, снимок не
# защищает — поэтому строка остаётся недоверенным вводом и разбирается
# строго при публикации (#556, #737).
- name: Снять расход модели
id: usage
if: always()
continue-on-error: true
env:
EXEC: ${{ steps.review.outputs.execution_file }}
TOOLS_SHA: ${{ needs.prepare.outputs.tools_sha }}
run: |
set -euo pipefail
if [ -z "$TOOLS_SHA" ]; then
echo "::error::нет SHA снимка скриптов из prepare — расход не снят"
exit 1
fi
tools="$RUNNER_TEMP/dev-tools"
rm -rf "$tools" && mkdir -p "$tools"
git cat-file -e "${TOOLS_SHA}^{commit}" 2>/dev/null || git fetch -q origin "$TOOLS_SHA"
git archive "$TOOLS_SHA" scripts | tar -x -C "$tools"
line=$(node "$tools/scripts/model-usage.mjs" --execution-file="$EXEC")
echo "line=$line" >> "$GITHUB_OUTPUT"
echo "- расход модели: \`$line\`" >> "$GITHUB_STEP_SUMMARY"
- name: Запечатать результат модели
id: result
@@ -1399,6 +1477,28 @@ jobs:
with:
node-version: 22
# #749: тело читается из dev (@dev, #623) — значит, и скрипты, которые
# оно зовёт со своими флагами и форматами, тоже из dev. Рабочая копия
# после публикации документа — ветка задачи, а ветка show/ship с чистым
# слиянием до ревью не ребейзится (§10.4) и несёт scripts/, отставшие на
# дни: неизвестный флаг review-doc-guard.mjs молча теряется, старый
# merge-candidate.mjs сливает по-старому. Рабочая копия — материал, а не
# инструмент: git, документ и пути судятся в ней, скрипты зовутся отсюда.
# Снимок один на job — одна версия на весь заход, а не смесь по шагам.
# validate.yml в снимке обязателен: workflow-jobs.mjs читает его по пути
# от себя, и без него ci-proof.mjs отвечает `failed (#622)` на каждом
# слиянии кода.
- name: Скрипты конвейера — из dev (#749)
id: tools
if: steps.ready.outputs.proceed == 'true'
run: |
set -euo pipefail
tools="$RUNNER_TEMP/dev-tools"
rm -rf "$tools" && mkdir -p "$tools"
git fetch -q origin dev
git archive origin/dev scripts .github/workflows/validate.yml | tar -x -C "$tools"
echo "dir=$tools" >> "$GITHUB_OUTPUT"
- name: Получить результат модели
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
@@ -1431,10 +1531,11 @@ jobs:
SPEC_BODY_CHANGED: ${{ needs.prepare.outputs.spec_body_changed }}
SPEC_BODY_DOC: ${{ needs.prepare.outputs.spec_body_doc }}
SPEC_BODY_RECORDED: ${{ needs.prepare.outputs.spec_body_recorded }}
TOOLS: ${{ steps.tools.outputs.dir }}
run: |
set -euo pipefail
dir="$RUNNER_TEMP/review-result"
node scripts/review-result-gate.mjs --dir="$dir"
node "$TOOLS/scripts/review-result-gate.mjs" --dir="$dir"
printf 'structured_output<<EOF_RESULT\n' >> "$GITHUB_OUTPUT"
cat "$dir/verdict.json" >> "$GITHUB_OUTPUT"
# structured_output не обязан оканчиваться LF: delimiter команды
@@ -1460,9 +1561,17 @@ jobs:
# Вердикт из structured_output попадает в блок якорей (#499): по нему
# следующий заход решает, можно ли применить зелёный вердикт повторно.
OUT: ${{ steps.result.outputs.structured_output }}
# #737: расход модели — последней строкой блока якорей; строка
# недоверенная, скрипт разбирает её строго.
USAGE: ${{ needs.model_review.outputs.usage }}
# #749: скрипты — из снимка dev, рабочая копия — ветка задачи.
TOOLS: ${{ steps.tools.outputs.dir }}
run: |
verdict=$(printf '%s' "$OUT" | jq -r '.verdict // empty' 2>/dev/null || true)
high=$(printf '%s' "$OUT" | jq -r '.high // empty' 2>/dev/null || true)
# #726: маршрут вердикта — хвостом строки якоря; вне словаря скрипт его не пишет.
route=$(printf '%s' "$OUT" | jq -r '.route // empty' 2>/dev/null || true)
criterion=$(printf '%s' "$OUT" | jq -r '.criterion // empty' 2>/dev/null || true)
# Ветки задачи может не быть: у задач, размеченных до появления
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
# выходил с нулём, и разбор ревью терялся — оставался только вердикт
@@ -1515,11 +1624,13 @@ jobs:
# выводом — через раунд команда из §2.10 не работала. Машина же
# снимает якоря в момент чтения материала и ошибиться в них не
# может; блок помечен как машинный, чтобы никто не правил его руками.
node scripts/review-doc-guard.mjs --anchor="$doc" \
node "$TOOLS/scripts/review-doc-guard.mjs" --anchor="$doc" \
--sha="$MATERIAL_SHA" --tree="$MATERIAL_TREE" \
--branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS" \
--issue-body="$MATERIAL_ISSUE_BODY" \
--verdict="$verdict" --high="$high"
--usage="$USAGE" \
--verdict="$verdict" --high="$high" \
--route="$route" --criterion="$criterion"
else
echo "::warning::$SOURCE не найден — документа для публикации нет"
fi
@@ -1532,7 +1643,7 @@ jobs:
# задачи — один документ: индекс там конфликтовал бы с документами
# соседей в dev по построению; его пересоберёт слияние кандидата.
if [ -f "$doc" ] && [ "$target" = "dev" ]; then
node scripts/reviews-index.mjs --dir=docs/reviews
node "$TOOLS/scripts/reviews-index.mjs" --dir=docs/reviews
git add -- docs/reviews/INDEX.md
fi
if git diff --cached --quiet; then
@@ -1553,24 +1664,41 @@ jobs:
exit 1
fi
# Первый рубеж: что вообще проиндексировано.
git diff --cached --name-only | node scripts/review-doc-guard.mjs
git diff --cached --name-only | node "$TOOLS/scripts/review-doc-guard.mjs"
# Сообщение коммита — построчно в файл, без heredoc в `run:` (#723).
msg="$RUNNER_TEMP/review-doc-commit.txt"
{
echo "docs: review document for #$NUM"
echo ""
echo "Issue: #$NUM"
echo "User-Visible: no"
} > "$msg"
git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
commit -q -F - <<EOF
docs: review document for #$NUM
Issue: #$NUM
User-Visible: no
EOF
commit -q -F "$msg"
# Второй рубеж, и он главный: что пуш ДОБАВИТ в целевую ветку. Первый
# судит намерение шага, этот — результат, а расходились они именно
# тогда, когда база оказывалась не той.
git diff --name-only "origin/$target...HEAD" | node scripts/review-doc-guard.mjs
git diff --name-only "origin/$target...HEAD" | node "$TOOLS/scripts/review-doc-guard.mjs"
# Публикация в dev идёт из детачнутого состояния поверх ветки задачи
# либо dev, поэтому push нужен с явным перебазированием при гонке:
# dev мог уйти вперёд, пока шло ревью — оно длится до 45 минут.
#
# #723: гонкой считается только устаревший lease. Отказ разбирает код
# слияния (merge-candidate.mjs --push-refusal, #705) — из снимка dev
# (#749): ветка, отставшая от dev, его может не нести. Отказ GitHub
# (право на workflow, правило ветки, хук) ребейз не лечит: шаг падает
# сразу, причина и ответ git без токена — в журнале и в сводке шага;
# метка не меняется.
push_err="$RUNNER_TEMP/review-doc-push.stderr"
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"; then
"HEAD:$target" 2> "$push_err"; then
kind=$(node "$TOOLS/scripts/merge-candidate.mjs" --push-refusal="$push_err" --ref="$target" \
--stage=review-doc --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
if [ "$kind" != "stale" ]; then
echo "::error::push документа ревью в $target отклонён ($kind) — это не сдвиг $target, ребейз не поможет; причина и ответ git — выше и в сводке шага"
exit 1
fi
git fetch -q origin "$target"
if ! git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
@@ -1582,9 +1710,14 @@ jobs:
fi
# После ребейза набор путей другой — проверяется заново. Форс здесь
# запрещён и не появляется: ветка двигается только вперёд.
git diff --name-only "origin/$target...HEAD" | node scripts/review-doc-guard.mjs
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"
git diff --name-only "origin/$target...HEAD" | node "$TOOLS/scripts/review-doc-guard.mjs"
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target" 2> "$push_err"; then
kind=$(node "$TOOLS/scripts/merge-candidate.mjs" --push-refusal="$push_err" --ref="$target" \
--stage=review-doc --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
echo "::error::документ ревью не опубликован в $target и после ребейза ($kind); причина и ответ git — выше"
exit 1
fi
fi
# Постусловие: до ветки дошёл именно ожидаемый файл. Коммит с
# документом, названным не по формату, — тот же вердикт без
@@ -1623,6 +1756,7 @@ jobs:
STAGE: ${{ needs.guard.outputs.stage }}
CYCLE: ${{ needs.guard.outputs.cycle }}
BRANCH: ${{ needs.prepare.outputs.branch }}
TOOLS: ${{ steps.tools.outputs.dir }}
run: |
marker=CODE-REVIEW
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
@@ -1631,7 +1765,7 @@ jobs:
git fetch -q origin "$target"
# Судится опубликованная версия, а не рабочая копия: именно её прочтёт
# следующий раунд.
git show "origin/$target:$doc" | node scripts/review-doc-guard.mjs --doc=-
git show "origin/$target:$doc" | node "$TOOLS/scripts/review-doc-guard.mjs" --doc=-
- name: Решение по вердикту
id: decide
if: steps.ready.outputs.proceed == 'true'
@@ -1643,26 +1777,51 @@ jobs:
REUSE_ROUND: ${{ needs.prepare.outputs.reuse_round }}
REUSE_TREE: ${{ needs.prepare.outputs.reuse_tree }}
SHIP: ${{ needs.prepare.outputs.ship }}
# #707: строка риска подтверждённого владельцем ship — для пакетного ревью.
SHIP_RISK: ${{ needs.prepare.outputs.ship_risk }}
MATERIAL: ${{ needs.prepare.outputs.material_sha }}
VALIDATE_URL: ${{ needs.prepare.outputs.validate_url }}
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
NUM: ${{ github.event.issue.number }}
# #726: маршрут вердикта — трек и его подтверждение владельцем (шаг
# трека), бюджет этапа (guard), ветка материала с документами ревью.
TRACK: ${{ needs.prepare.outputs.track }}
CONFIRMED: ${{ needs.prepare.outputs.confirmed }}
LABELS: ${{ needs.guard.outputs.labels }}
SPENT: ${{ needs.guard.outputs.spent }}
LIMIT: ${{ needs.guard.outputs.limit }}
CYCLE: ${{ needs.guard.outputs.cycle }}
BRANCH: ${{ needs.prepare.outputs.branch }}
TOOLS: ${{ steps.tools.outputs.dir }}
run: |
# ship и повторно применённый зелёный (#499) модель не вызывали:
# маршрута у них нет, решение прежнее — зелёный двигает вперёд.
green_forward() {
green=true
case "$STAGE" in
spec) from=S4-spec-review; to=S5-ready ;;
code) from=S7-code-review; to=S8-merged ;;
esac
}
green=""; from=""; to=""
# Повторно применимый зелёный вердикт (#499) главнее ship: код уже
# прочитан моделью, и пакетному ревью перечитывать его незачем.
if [ "$SHIP" = "true" ] && [ "$REUSE" != "true" ]; then
# #696: `ship` в рамках (PROCESS.md §5) сливается без ревью модели
# после зелёного лёгкого Validate. Комментарий — не вердикт ревью и
# так себя не называет: ревью не было. Машинный маркер находит
# пакетное ревью диапазона перед бетой (ship-review.mjs, §11.7).
# пакетное ревью ship — ночное и перед бетой (ship-review.mjs, §11.7).
verdict=green; high=0
short=$(printf '%s' "$MATERIAL" | cut -c1-8)
cat > /tmp/ship-merge.md <<EOF
**Слияние без ревью модели: трек ship (PROCESS.md §5, #696).** Правка укладывается в механические рамки ship, лёгкий Validate на \`$short\` зелёный${VALIDATE_URL:+ ([прогон]($VALIDATE_URL))}. Код до слияния не читал никто: его прочтёт пакетное ревью диапазона перед бетой (PROCESS.md §11.7), документ \`docs/reviews/SHIP-REVIEW-<тег>.md\`.
**Слияние без ревью модели: трек ship (PROCESS.md §5, #696).** Правка укладывается в механические рамки ship, лёгкий Validate на \`$short\` зелёный${VALIDATE_URL:+ ([прогон]($VALIDATE_URL))}. Код до слияния не читал никто: его прочтёт пакетное ревью ship — ночью (\`docs/reviews/SHIP-REVIEW-<база>-dev-<sha12>.md\`) и перед бетой то, что ночь не прочла (\`docs/reviews/SHIP-REVIEW-<тег>.md\`), PROCESS.md §11.7.
${SHIP_RISK}
<!-- hp:ship-merge material=$MATERIAL -->
EOF
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/ship-merge.md
green_forward
elif [ "$REUSE" = "true" ]; then
# Модель не вызывалась: вердикт — записанный конвейером зелёный
# прошлого захода, дерево вне docs/reviews с тех пор не менялось (#499).
@@ -1672,28 +1831,47 @@ jobs:
"Вердикт: зелёный · заход r${{ needs.guard.outputs.cycle }} · применён повторно из r${REUSE_ROUND} без вызова модели (#499) · High: 0 · Medium: 0 · Документ: docs/reviews/${REUSE_DOC}
Дерево материала \`${short_tree}\` с захода r${REUSE_ROUND} не изменилось ни в одном файле вне \`docs/reviews/\` (проверено \`git diff\` по содержимому). Новый документ не публикуется: разбирать нечего. Любое отличие дерева — ребейз, тест, фикстура, ТЗ — запустило бы полный разбор."
green_forward
else
verdict=$(echo "$OUT" | jq -r '.verdict')
high=$(echo "$OUT" | jq -r '.high')
# #726: вердикт модели решает скрипт трека командой route — ОДИН вызов:
# статус, метки трека, `blocked` и `review-4`, тело комментария
# файлом и строка сводки. Вперёд двигает только зелёный (§7.2);
# жёлтый и красный — цикл и возврат автору, на код-ревью show с
# `route: reclassify` — повышение до ask или вопрос владельцу.
# Bash только исполняет: комментарий — из файла скрипта, метки —
# по его выходу. Скрипт — из снимка dev (#749): рабочая копия здесь —
# ветка задачи после публикации, и ветка show/ship, отставшая от dev,
# его может ещё не нести. Метки — текущие: шаг трека мог в этом же
# прогоне сменить ship на show.
printf '%s' "$OUT" > "$RUNNER_TEMP/route-verdict.json"
labels=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json labels \
--jq '[.labels[].name] | join(",")') || labels="$LABELS"
route=$(node "$TOOLS/scripts/process-track.mjs" route --stage="$STAGE" --track="$TRACK" \
--confirmed="$CONFIRMED" --labels="$labels" --verdict="$RUNNER_TEMP/route-verdict.json" \
--spent="$SPENT" --limit="$LIMIT" --num="$NUM" --cycle="$CYCLE" --branch="$BRANCH" \
--out="$RUNNER_TEMP/route" \
--run-url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}")
printf '%s\n' "$route"
field() { printf '%s\n' "$route" | sed -n "s/^$1=//p"; }
verdict=$(field verdict); high=$(field high)
green=$(field green); from=$(field from); to=$(field to)
add=$(field add_labels); remove=$(field remove_labels); comment=$(field comment)
if [ -n "$comment" ]; then
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file "$comment"
fi
edit=()
if [ -n "$add" ]; then edit+=(--add-label "$add"); fi
if [ -n "$remove" ]; then edit+=(--remove-label "$remove"); fi
if [ "${#edit[@]}" -gt 0 ]; then
gh issue edit "$NUM" --repo "${{ github.repository }}" "${edit[@]}"
fi
fi
echo "вердикт: $verdict, High: $high"
# Вперёд двигает ТОЛЬКО зелёный. Жёлтый и красный возвращают
# автору: на прогоне #111 жёлтый означал, что AC описывает неверное
# изменение контракта — реализовать такое ТЗ значит сделать ошибку
# по инструкции. Оба считаются циклом.
if [ "$verdict" = "green" ] && [ "$high" -eq 0 ]; then
green=true
case "$STAGE" in
spec) from=S4-spec-review; to=S5-ready ;;
code) from=S7-code-review; to=S8-merged ;;
esac
else
green=false
case "$STAGE" in
spec) from=S4-spec-review; to=S3-spec ;;
code) from=S7-code-review; to=S6-in-progress ;;
esac
# Перехода нет — сбой шага, а не зелёный: метка остаётся, зовётся владелец.
case "$green" in true|false) ;; *) echo "::error::решение по вердикту не дало исхода (green=$green)"; exit 1 ;; esac
if [ -z "$from" ] || [ -z "$to" ]; then
echo "::error::решение по вердикту не дало перехода ($from -> $to)"
exit 1
fi
echo "green=$green" >> "$GITHUB_OUTPUT"
echo "from=$from" >> "$GITHUB_OUTPUT"
@@ -1745,13 +1923,17 @@ jobs:
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
# #696: show/ship сливаются по лёгкому Validate на кандидате.
MUTANTS: ${{ needs.prepare.outputs.mutants }}
# #749: из снимка dev — и суждение о Validate кандидата (ci-proof.mjs
# с validate.yml снимка), и reviews-index.mjs, который скрипт зовёт
# по пути от себя.
TOOLS: ${{ steps.tools.outputs.dir }}
run: |
if [ -z "$BRANCH" ]; then
echo "::error::ветки задачи нет — сливать нечего"
echo "merged=false" >> "$GITHUB_OUTPUT"
exit 0
fi
node scripts/merge-candidate.mjs --branch="$BRANCH" --material="$MATERIAL_SHA" \
node "$TOOLS/scripts/merge-candidate.mjs" --branch="$BRANCH" --material="$MATERIAL_SHA" \
--issue="$NUM" --repo="${{ github.repository }}" --mutants="${MUTANTS:-false}"
- name: Переставить метку
@@ -1761,6 +1943,7 @@ jobs:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
NUM: ${{ github.event.issue.number }}
FROM: ${{ steps.decide.outputs.from }}
TOOLS: ${{ steps.tools.outputs.dir }}
# Зелёное код-ревью без слияния ведёт не в S8-merged, а обратно к
# автору: метка утверждала бы, что код в dev, а его там нет.
# Точный кандидат (#492) сам называет исход: S8 после push, S6/S7 —
@@ -1772,7 +1955,7 @@ jobs:
# статуса. Скрипт снимает и ставит её заново, чтобы событие `labeled`
# запустило новый заход.
run: |
node scripts/status-label.mjs --repo="${{ github.repository }}" \
node "$TOOLS/scripts/status-label.mjs" --repo="${{ github.repository }}" \
--issue="$NUM" --from="$FROM" --to="$TO"
- name: Сводка длительности стадий
+415
View File
@@ -0,0 +1,415 @@
name: "Бета: пакетное ревью ship · тело (#716)"
# #696, PROCESS.md §11.7: задачи `track:ship` сливаются без ревью модели —
# правка в механических рамках и зелёный лёгкий Validate. Их код читает одна
# сессия модели перед бетой: все ship-задачи диапазона «прошлый тег..кандидат».
# Гейт публикации беты (`ship-review.mjs check`) требует этот документ, если в
# диапазоне есть ship-задачи.
#
# Три job, как у release-review.yml (#551, #556): детерминированный вход,
# модель без права записи, детерминированная публикация документа в `dev`.
#
# #727: `tag=nightly` — ночной режим. Его запускает `_nightly.yml` после
# полного Validate с `candidate` = голова `dev` того прогона. Читаются только
# ship-задачи без покрытия или изменившиеся после ревью (`shipCoverage`), документ —
# `SHIP-REVIEW-<база>-dev-<sha12>.md`. Бета так же читает только дельту.
on:
# #716: тело вызывается тонким файлом `ship-review.yml` по ссылке `@dev`;
# кнопка, входы ручного запуска, run-name и concurrency живут там. Без
# файла в ветке по умолчанию GitHub не даёт запустить workflow_dispatch вовсе.
workflow_call:
inputs:
# #727: `nightly` — зарезервированное значение (ночной режим, candidate обязателен).
tag:
description: "Beta tag the review is for, for example v1.79.0-beta.1"
required: true
type: string
candidate:
description: "Exact candidate SHA; empty = the dev tip"
required: false
type: string
default: ""
force:
description: "Review again even when the document already exists in dev"
required: false
type: boolean
default: false
permissions:
contents: read
# Concurrency уровня workflow — у вызывающего `ship-review.yml` (#716).
jobs:
prepare:
name: "Ship-ревью: задачи диапазона"
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
issues: read
outputs:
proceed: ${{ steps.range.outputs.proceed }}
candidate: ${{ steps.range.outputs.candidate }}
base: ${{ steps.range.outputs.base }}
doc: ${{ steps.range.outputs.doc }}
issues: ${{ steps.range.outputs.issues }}
mode: ${{ steps.range.outputs.mode }}
patches: ${{ steps.range.outputs.patches }}
subject: ${{ steps.range.outputs.subject }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
ref: dev
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
- name: Кандидат, база и ship-задачи
id: range
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
CANDIDATE: ${{ inputs.candidate }}
FORCE: ${{ inputs.force }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# Отказ prepare за `| tee` не должен читаться как «ship-задач нет» (#727).
set -o pipefail
git fetch -q --tags origin
# #727: tag=nightly без candidate — отказ до подстановки вершины dev.
node scripts/ship-review.mjs mode --tag="$TAG" --candidate="$CANDIDATE"
if [ -z "$CANDIDATE" ]; then CANDIDATE=$(git rev-parse origin/dev); fi
git cat-file -e "$CANDIDATE^{commit}"
CANDIDATE=$(git rev-parse "$CANDIDATE^{commit}")
doc=$(node scripts/ship-review.mjs doc --tag="$TAG" --candidate="$CANDIDATE")
if [ "$FORCE" != "true" ] && git cat-file -e "origin/dev:$doc" 2>/dev/null; then
echo "::notice::$doc уже есть в dev — повторное ревью не запускается (force=true, чтобы переснять)"
echo "proceed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
out="$RUNNER_TEMP/ship-review-input"
node scripts/ship-review.mjs prepare --tag="$TAG" --candidate="$CANDIDATE" --force="$FORCE" \
--out="$out" --repo="${{ github.repository }}" --run-url="$RUN_URL" | tee "$RUNNER_TEMP/range.txt"
cat "$RUNNER_TEMP/range.txt" >> "$GITHUB_OUTPUT"
echo "candidate=$CANDIDATE" >> "$GITHUB_OUTPUT"
issues=$(sed -n 's/^issues=//p' "$RUNNER_TEMP/range.txt")
if [ -z "$issues" ]; then
# #727 К5: пустая дельта — все ship-задачи покрыты документами базы.
note=$(sed -n 's/^note=//p' "$RUNNER_TEMP/range.txt")
echo "::notice::${note:-ship-задач в диапазоне нет — ревью не нужно}"
if [ -n "$note" ]; then echo "- $note" >> "$GITHUB_STEP_SUMMARY"; fi
echo "proceed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "proceed=true" >> "$GITHUB_OUTPUT"
(cd "$out" && sha256sum brief.md ship-issues.json > manifest.sha256)
cat "$out/brief.md" >> "$GITHUB_STEP_SUMMARY"
- name: Передать вход модели
if: steps.range.outputs.proceed == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ship-review-input-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ship-review-input
if-no-files-found: error
retention-days: 3
model_review:
name: "Ship-ревью: работа модели"
needs: prepare
if: needs.prepare.outputs.proceed == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
# #737: строка расхода сессии модели (scripts/model-usage.mjs).
outputs:
usage: ${{ steps.usage.outputs.line }}
# Недоверенная стадия без права записи (#556): документ публикует
# `publish`. `github_token` у шага Review обязателен — без него action
# меняет OIDC на собственный App-токен с правом записи.
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
ref: ${{ needs.prepare.outputs.candidate }}
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
cache: npm
- name: Получить вход
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: ship-review-input-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ship-review-input
- name: Проверить вход и кандидата
env:
CANDIDATE: ${{ needs.prepare.outputs.candidate }}
run: |
(cd "$RUNNER_TEMP/ship-review-input" && sha256sum -c manifest.sha256)
test "$(git rev-parse HEAD)" = "$CANDIDATE"
- name: Установить зависимости
run: npm ci
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install chromium
# Тот же обход, что у конвейера (_process.yml, anthropics issue 1817).
- name: Установить Claude Code детерминированно
id: claude_bin
run: |
src=$(ls "$RUNNER_WORKSPACE"/../_actions/anthropics/claude-code-*/v1/src/entrypoints/run.ts 2>/dev/null | head -1)
ver=$(grep -oE 'claudeCodeVersion = "[0-9]+\.[0-9]+\.[0-9]+"' "$src" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true)
ver="${ver:-2.1.265}"
base=https://downloads.claude.ai/claude-code-releases
bin="$HOME/.local/bin/claude"
mkdir -p "$(dirname "$bin")"
curl -fsSL --retry 3 "$base/$ver/linux-x64/claude" -o "$bin"
sum=$(curl -fsSL --retry 3 "$base/$ver/manifest.json" | jq -r '.platforms["linux-x64"].checksum')
echo "$sum $bin" | sha256sum -c -
chmod +x "$bin"
"$bin" --version
echo "path=$bin" >> "$GITHUB_OUTPUT"
- name: Review
id: review
uses: anthropics/claude-code-action@9cdae7f0d995e3ba7c33f226087fdf82a59cd520 # v1
env:
REVIEW_DOC: ${{ runner.temp }}/ship-review.md
REVIEW_INPUT: ${{ runner.temp }}/ship-review-input
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }}
prompt: |
Ты ревьюер кода проекта House Plan. Язык ответа — русский.
${{ needs.prepare.outputs.subject }}
Кандидат ${{ needs.prepare.outputs.candidate }} (рабочая копия уже на нём) · база
диапазона: ${{ needs.prepare.outputs.base || 'нет' }}.
Задачи: ${{ needs.prepare.outputs.issues }} — вход в $REVIEW_INPUT/brief.md
(строка ТЗ каждой задачи и её коммиты) и $REVIEW_INPUT/ship-issues.json.
Эти задачи шли по треку ship и слиты в dev без ревью модели: правка
в механических рамках и зелёный лёгкий Validate. Ты — первый, кто
читает их код. Правила — docs/process/REVIEWER.md, раздел
«Пакетное ревью ship», канон — PROCESS.md §5 и §11.7. Прочитай их
первыми.
По каждой задаче: `git show <sha>` каждого коммита; делает ли код
то, что сказано в строке ТЗ, и только это; не ломает ли соседнее
поведение; не вышла ли правка из трека ship по смыслу (новый
UX-контракт, геометрия, конфиг, перф) — это находка Medium с
предложением трека. Исполняй, где дёшево: `npm test`, смоук,
который называет строка ТЗ или выбирает
`node scripts/smoke-select.mjs --base <sha>^ --head <sha>`.
Серьёзность: High — дефект, который увидит пользователь беты, или
правка не делает заявленного; High блокирует публикацию беты.
Medium — поведение вне заявленного или вне рамок трека; Low —
остальное. Находка чинится отдельной задачей, ты ничего не правишь.
Ты ничего не правишь и не публикуешь: ни код, ни issue, ни
комментарии. Права на запись у тебя нет. Любые изменения рабочей
копии будут отброшены — после проверок восстанови её сам
(`git checkout -- . && git clean -fd`), если что-то менял.
Напиши документ в файл по пути из переменной REVIEW_DOC (абсолютный,
вне репозитория). Первой строкой после заголовка —
`Итог: High N · Medium N · Low N`. Затем по задаче на раздел
`## #NN — заголовок`: что проверил (команда → результат), находки с
воспроизведением, чего не проверял.
Затем верни JSON по схеме — последнее обязательное действие.
claude_args: |
--max-turns 150
--allowedTools Read,Write,Grep,Glob,Bash
--json-schema '{"type":"object","properties":{"high":{"type":"integer"},"medium":{"type":"integer"},"low":{"type":"integer"},"summary":{"type":"string"}},"required":["high","medium","low","summary"]}'
# #737: как у конвейера (_process.yml) — расход выходом job, не через
# artifact; execution_file не выгружается и не печатается.
- name: Снять расход модели
id: usage
if: always()
continue-on-error: true
env:
EXEC: ${{ steps.review.outputs.execution_file }}
run: |
line=$(node scripts/model-usage.mjs --execution-file="$EXEC")
echo "line=$line" >> "$GITHUB_OUTPUT"
echo "- расход модели: \`$line\`" >> "$GITHUB_STEP_SUMMARY"
- name: Запечатать результат модели
env:
SOURCE: ${{ runner.temp }}/ship-review.md
OUT: ${{ steps.review.outputs.structured_output }}
run: |
test -s "$SOURCE" || { echo "::error::модель не оставила документ ревью"; exit 1; }
dir="$RUNNER_TEMP/ship-review-result"
mkdir -p "$dir"
printf '%s' "$OUT" > "$dir/result.json"
jq -e '(.high|type=="number") and (.medium|type=="number") and (.low|type=="number") and (.summary|type=="string")' \
"$dir/result.json" >/dev/null
cp "$SOURCE" "$dir/ship-review.md"
(cd "$dir" && sha256sum ship-review.md result.json > manifest.sha256)
- name: Передать результат публикации
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ship-review-result-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ship-review-result
if-no-files-found: error
retention-days: 3
publish:
name: "Ship-ревью: документ в dev"
needs: [prepare, model_review]
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
ref: dev
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
- name: Получить результат модели
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: ship-review-result-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ship-review-result
# Модель пишет только текст. Путь, машинный блок (его читает гейт беты),
# индекс и коммит решает этот шаг. #727: `mode` и `patches` блока — из
# prepare (детерминированный патч-набор), не из результата модели.
- name: Опубликовать документ
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
TAG: ${{ inputs.tag }}
DOC: ${{ needs.prepare.outputs.doc }}
CANDIDATE: ${{ needs.prepare.outputs.candidate }}
BASE: ${{ needs.prepare.outputs.base }}
ISSUES: ${{ needs.prepare.outputs.issues }}
MODE: ${{ needs.prepare.outputs.mode }}
PATCHES: ${{ needs.prepare.outputs.patches }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# #737: расход модели — строкой сразу после блока; недоверенный ввод.
USAGE: ${{ needs.model_review.outputs.usage }}
run: |
dir="$RUNNER_TEMP/ship-review-result"
(cd "$dir" && sha256sum -c manifest.sha256)
test "$DOC" = "$(node scripts/ship-review.mjs doc --tag="$TAG" --candidate="$CANDIDATE")"
high=$(jq -r '.high' "$dir/result.json")
medium=$(jq -r '.medium' "$dir/result.json")
low=$(jq -r '.low' "$dir/result.json")
# Сообщение коммита — построчно в файл, без heredoc в `run:` (#730, как #723).
# #748: ночью — своё: имя документа без префикса, задача ночного режима.
if [ "$MODE" = "nightly" ]; then
night=${DOC##*/SHIP-REVIEW-}
subject="docs: nightly ship review ${night%.md}"
lead="Ночное пакетное ревью задач track:ship (PROCESS.md §11.7)."
issue="#727"
else
subject="docs: ship review for $TAG"
lead="Пакетное ревью задач track:ship перед бетой (PROCESS.md §11.7)."
issue="#696"
fi
msg="$RUNNER_TEMP/ship-review-commit.txt"
push_err="$RUNNER_TEMP/ship-review-push.stderr"
for attempt in 1 2 3; do
git fetch -q origin dev
git reset -q --hard origin/dev
git clean -fdq
mkdir -p docs/reviews
{
cat "$dir/ship-review.md"
printf '\n\n'
node -e '
import("./scripts/ship-review.mjs").then((m) => process.stdout.write(m.anchorBlock({
tag: process.env.TAG, candidate: process.env.CANDIDATE, base: process.env.BASE || null,
issues: String(process.env.ISSUES || "").split(",").filter(Boolean).map(Number),
high: Number(process.argv[1]), medium: Number(process.argv[2]), low: Number(process.argv[3]),
runUrl: process.env.RUN_URL, mode: process.env.MODE, patches: m.parsePatches(process.env.PATCHES),
usage: process.env.USAGE ?? "",
})));
' "$high" "$medium" "$low"
} > "$DOC"
node scripts/reviews-index.mjs --dir=docs/reviews --strict
git add -- "$DOC" docs/reviews/INDEX.md
git diff --cached --name-only | node scripts/review-doc-guard.mjs
{
echo "$subject"
echo ""
echo "$lead"
echo "Задачи: $ISSUES. Итог: High $high · Medium $medium · Low $low."
echo ""
echo "Issue: $issue"
echo "User-Visible: no"
} > "$msg"
git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
commit -q -F "$msg"
git diff --name-only "origin/dev...HEAD" | node scripts/review-doc-guard.mjs
if git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev 2> "$push_err"; then
echo "### Пакетное ревью ship $TAG" >> "$GITHUB_STEP_SUMMARY"
echo "Задачи $ISSUES · High $high · Medium $medium · Low $low — \`$DOC\` в dev." >> "$GITHUB_STEP_SUMMARY"
echo "::notice::$DOC опубликован: High $high · Medium $medium · Low $low"
exit 0
fi
# #730: сдвигом dev считается только устаревший lease — отказ
# разбирает код слияния (merge-candidate.mjs --push-refusal, #705),
# как у ревью релиза (#723). Скрипт — из dev: рабочая копия только что
# сброшена на origin/dev. Отказ GitHub (право на workflow, правило
# ветки, хук) повтор не лечит: шаг останавливается, причина и ответ
# git без токена — в журнале и в сводке шага.
kind=$(node scripts/merge-candidate.mjs --push-refusal="$push_err" --ref=dev \
--stage=ship-review --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
if [ "$kind" != "stale" ]; then
echo "::error::push $DOC в dev отклонён ($kind) — это не сдвиг dev, повтор не поможет; причина и ответ git — выше и в сводке шага"
exit 1
fi
echo "::warning::dev ушёл вперёд — попытка $attempt из 3, документ собирается заново"
sleep $((attempt * 10))
done
echo "::error::документ ревью не опубликован в dev за три попытки"
exit 1
# #727 К8: High ночного документа — одна строка в каждую его задачу.
# Повтор на тот же документ не пишется (метка hp:ship-review-high doc=…),
# Medium и Low в issue не идут — их решает владелец (§11.7). Режим и
# High проверяет и скрипт: шаг вне ночи или без High ничего не пишет.
- name: High ночью — строка в задачи документа
if: needs.prepare.outputs.mode == 'nightly'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
MODE: ${{ needs.prepare.outputs.mode }}
DOC: ${{ needs.prepare.outputs.doc }}
ISSUES: ${{ needs.prepare.outputs.issues }}
run: |
high=$(jq -r '.high' "$RUNNER_TEMP/ship-review-result/result.json")
node scripts/ship-review.mjs comment-high --mode="$MODE" --high="$high" --doc="$DOC" \
--issues="$ISSUES" --repo="${{ github.repository }}"
+23 -172
View File
@@ -1,22 +1,17 @@
name: "Бета: производные артефакты на dev"
run-name: "Derived artifacts ${{ inputs.tag }}"
# #697, PROCESS.md §8 и §11.4: ветки задач не коммитят ни отпечаток и кадры
# скриншотов документации, ни эталоны golden. Их обновляет один коммит бота на
# `dev` перед кандидатом беты; коммит проверяет релиз-менеджер.
#
# - Скриншоты: съёмка на `dev` тем же каноническим способом, что
# `docs-screenshots.yml`, и приёмка `docs:accept --reviewed`. Кадры, которые
# менять не собирались, обязаны совпасть байт-в-байт (свидетели среды);
# изменившийся кадр принимается, только если назван в `docs_expect_change`.
# Ничего не изменилось — обновляется один отпечаток исходников.
# - Golden: артефакт `golden-images` полного Validate на `dev`
# (`golden_run`) и `golden:accept --reviewed` с объявленными сценами. Коммит
# несёт `Release:` и `Baseline-Reviewed:` — провенанс, который требует
# validate-commit-provenance.mjs.
#
# Необъявленная разница — отказ с перечнем кадров, артефакт съёмки остаётся
# для просмотра: решение, какие кадры сдвинулись законно, принимает человек.
# Тонкий вызывающий файл (#716, устройство #623). `workflow_dispatch` GitHub
# исполняет с выбранной ветки, но кнопку и сам запуск (`gh workflow run`)
# даёт только workflow, чей файл лежит в ветке по умолчанию (`main`). Поэтому
# здесь только то, что обязано жить там: триггер, входы ручного запуска,
# run-name, права и concurrency. Тело — `_beta-derived.yml` по ссылке `@dev`:
# правка съёмки и приёмки — один коммит в `dev`, а запуск с любой ветки (в
# кнопке по умолчанию выбрана `main`) исполняет тело из `dev`.
# Этот файл меняется, только когда меняются входы или потолок прав; тогда он
# зеркалится в `main`, и preflight `workflow_sync` (validate.yml) держит копии
# равными.
on:
workflow_dispatch:
inputs:
@@ -53,162 +48,18 @@ concurrency:
cancel-in-progress: false
jobs:
accept:
name: "Отпечаток, кадры и эталоны — одним коммитом в dev"
runs-on: ubuntu-24.04
timeout-minutes: 40
# Потолок прав тела: объединение job-level прав `_beta-derived.yml`. Вызываемый
# workflow может права только сузить, поэтому каждая его job по-прежнему
# получает свой прежний минимум (#556), а шире этого набора не получит никто.
dev:
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: dev
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
cache: npm
- run: npm ci
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install pinned Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install chromium
- name: Build the bundle the screenshots must come from
run: npm run build
# Тот же закреплённый упаковщик, что у docs-screenshots.yml: другой
# переписал бы все кадры без единого изменённого пикселя.
- name: Установить oxipng
env:
OXIPNG_VERSION: 10.2.0
OXIPNG_SHA256: b33f84c73d42cb592bea5d84c431030b1e97784817693380dfcec7d9575f871e
run: |
set -euo pipefail
asset="oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
curl -fsSL -o "$asset" \
"https://github.com/oxipng/oxipng/releases/download/v${OXIPNG_VERSION}/${asset}"
echo "${OXIPNG_SHA256} ${asset}" | sha256sum -c -
mkdir -p "$HOME/.local/bin"
tar -xzf "$asset" --strip-components=1 -C "$HOME/.local/bin" \
"oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu/oxipng"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
"$HOME/.local/bin/oxipng" --version
- name: "Съёмка воспроизводима между прогонами (#410, #422)"
run: node scripts/capture-determinism.mjs
- name: Кадры документации — съёмка и приёмка
id: docs
env:
EXPECT: ${{ inputs.docs_expect_change }}
run: |
set -euo pipefail
node demo/docs/capture.mjs --stability=3
cand="$RUNNER_TEMP/docs-candidate"
rm -rf "$cand" && mkdir -p "$cand"
cp docs/images/*.png docs/images/screenshots.json "$cand/"
# Приёмка сравнивает кандидата с ЗАКОММИЧЕННЫМИ кадрами: рабочая
# копия возвращается к dev, и заменить файлы может только она.
git checkout -- docs/images
git clean -fdq -- docs/images
args=(--reviewed "--from=$cand")
if [ -n "$EXPECT" ]; then args+=("--expect-change=$EXPECT"); fi
node scripts/docs-accept.mjs "${args[@]}"
if git diff --quiet -- docs/images; then changed=false; else changed=true; fi
echo "changed=$changed" >> "$GITHUB_OUTPUT"
git diff --stat -- docs/images
- name: Сохранить кандидата скриншотов для просмотра
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: docs-candidate-${{ github.run_id }}
path: ${{ runner.temp }}/docs-candidate
if-no-files-found: ignore
retention-days: 7
- name: Эталоны golden из прогона Validate
id: golden
if: inputs.golden_run != ''
env:
GH_TOKEN: ${{ github.token }}
RUN: ${{ inputs.golden_run }}
EXPECT_CHANGE: ${{ inputs.golden_expect_change }}
EXPECT_NEW: ${{ inputs.golden_expect_new }}
run: |
set -euo pipefail
case "$RUN" in ''|*[!0-9]*) echo "::error::golden_run — числовой id прогона Validate"; exit 1 ;; esac
meta=$(gh api "repos/${{ github.repository }}/actions/runs/$RUN" \
--jq '[.path, .head_branch, .status, .html_url] | @tsv')
IFS=$'\t' read -r path branch status url <<< "$meta"
# Провенанс эталонов — завершённый Validate на dev: другой workflow
# или ветка задачи доказательством для кандидата беты не являются.
if [ "$path" != ".github/workflows/validate.yml" ] || [ "$branch" != "dev" ] || [ "$status" != "completed" ]; then
echo "::error::прогон $RUN — не завершённый Validate на dev ($path, $branch, $status)"
exit 1
fi
from="$RUNNER_TEMP/golden-candidate"
rm -rf "$from" && mkdir -p "$from"
gh run download "$RUN" --repo "${{ github.repository }}" -n golden-images -D "$from"
# Отчёт обязан быть снят с этого же дерева: accept.mjs сверяет его
# отпечаток с исходниками и откажет, если dev ушёл вперёд.
args=(--reviewed "--from=$from")
if [ -n "$EXPECT_CHANGE" ]; then args+=("--expect-change=$EXPECT_CHANGE"); fi
if [ -n "$EXPECT_NEW" ]; then args+=("--expect-new=$EXPECT_NEW"); fi
node scripts/golden-accept.mjs "${args[@]}"
if git diff --quiet -- demo/golden/baselines; then changed=false; else changed=true; fi
{ echo "changed=$changed"; echo "url=$url"; } >> "$GITHUB_OUTPUT"
git diff --stat -- demo/golden/baselines
- name: Коммит в dev
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
TAG: ${{ inputs.tag }}
DOCS_CHANGED: ${{ steps.docs.outputs.changed }}
DOCS_EXPECT: ${{ inputs.docs_expect_change }}
GOLDEN_CHANGED: ${{ steps.golden.outputs.changed }}
GOLDEN_URL: ${{ steps.golden.outputs.url }}
GOLDEN_EXPECT_CHANGE: ${{ inputs.golden_expect_change }}
GOLDEN_EXPECT_NEW: ${{ inputs.golden_expect_new }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# Хук pre-push гоняет process-gate; gate:small здесь не нужен —
# продуктового кода коммит не несёт.
HP_PREPUSH_GATE: "0"
run: |
set -euo pipefail
git add -- docs/images demo/golden/baselines
if git diff --cached --quiet; then
echo "::notice::отпечаток, кадры и эталоны на dev уже свежие — коммитить нечего"
exit 0
fi
msg="$RUNNER_TEMP/message.txt"
{
echo "docs: accept derived artifacts on dev for $TAG"
echo ""
echo "Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697)."
echo "Прогон: $RUN_URL"
if [ "$DOCS_CHANGED" = "true" ]; then
echo "Скриншоты документации: отпечаток исходников; изменённые кадры: ${DOCS_EXPECT:-нет}."
fi
if [ "$GOLDEN_CHANGED" = "true" ]; then
echo "Golden: изменённые сцены: ${GOLDEN_EXPECT_CHANGE:-нет}; новые: ${GOLDEN_EXPECT_NEW:-нет}."
fi
echo ""
if [ "$GOLDEN_CHANGED" = "true" ]; then
echo "Release: $TAG"
echo "Baseline-Reviewed: $GOLDEN_URL"
fi
echo "Issue: #697"
echo "User-Visible: no"
} > "$msg"
git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
commit -q -F "$msg"
git show --stat --format='%H %s' HEAD | head -40
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev; then
echo "::error::dev ушёл вперёд за время съёмки — запустить workflow заново: отпечаток судит дерево, а оно уже другое"
exit 1
fi
echo "### Производные артефакты $TAG" >> "$GITHUB_STEP_SUMMARY"
echo "Коммит \`$(git rev-parse --short HEAD)\` в dev — проверить перед кандидатом беты." >> "$GITHUB_STEP_SUMMARY"
uses: Matysh/houseplan-card/.github/workflows/_beta-derived.yml@dev # #716: тело съёмки и приёмки из dev
with:
tag: ${{ inputs.tag }}
docs_expect_change: ${{ inputs.docs_expect_change }}
golden_run: ${{ inputs.golden_run }}
golden_expect_change: ${{ inputs.golden_expect_change }}
golden_expect_new: ${{ inputs.golden_expect_new }}
secrets: inherit
+20
View File
@@ -40,6 +40,7 @@ jobs:
profile:
- large-house
- isometric
- isometric-backdrop
- isometric-stage3
- plan-snap
- interaction
@@ -47,6 +48,7 @@ jobs:
- overlay
- space-default
- space-glow
- led-strips
steps:
- name: Check out candidate
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -134,6 +136,10 @@ jobs:
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
;;
isometric-backdrop)
npm run benchmark:large-house-isometric-backdrop -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-backdrop-baseline.json
npm run benchmark:large-house-isometric-backdrop -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-backdrop-candidate.json
;;
isometric-stage3)
npm run benchmark:isometric-stage3-dense -- --allow-stage2-base --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-stage3-baseline.json
npm run benchmark:isometric-stage3-dense -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-stage3-candidate.json
@@ -170,6 +176,14 @@ jobs:
cp ../artifacts/performance/space-glow-candidate.json ../artifacts/performance/space-glow-baseline.json
fi
;;
led-strips)
# #780 led-strips-v1: base predates LED strips, so the profile is
# judged by its absolute limits inside the runner; the zero-LED
# View stays covered by the relative interaction profile above.
npm run benchmark:led-strips -- --size=10x5 --samples=7 --warmups=1 --output=../artifacts/performance/led-strips-10x5.json
npm run benchmark:led-strips -- --size=50x50 --samples=7 --warmups=1 --output=../artifacts/performance/led-strips-50x50.json
npm run benchmark:led-strips -- --size=none --samples=7 --warmups=1 --output=../artifacts/performance/led-strips-none.json
;;
*)
echo "::error::Unknown performance profile: $PROFILE"
exit 1
@@ -188,6 +202,9 @@ jobs:
isometric)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --baseline-sha="$(git -C ../baseline rev-parse HEAD)" --candidate-sha="$(git rev-parse HEAD)" --output=../artifacts/performance/isometric-comparison.json
;;
isometric-backdrop)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric-backdrop.json --baseline=../artifacts/performance/isometric-backdrop-baseline.json --candidate=../artifacts/performance/isometric-backdrop-candidate.json --baseline-sha="$(git -C ../baseline rev-parse HEAD)" --candidate-sha="$(git rev-parse HEAD)" --output=../artifacts/performance/isometric-backdrop-comparison.json
;;
isometric-stage3)
npm run benchmark:compare -- --budgets=demo/performance/budgets-isometric-stage3-dense.json --baseline=../artifacts/performance/isometric-stage3-baseline.json --candidate=../artifacts/performance/isometric-stage3-candidate.json --baseline-sha="$(git -C ../baseline rev-parse HEAD)" --candidate-sha="$(git rev-parse HEAD)" --output=../artifacts/performance/isometric-stage3-comparison.json
;;
@@ -209,6 +226,9 @@ jobs:
space-glow)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-space-card-glow.json --baseline=../artifacts/performance/space-glow-baseline.json --candidate=../artifacts/performance/space-glow-candidate.json --output=../artifacts/performance/space-glow-comparison.json
;;
led-strips)
echo "led-strips-v1: absolute limits and counters already enforced by the runner (budgets-led-strips.json)"
;;
esac
- name: Upload full performance report
+36 -10
View File
@@ -75,6 +75,9 @@ jobs:
FORCE: ${{ inputs.force }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# Отказ prepare за `| tee` не должен идти дальше с неполным
# GITHUB_OUTPUT и proceed=true (#751).
set -o pipefail
doc=$(node scripts/release-review.mjs doc --tag="$TAG")
git fetch -q --tags origin
if [ -z "$CANDIDATE" ]; then
@@ -183,6 +186,14 @@ jobs:
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
# #704: `release.yml` ставит ревью в очередь токеном GITHUB_TOKEN, и
# прогон начинает `github-actions[bot]` (тип Bot). Action отказывает
# ботам, которых нет в списке («Workflow initiated by non-human actor:
# github-actions (type: Bot)», v1.78.0). Разрешён ровно этот бот —
# не '*': любой другой бот по-прежнему отклоняется. Action сравнивает
# имена без регистра и без суффикса `[bot]`. Человек, запустивший
# ревью руками, списка не касается.
allowed_bots: "github-actions[bot]"
path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }}
prompt: |
Ты независимый ревьюер релиза House Plan. Язык ответа — русский.
@@ -295,6 +306,9 @@ jobs:
(cd "$dir" && sha256sum -c manifest.sha256)
test "$DOC" = "$(node scripts/release-review.mjs doc --tag="$TAG")"
counts=$(jq -r '"High \(.high) · Medium \(.medium) · Low \(.low)"' "$dir/result.json")
# Сообщение коммита — построчно в файл, без heredoc в `run:` (#723).
msg="$RUNNER_TEMP/release-review-commit.txt"
push_err="$RUNNER_TEMP/release-review-push.stderr"
for attempt in 1 2 3; do
git fetch -q origin dev
git reset -q --hard origin/dev
@@ -310,24 +324,36 @@ jobs:
node scripts/reviews-index.mjs --dir=docs/reviews --strict
git add -- "$DOC" docs/reviews/INDEX.md
git diff --cached --name-only | node scripts/review-doc-guard.mjs
{
echo "docs: release review for $TAG"
echo ""
echo "Независимое ревью линии перед стабильным релизом (PROCESS.md §11.5)."
echo "Итог: $counts. Выпуск не блокирует; решение по находкам — за владельцем."
echo ""
echo "Issue: #638"
echo "User-Visible: no"
} > "$msg"
git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
commit -q -F - <<MSG
docs: release review for $TAG
Независимое ревью линии перед стабильным релизом (PROCESS.md §11.5).
Итог: $counts. Выпуск не блокирует; решение по находкам — за владельцем.
Issue: #638
User-Visible: no
MSG
commit -q -F "$msg"
git diff --name-only "origin/dev...HEAD" | node scripts/review-doc-guard.mjs
if git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev; then
if git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev 2> "$push_err"; then
echo "### Независимое ревью $TAG" >> "$GITHUB_STEP_SUMMARY"
echo "Итог: $counts — \`$DOC\` в dev. Выпуск не блокируется." >> "$GITHUB_STEP_SUMMARY"
echo "::notice::$DOC опубликован: $counts"
exit 0
fi
# #723: сдвигом dev считается только устаревший lease — отказ
# разбирает код слияния (merge-candidate.mjs --push-refusal, #705).
# Отказ GitHub (право на workflow, правило ветки, хук) повтор не
# лечит: шаг останавливается, причина и ответ git без токена — в
# журнале и в сводке шага.
kind=$(node scripts/merge-candidate.mjs --push-refusal="$push_err" --ref=dev \
--stage=release-review --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
if [ "$kind" != "stale" ]; then
echo "::error::push $DOC в dev отклонён ($kind) — это не сдвиг dev, повтор не поможет; причина и ответ git — выше и в сводке шага"
exit 1
fi
echo "::warning::dev ушёл вперёд — попытка $attempt из 3, документ собирается заново"
sleep $((attempt * 10))
done
+49 -3
View File
@@ -153,14 +153,60 @@ jobs:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.candidate.outputs.tag }}
SHA: ${{ needs.candidate.outputs.sha }}
# #704: сколько ждать, что dispatch стал прогоном, — выпуск не ждёт
# дольше нескольких минут; укладывается в timeout-minutes job.
APPEAR_SECONDS: 180
POLL_SECONDS: 15
run: |
if gh workflow run release-review.yml --repo "${{ github.repository }}" --ref dev \
repo="${{ github.repository }}"
# Отсчёт окна — до dispatch, с запасом минута на расхождение часов.
since=$(( $(date -u +%s) - 60 ))
if ! gh workflow run release-review.yml --repo "${{ github.repository }}" --ref dev \
-f tag="$TAG" -f candidate="$SHA"; then
echo "Независимое ревью $TAG поставлено в очередь: release-review.yml на dev" >> "$GITHUB_STEP_SUMMARY"
else
echo "::warning::ревью линии $TAG не запущено — выпуск продолжается; запустить руками: gh workflow run release-review.yml --ref dev -f tag=$TAG -f candidate=$SHA"
echo "Независимое ревью $TAG: **не запущено** — dispatch release-review.yml отклонён." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
# #704: dispatch не возвращает прогона, и v1.78.0 показал цену: ревью
# упало в прогоне, о котором выпуск не знал. Прогон ищется по workflow,
# ветке dev, событию, времени и run-name «Release review <тег>».
# Найден и стартовал — ссылка и статус в сводке; нет — предупреждение,
# выпуск не блокируется.
title="Release review $TAG"
run=""
started=false
tries=$(( APPEAR_SECONDS / POLL_SECONDS ))
for try in $(seq 1 "$tries"); do
found=$(gh run list --repo "$repo" --workflow release-review.yml --branch dev \
--event workflow_dispatch --limit 20 \
--json databaseId,url,status,conclusion,displayTitle,createdAt \
| jq -c --arg title "$title" --argjson since "$since" \
'[.[] | select(.displayTitle == $title and (.createdAt | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) >= $since)] | sort_by(.createdAt) | last // empty') || found=""
if [ -n "$found" ]; then
run="$found"
case "$(jq -r .status <<< "$run")" in
in_progress|completed) started=true; break ;;
esac
fi
if [ "$try" -lt "$tries" ]; then sleep "$POLL_SECONDS"; fi
done
minutes=$(( (APPEAR_SECONDS + 59) / 60 ))
if [ -z "$run" ]; then
echo "::warning::прогон ревью линии $TAG не появился за $minutes мин — выпуск продолжается; проверить Actions → release-review.yml"
echo "Независимое ревью $TAG: **не стартовало за $minutes мин** — прогон release-review.yml на dev не найден." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
url=$(jq -r .url <<< "$run")
status=$(jq -r '.status + (if (.conclusion // "") != "" then " · " + .conclusion else "" end)' <<< "$run")
if [ "$started" != "true" ]; then
echo "::warning::ревью линии $TAG в очереди и не стартовало за $minutes мин: $url"
echo "Независимое ревью $TAG: **не стартовало за $minutes мин** (в очереди) — [прогон]($url), статус $status." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
if [ "$(jq -r '.conclusion // ""' <<< "$run")" = "failure" ]; then
echo "::warning::ревью линии $TAG уже упало: $url"
fi
echo "Независимое ревью $TAG **запущено**: [прогон]($url), статус $status. Выпуск его не ждёт." >> "$GITHUB_STEP_SUMMARY"
gate:
name: "Гейт: контракт, Validate, Full Performance и E2E на точном SHA"
+21 -302
View File
@@ -1,16 +1,17 @@
name: "Бета: пакетное ревью ship"
run-name: "Ship review ${{ inputs.tag }}"
# #696, PROCESS.md §11.7: задачи `track:ship` сливаются без ревью модели —
# правка в механических рамках и зелёный лёгкий Validate. Их код читает одна
# сессия модели перед бетой: все ship-задачи диапазона «прошлый тег..кандидат».
# Гейт публикации беты (`ship-review.mjs check`) требует этот документ, если в
# диапазоне есть ship-задачи.
#
# Только `workflow_dispatch`: файл исполняется с ветки прогона (`--ref dev`),
# зеркало в `main` не нужно. Три job, как у release-review.yml (#551, #556):
# детерминированный вход, модель без права записи, детерминированная
# публикация документа в `dev`.
# Тонкий вызывающий файл (#716, устройство #623). `workflow_dispatch` GitHub
# исполняет с выбранной ветки, но кнопку и сам запуск (`gh workflow run`)
# даёт только workflow, чей файл лежит в ветке по умолчанию (`main`). Поэтому
# здесь только то, что обязано жить там: триггер, входы ручного запуска,
# run-name, права и concurrency. Тело — `_ship-review.yml` по ссылке `@dev`:
# правка ревью — один коммит в `dev`, а запуск с любой ветки (в кнопке по
# умолчанию выбрана `main`) исполняет тело из `dev`.
# Этот файл меняется, только когда меняются входы или потолок прав; тогда он
# зеркалится в `main`, и preflight `workflow_sync` (validate.yml) держит копии
# равными.
on:
workflow_dispatch:
inputs:
@@ -37,298 +38,16 @@ concurrency:
cancel-in-progress: false
jobs:
prepare:
name: "Ship-ревью: задачи диапазона"
runs-on: ubuntu-24.04
timeout-minutes: 10
# Потолок прав тела: объединение job-level прав `_ship-review.yml`. Вызываемый
# workflow может права только сузить, поэтому каждая его job по-прежнему
# получает свой прежний минимум (#556), а шире этого набора не получит никто.
dev:
permissions:
contents: read
issues: read
outputs:
proceed: ${{ steps.range.outputs.proceed }}
candidate: ${{ steps.range.outputs.candidate }}
base: ${{ steps.range.outputs.base }}
doc: ${{ steps.range.outputs.doc }}
issues: ${{ steps.range.outputs.issues }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
ref: dev
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
- name: Кандидат, база и ship-задачи
id: range
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
CANDIDATE: ${{ inputs.candidate }}
FORCE: ${{ inputs.force }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
doc=$(node scripts/ship-review.mjs doc --tag="$TAG")
git fetch -q --tags origin
if [ -z "$CANDIDATE" ]; then CANDIDATE=$(git rev-parse origin/dev); fi
git cat-file -e "$CANDIDATE^{commit}"
CANDIDATE=$(git rev-parse "$CANDIDATE^{commit}")
if [ "$FORCE" != "true" ] && git cat-file -e "origin/dev:$doc" 2>/dev/null; then
echo "::notice::$doc уже есть в dev — повторное ревью не запускается (force=true, чтобы переснять)"
echo "proceed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
out="$RUNNER_TEMP/ship-review-input"
node scripts/ship-review.mjs prepare --tag="$TAG" --candidate="$CANDIDATE" \
--out="$out" --repo="${{ github.repository }}" --run-url="$RUN_URL" | tee "$RUNNER_TEMP/range.txt"
cat "$RUNNER_TEMP/range.txt" >> "$GITHUB_OUTPUT"
echo "candidate=$CANDIDATE" >> "$GITHUB_OUTPUT"
issues=$(sed -n 's/^issues=//p' "$RUNNER_TEMP/range.txt")
if [ -z "$issues" ]; then
echo "::notice::ship-задач в диапазоне нет — ревью не нужно"
echo "proceed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "proceed=true" >> "$GITHUB_OUTPUT"
(cd "$out" && sha256sum brief.md ship-issues.json > manifest.sha256)
cat "$out/brief.md" >> "$GITHUB_STEP_SUMMARY"
- name: Передать вход модели
if: steps.range.outputs.proceed == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ship-review-input-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ship-review-input
if-no-files-found: error
retention-days: 3
model_review:
name: "Ship-ревью: работа модели"
needs: prepare
if: needs.prepare.outputs.proceed == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
# Недоверенная стадия без права записи (#556): документ публикует
# `publish`. `github_token` у шага Review обязателен — без него action
# меняет OIDC на собственный App-токен с правом записи.
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
ref: ${{ needs.prepare.outputs.candidate }}
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
cache: npm
- name: Получить вход
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: ship-review-input-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ship-review-input
- name: Проверить вход и кандидата
env:
CANDIDATE: ${{ needs.prepare.outputs.candidate }}
run: |
(cd "$RUNNER_TEMP/ship-review-input" && sha256sum -c manifest.sha256)
test "$(git rev-parse HEAD)" = "$CANDIDATE"
- name: Установить зависимости
run: npm ci
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install chromium
# Тот же обход, что у конвейера (_process.yml, anthropics issue 1817).
- name: Установить Claude Code детерминированно
id: claude_bin
run: |
src=$(ls "$RUNNER_WORKSPACE"/../_actions/anthropics/claude-code-*/v1/src/entrypoints/run.ts 2>/dev/null | head -1)
ver=$(grep -oE 'claudeCodeVersion = "[0-9]+\.[0-9]+\.[0-9]+"' "$src" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true)
ver="${ver:-2.1.265}"
base=https://downloads.claude.ai/claude-code-releases
bin="$HOME/.local/bin/claude"
mkdir -p "$(dirname "$bin")"
curl -fsSL --retry 3 "$base/$ver/linux-x64/claude" -o "$bin"
sum=$(curl -fsSL --retry 3 "$base/$ver/manifest.json" | jq -r '.platforms["linux-x64"].checksum')
echo "$sum $bin" | sha256sum -c -
chmod +x "$bin"
"$bin" --version
echo "path=$bin" >> "$GITHUB_OUTPUT"
- name: Review
id: review
uses: anthropics/claude-code-action@9cdae7f0d995e3ba7c33f226087fdf82a59cd520 # v1
env:
REVIEW_DOC: ${{ runner.temp }}/ship-review.md
REVIEW_INPUT: ${{ runner.temp }}/ship-review-input
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }}
prompt: |
Ты ревьюер кода проекта House Plan. Язык ответа — русский.
Бета: ${{ inputs.tag }} · кандидат ${{ needs.prepare.outputs.candidate }}
(рабочая копия уже на нём) · база диапазона: ${{ needs.prepare.outputs.base || 'нет' }}.
Задачи: ${{ needs.prepare.outputs.issues }} — вход в $REVIEW_INPUT/brief.md
(строка ТЗ каждой задачи и её коммиты) и $REVIEW_INPUT/ship-issues.json.
Эти задачи шли по треку ship и слиты в dev без ревью модели: правка
в механических рамках и зелёный лёгкий Validate. Ты — первый, кто
читает их код. Правила — docs/process/REVIEWER.md, раздел
«Пакетное ревью ship», канон — PROCESS.md §5 и §11.7. Прочитай их
первыми.
По каждой задаче: `git show <sha>` каждого коммита; делает ли код
то, что сказано в строке ТЗ, и только это; не ломает ли соседнее
поведение; не вышла ли правка из трека ship по смыслу (новый
UX-контракт, геометрия, конфиг, перф) — это находка Medium с
предложением трека. Исполняй, где дёшево: `npm test`, смоук,
который называет строка ТЗ или выбирает
`node scripts/smoke-select.mjs --base <sha>^ --head <sha>`.
Серьёзность: High — дефект, который увидит пользователь беты, или
правка не делает заявленного; High блокирует публикацию беты.
Medium — поведение вне заявленного или вне рамок трека; Low —
остальное. Находка чинится отдельной задачей, ты ничего не правишь.
Ты ничего не правишь и не публикуешь: ни код, ни issue, ни
комментарии. Права на запись у тебя нет. Любые изменения рабочей
копии будут отброшены — после проверок восстанови её сам
(`git checkout -- . && git clean -fd`), если что-то менял.
Напиши документ в файл по пути из переменной REVIEW_DOC (абсолютный,
вне репозитория). Первой строкой после заголовка —
`Итог: High N · Medium N · Low N`. Затем по задаче на раздел
`## #NN — заголовок`: что проверил (команда → результат), находки с
воспроизведением, чего не проверял.
Затем верни JSON по схеме — последнее обязательное действие.
claude_args: |
--max-turns 150
--allowedTools Read,Write,Grep,Glob,Bash
--json-schema '{"type":"object","properties":{"high":{"type":"integer"},"medium":{"type":"integer"},"low":{"type":"integer"},"summary":{"type":"string"}},"required":["high","medium","low","summary"]}'
- name: Запечатать результат модели
env:
SOURCE: ${{ runner.temp }}/ship-review.md
OUT: ${{ steps.review.outputs.structured_output }}
run: |
test -s "$SOURCE" || { echo "::error::модель не оставила документ ревью"; exit 1; }
dir="$RUNNER_TEMP/ship-review-result"
mkdir -p "$dir"
printf '%s' "$OUT" > "$dir/result.json"
jq -e '(.high|type=="number") and (.medium|type=="number") and (.low|type=="number") and (.summary|type=="string")' \
"$dir/result.json" >/dev/null
cp "$SOURCE" "$dir/ship-review.md"
(cd "$dir" && sha256sum ship-review.md result.json > manifest.sha256)
- name: Передать результат публикации
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ship-review-result-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ship-review-result
if-no-files-found: error
retention-days: 3
publish:
name: "Ship-ревью: документ в dev"
needs: [prepare, model_review]
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
ref: dev
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
- name: Получить результат модели
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: ship-review-result-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ship-review-result
# Модель пишет только текст. Путь, машинный блок (его читает гейт беты),
# индекс и коммит решает этот шаг.
- name: Опубликовать документ
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
TAG: ${{ inputs.tag }}
DOC: ${{ needs.prepare.outputs.doc }}
CANDIDATE: ${{ needs.prepare.outputs.candidate }}
BASE: ${{ needs.prepare.outputs.base }}
ISSUES: ${{ needs.prepare.outputs.issues }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
dir="$RUNNER_TEMP/ship-review-result"
(cd "$dir" && sha256sum -c manifest.sha256)
test "$DOC" = "$(node scripts/ship-review.mjs doc --tag="$TAG")"
high=$(jq -r '.high' "$dir/result.json")
medium=$(jq -r '.medium' "$dir/result.json")
low=$(jq -r '.low' "$dir/result.json")
for attempt in 1 2 3; do
git fetch -q origin dev
git reset -q --hard origin/dev
git clean -fdq
mkdir -p docs/reviews
{
cat "$dir/ship-review.md"
printf '\n\n'
node -e '
import("./scripts/ship-review.mjs").then((m) => process.stdout.write(m.anchorBlock({
tag: process.env.TAG, candidate: process.env.CANDIDATE, base: process.env.BASE || null,
issues: String(process.env.ISSUES || "").split(",").filter(Boolean).map(Number),
high: Number(process.argv[1]), medium: Number(process.argv[2]), low: Number(process.argv[3]),
runUrl: process.env.RUN_URL,
})));
' "$high" "$medium" "$low"
} > "$DOC"
node scripts/reviews-index.mjs --dir=docs/reviews --strict
git add -- "$DOC" docs/reviews/INDEX.md
git diff --cached --name-only | node scripts/review-doc-guard.mjs
git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
commit -q -F - <<MSG
docs: ship review for $TAG
Пакетное ревью задач track:ship перед бетой (PROCESS.md §11.7).
Задачи: $ISSUES. Итог: High $high · Medium $medium · Low $low.
Issue: #696
User-Visible: no
MSG
git diff --name-only "origin/dev...HEAD" | node scripts/review-doc-guard.mjs
if git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev; then
echo "### Пакетное ревью ship $TAG" >> "$GITHUB_STEP_SUMMARY"
echo "Задачи $ISSUES · High $high · Medium $medium · Low $low — \`$DOC\` в dev." >> "$GITHUB_STEP_SUMMARY"
echo "::notice::$DOC опубликован: High $high · Medium $medium · Low $low"
exit 0
fi
echo "::warning::dev ушёл вперёд — попытка $attempt из 3, документ собирается заново"
sleep $((attempt * 10))
done
echo "::error::документ ревью не опубликован в dev за три попытки"
exit 1
uses: Matysh/houseplan-card/.github/workflows/_ship-review.yml@dev # #716: тело ревью из dev
with:
tag: ${{ inputs.tag }}
candidate: ${{ inputs.candidate }}
force: ${{ inputs.force }}
secrets: inherit
+42 -13
View File
@@ -68,8 +68,10 @@ jobs:
# Коммиты и деревья скачиваются целиком, поэтому диапазоны и `merge-base`
# работают как раньше. Единственная догрузка по требованию здесь —
# `git show origin/main:.github/workflows/<тонкий файл>`: по блобу на файл.
# `fetch-tags: true` (#703): теги релиза — граница проверенного материала
# для базы диапазона, а checkout по умолчанию качает с `--no-tags`.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: { fetch-depth: 0, filter: 'blob:none' }
with: { fetch-depth: 0, filter: 'blob:none', fetch-tags: true }
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: { node-version: 22 }
@@ -122,9 +124,16 @@ jobs:
# ни одним событием. Тонкий файл сверяется: правка триггера или потолка
# прав, не доехавшая до `main`, действовала бы только в dev-копии.
#
# #716: `ship-review.yml` и `beta-derived.yml` — только `workflow_dispatch`,
# он исполняет файл с выбранной ветки, но запуск GitHub даёт лишь workflow,
# чей файл есть в `main`. Шагам беты он нужен до промоушена, поэтому они
# устроены так же и сверяются здесь же: правка входа или потолка прав,
# не доехавшая до `main`, не действовала бы в запуске с `main`, а именно
# его кнопка выбирает по умолчанию.
#
# `performance.yml` не входит: по расписанию он судит `main` собственным
# телом из `main`, расхождение с `dev` до промоушена законно. Список ниже
# держит равным множеству файлов с такими триггерами
# держит равным множеству тонких файлов
# test/default-branch-workflows.test.mjs.
- name: "Процесс: тонкие вызывающие workflow идентичны в main и dev"
id: workflow_sync
@@ -132,7 +141,7 @@ jobs:
run: |
git fetch --quiet origin main dev
status=0
for file in process.yml mutation-gate.yml process-resume.yml nightly.yml process-reconcile.yml process-metrics.yml; do
for file in process.yml mutation-gate.yml process-resume.yml nightly.yml process-reconcile.yml process-metrics.yml ship-review.yml beta-derived.yml; do
if diff <(git show "origin/main:.github/workflows/$file") \
<(git show "origin/dev:.github/workflows/$file"); then
echo "$file: main и dev идентичны"
@@ -187,9 +196,14 @@ jobs:
# прежним `before`: расширять диапазон здесь нельзя, иначе гейт, который
# сам красит прогон, лишает следующий пуш зелёного предка и запирает dev
# в красноте навсегда.
#
# #703: то же на `main`. Stable-промоушен — пуш в `main` SHA, уже
# судимого на `dev`; с `event.before` (прошлый stable) провенанс и
# процессный гейт судили бы всю бета-линию заново. Прогоны читаются с
# обеих интеграционных веток, сам HEAD засчитывает только успешный.
- name: "База диапазона: последний доказанно зелёный предок"
id: range
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
if: github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.sha }}
@@ -199,11 +213,14 @@ jobs:
# `status=completed`, а не `success`: гейтам диапазона нужен факт
# «коммит судили», а не «вердикт был оправдательный». Упавший прогон
# коммит судил; отменённый — нет, его отсеивает judgedShas (#388).
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
-f branch=dev -f status=completed -F per_page=100 \
> /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json
for branch in dev main; do
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
-f branch="$branch" -f status=completed -F per_page=100 \
> "/tmp/validate-runs-$branch.json" || echo '{}' > "/tmp/validate-runs-$branch.json"
done
node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \
--fallback="$FALLBACK" --runs=/tmp/validate-runs.json
--fallback="$FALLBACK" \
--runs=/tmp/validate-runs-dev.json --runs=/tmp/validate-runs-main.json
- name: "Провенанс коммитов: трейлеры и эталоны"
id: provenance
continue-on-error: true
@@ -330,9 +347,11 @@ jobs:
range_base: ${{ steps.base.outputs.range_base }}
steps:
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
# блобы истории этой job не нужны ни на одном шаге (#345).
# блобы истории этой job не нужны ни на одном шаге (#345). Теги — нужны:
# они граница проверенного материала для `range_base` (#703), а checkout
# по умолчанию передаёт `git fetch --no-tags`.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: { fetch-depth: 0, filter: 'blob:none' }
with: { fetch-depth: 0, filter: 'blob:none', fetch-tags: true }
# База диапазона — самый новый предок с УСПЕШНО завершённым Validate
# (#387). Прежде бралась голова предыдущего пуша (`github.event.before`),
# то есть допущение «до этого всё проверено». Concurrency отменяет прогон
@@ -349,7 +368,11 @@ jobs:
FULL_INPUT: ${{ inputs.full }}
MUTANTS_INPUT: ${{ inputs.mutants }}
REF_NAME: ${{ github.ref_name }}
run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
# #751: без pipefail упавший classify-changes оставлял heavy пустым —
# тяжёлые job молча пропускались, а job changes зеленела.
run: |
set -o pipefail
node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
- id: base
if: github.event_name != 'pull_request'
env:
@@ -371,9 +394,15 @@ jobs:
if [ "$REF" = "refs/heads/dev" ] || [ "$REF" = "refs/heads/main" ]; then
# На интеграционных ветках классифицировать нечего (всё true), но
# база диапазона нужна гейту «новый код не добавляет any» в job
# frontend (#388, #619).
# frontend (#388, #619). #703: прогоны обеих интеграционных веток —
# на `main` приходит SHA, уже судимый на `dev`.
other=dev; [ "$BRANCH" = "dev" ] && other=main
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
-f branch="$other" -f status=completed -F per_page=100 \
> /tmp/validate-runs-other.json || echo '{}' > /tmp/validate-runs-other.json
node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \
--name=range_base --fallback="$FALLBACK" --runs=/tmp/validate-runs.json
--name=range_base --fallback="$FALLBACK" \
--runs=/tmp/validate-runs.json --runs=/tmp/validate-runs-other.json
exit 0
fi
merge_base=$(git merge-base origin/dev "$HEAD_SHA" || git rev-parse "$HEAD_SHA~1")
+21 -7
View File
@@ -63,7 +63,11 @@ gh issue view <NN> --repo Matysh/houseplan-card --json number,state,labels
The label must be one of `S5-ready`, `S6-in-progress`, `S7-code-review`. Anything
else — refuse and say why. "Issue #83 is in `S2-analysis`, code is off limits.
Start with the spec?" is the correct answer, not a smaller patch.
Start with the spec?" is the correct answer, not a smaller patch. The one
exception is a local, unpushed draft on `track:ask` while the spec is in
`S4-spec-review`, each commit carrying the `Spec-Draft` trailer from the task
packet; it reaches the branch only after a green spec review (`PROCESS.md`
§11.8).
GitHub Issues are the canonical task records and the **labels** are the status
(`PROCESS.md` §9); when repository documentation disagrees with an issue, the
@@ -80,11 +84,18 @@ committing.
default: up to three AC in the issue body, no spec review, `S2` → `S5`. `ship` is a
one-sentence change within fixed limits, `S1` → `S5`. `ask` is the full route with
a spec review. Any agent may raise a track with a reason; only the owner lowers it.
A label is a proposal until the owner confirms it with a comment line
`Трек: <ship|show|ask> — решение владельца`; an agent never writes that line.
Several track labels at once read as the strictest. A risky changed hunk
(geometry, touch, migration, devices, perf, a new UX key) raises an unconfirmed
`ship` to `show` at `S7`; on `show` it is a question to the reviewer (#707).
`small` and `trivial` read as `show`. An **infrastructure** task — not a single
class A file — skips analysis and spec and enters at `S7-code-review`
(`PROCESS.md` §1). Every change is code-reviewed; on `ship` the review moves to a
batch review of the beta range before the tag (`ship-review.yml`, `PROCESS.md`
§11.7). The review pipeline prices each round by track (§10.4): no mutants run
batch review (`ship-review.yml`, `PROCESS.md` §11.7): nightly
(`SHIP-REVIEW-<base>-dev-<sha12>.md`), and before the beta tag whatever the night
did not read (`SHIP-REVIEW-<tag>.md`). The review pipeline prices each round by
track (§10.4): no mutants run
during development on any track — the whole registry runs nightly (#709); a
rebase before review only on `ask` or when
the branch does not merge cleanly into `dev`. Review checks scope, risks and the
@@ -147,9 +158,12 @@ fresh checkout. A worktree works only on the machine that created it — its
## Handoff and the verdict
Start a task from its packet: `node scripts/task-packet.mjs --issue NN` (status,
track, what the status permits, the branch against `dev`, the previous verdict
and the unwitnessed AC; it writes nothing). The local gate is
Start a task from its packet: `node scripts/task-packet.mjs --issue NN` (status;
track with its basis, cycle limit and rebase policy; what the status permits;
the branch against `dev` and the next step — no rebase a clean `show`/`ship`
merge does not need; risk by changed hunks and what it means on this track; the
required checks, each with its reason; changelog and visual evidence; the
previous verdict and the unwitnessed AC; it writes nothing). The local gate is
`npm run gate:small` (`docs/TESTING.md` › Локальный набор перед пушем); run the
smokes named in the AC before `S7-code-review`. **"Verified" without a named
command and its result is not evidence.** Comment formats — claim, handoff,
@@ -170,7 +184,7 @@ set.
| Now reads | What happened | What you do |
|---|---|---|
| `S5-ready` | the spec is accepted | write the code |
| `S5-ready` | the spec is accepted | write the code (a draft from `S4`: rebase onto `dev`, check, push — §11.8) |
| `S3-spec` | the spec came back | read the verdict, revise, re-apply `S4-spec-review` |
| `S6-in-progress` | the code came back | revise, re-apply `S7-code-review` — **or**, if the verdict was green and only the merge conflicted, just rebase and re-apply. The comment says which |
| `S8-merged` | accepted and already in `dev` | nothing |
+409 -33
View File
@@ -43,7 +43,8 @@
**Изменение продуктового кода без issue запрещено.** Код меняется только тогда,
когда issue существует и находится в статусе «Готово к разработке» или дальше.
Исключения — только §11, и каждое оставляет след.
Исключения — только §11 (в том числе черновик `track:ask` во время ревью ТЗ,
§11.8), и каждое оставляет след.
Правило работает лишь при точной границе «продуктового кода», иначе спор
переносится на границу:
@@ -172,13 +173,15 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
правится. «Оставили в тексте ревью» не считается закрытием ни для одной
(решение владельца 2026-08-19, #202: отдельный issue дороже правки на месте).
Low либо правится, либо снимается решением ревьюера с записью.
- **Черновик автора** (§11.8) ревьюер ТЗ не читает и доводом не считает:
судится тело issue.
- **Выход:** «Готово к разработке» либо возврат в «ТЗ в работе» — не более
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
### 2.5 Готово к разработке (DoR)
Не работа, а **очередь**: единственный статус, из которого можно трогать код.
Все пункты обязательны:
Не работа, а **очередь**: единственный статус, из которого можно трогать код
в ветку задачи; локальный черновик `track:ask` — §11.8. Все пункты обязательны:
- ТЗ существует, на `track:ask` ревью ТЗ зелёное, ссылки issue ↔ ТЗ на месте.
На `ship` и `show` ТЗ — в объёме §5, а пункты ниже закрываются одним словом
@@ -203,7 +206,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
- **Занятие (claim):** назначить себя, поставить метку, комментарий
«Взял: <роль> · сессия <id> · ветка `issue/<NN>-<slug>`».
- **WIP-лимиты:** не более **1** issue в «В разработке» на исполнителя, не более
**3** одновременно на цикл релиза, не более **2** в «Код-ревью».
**3** одновременно на цикл релиза, не более **2** в «Код-ревью». Черновик
`track:ask` во время ревью ТЗ (§11.8) занимает тот же слот: у исполнителя
одновременно одна задача в `S6-in-progress` или с черновиком.
- **Трассируемость:** ветка `issue/<NN>-<slug>`; каждый коммит с файлами классов A, B или D несёт трейлеры
`Issue: #<NN>` и `User-Visible: yes|no`; коммит только из документации — без
трейлеров (§3 п.10, #701).
@@ -459,7 +464,8 @@ patch-id кандидата слияния: вердикт к работе за
Продолжение черновика владельца. Каждое правило проверяемо — глазами или машиной.
1. **Никаких изменений в код, если нет issue** и он не помечен «Готово к
разработке» или дальше.
разработке» или дальше. Исключение — локальный черновик на `track:ask` во
время ревью ТЗ (§11.8): в ветку он попадает только после зелёного ревью ТЗ.
2. **Issue не может быть взят в разработку**, пока у него нет ТЗ в объёме трека
(§5: на `track:ask` — с зелёным ревью ТЗ; на `show` — до трёх AC; на `ship` —
строка «что меняется и чем проверить»), доказательства для каждого AC и
@@ -539,6 +545,16 @@ patch-id кандидата слияния: вердикт к работе за
- Метка `review-4` ставится, когда исчерпан **бюджет циклов**; конвейер снимать
её не вправе — это решение владельца. Если бюджет пересчитан и оказался ниже
лимита, конвейер сообщает пересчёт, но метку не трогает.
- **Бюджет этапа один на все треки задачи** (#726). Повышение трека меняет
лимит, а не счёт: блокирующие вердикты прежних заходов остаются в бюджете
этапа, и после `show` → `ask` код-ревью продолжает тот же счёт с лимитом 4 —
на всех треках вместе не больше 4 циклов. Ревью ТЗ между ними — свой этап и
свой бюджет.
- **Вердикт, исчерпавший бюджет, сразу ставит `review-4`** (#726): шаг решения
по вердикту возвращает задачу (`S6-in-progress` или `S3-spec`) с меткой и
комментарием «Лимит циклов ревью исчерпан» — счёт, учтённые вердикты и
варианты ниже. Прежде об исчерпании говорил только следующий `S7`, после
правки, которую уже никто не прочтёт; его проверка осталась страховкой.
- **Исчерпание лимита — не «пятая попытка», а разбор.** Задача уходит владельцу,
решение одно из трёх:
1. **разделить** — issue закрывается как «заменён», вместо него 2–3 меньших с
@@ -562,15 +578,26 @@ patch-id кандидата слияния: вердикт к работе за
короткий (`trivial`) — 41 мин и 5 событий. Трек определялся формальными
критериями, и у владельца не было метки, чтобы задать его самому.
Эффект треков меряет еженедельный отчёт `process-metrics.yml` (#728).
**Трек задаёт метка `track:ship`, `track:show` или `track:ask`. Метка владельца
главнее критериев:** критерии ниже — подсказка аналитика, а не приговор.
- Аналитик предлагает трек в «Оценке» (§7.2) и ставит метку. По умолчанию —
`track:show`.
- Владелец ставит или меняет любую из трёх меток в любой момент; его метка
окончательна.
- Владелец ставит или меняет любую из трёх меток в любой момент. Трек он
подтверждает строкой `Трек: <ship|show|ask> — решение владельца` в начале
строки своего комментария (тире любое, регистр не важен, #707); окончательна
метка, подтверждённая строкой владельца. Метка без такой строки — предложение:
аналитика, агента или конвейера. Действует самая поздняя строка владельца и
только для текущего трека. Агент эту строку не пишет никогда: агенты и
конвейер действуют от учётной записи владельца, и по автору события их не
отличить.
- Повысить трек (`ship` → `show` → `ask`) вправе любой агент, с причиной в
комментарии. Понизить — только владелец.
- Несколько трековых меток сразу — дефект разметки: действует строжайшая
(`ask` > `show` > `ship`), пакет задачи и сводка конвейера печатают
предупреждение (#707).
- Трек пересматривается, когда владелец снял усложнявший пункт (#688):
аналитик предлагает понижение, решает владелец.
@@ -590,6 +617,35 @@ patch-id кандидата слияния: вердикт к работе за
проверяет и исполняет конвейер (§10.4, #696); код `ship` читает пакетное ревью
диапазона перед бетой (§11.7).
**Проверка риска по изменённым участкам** (#707). Рамки считают строки и файлы,
но не видят, что именно тронуто: обработчик `pointerdown` в двенадцать строк
проходит их так же, как опечатка. Поэтому на `S7` конвейер судит и изменённые
строки файлов класса A — дифф `git diff --unified=0` от merge-base с `dev`
(`scripts/change-risk.mjs`). Правило «участок» — любая изменённая строка кода в
файле из списка, правило «токен» — токен в изменённой строке; пустые строки,
комментарии и чистое переименование риска не дают, а также строки импорта и
строки только типов TypeScript (кроме файлов участка `migration`: типы конфига —
контракт, #755); удаление кода — даёт.
Классы: `geometry`, `touch`, `migration`, `devices`, `perf`, `ux` и `visual`
(области `render` — путь отрисовки плана — и `ui` — стили интерфейса). Таблица
путей и токенов — эвристика и живёт в скрипте.
- `ship` с любым классом, кроме `visual`, без подтверждения владельца конвейер
повышает до `show` тем же путём, что при выходе за рамки: комментарий с
классами и доказательствами `путь:строка`. Подтверждённый владельцем `ship`
остаётся, риск уходит в пакетное ревью (§11.7). `visual` ship не повышает:
CSS-мелочь — законный ship. Выход за рамки повышает трек и при
подтверждённом `ship`, как прежде.
- На `show` риск — вопрос ревьюеру, сам по себе трек он не меняет: по каждому
классу ревьюер называет документ или AC, где поведение уже зафиксировано; не
нашёл — вердикт с `route: reclassify` и названным критерием (#726, ниже).
`show`, подтверждённый владельцем, конвейер не повышает: `blocked` и вопрос
владельцу с вариантом по умолчанию «повысить до `ask`». На `ask` ревьюер
сверяет, что каждый класс покрыт AC ТЗ.
- Автор видит те же классы и их следствие в пакете задачи
(`node scripts/task-packet.mjs --issue NN`) до `S7`: пакет и конвейер читают
одно правило.
**Подсказка аналитику.** `track:show` уместен, когда выполнено всё сразу:
- сложность и риск ≤ 3;
@@ -609,7 +665,21 @@ patch-id кандидата слияния: вердикт к работе за
подтверждать ощущением — только ссылкой на уже зафиксированное поведение. Если по
ходу выясняется, что решать есть что, трек повышается до `ask`: issue уходит в
`S3-spec` и получает полное ТЗ в теле issue по §7.1. Это не провал, а ранняя
диагностика.
диагностика. Проверка риска по участкам (выше) даёт признаку механическую опору:
рискованный участок повышает `ship` и требует от ревью `show` ссылки на
зафиксированное поведение.
На код-ревью `show` повышение исполняет конвейер (#726). Ревьюер возвращает не
зелёный вердикт с `route: reclassify` и `criterion` — идентификатором
невыполненного пункта подсказки выше: `complexity`, `surfaces`, `migration`,
`ux-contract`, `perf-touch`, `undocumented`. Шаг решения по вердикту ставит
`track:ask`, снимает `track:show` и переводит задачу в `S3-spec` комментарием:
критерий словами, документы код-ревью, бюджет. Код остаётся в ветке, полное ТЗ
пишется в теле issue, код класса A не пушится до `S5`; бюджет код-ревью не
обнуляется (§4). Подтверждённый владельцем `show` конвейер не повышает: задача
получает `blocked` и вопрос владельцу с вариантом по умолчанию «повысить до
`ask`». `reclassify` без критерия из списка, на `ask` или на ревью ТЗ —
обычный возврат с пометкой в комментарии.
**Что не меняется ни на одном треке:** issue и правило №1 (§1), трейлеры
коммитов, changelog для видимого изменения, зелёный Validate на точном SHA тега
@@ -630,6 +700,13 @@ patch-id кандидата слияния: вердикт к работе за
Validate с `full=true` на материале ревью (#697). Метка `ci:mutants` снята
(#709): мутанты в разработке не гоняются ни на каком треке.
**`ci:golden` ставится по сигналу, а не на глаз** (#707). Сигнал — визуальный
риск в пути отрисовки плана (класс `visual`, область `render`, §5): пакет задачи
рекомендует `ci:golden`, если сдвиг кадров намерен, а заметка ревьюеру без метки
просит либо её, либо запись в «чего не проверял». Правка стилей интерфейса
(`ui`), только комментариев или только тестов сигнала не даёт. Риск сам полного
набора не включает, `visual` тоже: `full=true` дают только метки.
**Прежние метки.** `trivial` и `small` читаются как `track:show`; продуктовая
задача без трековой метки — как `track:ask`; инфраструктурная задача (§1) без
трековой метки — как `track:show`. Новые задачи получают только `track:*`.
@@ -735,6 +812,8 @@ issue #NN
- **Аналитика:** `Оценка: ценность N/10 · сложность N/10 · P<1-3> · тип ·
поверхности: … · дубликаты: … · трек: ship/show/ask (причина)`
- **Занятие:** `Взял: <роль> · сессия <id> · ветка issue/NN-slug`
- **Черновик** (§11.8): `Черновик: <роль> · сессия <id> · локальная ветка
issue/NN-slug · Spec-Draft sha256:<первые 12>`
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · заход r<N> ·
@@ -744,6 +823,13 @@ issue #NN
Заход — номер прогона ревью, K — израсходованный бюджет §4: зелёные вердикты
его не тратят, поэтому заход и K расходятся, #227)
- **Закрытие:** `Выпущено в <тег беты> · CI: <ссылка> · Changelog: <ссылка>`
- **Маршрут вердикта** (пишет конвейер, #726): первой строкой «**Ревью show:
решать есть что — трек повышен до `track:ask`.**» либо «**Ревью show: решать
есть что — вопрос владельцу.**», последней — машинная
`<!-- hp:route <reclassify|owner-question> criterion=<id> -->`. Исчерпание
бюджета — первой строкой `Лимит циклов ревью исчерпан`, дальше счёт,
учтённые вердикты и варианты §4. Маршрут, не применённый к вердикту, —
строка «маршрут reclassify не применён: <причина>».
**Вперёд двигает только зелёный вердикт.** Жёлтый и красный возвращают автору;
разница между ними содержательна для человека, но не для маршрута. Первая
@@ -852,7 +938,11 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
(`golden:accept --reviewed`, `Release:` и `Baseline-Reviewed:` в коммите).
Изменившийся кадр или сцена принимается, только если назван во входах
workflow; необъявленная разница — отказ с перечнем. Коммит проверяет
релиз-менеджер;
релиз-менеджер. Запуск — `gh workflow run beta-derived.yml --ref dev -f
tag=vX.Y.Z-beta.N` (сдвиги — `-f docs_expect_change=…`, эталоны — `-f
golden_run=<id Validate на dev>` с `golden_expect_change`/`golden_expect_new`)
или кнопка в Actions: тонкий вызывающий лежит и в `main`, тело
`_beta-derived.yml` читается из `dev` (§10.4, #716);
- **задача, которая меняет визуал намеренно**, ставит `ci:golden`: конвейер
прогоняет полный набор на материале ревью, и сдвинутые кадры задача
принимает сама — по §3 п.13;
@@ -938,7 +1028,7 @@ Performance зелёные на точном SHA, плюс зелёный E2E н
| `S2-analysis` | Аналитика и оценка |
| `S3-spec` | ТЗ в работе |
| `S4-spec-review` | ТЗ на ревью |
| `S5-ready` | Готово к разработке — единственный статус, из которого можно начать трогать код |
| `S5-ready` | Готово к разработке — единственный статус, из которого можно начать трогать код в ветку задачи; локальный черновик `track:ask` — §11.8 |
| `S6-in-progress` | В разработке, занято исполнителем |
| `S7-code-review` | Код-ревью |
| `S8-merged` | Ревью пройдено, код в `dev`, ждёт беты. Issue закрывается пачкой при выпуске |
@@ -1079,7 +1169,9 @@ Validate стартует от этого push и успевает прочит
**Инфраструктурный диапазон статуса не требует** (#562, §1): если в диапазоне нет
ни одного файла класса A, задача ещё не вошла в поток — она войдёт в него сразу на
`S7-code-review`, — и отсутствие S-метки не отказ. Признак механический, по
диффу, а не по метке `infra`.
диффу, а не по метке `infra`. Кроме задачи в `S3-spec` или `S4-spec-review`:
она уже в маршруте ТЗ, её ветка до `S5` не пушится (§11.8), и диапазон без
класса A судится, как любой другой (#753).
**При продвижении в `main` не перепроверяются коммиты, уже достижимые из
prerelease-тега.** После выпуска беты их issue по §2.8 должны быть закрыты, а
@@ -1088,6 +1180,45 @@ Pre-push передаёт целевую remote ref через `--target-ref`,
`TARGET_REF`; оба исключают только уже опубликованную prerelease-историю. Любой
post-beta коммит остаётся в проверке и по закрытому issue отклоняется fail-closed.
**Граница проверенного материала на `dev` и `main` одна** (#703). Гейты диапазона
Validate — провенанс, процессный гейт, `no-new-any`, `no-new-private-writes` —
судят коммиты после самого нового из:
- коммита, который судил завершённый (не отменённый) Validate на `dev` **или**
`main` — прогоны обеих интеграционных веток читаются вместе;
- коммита с опубликованным тегом `vX.Y.Z[-beta.N|-rc.N]`: тег ставится только на
SHA с зелёным Validate (§8) и не зависит от окна в сто прогонов API;
- самого HEAD, если у него уже есть **успешный** Validate (или тег).
Поэтому stable-промоушен — пуш в `main` SHA, проверенного на `dev`, — получает
пустой диапазон и тот же вердикт, что дерево на `dev`. Бета-линия не судится
заново по сегодняшним правилам: до #703 база бралась по прогонам одной `main`, то
есть от прошлого stable, и run 36468413524 предъявил 55 записей в смоках, сделанных
до #629. Упавший прогон HEAD границей не служит: перезапуск красного SHA судит тот
же диапазон, а hotfix поверх кандидата — от кандидата. Реализация —
`scripts/classify-base.mjs --mode=range`, тесты — `test/promotion-range.test.mjs`.
С тем же `--issues`, по timeline issue (#311, #738):
10. статус задачи **на момент написания** кода. Правило 8 видит только текущую
метку, поэтому каждый коммит класса A, кроме релизного, сверяется по
`authorDate` со шкалой `labeled`-событий статусных меток: эпоху «можно
трогать код» открывает метка из множества проверки 8 (с `--no-merged` — без
`S8-merged`), закрывают `S1-new`, `S2-analysis`, `S3-spec` и
`S4-spec-review`, остальные метки (`blocked`, `track:*`, `review-4`) её не
меняют; статус на момент коммита — последнее статусное событие не позже
`authorDate`. Отказ — код, написанный до первого «Готово к разработке» или
после возврата `S5+ → S3/S4` (`reclassify`, ручной возврат) и до повторной
готовности: в эпоху `S4` код класса A не пишется, как и в `S3`. Коммиты,
написанные до возврата, остаются законными. Без timeline или без единого
события из множества проверки 8 — предупреждение, а не отказ: проверка
вторична, fail closed по статусу держит правило 8. Исключение одно —
черновик `track:ask` (§11.8, #729): коммит из эпохи `S4-spec-review`,
которую закрыл `S5-ready`, принимается, если трек на момент написания —
`ask`, а трейлер `Spec-Draft` равен «Тело issue» зелёного `SPEC-REVIEW`
этой эпохи из вершины диапазона или `origin/dev`; трейлер на коммите вне
`S4` — предупреждение.
Не реализовано и остаётся долгом:
9. `npm run release:prerelease -- --issues=…` не проверяет, есть ли у issue
@@ -1126,6 +1257,7 @@ SHA. Manifest публикуется и входит в `SHA256SUMS`; свежа
```
S4-spec-review → ревью ТЗ → S5-ready либо возврат в S3-spec
S7-code-review → код-ревью → слияние в dev → S8-merged либо возврат в S6-in-progress
(show, route: reclassify → S3-spec, #726)
```
Текущая техническая реализация независимого ревьюера —
@@ -1149,15 +1281,23 @@ Medium-находки вне скоупа задачи (#202), кладёт до
умолчанию», — общее для всех таких файлов;
3. слияние в `dev` происходит **до** простановки `S8-merged`, иначе метка врёт в
промежутке — она утверждает, что код в `dev`;
4. многострочный текст внутри `run:` — только через heredoc: строка с нулевым
отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
4. многострочный текст в `run:` строкой не пишется: сообщение коммита —
`echo` построчно в файл, текст комментария или сводки — из скрипта (файлом
или `--summary`). Строка с нулевым отступом обрывает блок YAML, и скрипт
обрезается без ошибки парсера; текст из кода к тому же проверяет тест, а
текст из YAML — нет. Новые шаги heredoc не добавляют, старые переводятся при
переработке шага (#723, #730).
**Workflow из ветки по умолчанию: тонкий файл и тело из `dev`** (#623). Для
событий `issues`, `schedule` и `workflow_run` GitHub исполняет workflow из
`main`. Таких файлов шесть: `process.yml`, `process-resume.yml`,
**Workflow из ветки по умолчанию: тонкий файл и тело из `dev`** (#623, #716).
Для событий `issues`, `schedule` и `workflow_run` GitHub исполняет workflow из
`main`. `workflow_dispatch` исполняет файл с выбранной ветки, но кнопку и сам
запуск (`gh workflow run`, API) даёт только workflow, чей файл лежит в `main`:
файл, добавленный в `dev`, до промоушена не запускается вовсе (#716). Таких
файлов восемь: по событиям — `process.yml`, `process-resume.yml`,
`process-reconcile.yml`, `mutation-gate.yml`, `nightly.yml`,
`process-metrics.yml`. Каждый — тонкий вызывающий: триггеры, run-name,
права, concurrency и одна job `uses:
`process-metrics.yml`; по кнопке, но до промоушена — шаги беты
`ship-review.yml` (§11.7) и `beta-derived.yml` (§8). Каждый — тонкий
вызывающий: триггеры, run-name, права, concurrency и одна job `uses:
Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit`.
Тело `_<имя>.yml` читается из `dev` в момент запуска, поэтому **правка
конвейера — один коммит в `dev`**, зеркало в `main` и возврат `main` в `dev`
@@ -1166,19 +1306,106 @@ Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit`
получает прежний минимум (#556). Тонкий файл меняется, только когда меняются
триггеры, входы ручного запуска или потолок прав; тогда он зеркалится в
`main`, и preflight `workflow_sync` в `validate.yml` держит копии равными —
сверяются ровно эти шесть файлов, список держит
`test/default-branch-workflows.test.mjs`. Расхождение красит push в `dev` и
сверяются ровно эти восемь файлов, список держит
`test/default-branch-workflows.test.mjs`. Новый тонкий файл сначала
зеркалится в `main`, затем сливается в `dev`: в обратном порядке push в `dev`
найдёт файл, которого нет в `main`. Расхождение красит push в `dev` и
заводит одно issue владельцу (`[workflow-sync]`), а на ветке задачи —
предупреждение в сводке (#700): к её изменению оно отношения не имеет, и чинит
его тот, кто зеркалит в `main`. Так же судятся внешние ссылки документации
(`check-docs --external=warn` на ветках `issue/*`): упавший чужой сайт не
возвращает задачу. `performance.yml` в список не входит:
по расписанию он судит `main` собственным телом из `main`.
по расписанию он судит `main` собственным телом из `main`. Прочие workflow по
кнопке приезжают в `main` целиком с промоушеном; новый такой файл, который
нужен до него, заводится тонким и входит в список. Ночь (`_nightly.yml`) после
Validate запускает `ship-review.yml` dispatch'ем с `-f tag=nightly` (§11.7,
#727): входы и права для этого в `main` уже есть, тонкие файлы не меняются.
**Скрипты конвейера — из `dev`** (#749). Тело читается из `dev`, поэтому и
скрипты, которые оно зовёт со своими флагами и форматами, — версии `dev`. Job
`integrate` сразу после `setup-node` снимает один снимок `git archive
origin/dev scripts .github/workflows/validate.yml` и зовёт из него каждый
repo-скрипт: `review-result-gate`, `review-doc-guard`, `reviews-index`,
`merge-candidate`, `process-track route`, `status-label`. Снимок один на job —
одна версия на весь заход, а не смесь по шагам. Job `prepare` (#765) делает
то же до перехода на ветку задачи, но архивом **закреплённого** SHA
(`git rev-parse origin/dev` один раз — `git fetch` трека и ребейза его не
сдвигают): из снимка идут шаг трека (#707), страж ребейза (#698) с разбором
отказа push (#723), хеш тела issue для якоря, reuse зелёного
вердикта (#499), Validate-гейт (#510) и проверка изменившегося ТЗ (#517).
Прежде четыре последних исполняли скрипты ветки — материал сам решал, допущен
ли он к ревью: подменённый `review-doc-guard.mjs` выдавал себе `reuse=true`.
SHA уходит выходом `tools_sha` в `model_review`. Рабочая копия ветки
задачи — только материал: git-команды, документ и пути судятся в ней. Ветка
`show`/`ship` с чистым слиянием до ревью не ребейзится и может нести
`scripts/`, отставшие от `dev` на дни: её `review-doc-guard.mjs` молча
проглотил бы неизвестный флаг, её `merge-candidate.mjs` слил бы по-старому.
`validate.yml` в снимке обязателен: `workflow-jobs.mjs` читает его по пути от
себя, без него доказательство Validate кандидата — `failed (#622)`. В job
`model_review` скрипты материала исполняет ревьюер — это его работа, а не
конвейера; детерминированный шаг «Снять расход модели» (#737) — работа
конвейера, и `model-usage.mjs` он берёт архивом `tools_sha` (#765): на
отставшей ветке скрипта нет, и публикация молча писала `reason=missing`. От
сессии модели на том же раннере снимок не защищает, поэтому строка расхода
по-прежнему недоверенный ввод и разбирается строго. Следствие для задач, меняющих сам конвейер: их
слияние судит версия `dev`, новая начинает действовать со следующей задачи.
Правка контракта доказательства Validate (имена потребляемых job
`validate.yml`, политики `ci-proof.mjs`, #622) пишется совместимой с
собственным слиянием — старое имя потребляемой job живёт до слияния, — иначе
задачу сливает владелец.
**Красная ночь** (#736). Красный ночной Validate — сигнал с адресатом: job
`night_red` в `_nightly.yml` (`scripts/night-red.mjs`) пишет по одному
комментарию в каждую задачу, чьи коммиты классов A/B вошли в `dev` после
последней зелёной ночи. Пишет только при `conclusion: failure`: отменённый или
прерванный по времени прогон — строка сводки. Последняя зелёная ночь — новейший
из 50 последних dispatch-прогонов Validate на `dev`, который создан раньше
красного, завершён успехом, стоит на предке красного SHA и несёт доказательство
`ci-proof` по политике `release` (лёгкий зелёный прогон — `stale`, ручной полный
запуск считается ночью). Не нашлась — комментариев нет. Подозреваемая задача —
каждый `Issue: #NN` коммита из `git rev-list --no-merges <зелёная>..<красная>`
(коммиты ветки, влитой слиянием, входят) с файлом класса A или B и без трейлера
`Release:`; коммит только из `docs/**` не считается, коммит без трейлера —
строка «без задачи» в сводке, пустой диапазон — «вероятен флак». Комментарий
называет прогоны, коммиты задачи и упавшие job, говорит «подозреваемая, а не
виновная» и несёт метку `hp:night-red green=… red=… commits=…`. Повтор не
пишется в закрытую задачу и туда, где метка с той же зелёной ночью уже
перечисляет все нынешние коммиты задачи в диапазоне: серия красных ночей от
одной зелёной — один комментарий, пока задача не докоммитит; зелёная ночь
начинает новую серию. Комментарии пишет `HP_PROCESS_TOKEN`, Actions читает
`github.token`: `issues: write` у ночи нет, потолок тонкого `nightly.yml` не
меняется. Цвет ночи — цвет Validate: сбой `night_red` — предупреждение.
**Цена захода зависит от трека** (#696, решение владельца 2026-09-28). Трек
снимает `scripts/process-track.mjs` в стадии подготовки — по текущим меткам и
диффу от merge-base с `dev`, до ребейза. Прежние метки читаются по §5.1:
инфраструктурная задача без трековой метки — `show`, продуктовая — `ask`.
Шаг трека делает **один вызов** скрипта по меткам, диффу и комментариям issue
(#707): трек и его основание (строка владельца, §5), рамки `ship`, риск по
изменённым участкам, повышение `ship` → `show`, заметку риска в промпт ревью
(не длиннее 25 строк) и строку риска для комментария слияния `ship`. Bash шага
только исполняет: комментарий берёт из файла скрипта, метки меняет по его флагу.
Guard берёт трек и лимит циклов из того же скрипта (`process-track.mjs
limit`) и своей логики трека не держит; ответ compare API на 300 файлов и
больше инфраструктуру не доказывает — `ask`, лимит 4. Этап `spec`, отсутствие
ветки и инфраструктурный дифф дают пустой риск и прежнее поведение.
**Маршрут вердикта** (#726). Структурный вердикт модели — `verdict`, `high`,
`medium`, `summary`, `route` (`fix` | `reclassify`, обязательное в схеме) и
необязательный `criterion`. Граница доверия (`scripts/review-result-gate.mjs`)
читает отсутствующий `route` как `fix`, отвергает значение вне словаря и
`reclassify` при зелёном вердикте: результат противоречив, модель не
перезапускается. Смысл `criterion` судит шаг «Решение по вердикту»: **один
вызов** `process-track.mjs route` по вердикту, треку и его подтверждению
владельцем (выход шага трека), бюджету guard и текущим меткам; статус, метки и
тело комментария (файлом) — из выхода скрипта. Метки трека, `blocked` и
`review-4` ставит `gh issue edit` по этому выходу, статус — `status-label.mjs`,
как прежде. Шаги без модели (`ship` в рамках, повторно применённый зелёный)
скрипт не зовут. Заметку маршрута в промпт даёт шаг трека: на код-ревью `show` —
поля и список критериев §5, иначе `route: fix`. Документ ревью несёт маршрут
хвостом строки якоря — «Вердикт конвейера: `yellow` · High 0 · маршрут
`reclassify` (критерий `undocumented`)», — сводка прогона печатает маршрут и
бюджет после вердикта.
| | `ship` | `show` | `ask` |
|---|---|---|---|
@@ -1189,10 +1416,14 @@ Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit`
Мутантов по диффу нет ни на одном треке (#709, §2.7). Рамки `ship`
(§5) проверяет тот же шаг; выход за них — комментарий в issue и замена
`track:ship` на `track:show` в этом же заходе. Слияние `ship` оставляет в issue
комментарий с машинным маркером `hp:ship-merge`. Это не вердикт ревью и так себя
не называет; по маркеру пакетное ревью находит задачи диапазона. Повторно
применимый зелёный вердикт (#499) главнее `ship`: код уже прочитан.
`track:ship` на `track:show` в этом же заходе; так же — рискованный участок без
подтверждения владельца (§5, #707), одним комментарием с нарушениями рамок.
Слияние `ship` оставляет в issue комментарий с машинным маркером `hp:ship-merge`.
Это не вердикт ревью и так себя не называет; по маркеру пакетное ревью находит
задачи диапазона. Если `ship` слит с риском (подтверждён владельцем или риск
только `visual`), комментарий несёт строку «Риск по участкам …» и машинную
`<!-- hp:ship-risk classes=… -->`; строка маркера `hp:ship-merge` не меняется.
Повторно применимый зелёный вердикт (#499) главнее `ship`: код уже прочитан.
В `dev` по-прежнему уезжает только SHA с зелёным Validate. Без ребейза до ревью
кандидат собирается один раз, при слиянии, и лёгкий Validate проходит там.
@@ -1226,6 +1457,16 @@ Low и цикла не открывает. Отсутствие прогона
весь реестр проверяет ночь (`mutation-gate.yml`, 00:43 UTC). Ни ревью, ни
слияние, ни релизный гейт (#541) mutant-jobs не требуют.
**Поиск ожидающего раунда (#775).** Resume и автосверка читают историю до
последней постановки review-метки, без окна «200 последних событий» и без
лимита фильтрованного поиска Actions. Чужая разметка и полностью skipped-прогоны
не заслоняют ожидание; новый запрос метки отделяет старый раунд. Resume сверяет
issue, stage, run/attempt, ветку, её текущий SHA и точный ID Validate с запечатанным
маркером. Перед перестановкой метки состояние перечитывается. При ещё невидимом
прогоне/артефакте — не более трёх снимков с паузами по 5 секунд, далее страховка
автосверкой. Поиск не перескакивает через завершённую попытку ревью без маркера:
так нельзя доказать, что повторный вызов модели безопасен.
Ожидание gates, работа модели и публикация/интеграция — три независимых jobs
(#551) с отдельными бюджетами 55, 45 и 55 минут. Поэтому долгий Validate не
съедает время модели (с #636 — и не занимает раннер: до этого подготовка спала
@@ -1238,6 +1479,25 @@ merge. Timeout/cancel/failure называет конкретную стадию
если модель не запускалась, цикл ревью не расходуется. Длительности всех трёх
стадий печатаются отдельной таблицей в summary прогона.
**Расход модели** (#737). Шаг `Review` отдаёт `execution_file` — все сообщения
сессии SDK, а журнал Actions расход намеренно прячет. Шаг «Снять расход модели»
сразу после `Review` (`if: always()`, `continue-on-error`) — шаг конвейера, а не
ревьюера, со скриптом из снимка `dev` (#765) — берёт из файла только
последнее сообщение `result` (`scripts/model-usage.mjs`) и отдаёт одну строку
выходом job `usage`: `<!-- hp:usage input_tokens=N output_tokens=N
cache_creation_input_tokens=N cache_read_input_tokens=N num_turns=N -->` — суммы
по всем моделям `modelUsage`, без него — из `usage`. Нет данных —
`<!-- hp:usage-none reason=<код> -->`: `no-execution-file`, `unreadable`,
`no-result`, `no-usage`, у публикации ещё `missing` и `invalid`. Расход —
отчётная величина, как длительность стадии: по нему ничего не решается, поэтому
он идёт выходом job, а не запечатанным artifact, и `REQUIRED_FILES` остаётся
«ровно эти файлы». Сам файл не выгружается и не печатается: в нём результаты
инструментов. Публикация разбирает строку как недоверенный ввод и пишет её
последней строкой блока якорей (`review-doc-guard.mjs --anchor --usage=`), а в
`SHIP-REVIEW` — сразу после машинного блока. «Метрики процесса» читают строку
только из машинного блока: цитата в прозе не считается, «нет данных» — не ноль.
Расход упавшей сессии виден только в сводке прогона.
Каждый раунд ревью платит только за то, что в нём изменилось (#518). Свидетель
судится по **области своего якоря** — строкам патча плюс сорок строк с каждой
стороны (`ANCHOR_RADIUS_LINES`): и в отпечатке журнала (#481), и в отборе по
@@ -1315,6 +1575,16 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
затем push в `dev` с lease на ту вершину, поверх которой кандидат собран.
Отклонённый lease — `dev` двинулся снова
— новая попытка; после третьей — `S6-in-progress` с комментарием;
- push, отклонённый самим GitHub (`! [remote rejected]`), — не lease и не
#312: `S6-in-progress` с причиной и ответом git без токена (#705). Отказ по
праву на workflow (кандидат меняет `.github/workflows/`, токен конвейера его
не публикует) называется отдельно: ребейз и push делает автор, либо
владелец выдаёт право. Страж ребейза до ревью разбирает отказ тем же кодом, как
и публикация документов ревью — в ветку задачи и ревью релиза в `dev` (#723),
пакетного ревью ship в `dev` и бот-коммит производных артефактов беты (#730):
повтор, ребейз или совет перезапустить — только на устаревший lease, отказ
GitHub останавливает шаг с причиной и ответом git без токена в сводке (у
стража ребейза тоже, #730);
- красный Validate на кандидате или прогон, не появившийся за три минуты, —
`S6-in-progress` с ссылкой; `S8-merged` ставится только после push;
- после push ветка задачи удаляется с lease на влитую вершину (#702): коммит,
@@ -1483,6 +1753,10 @@ Golden, браузерные смоки, performance и полный HA-харн
**Выпуск не блокирует.** `release.yml` ставит ревью в очередь job
`independent-review` сразу после закрепления SHA кандидата — параллельно
гейтам; ни один job выпуска от него не зависит, его отказ — предупреждение.
Dispatch идёт токеном `GITHUB_TOKEN`, прогон начинает `github-actions[bot]`, и
action ревью пускает ровно этого бота (`allowed_bots`, не `'*'`). Job находит
поставленный прогон и пишет в сводку выпуска ссылку и статус; прогон, не
стартовавший за три минуты, — предупреждение (#704).
Документ — рекомендация: владелец берёт находки в работу (issue в очередь
следующей беты) либо оставляет без действий. Автоматически находки в issue
не превращаются.
@@ -1520,7 +1794,10 @@ Golden, браузерные смоки, performance и полный HA-харн
диапазона дешевле ревью на каждую, а рамки ship держат объём малым.
**Шаг.** Перед публикацией беты — `ship-review.yml`
(`gh workflow run ship-review.yml --ref dev -f tag=vX.Y.Z-beta.N`):
(`gh workflow run ship-review.yml --ref dev -f tag=vX.Y.Z-beta.N` или кнопка
«Run workflow» в Actions). Это тонкий вызывающий файл, он лежит и в `main`:
без этого GitHub запуск не даёт. Тело `_ship-review.yml` читается из `dev` при
любой выбранной ветке (§10.4, #716):
- **вход** — issue из трейлеров `Issue: #NN` в диапазоне «прошлый тег..кандидат»
(тот же построитель, что `RELEASE-MEMBERSHIP.json`, #547), из них — ship: с
@@ -1528,23 +1805,122 @@ Golden, браузерные смоки, performance и полный HA-харн
`scripts/ship-review.mjs prepare`; ship-задач нет — модель не запускается;
- **суждение** — по строке ТЗ каждой задачи и её коммитам: делает ли код
заявленное и только его, не ломает ли соседнее, не вышла ли правка из ship по
смыслу. Правила ревьюера — `docs/process/REVIEWER.md`, «Пакетное ревью ship»;
смыслу. Под задачей бриф печатает строку риска из её комментария
`hp:ship-merge` (`hp:ship-risk`, #707): рискованные участки ship,
подтверждённого владельцем, конвейер не повышал — их первым читает пакетное
ревью. Комментарии до #707 строки не несут — риск не записан. Правила
ревьюера — `docs/process/REVIEWER.md`, «Пакетное ревью ship»;
- **выход** — `docs/reviews/SHIP-REVIEW-<тег>.md` в `dev` с машинным блоком:
задачи и счёт High/Medium/Low. У модели нет прав записи, документ публикует
задачи, счёт High/Medium/Low, режим (`mode`) и патч-набор каждой задачи
(`patches`, #727). У модели нет прав записи, документ публикует
детерминированный шаг.
**Ночь** (#727). После ночного полного Validate, при любом его исходе,
`_nightly.yml` запускает `ship-review.yml -f tag=nightly -f candidate=<SHA
прогона Validate>`: ревью читает код, а не прогон. `nightly` — зарезервированное
значение входа `tag`, кандидат в нём обязателен. Ночь читает только непокрытые
ship-задачи диапазона «прошлый тег..кандидат» и пишет
`docs/reviews/SHIP-REVIEW-<база>-dev-<sha12>.md`. Читать нечего — модели и
документа нет; документ на этот SHA уже есть — прогон ничего не делает
(`force=true` — переснять). High ночью — одна строка в каждую задачу документа
(метка `hp:ship-review-high`, повтор на тот же документ не пишется); Medium и
Low в issue не пишутся. Цвет ночи — цвет Validate.
**Патч-набор и покрытие** (#727). Патч-набор задачи — `git patch-id --stable`
её коммитов диапазона, кроме коммитов с трейлером `Release:` (кандидат беты
несёт `Issue:` всех задач линии) и коммитов только в `docs/reviews/**`; его
пишет детерминированный шаг, не модель. Покрытие задачи решает последний
опубликованный документ той же базы, где она есть: тот же патч-набор и High 0 —
`clean`, High больше нуля — `high` (High снимает покрытие со всех задач
документа), другой патч-набор — `stale`: код изменился после ревью. Задачи нет
ни в одном документе — `none`. Документ без строки `patches` (до #727)
покрывает по номеру; документы другой базы не учитываются.
**Бета читает дельту** (#727). `ship-review.yml -f tag=<тег>` читает только
задачи `none` и `stale`, бриф называет прочитанные ночью. Задачи `high` без
`force=true` не перечитываются: их держит гейт. Дельта пуста — модель не
запускается, документа тега нет. Документ тега перечисляет только прочитанные
в нём задачи. `force=true` читает все ship-задачи диапазона и на ночные
документы не смотрит.
**Гейт беты.** `ship-review.mjs check` стоит в обоих путях публикации —
`publish-prerelease.yml` и `npm run release:prerelease` (включая `--check`).
Если в диапазоне есть ship-задачи, документ обязан лежать в кандидате или в
`dev`, покрывать их все и не нести High. Задача, слитая после ревью, требует
пересъёмки (`-f force=true`). High чинится отдельной задачей, затем ревью
переснимается. Medium и Low решает владелец, как в §11.5.
Каждая ship-задача диапазона обязана быть `clean`: гейт беты принимает ночной
документ при том же патч-наборе, документ тега для этого не нужен. `none` и
`stale` — отказ с номерами и командой `ship-review.yml -f tag=<тег>`. High
чинится отдельной задачей, затем ревью переснимается (`-f force=true`). Medium
и Low решает владелец, как в §11.5.
### 11.8 Черновик кода во время ревью ТЗ
Решение владельца 2026-10-01, issue #729 (выделено из #707).
**Зачем.** На `track:ask` каждый раунд ревью ТЗ — 10–45 минут ожидания
(§10.4), и по правилу №1 автор всё это время код не пишет. Черновик позволяет
вести реализацию параллельно ревью. **Цена:** окно, в котором код пишется по
ещё не принятому тексту; после жёлтого или красного вердикта такой код
переделывается, и токены на него потрачены зря. Экономия ожидания против этого
окна — выбор владельца; поэтому **черновик — возможность, а не обязанность**.
**Условия.**
- Только `track:ask`, только после того, как ТЗ в теле issue отправлено и
стоит `S4-spec-review`. Под `blocked` и `review-4` черновик не ведётся.
- Один checkout и одна каноническая ветка: черновик живёт в рабочей копии
автора задачи, на локальной ветке `issue/<NN>-<slug>` — той, что будет
запушена после `S5`. Вторых веток `issue/<NN>-*` и параллельных черновиков
нет.
- До `S5` ничего не пушится — ни коммиты класса A, ни сама ветка: на этапе
spec конвейер выбирает ветку `issue/<NN>-*` материалом и кладёт в неё
SPEC-REVIEW. Если ветка уже есть на origin (после `reclassify` #726 или
запушена в `S3`), черновик после `S5` ребейзится на `origin/issue/<NN>-*`,
где лежит SPEC-REVIEW, а не только на `dev`; force-push поверх неё запрещён.
- Статус остаётся `S4-spec-review`; занятие `S6-in-progress` — только после
зелёного ревью ТЗ.
- Начиная черновик, автор пишет комментарий «Черновик:» по шаблону §7.2. С
этого момента черновик занимает слот WIP автора (§2.6).
- **Трейлер.** Каждый черновой коммит несёт ровно один трейлер
`Spec-Draft: sha256:<64 строчных hex>` в финальном блоке рядом с `Issue:` и
`User-Visible:`. Значение — `issueBodyDigest(<тело issue>)` из
`scripts/review-doc-guard.mjs`: та же функция, которой конвейер пишет «Тело
issue:» в якорь документа ревью. Строку печатает пакет задачи в `S4`
(`node scripts/task-packet.mjs --issue NN`, раздел «Черновик»). Хеш
считается по телу на момент начала черновика; тело правилось после этого —
хеш другой, это и есть «ТЗ правилось».
**Выход.** Зелёный вердикт ставит `S5-ready`. Автор берёт `S6-in-progress`,
делает `git fetch`, ребейзит черновик на `origin/dev` (или на
`origin/issue/<NN>-*`, если ветка была), проверяет
`node scripts/process-gate.mjs --range origin/dev..HEAD --issues --report` и
пушит. **В ветку черновик попадает только после зелёного ревью ТЗ.** Push,
`S7` и слияние, как и прежде, идут только после зелёного ревью актуальной
версии ТЗ: это держат правило 8 (§10.2) и шаг «ТЗ менялось после зелёного
ревью ТЗ» (#517).
**Жёлтый или красный вердикт** возвращает задачу в `S3-spec`, и черновик
останавливается. Его коммиты в ветку не попадают: как есть их отклонит гейт, а
перенос с переписанной датой автора (`--reset-author`, `--date`, новый коммит
из старого диффа) запрещён §12. В следующей эпохе `S4` можно начать новый
черновик — по новому тексту и с новым хешем.
**Проверка** — правило 10 `process-gate.mjs` (§10.2): коммит класса A,
написанный в эпохе `S4-spec-review`, принимается, только если эту эпоху закрыл
`S5-ready`, трек на момент написания — `ask`, и трейлер равен «Тело issue»
зелёного (High 0) `SPEC-REVIEW` этой эпохи из вершины диапазона или
`origin/dev`. Отказ называет причину; при устаревшем ТЗ — оба хеша и документ.
Комментарий «Черновик:» и учёт в WIP машиной не проверяются, как занятие `S6`.
Ревьюер ТЗ черновик не читает и доводом не считает: судится тело issue
(§2.4).
---
## 12. Запрещено
- код без issue или из статуса раньше «Готово к разработке»;
- код без issue или из статуса раньше «Готово к разработке» (кроме черновика
`track:ask` по §11.8);
- push черновика до `S5`; перенос черновика по устаревшему ТЗ коммитами с
переписанной датой автора;
- ТЗ, написанное после кода (кроме §11.2, и тогда с пометкой «как сделано»);
- ревью своей работы; перевод своей работы через ревью-гейт;
- пятый цикл ревью вместо разбора по §4;
+1 -1
View File
@@ -57,7 +57,7 @@ SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
ASSETS_DIR = "houseplan/assets"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.79.0-beta.1"
VERSION = "1.79.0"
DECOR_ASSETS_API_VERSION = 1
SUMMARY_PANEL_API_VERSION = 1
@@ -86,6 +86,16 @@ def _lattice_points(value: Any) -> None:
_lattice_point(point)
def _scalar_points(value: Any) -> None:
if not isinstance(value, list):
return
for point in value:
if not isinstance(point, list):
continue
for index in range(min(2, len(point))):
point[index] = canonicalize_number(point[index])
def canonicalize_position(position: Any) -> Any:
"""Canonicalise lattice x/y in one layout record, preserving metadata."""
result = copy.deepcopy(position)
@@ -182,6 +192,12 @@ def canonicalize_config_geometry(config: Any) -> Any:
_lattice_point(span.get("a"))
_lattice_point(span.get("b"))
for strip in _records(space.get("led_strips")):
# #780: LED strips snap to physical wall faces, like stairs and
# furniture: remove JSON noise only, never pull a face contact
# onto a lattice node.
_scalar_points(strip.get("points"))
for marker in _records(root.get("markers")):
_scalar_fields(marker, ("angle",))
@@ -1,399 +1,544 @@
{
"schema": 1,
"fingerprint": "1e24262d325c178a2dcff7f9ded0cdbe6cca8fc53344f2ce137be568178fb802",
"fingerprint": "ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172",
"entry": "houseplan-card.js",
"panelEntry": "houseplan-panel.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-EmxAmZMt.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 300142,
"initialViewGzipBytes": 300133,
"initialPanelFiles": [
"houseplan-assets/houseplan-card-EmxAmZMt.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-panel.js"
],
"initialPanelGzipBytes": 301665,
"initialPanelGzipBytes": 301657,
"initialPanelOnlyFiles": [
"houseplan-panel.js"
],
"initialPanelOnlyGzipBytes": 2325,
"initialPanelOnlyGzipBytes": 2328,
"lazyFiles": [
"houseplan-assets/backdrop-pick-xDPBAs3k.js",
"houseplan-assets/de-Ofk1Ol_F.js",
"houseplan-assets/editor-S8mH9zBJ.js",
"houseplan-assets/form-kit.styles-BFqEQFqK.js",
"houseplan-assets/fr-FzNC6MIf.js",
"houseplan-assets/furniture-plan-art.generated-WAA27063.js",
"houseplan-assets/houseplan-editor-runtime-T45EChf6.js",
"houseplan-assets/houseplan-onboarding-runtime-DE6DUf5w.js",
"houseplan-assets/hp-zigbee-topology-overlay-BqM50por.js",
"houseplan-assets/iso-scene-render-CHCy2Sak.js",
"houseplan-assets/live-interaction-runtime-wyVBI9RS.js",
"houseplan-assets/moon-runtime-BoXIdEp3.js",
"houseplan-assets/namespace-language-aBVqQLAD.js",
"houseplan-assets/pdf-export-Dc8BpJbx.js",
"houseplan-assets/summary-panel-runtime-loaded-Cz5dApFg.js",
"houseplan-assets/zigbee-topology-ZJgB0un9.js",
"houseplan-assets/zigbee-topology-runtime-bwrrUQoZ.js"
"houseplan-assets/backdrop-pick-DHF8oaNH.js",
"houseplan-assets/de-C4xCvmYr.js",
"houseplan-assets/editor-CFo6BxSe.js",
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/fr-BJJefRIG.js",
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"houseplan-assets/houseplan-editor-runtime-CqreRULk.js",
"houseplan-assets/houseplan-onboarding-runtime-DOfjrx0M.js",
"houseplan-assets/hp-zigbee-topology-overlay-BAaZNyAl.js",
"houseplan-assets/iso-scene-render-CR7lxY-w.js",
"houseplan-assets/led-strip-editor-kcNyoue_.js",
"houseplan-assets/led-strip-geometry-DaLifxqY.js",
"houseplan-assets/led-strip-runtime-Bx6qpdWk.js",
"houseplan-assets/live-interaction-runtime-C1983oH8.js",
"houseplan-assets/moon-runtime-Ca1mxhJl.js",
"houseplan-assets/namespace-language-D86tJUeD.js",
"houseplan-assets/pdf-export-C-Y4BSVu.js",
"houseplan-assets/space-editor-EDOQT48H.js",
"houseplan-assets/summary-panel-runtime-loaded-CcLP5B5v.js",
"houseplan-assets/zigbee-topology-BG_a6cIb.js",
"houseplan-assets/zigbee-topology-runtime-CynQ2m3K.js"
],
"lazyGzipBytes": 464808,
"lazyGzipBytes": 478817,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-xDPBAs3k.js",
"houseplan-assets/editor-S8mH9zBJ.js",
"houseplan-assets/form-kit.styles-BFqEQFqK.js",
"houseplan-assets/furniture-plan-art.generated-WAA27063.js",
"houseplan-assets/houseplan-editor-runtime-T45EChf6.js",
"houseplan-assets/namespace-language-aBVqQLAD.js",
"houseplan-assets/zigbee-topology-ZJgB0un9.js"
"houseplan-assets/backdrop-pick-DHF8oaNH.js",
"houseplan-assets/editor-CFo6BxSe.js",
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"houseplan-assets/houseplan-editor-runtime-CqreRULk.js",
"houseplan-assets/namespace-language-D86tJUeD.js",
"houseplan-assets/zigbee-topology-BG_a6cIb.js"
],
"lazyEditorGzipBytes": 238559,
"lazyEditorGzipBytes": 240843,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-xDPBAs3k.js",
"houseplan-assets/form-kit.styles-BFqEQFqK.js",
"houseplan-assets/houseplan-onboarding-runtime-DE6DUf5w.js",
"houseplan-assets/namespace-language-aBVqQLAD.js"
"houseplan-assets/backdrop-pick-DHF8oaNH.js",
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/houseplan-onboarding-runtime-DOfjrx0M.js",
"houseplan-assets/namespace-language-D86tJUeD.js"
],
"lazyOnboardingGzipBytes": 28214,
"lazyOnboardingGzipBytes": 28402,
"lazyLocaleFiles": [
"houseplan-assets/de-Ofk1Ol_F.js",
"houseplan-assets/fr-FzNC6MIf.js"
"houseplan-assets/de-C4xCvmYr.js",
"houseplan-assets/fr-BJJefRIG.js"
],
"lazyLocaleGzipBytes": 56075,
"lazyLocaleGzipBytes": 53876,
"lazyIsometricFiles": [
"houseplan-assets/iso-scene-render-CHCy2Sak.js"
"houseplan-assets/iso-scene-render-CR7lxY-w.js"
],
"lazyIsometricGzipBytes": 14807,
"lazyIsometricGzipBytes": 12348,
"lazyFurnitureArtFiles": [
"houseplan-assets/furniture-plan-art.generated-WAA27063.js"
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js"
],
"lazyFurnitureArtGzipBytes": 16941,
"lazyFurnitureArtGzipBytes": 16936,
"lazyPdfFiles": [
"houseplan-assets/furniture-plan-art.generated-WAA27063.js",
"houseplan-assets/pdf-export-Dc8BpJbx.js"
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"houseplan-assets/pdf-export-C-Y4BSVu.js"
],
"lazyPdfGzipBytes": 126936,
"lazyPdfGzipBytes": 126934,
"lazyMoonFiles": [
"houseplan-assets/moon-runtime-BoXIdEp3.js"
"houseplan-assets/moon-runtime-Ca1mxhJl.js"
],
"lazyMoonGzipBytes": 11386,
"lazyMoonGzipBytes": 11713,
"lazyLedFiles": [
"houseplan-assets/led-strip-field-ObbckCkp.js",
"houseplan-assets/led-strip-geometry-DaLifxqY.js",
"houseplan-assets/led-strip-runtime-Bx6qpdWk.js"
],
"lazyLedGzipBytes": 10613,
"lazyLedEditorFiles": [
"houseplan-assets/led-strip-editor-kcNyoue_.js",
"houseplan-assets/led-strip-geometry-DaLifxqY.js",
"houseplan-assets/namespace-language-D86tJUeD.js"
],
"lazyLedEditorGzipBytes": 10707,
"lazyNamespaceLocaleFiles": [
"houseplan-assets/settings-de-BjL5B0dc.js",
"houseplan-assets/settings-fr-DDSje21P.js",
"houseplan-assets/settings-ru-Dg2cYOQC.js",
"houseplan-assets/support-de-DHHLrX28.js",
"houseplan-assets/support-fr-2qq1lzjv.js",
"houseplan-assets/support-ru-B6q8BWu5.js",
"houseplan-assets/topology-de-BCTuDcaH.js",
"houseplan-assets/topology-fr-DaC6px8t.js",
"houseplan-assets/topology-ru-FyTSL7gH.js"
"houseplan-assets/led-de-yp1GefDC.js",
"houseplan-assets/led-fr-CHODsRJm.js",
"houseplan-assets/led-ru-D2-9Yy6l.js",
"houseplan-assets/settings-de-ZP6F6dIF.js",
"houseplan-assets/settings-fr-doqd_TsB.js",
"houseplan-assets/settings-ru-CQ1W2gik.js",
"houseplan-assets/support-de-haZQ7q8i.js",
"houseplan-assets/support-fr-DnRyZ_Ga.js",
"houseplan-assets/support-ru-B7zzXsuL.js",
"houseplan-assets/tools-de-CYXPPfUH.js",
"houseplan-assets/tools-fr-DvygRB10.js",
"houseplan-assets/tools-ru-2xmu2w_8.js",
"houseplan-assets/topology-de-COda8n9n.js",
"houseplan-assets/topology-fr-BT9yhzay.js",
"houseplan-assets/topology-ru-C-d06yvD.js"
],
"lazyNamespaceLocaleGzipBytes": 22012,
"lazyNamespaceLocaleGzipBytes": 30082,
"files": [
{
"path": "houseplan-assets/backdrop-pick-xDPBAs3k.js",
"sha256": "dd84ed7c9ec146d9a78334b834d647ab5452152b89e40f8219d8dcdcb0270fc1",
"rawBytes": 54738,
"gzipBytes": 17841,
"path": "houseplan-assets/backdrop-pick-DHF8oaNH.js",
"sha256": "7262c67e3f88f44ee6c6a0973073d1f7c3c9f177ff8254758a21c4e3867c5b06",
"rawBytes": 55329,
"gzipBytes": 18027,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-BFqEQFqK.js",
"houseplan-assets/houseplan-card-EmxAmZMt.js",
"houseplan-assets/namespace-language-aBVqQLAD.js"
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/namespace-language-D86tJUeD.js"
],
"dynamicImports": [
"houseplan-assets/settings-de-BjL5B0dc.js",
"houseplan-assets/settings-fr-DDSje21P.js",
"houseplan-assets/settings-ru-Dg2cYOQC.js"
"houseplan-assets/settings-de-ZP6F6dIF.js",
"houseplan-assets/settings-fr-doqd_TsB.js",
"houseplan-assets/settings-ru-CQ1W2gik.js"
]
},
{
"path": "houseplan-assets/de-Ofk1Ol_F.js",
"sha256": "2eb07afbbd1bf2a09218a00527704e844a3a70875cb6eb457b21f230161403a4",
"rawBytes": 93620,
"gzipBytes": 28268,
"path": "houseplan-assets/de-C4xCvmYr.js",
"sha256": "e3778d3139c05e075281f96460e15a6aaafe8dd9200306b0a3799a1bc5710828",
"rawBytes": 89873,
"gzipBytes": 27169,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-S8mH9zBJ.js",
"sha256": "ed788b71afa034bb7cda601ab7ff48c31675ce29927f36ceb905bcbe144a8233",
"path": "houseplan-assets/editor-CFo6BxSe.js",
"sha256": "81d90441a06989a01a09c19e8c73567b80cd4d6ba52e4ea4796e096e1e5b7aa4",
"rawBytes": 3833,
"gzipBytes": 1583,
"gzipBytes": 1584,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-EmxAmZMt.js"
"houseplan-assets/houseplan-card-BOdiRDFR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/form-kit.styles-BFqEQFqK.js",
"sha256": "39c094bb85b58380b4fd40be465d2a858c69bde01b167d7808bce4c908f281af",
"rawBytes": 25600,
"gzipBytes": 5055,
"path": "houseplan-assets/form-kit.styles-B6HsmMPA.js",
"sha256": "d18d3eb8bf1dd9dda6d6fdeed84edc16d3945e8a7e23b1d88e5f2edcef0a0af6",
"rawBytes": 25605,
"gzipBytes": 5059,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-FzNC6MIf.js",
"sha256": "c22ef460cba207ba968bef5af6dc92e12d02c1d4b8f6be0e5bfa0284fae14297",
"rawBytes": 96021,
"gzipBytes": 27807,
"path": "houseplan-assets/fr-BJJefRIG.js",
"sha256": "4932ff13f6fdc339e9d67123105f9bd97f051395a3c3310240fc07b119d87024",
"rawBytes": 92266,
"gzipBytes": 26707,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/furniture-plan-art.generated-WAA27063.js",
"sha256": "0a2663baf6a58a8907a077d97c0dc0d920e334d91455e26b5dcbd28b4f8da748",
"path": "houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"sha256": "10a0b09e4b1513bd02ffba047d6931add1d1106c453af03f6b3c778b5bb55e15",
"rawBytes": 52035,
"gzipBytes": 16941,
"gzipBytes": 16936,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-EmxAmZMt.js",
"sha256": "1a9c922a3e749024a1dbc95f39b3850b200b4eebdb2cc83dba2f5df1b355ca48",
"rawBytes": 1058245,
"gzipBytes": 299340,
"path": "houseplan-assets/houseplan-card-BOdiRDFR.js",
"sha256": "37c784960d53789a66988a4e4ab8a8afe88aeddb80c5f244f3d453ef86c3ba8c",
"rawBytes": 1056405,
"gzipBytes": 299329,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-Ofk1Ol_F.js",
"houseplan-assets/editor-S8mH9zBJ.js",
"houseplan-assets/fr-FzNC6MIf.js",
"houseplan-assets/furniture-plan-art.generated-WAA27063.js",
"houseplan-assets/houseplan-editor-runtime-T45EChf6.js",
"houseplan-assets/houseplan-onboarding-runtime-DE6DUf5w.js",
"houseplan-assets/hp-zigbee-topology-overlay-BqM50por.js",
"houseplan-assets/iso-scene-render-CHCy2Sak.js",
"houseplan-assets/live-interaction-runtime-wyVBI9RS.js",
"houseplan-assets/moon-runtime-BoXIdEp3.js",
"houseplan-assets/pdf-export-Dc8BpJbx.js",
"houseplan-assets/summary-panel-runtime-loaded-Cz5dApFg.js"
"houseplan-assets/de-C4xCvmYr.js",
"houseplan-assets/editor-CFo6BxSe.js",
"houseplan-assets/fr-BJJefRIG.js",
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"houseplan-assets/houseplan-editor-runtime-CqreRULk.js",
"houseplan-assets/houseplan-onboarding-runtime-DOfjrx0M.js",
"houseplan-assets/hp-zigbee-topology-overlay-BAaZNyAl.js",
"houseplan-assets/iso-scene-render-CR7lxY-w.js",
"houseplan-assets/led-strip-editor-kcNyoue_.js",
"houseplan-assets/led-strip-runtime-Bx6qpdWk.js",
"houseplan-assets/live-interaction-runtime-C1983oH8.js",
"houseplan-assets/moon-runtime-Ca1mxhJl.js",
"houseplan-assets/pdf-export-C-Y4BSVu.js",
"houseplan-assets/space-editor-EDOQT48H.js",
"houseplan-assets/summary-panel-runtime-loaded-CcLP5B5v.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-T45EChf6.js",
"sha256": "ac298288a6cc4ba672a27609b8674a7f3df5508eac0bd495a6d27731f629e430",
"rawBytes": 689084,
"gzipBytes": 192432,
"path": "houseplan-assets/houseplan-editor-runtime-CqreRULk.js",
"sha256": "772b538d5b87265a0e88117f01034a3ccadd5f4e928ced77cb99254ab74e299a",
"rawBytes": 695353,
"gzipBytes": 194530,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-xDPBAs3k.js",
"houseplan-assets/form-kit.styles-BFqEQFqK.js",
"houseplan-assets/furniture-plan-art.generated-WAA27063.js",
"houseplan-assets/houseplan-card-EmxAmZMt.js",
"houseplan-assets/namespace-language-aBVqQLAD.js",
"houseplan-assets/zigbee-topology-ZJgB0un9.js"
"houseplan-assets/backdrop-pick-DHF8oaNH.js",
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/namespace-language-D86tJUeD.js",
"houseplan-assets/zigbee-topology-BG_a6cIb.js"
],
"dynamicImports": [
"houseplan-assets/support-de-DHHLrX28.js",
"houseplan-assets/support-fr-2qq1lzjv.js",
"houseplan-assets/support-ru-B6q8BWu5.js",
"houseplan-assets/zigbee-topology-runtime-bwrrUQoZ.js"
"houseplan-assets/support-de-haZQ7q8i.js",
"houseplan-assets/support-fr-DnRyZ_Ga.js",
"houseplan-assets/support-ru-B7zzXsuL.js",
"houseplan-assets/tools-de-CYXPPfUH.js",
"houseplan-assets/tools-fr-DvygRB10.js",
"houseplan-assets/tools-ru-2xmu2w_8.js",
"houseplan-assets/zigbee-topology-runtime-CynQ2m3K.js"
]
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-DE6DUf5w.js",
"sha256": "eba35c752987efdb28b294d66780e3d7eb3db726599375bffe2b1fb00bbc6649",
"path": "houseplan-assets/houseplan-onboarding-runtime-DOfjrx0M.js",
"sha256": "242ae570916ec2a000a8a0079a948826faeb45c0968cc1006e9670f9d3c4088b",
"rawBytes": 15886,
"gzipBytes": 4908,
"gzipBytes": 4907,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-xDPBAs3k.js",
"houseplan-assets/form-kit.styles-BFqEQFqK.js",
"houseplan-assets/houseplan-card-EmxAmZMt.js",
"houseplan-assets/namespace-language-aBVqQLAD.js"
"houseplan-assets/backdrop-pick-DHF8oaNH.js",
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/namespace-language-D86tJUeD.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/hp-zigbee-topology-overlay-BqM50por.js",
"sha256": "ea17b97808fed5d221ac930d77fbddc6a458b3a1d51c5f30b0f1c9ee29153425",
"path": "houseplan-assets/hp-zigbee-topology-overlay-BAaZNyAl.js",
"sha256": "8cb7c3f010cf7ef792a676bfd994c4498f31222dd12ded79e3e2f37dfae4f52e",
"rawBytes": 10958,
"gzipBytes": 3765,
"gzipBytes": 3768,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-EmxAmZMt.js",
"houseplan-assets/namespace-language-aBVqQLAD.js",
"houseplan-assets/zigbee-topology-ZJgB0un9.js",
"houseplan-assets/zigbee-topology-runtime-bwrrUQoZ.js"
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/namespace-language-D86tJUeD.js",
"houseplan-assets/zigbee-topology-BG_a6cIb.js",
"houseplan-assets/zigbee-topology-runtime-CynQ2m3K.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/iso-scene-render-CHCy2Sak.js",
"sha256": "ca3ccfdb35f174e0df22dc6ad2e8c44e617a8e87ba44d1ebe1e431b354137d3a",
"rawBytes": 43395,
"gzipBytes": 14807,
"path": "houseplan-assets/iso-scene-render-CR7lxY-w.js",
"sha256": "1986dd43c8b76580f893c1629d494f7747f02aa8d544baff20d56fe10b470bb5",
"rawBytes": 36700,
"gzipBytes": 12348,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-EmxAmZMt.js"
"houseplan-assets/houseplan-card-BOdiRDFR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/live-interaction-runtime-wyVBI9RS.js",
"sha256": "33a2c690f2754df1550ea63ff74f014589ae40b502a3921f3e32549b81c976ef",
"rawBytes": 8370,
"gzipBytes": 3278,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-EmxAmZMt.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/moon-runtime-BoXIdEp3.js",
"sha256": "46d7177ce90d9c4bdd9cd8a984103da3488c62c014110ea28b72f66c91f07d9f",
"rawBytes": 29462,
"gzipBytes": 11386,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-EmxAmZMt.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/namespace-language-aBVqQLAD.js",
"sha256": "ed8d21e825a8084b3444ba49cf8c82f3e65505cf723dc2f40a8c5e1b76f7981a",
"rawBytes": 675,
"gzipBytes": 410,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-EmxAmZMt.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/pdf-export-Dc8BpJbx.js",
"sha256": "9a4e0cd0f57e89645ad63973de1e7d44918b8c5eee38d66ca90f911701ab5c00",
"rawBytes": 236714,
"gzipBytes": 109995,
"isEntry": false,
"imports": [
"houseplan-assets/furniture-plan-art.generated-WAA27063.js",
"houseplan-assets/houseplan-card-EmxAmZMt.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-de-BjL5B0dc.js",
"sha256": "8238ebbb5e6918509a4e86567cc01f6b11b1a77d2816208acfffd7a09ba9ab33",
"rawBytes": 8185,
"gzipBytes": 3177,
"path": "houseplan-assets/led-de-yp1GefDC.js",
"sha256": "eb9ce8bb536ac20e11cf4fe925d68f28b1ba8a5ea02c61abf9c3bb939de0a4c4",
"rawBytes": 1907,
"gzipBytes": 921,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-fr-DDSje21P.js",
"sha256": "c0c585d69f470a396bf13ce779ee32d9f7cf06ec747348f712d91da712eac8f2",
"rawBytes": 8493,
"gzipBytes": 3153,
"path": "houseplan-assets/led-fr-CHODsRJm.js",
"sha256": "adb428bd63b6157ef0a5d749badf94e95204c9c3b83e5f3022accf65d304435e",
"rawBytes": 1886,
"gzipBytes": 906,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-ru-Dg2cYOQC.js",
"sha256": "fa0fe59efe52b3ea58c170ef7cd7ef9ec73d708cfda862498c9125c5996cd394",
"rawBytes": 11379,
"gzipBytes": 3680,
"path": "houseplan-assets/led-ru-D2-9Yy6l.js",
"sha256": "bc5575139b30d6e93e07673f47865c332e71011796929a52043645a904d6df31",
"rawBytes": 2525,
"gzipBytes": 1106,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-editor-CBTbo3v8.js",
"sha256": "f5da0eedb89fbf4173f7fea91f011baad8872af279af27fd7cc68c3c5a3935ac",
"rawBytes": 14862,
"gzipBytes": 3426,
"path": "houseplan-assets/led-strip-editor-kcNyoue_.js",
"sha256": "dac9fdcaae6444caa1773a07384155cc92b916d2ebf3498e1dfc1b3525a84753",
"rawBytes": 21704,
"gzipBytes": 7381,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-BFqEQFqK.js",
"houseplan-assets/houseplan-card-EmxAmZMt.js",
"houseplan-assets/summary-panel-runtime-loaded-Cz5dApFg.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-metrics-BVDREExV.js",
"sha256": "8c350023c60de784dae6b2bcb6590b0d1ff32694c6ff13a24b6e8cfe7a90f787",
"rawBytes": 2701,
"gzipBytes": 1345,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-EmxAmZMt.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-runtime-loaded-Cz5dApFg.js",
"sha256": "ce816fedc5b5e57b6e25ddc5fab5a70d782b782be6a74ac0718639bb0359cdf1",
"rawBytes": 72959,
"gzipBytes": 20245,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-BFqEQFqK.js",
"houseplan-assets/houseplan-card-EmxAmZMt.js"
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/led-strip-geometry-DaLifxqY.js",
"houseplan-assets/namespace-language-D86tJUeD.js"
],
"dynamicImports": [
"houseplan-assets/summary-panel-editor-CBTbo3v8.js",
"houseplan-assets/summary-panel-metrics-BVDREExV.js"
"houseplan-assets/led-de-yp1GefDC.js",
"houseplan-assets/led-fr-CHODsRJm.js",
"houseplan-assets/led-ru-D2-9Yy6l.js"
]
},
{
"path": "houseplan-assets/support-de-DHHLrX28.js",
"sha256": "0f2cad53a3b6ae657153a7c297cdb82419e1a31d2280623a21a26708d8844d4d",
"path": "houseplan-assets/led-strip-field-ObbckCkp.js",
"sha256": "b85d8de01906a7e70f28baff534cf1a211216c99b72005b6d77a0d19cbb5ce8f",
"rawBytes": 7920,
"gzipBytes": 3321,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/led-strip-geometry-DaLifxqY.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/led-strip-geometry-DaLifxqY.js",
"sha256": "2dfbe1509da809008c9a525a7ed02226c026aaa5c0dafcb9f3dc1c44151ed63e",
"rawBytes": 7055,
"gzipBytes": 2917,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/led-strip-runtime-Bx6qpdWk.js",
"sha256": "6fd2d3578e550dfbe8c38308923e21f9fddeb469f64e8fcf295dc292949ddf32",
"rawBytes": 11179,
"gzipBytes": 4375,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/led-strip-geometry-DaLifxqY.js"
],
"dynamicImports": [
"houseplan-assets/led-strip-field-ObbckCkp.js"
]
},
{
"path": "houseplan-assets/live-interaction-runtime-C1983oH8.js",
"sha256": "04be552ac07f99c03713f162ce0b6129c8491dfd89da9cd8e07cd71866fe309c",
"rawBytes": 8370,
"gzipBytes": 3281,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/moon-runtime-Ca1mxhJl.js",
"sha256": "97b9f55cb8e2915acb5eaa260f21e2103bc08f0e654c1c0b03c3000604319730",
"rawBytes": 30292,
"gzipBytes": 11713,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/namespace-language-D86tJUeD.js",
"sha256": "012aa30cbf8bbbb2eff6b7ef05970ff90fb2b4b576910578e3527b75e68ffda6",
"rawBytes": 675,
"gzipBytes": 409,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/pdf-export-C-Y4BSVu.js",
"sha256": "fba700bc14c66c695f1b3de3e82abdf5fb6b14b70773c3d0f0e185dcf2989491",
"rawBytes": 236714,
"gzipBytes": 109998,
"isEntry": false,
"imports": [
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-de-ZP6F6dIF.js",
"sha256": "12dd3028d2de2e4fc3bfcf2299e45e253075ca23b95235436ef1cf9156738a3e",
"rawBytes": 8886,
"gzipBytes": 3411,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-fr-doqd_TsB.js",
"sha256": "0c1f01b69c2b1704ddd29e99dd7609ad679655b5241b3e2b8921057198005ff3",
"rawBytes": 9297,
"gzipBytes": 3385,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-ru-CQ1W2gik.js",
"sha256": "bad5c925aac84429d0831a1036ea3162ead8d4431db35ea577b0e806c8fbdbf1",
"rawBytes": 12284,
"gzipBytes": 3900,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/space-editor-EDOQT48H.js",
"sha256": "47339ab7329ffd1a0cbd21980c1cf2a2041d7a514ed5992e73a07abdce6fb764",
"rawBytes": 2619,
"gzipBytes": 1130,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-editor-BZnGwuVh.js",
"sha256": "516e3462d3896a9ca782ee0f2f3266a4da6e6b6664e92a619a23b8991e600795",
"rawBytes": 14862,
"gzipBytes": 3427,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/summary-panel-runtime-loaded-CcLP5B5v.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-metrics-D_ZXPJxz.js",
"sha256": "3be26833b041416cef359e4c7086a0b0e6788680dda8f1583ae76d9256f944fa",
"rawBytes": 2701,
"gzipBytes": 1348,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-runtime-loaded-CcLP5B5v.js",
"sha256": "7db94205b54996355041487465d6bade0530e5ac0d13a0eb9b7645086171ce58",
"rawBytes": 73476,
"gzipBytes": 20491,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js"
],
"dynamicImports": [
"houseplan-assets/summary-panel-editor-BZnGwuVh.js",
"houseplan-assets/summary-panel-metrics-D_ZXPJxz.js"
]
},
{
"path": "houseplan-assets/support-de-haZQ7q8i.js",
"sha256": "f377e9d552978c850f069eeb05b20c7572f81c7a8c7b2d60838a7907f1c2b3ef",
"rawBytes": 6167,
"gzipBytes": 2395,
"gzipBytes": 2400,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/support-fr-2qq1lzjv.js",
"sha256": "afa9611e3a1097718beb1db7a6c9c59f59f115893737c47776302dc99718081e",
"path": "houseplan-assets/support-fr-DnRyZ_Ga.js",
"sha256": "bb66f839f3b63a74212dc83d273db535dd4597e5f43c5c1b35f0e1fac401e424",
"rawBytes": 6333,
"gzipBytes": 2430,
"gzipBytes": 2433,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/support-ru-B6q8BWu5.js",
"sha256": "64bfdbf1b3f4114fe48280eb3a636c8c814b31849f47762fd205e18669d7046c",
"path": "houseplan-assets/support-ru-B7zzXsuL.js",
"sha256": "63ec59059f3a10c994e4292c4c2b125bb7b2916326dbae27f9975c14be008822",
"rawBytes": 8376,
"gzipBytes": 2876,
"gzipBytes": 2878,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-de-BCTuDcaH.js",
"sha256": "aed9319ce44841b57620ae2218d22dace5e81f54b0c64e7f73e113cfa6714cb3",
"path": "houseplan-assets/tools-de-CYXPPfUH.js",
"sha256": "411c98cf876e42d7255882d0faa486602453f871d443b0c2b59c22e3b2552efb",
"rawBytes": 4130,
"gzipBytes": 1378,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/tools-fr-DvygRB10.js",
"sha256": "f7d35402d5f3504baa8c00dc8bd5170487535c1b87f9a8367a28e332cda92bf2",
"rawBytes": 4136,
"gzipBytes": 1360,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/tools-ru-2xmu2w_8.js",
"sha256": "14e6c4c22d615293903fc4f25d831f0af042b4e48234d4f2e114ef7d8817d57a",
"rawBytes": 5633,
"gzipBytes": 1697,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-de-COda8n9n.js",
"sha256": "88ff4a8cccc94e3eba040af628d9a85db664cbf20586f95704e0823a6215c1f9",
"rawBytes": 2698,
"gzipBytes": 1333,
"gzipBytes": 1336,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-fr-DaC6px8t.js",
"sha256": "b5bb991adbae3122fe70d427592d3f00e4375ed661f5fa90662601350df0a0fa",
"path": "houseplan-assets/topology-fr-BT9yhzay.js",
"sha256": "60cb68c5c9086ec5f4901627a6baa3e3c8cdfda430b0977622b260984122fcfa",
"rawBytes": 2718,
"gzipBytes": 1353,
"gzipBytes": 1356,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-ru-FyTSL7gH.js",
"sha256": "535fba457bd10c13eef9ab856139cc543ab1dec9c9d1e4a911f0835a2214619c",
"path": "houseplan-assets/topology-ru-C-d06yvD.js",
"sha256": "ca9a68f252b8275c954ae7ff7ea56d07a8ea5cf14f18eaac285fd0c63a7bad8f",
"rawBytes": 3669,
"gzipBytes": 1615,
"isEntry": false,
@@ -401,53 +546,53 @@
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-runtime-bwrrUQoZ.js",
"sha256": "4af0b40c967c51010a08cebec38570a924433e1387d17b9a2d1c898868f27e9b",
"rawBytes": 3983,
"gzipBytes": 1790,
"path": "houseplan-assets/zigbee-topology-BG_a6cIb.js",
"sha256": "98d8e54ec59e2d1810b124a0a0c6c2f528c112ab17d12bc3c0a129e13c456c72",
"rawBytes": 10958,
"gzipBytes": 4298,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-EmxAmZMt.js",
"houseplan-assets/namespace-language-aBVqQLAD.js",
"houseplan-assets/zigbee-topology-ZJgB0un9.js"
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/namespace-language-D86tJUeD.js"
],
"dynamicImports": [
"houseplan-assets/topology-de-COda8n9n.js",
"houseplan-assets/topology-fr-BT9yhzay.js",
"houseplan-assets/topology-ru-C-d06yvD.js"
]
},
{
"path": "houseplan-assets/zigbee-topology-runtime-CynQ2m3K.js",
"sha256": "966ef9fa87888f0ed561d1061b36f0192c27700f040305ae038fe9509c7a7e33",
"rawBytes": 3983,
"gzipBytes": 1789,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/namespace-language-D86tJUeD.js",
"houseplan-assets/zigbee-topology-BG_a6cIb.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-ZJgB0un9.js",
"sha256": "dbe3fa86813b1f2557fe36e8225bec46153903cb9ab1e9aa35246a3e902091f6",
"rawBytes": 10958,
"gzipBytes": 4297,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-EmxAmZMt.js",
"houseplan-assets/namespace-language-aBVqQLAD.js"
],
"dynamicImports": [
"houseplan-assets/topology-de-BCTuDcaH.js",
"houseplan-assets/topology-fr-DaC6px8t.js",
"houseplan-assets/topology-ru-FyTSL7gH.js"
]
},
{
"path": "houseplan-card.js",
"sha256": "5a359b88449d0b0d712b4506b642fe61535133bb355086ffe43a7f8392703dd0",
"sha256": "80338f4a64b84431f95438482aeabe1cc6b0f399ccb0c600b318c0f6e6cccc38",
"rawBytes": 1205,
"gzipBytes": 802,
"gzipBytes": 804,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-EmxAmZMt.js"
"houseplan-assets/houseplan-card-BOdiRDFR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-panel.js",
"sha256": "b3f5cdf1f3a7f140365c75d7e4f1075eadb2e06a842df264cea79269bc3650f5",
"sha256": "ce8c313af85073d1c54ccc74e2111c1f14c5d8e3e8539bc59ec436d6d3e0d574",
"rawBytes": 6165,
"gzipBytes": 2325,
"gzipBytes": 2328,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-EmxAmZMt.js"
"houseplan-assets/houseplan-card-BOdiRDFR.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1e24262d325c178a2dcff7f9ded0cdbe6cca8fc53344f2ce137be568178fb802";import{aD as e,l as o,aG as t,ez as s,A as a,eA as i,eB as l,eC as n,E as r,c}from"./houseplan-card-EmxAmZMt.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";import{aG as e,l as o,aJ as t,eE as s,A as a,eF as i,eG as l,eH as n,E as r,c}from"./houseplan-card-BOdiRDFR.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},_=this._schema,f=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),u=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>f[e.name]||e.name}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(h.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
${u(_.slice(0,3))}
${f?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
${t(s,"editor.default_floor_missing",{id:f})}
</div>`:a}
${d(h.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
${u(_.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";var e={"led.tool":"LED-Streifen","led.hint":"Zeichnen Sie den Streifen Punkt für Punkt entlang der Wandflächen oder über den Boden; er rastet am Raster und an Wandflächen ein. Danach wählen Sie das Gerät, das er darstellt.","led.start":"Klicken, um den Streifen zu beginnen","led.start_touch":"Tippen, um den Streifen zu beginnen","led.points":"Punkte: {n} · Umschalt — 45°-Schritte · Esc — fertig · Strg+Z — Punkt entfernen","led.points_touch":"Punkte: {n} · Werkzeug wechseln zum Beenden","led.finish":"Fertig","led.stopped":"Der Streifen stößt an eine Wand","led.unbound":"Streifen ist mit keinem Gerät verbunden","led.bind":"Gerät verbinden","led.change":"Gerät wechseln","led.later":"Später","led.unbind":"Trennen","led.delete":"Streifen löschen","led.settings":"Geräteeinstellungen","led.type_note":"LED-Streifen: Form im Geräte-Editor auf dem Plan bearbeiten","led.show_strip":"Als LED-Streifen zeigen","led.show_icon":"Als Symbol zeigen","led.cancel":"Abbrechen","led.taken":"Bereits mit einem anderen LED-Streifen verbunden","led.space":"Zuerst den LED-Streifen lösen: Er gehört zu seinem Bereich","led.limit_points":"Höchstens 50 Punkte pro Streifen","led.limit_strips":"Höchstens 50 Streifen pro Bereich","led.pick_title":"Gerät für den Streifen wählen","led.pick_new":"Neues Gerät…","led.pick_empty":"Auf diesem Plan gibt es noch keine freien Geräte.","led.saved":"LED-Streifen gespeichert","led.save_failed":"Streifen nicht gespeichert: {err}","led.history_draw":"LED-Streifen","led.history_shape":"Form des LED-Streifens","led.history_view":"Darstellung des Geräts","led.optimize_walls":"Streifen durch Wände: {n} ({spaces})"};const n="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";export{e as dictionary,n as fingerprint};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";var e={"led.tool":"Ruban LED","led.hint":"Tracez le ruban point par point le long des faces des murs ou sur le sol ; il s’aimante à la grille et aux faces des murs. Ensuite, choisissez l’appareil qu’il représente.","led.start":"Cliquez pour commencer le ruban","led.start_touch":"Touchez pour commencer le ruban","led.points":"Points : {n} · Maj — pas de 45° · Échap — terminer · Ctrl+Z — retirer le point","led.points_touch":"Points : {n} · changez d’outil pour terminer","led.finish":"Terminer","led.stopped":"Le ruban bute contre un mur","led.unbound":"Ruban non lié à un appareil","led.bind":"Lier un appareil","led.change":"Changer d’appareil","led.later":"Plus tard","led.unbind":"Délier","led.delete":"Supprimer le ruban","led.settings":"Réglages de l’appareil","led.type_note":"Ruban LED : modifiez sa forme sur le plan dans l’éditeur d’appareils","led.show_strip":"Afficher en ruban LED","led.show_icon":"Afficher en icône","led.cancel":"Annuler","led.taken":"Déjà lié à un autre ruban LED","led.space":"Détachez d’abord le ruban LED : il appartient à son espace","led.limit_points":"50 points au maximum par ruban","led.limit_strips":"50 rubans au maximum par espace","led.pick_title":"Choisissez l’appareil du ruban","led.pick_new":"Nouvel appareil…","led.pick_empty":"Aucun appareil libre sur ce plan pour l’instant.","led.saved":"Ruban LED enregistré","led.save_failed":"Ruban non enregistré : {err}","led.history_draw":"Ruban LED","led.history_shape":"Forme du ruban LED","led.history_view":"Représentation de l’appareil","led.optimize_walls":"Rubans traversant des murs : {n} ({spaces})"};const a="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";export{e as dictionary,a as fingerprint};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";var e={"led.tool":"LED-лента","led.hint":"Нарисуйте ленту по точкам вдоль граней стен или по полу — она прилипает к сетке и граням. После завершения выберите устройство, которое она показывает.","led.start":"Нажмите, чтобы начать ленту","led.start_touch":"Коснитесь, чтобы начать ленту","led.points":"Точек: {n} · Shift — шаг 45° · Esc — завершить · Ctrl+Z — убрать точку","led.points_touch":"Точек: {n} · смените инструмент, чтобы завершить","led.finish":"Завершить","led.stopped":"Лента упёрлась в стену","led.unbound":"Лента не привязана к устройству","led.bind":"Привязать устройство","led.change":"Сменить устройство","led.later":"Позже","led.unbind":"Отвязать","led.delete":"Удалить ленту","led.settings":"Настройки устройства","led.type_note":"LED-лента: форма редактируется на плане в редакторе устройств","led.show_strip":"Показывать LED-лентой","led.show_icon":"Показывать значком","led.cancel":"Отмена","led.taken":"Уже привязано к другой LED-ленте","led.space":"Сначала отвяжите LED-ленту: она принадлежит своему пространству","led.limit_points":"Не более 50 точек в ленте","led.limit_strips":"Не более 50 лент в пространстве","led.pick_title":"Выберите устройство для ленты","led.pick_new":"Новое устройство…","led.pick_empty":"На этом плане пока нет свободных устройств.","led.saved":"LED-лента сохранена","led.save_failed":"Лента не сохранена: {err}","led.history_draw":"LED-лента","led.history_shape":"Форма LED-ленты","led.history_view":"Вид устройства","led.optimize_walls":"Лент, проходящих сквозь стены: {n} ({spaces})"};const l="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";export{e as dictionary,l as fingerprint};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,47 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";import{eu as e,eC as t,c6 as o,eJ as s,eK as i,ad as n,ee as a,O as r,eL as l,az as d,ef as c,aL as p,eM as u,eN as h,eO as f,A as m,eP as v}from"./houseplan-card-BOdiRDFR.js";import{L as g,v as w,s as $,a as _,b as y,p as k,c as b,d as C,e as P}from"./led-strip-geometry-DaLifxqY.js";const M="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";let F,L=null,U=null;function x(e,t){return L||U||F===e||""===F||(U=(void 0===F?import("./led-strip-field-ObbckCkp.js"):import(new URL(`./led-strip-field-ObbckCkp.js?${Date.now()}`,import.meta.url).href)).then(e=>{e.LED_FIELD_FINGERPRINT===s?L=e:F=""},()=>{F=e}).finally(()=>{U=null})),L||U?.then(()=>L&&t()),L}const D=e=>e.some(e=>e.glow&&"on"===e.state),R=e=>e.map(e=>[e[0],e[1]]),E="#383838";function A(s,i){if(!s)return null;const n=Array.isArray(s.masonryGeometry)?s.masonryGeometry:[],a=[...e(s.masonryGeometry),...s.opaqueBodies],r=[];for(const e of a)for(let t=0;t<e.length;t++){const o=e[t],s=e[(t+1)%e.length];o&&s&&r.push({a:[o[0],o[1]],b:[s[0],s[1]]})}const l=25,d=new Map;r.forEach((e,t)=>{const o=Math.floor(Math.min(e.a[0],e.b[0])/l),s=Math.floor(Math.max(e.a[0],e.b[0])/l),i=Math.floor(Math.min(e.a[1],e.b[1])/l),n=Math.floor(Math.max(e.a[1],e.b[1])/l);for(let e=o;e<=s;e++)for(let o=i;o<=n;o++){const s=`${e},${o}`,i=d.get(s);i?i.push(t):d.set(s,[t])}});const c=e=>{let t=1/0,o=1/0,s=-1/0,i=-1/0;for(const n of e)t=Math.min(t,n[0]),o=Math.min(o,n[1]),s=Math.max(s,n[0]),i=Math.max(i,n[1]);return[t,o,s,i]},p=(e,t)=>e[0]>=t[0]&&e[0]<=t[2]&&e[1]>=t[1]&&e[1]<=t[3],u=n.filter(e=>e?.[0]?.length).map(e=>({box:c(e[0]),poly:e})),h=s.opaqueBodies.map(e=>({box:c(e),body:e}));return{faces:r,inside:e=>u.some(({box:o,poly:s})=>p(e,o)&&t([e[0],e[1]],[s]))||h.some(({box:t,body:s})=>p(e,t)&&o([e[0],e[1]],s)),epsilon:i,near:e=>{const t=new Set;for(let o=Math.floor(e[0]/l);o<=Math.floor(e[2]/l);o++)for(let s=Math.floor(e[1]/l);s<=Math.floor(e[3]/l);s++)for(const e of d.get(`${o},${s}`)||[])t.add(e);return[...t].map(e=>r[e])}}}function I(e){const t=e.candidate?.sourceEid||e.device.primary||"",o=t?e.hass?.states?.[t]?.state:void 0,s="unavailable"===o||"unknown"===o||!!t&&!!e.hass&&!e.hass.states?.[t]&&!e.device.virtual,i=Number(e.device.marker?.glow_radius_cm),n=Number.isFinite(i)&&i>0?i/e.cellCm*e.gridPitch:e.defaultRadius,a=s?null:e.candidate?.appearance??null;return{strip:e.strip,device:e.device,state:s?"unavailable":a?"on":"off",glow:e.glow,appearance:a,radius:n}}const T=new WeakMap;function j(e,t,o){const s=P*o;const i=T.get(e);if(i&&i.faces===t&&i.epsilon===t?.epsilon&&i.value.t===s&&i.points.length===e.points.length&&i.points.every((t,o)=>t[0]===e.points[o][0]&&t[1]===e.points[o][1]))return i.value;const n=R(e.points),a=y(n,t,s/2),r={t:s,path:a,d:k(a)};return T.set(e,{faces:t,epsilon:t?.epsilon,points:n,value:r}),r}function G(e){if(!e.views.length)return d``;const t=e.handlers,o=[...e.views].sort((e,t)=>e.strip.id<t.strip.id?1:e.strip.id>t.strip.id?-1:0),s=e.iso;return d`<g class="led-strips" data-hp-led-strips="${o.length}"
data-hp-iso=${s?"raised":m}>
${s?d`<defs><filter id="hp-led-iso-shadow" filterUnits="userSpaceOnUse"
x="-100000" y="-100000" width="200000" height="200000">
<feGaussianBlur stdDeviation="${s.shadow.sigma}"></feGaussianBlur></filter></defs>`:m}
<style>
.led-strip .led-focus { fill: none; stroke: transparent; }
.led-strip:has(.led-hit:focus-visible) .led-focus { stroke: var(--primary-color, #03a9f4); }
.led-strip .led-hit { fill: none; stroke: transparent; cursor: pointer; outline: none; }
</style>
${v(o,e=>e.strip.id,o=>{const{t:i,path:n,d:a}=j(o.strip,e.faces,e.d),r="unavailable"===o.state,l=r?"#9e9e9e":"on"===o.state&&!o.glow&&o.appearance?o.appearance.c:"#FFFFFF",c=2*b(i*e.perUnit)/(e.perUnit||1),p=o.device,u=t=>function(e,t,o){const s=e.currentTarget,i=s?.getScreenCTM?.();if(!i||o.views.length<2)return t;const n=i.inverse(),a=[n.a*e.clientX+n.c*e.clientY+n.e,n.b*e.clientX+n.d*e.clientY+n.f],r=o.views.map(e=>{const{t:t,path:s}=j(e.strip,o.faces,o.d);return{id:e.strip.id,points:s.points.map(e=>[e[0]*o.perUnit,e[1]*o.perUnit]),closed:s.closed,thicknessPx:t*o.perUnit}}),l=C([a[0]*o.perUnit,a[1]*o.perUnit],r);return o.views.find(e=>e.strip.id===l)||t}(t,o,e).device,h=s?`translate(0 ${-s.lift})`:m;return d`<g class="led-strip state-${o.state}" data-led-strip="${o.strip.id}"
data-marker="${p.id}" data-state="${o.state}" data-closed="${n.closed?"true":"false"}">
${s?d`<path class="led-iso-shadow" d="${a}" fill="none" stroke="black"
stroke-opacity="${s.shadow.opacity}" stroke-width="${i}" stroke-linecap="round"
stroke-linejoin="round" filter="url(#hp-led-iso-shadow)" pointer-events="none"
transform="translate(${s.shadow.dx} ${s.shadow.dy-s.lift})"></path>
${[1,.5].map(e=>d`<path class="led-iso-edge" d="${a}" fill="none" stroke="${s.edge}"
stroke-width="${i}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"
transform="translate(0 ${-s.lift+s.depth*e})"></path>`)}`:m}
<g transform=${h}>
<path class="led-outline" d="${a}" fill="none" stroke="${E}" stroke-width="${i}"
stroke-linecap="round" stroke-linejoin="round"
stroke-dasharray=${r?`${2*i} ${1.5*i}`:m}></path>
<path class="led-core" d="${a}" fill="none" stroke="${l}" stroke-width="${i/2}"
stroke-linecap="round" stroke-linejoin="round"
stroke-dasharray=${r?`${2*i} ${1.5*i}`:m}></path>
<path class="led-focus" d="${a}" stroke-width="${1.6*i}" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true"></path>
${t?d`<path class="led-hit" d="${a}" stroke-width="${c}"
stroke-linecap="round" stroke-linejoin="round" pointer-events="stroke"
role="button" tabindex="0" aria-label="${t.label(p)}"
data-hp="device" data-id="${p.id}"
@click=${e=>t.click(e,u(e))}
@keydown=${e=>t.keydown(e,p)}
@contextmenu=${e=>t.contextmenu(e,u(e))}
@pointerdown=${e=>t.pointerdown(e,u(e))}
@pointermove=${e=>t.pointermove(e,u(e))}
@pointerup=${e=>t.pointerup(e,u(e))}
@pointercancel=${e=>t.pointercancel(e,p)}
@lostpointercapture=${e=>t.pointercancel(e,p)}
@pointerover=${e=>t.pointerover(e,u(e))}
@pointerleave=${()=>t.pointerleave()}
@focus=${e=>t.focus(e,p)}
@blur=${()=>t.blur(p)}></path>`:m}
</g>
</g>`})}
</g>`}function H(e,t,o,s){return(t?e.find(e=>e.id===t):void 0)??(o?e.find(e=>s(o,e)):void 0)}function N(e){const t=new Map(e.devices.map(e=>[e.id,e])),o=g/e.cellCm*e.gridPitch,s=new Map(i({hass:e.hass,devices:e.devices,virtualLights:e.virtualLights,spaceId:e.space.id,defaultColor:e.defaultColor,paletteAlpha:e.paletteAlpha,defaultRadiusUnits:o,cellCm:e.cellCm,gridPitch:e.gridPitch,position:()=>({x:0,y:0})}).map(e=>[e.key,e])),r=[];for(const i of e.space.led_strips||[]){if(!1===i?.active||!i.marker||!w(i.points))continue;const a={...i,points:i.points.map(e=>[e[0]*n,e[1]*n])},l=t.get(a.marker);if(!l||l.space!==e.space.id)continue;if(l.hidden&&!e.showHidden)continue;const d=H(e.space.rooms,l.marker?.room_id,$(R(a.points)),e.inRoom);r.push(I({strip:a,device:l,candidate:s.get(`${e.space.id}|${l.id}`)??null,hass:e.hass,glow:!!d&&e.glowFor(d),defaultRadius:o,cellCm:e.cellCm,gridPitch:e.gridPitch}))}const l=_/e.cellCm*e.gridPitch;return{views:r,faces:A(e.scene,l),d:e.iconPct/100*a(e.space),scene:e.scene,polygons:e.polygons}}const q=new WeakMap;function B(e){for(const t of q.get(e)?.frame.views??[])T.delete(t.strip);q.delete(e),L?.releaseLedField(e)}function S(e){return{shapes:q.get(e)?.frame.views.length??0,...L?.ledFieldStats(e)??{visibility:0,sources:0,visibilityPaths:0,pathChars:0,recomputes:0}}}function O(e,t,o){const s=t.rooms.flatMap(e=>{const t=r(e);return t?[{room:e,poly:t}]:[]}),i=s.length?e._lightBarriers(t,s.map(({room:e,poly:t})=>({r:e,poly:t}))):null,n=[t,e._renderDevices,e._renderPlanHass,o,i,e._mode,e._showAll],a=q.get(e);if(a&&a.key.every((e,t)=>e===n[t]))return a.frame;for(const e of a?.frame.views??[])T.delete(e.strip);const d=e._config?.icon_size??2.5,c=N({space:t,devices:e._renderDevices,hass:e._renderPlanHass,virtualLights:e._virtualLights,defaultColor:e._fillColors.glow_light.c,paletteAlpha:e._fillColors.glow_light.a,cellCm:e._cellCm,gridPitch:e._gridPitch,iconPct:d>8?2.5:d,scene:i,polygons:s,glowFor:e=>l(o,e),inRoom:(t,o)=>e._pointInRoom(t,o),showHidden:"devices"===e._mode&&e._showAll});return!1!==e.isConnected&&q.set(e,{key:n,frame:c}),c}function W(e,t,o,s){const i=O(e,t,o),n=e._stageEl?.clientWidth,a="iso"===e._renderProjection&&"view"===e._mode,r=a?i.d*c:i.d,l="theme-dark"===p(e._renderPlanHass)?"dark":"light",m=!!e._isoLightFloors?.size,v=u(l,m),g=G({views:i.views,d:r,iso:a?{lift:f.lift*r,depth:f.depth*r,edge:h(E,l,m),shadow:{dx:v.dx*r,dy:v.dy*r,sigma:v.sigma*r,opacity:v.opacityWhite}}:null,faces:i.faces,perUnit:n&&s.w?n/s.w:1,handlers:"view"===e._mode?{click:(t,o)=>e._clickDevice(t,o),keydown:(t,o)=>e._keyDevice(t,o),contextmenu:(t,o)=>e._ctxDevice(t,o),pointerdown:(t,o)=>e._pointerDown(t,o),pointermove:(t,o)=>{const s=e._pointerMove(t,o);s&&e._showDeviceTip(t,s)},pointerup:(t,o)=>e._pointerUp(t,o),pointercancel:(t,o)=>e._pointerCancel(t,o),pointerover:(t,o)=>e._showDeviceTip(t,o),pointerleave:()=>e._clearPointerHover(),focus:(t,o)=>e._showDeviceFocusTip(t,o),blur:t=>e._hideDeviceFocusTip(t.id),label:e=>e.name}:null});return"plan"===e._mode||"decor"===e._mode?d`<g class="led-passive" opacity="0.45" pointer-events="none">${g}</g>`:g}function z(e,t,o){if(!1===e.isConnected)return d``;const s=O(e,t,o),i=s.scene&&(D(s.views)||L?.hasLedField(e))?L||x(t.id,()=>!1!==e.isConnected&&e.requestUpdate()):null;return i?i.renderLedField({views:s.views,scene:s.scene,polygons:s.polygons,faces:s.faces,spaceId:t.id,owner:e,requestUpdate:()=>e.requestUpdate(),isConnected:()=>!1!==e.isConnected}):d``}function Y(e){const t=e.space.rooms.flatMap(e=>{const t=r(e);return t?[{room:e,poly:t}]:[]}),o=N({space:e.space,devices:e.devices,hass:e.hass,virtualLights:e.virtualLights,defaultColor:e.defaultColor,paletteAlpha:e.paletteAlpha,cellCm:e.cellCm,gridPitch:e.gridPitch,iconPct:e.iconPct,scene:e.scene,polygons:t,glowFor:e.glowFor,inRoom:e.inRoom,showHidden:!1}),s=e.live?o.views:o.views.map(e=>({...e,state:"off",appearance:null,glow:!1})),i=A({masonryGeometry:e.bodies.masonryGeometry,opaqueBodies:e.bodies.opaqueBodies},_/e.cellCm*e.gridPitch),n=e.scene&&e.live&&(D(s)||L?.hasLedField(e.owner))?x(e.space.id,e.ready):null;return d`${n?n.renderLedField({views:s,scene:e.scene,polygons:t,faces:i,spaceId:e.space.id,owner:e.owner,requestUpdate:e.ready,isConnected:()=>!0}):m}${G({views:s,d:o.d,faces:i,perUnit:e.perUnit,handlers:null})}`}export{M as LED_RUNTIME_FINGERPRINT,A as faceContext,x as ledField,N as ledFrame,O as ledFrameFor,S as ledStats,I as ledStripView,j as ledStripePath,B as releaseLed,z as renderLedFieldFor,W as renderLedLayerFor,G as renderLedStripes,Y as renderStaticLed,H as stripRoom};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";import{eR as e,eS as a,eG as n}from"./houseplan-card-BOdiRDFR.js";function o(n,o){return new e([{code:"en",dictionary:n},{code:"ru",loadDictionary:o.ru},{code:"de",loadDictionary:o.de},{code:"fr",loadDictionary:o.fr}],"ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172",console.warn,a)}function r(e){return function(e,a){return{state:n=>{const o=e.state(n);return"pending"!==o&&a.some(e=>"pending"===e.state(n))?"pending":o},dictionary:a=>e.dictionary(a),ensure:n=>Promise.all([e,...a].map(e=>e.ensure(n))).then(()=>{})}}(n,e)}export{o as n,r as s};
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1e24262d325c178a2dcff7f9ded0cdbe6cca8fc53344f2ce137be568178fb802";import{eE as e,eF as n,eB as c}from"./houseplan-card-EmxAmZMt.js";function a(c,a){return new e([{code:"en",dictionary:c},{code:"ru",loadDictionary:a.ru},{code:"de",loadDictionary:a.de},{code:"fr",loadDictionary:a.fr}],"1e24262d325c178a2dcff7f9ded0cdbe6cca8fc53344f2ce137be568178fb802",console.warn,n)}function d(e){return function(e,n){return{state:c=>{const a=e.state(c);return"pending"!==a&&n.some(e=>"pending"===e.state(c))?"pending":a},dictionary:n=>e.dictionary(n),ensure:c=>Promise.all([e,...n].map(e=>e.ensure(c))).then(()=>{})}}(c,e)}export{a as n,d as s};
@@ -0,0 +1,7 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";import{aG as e,l as t,aJ as s,A as a,eF as o,eG as i,eH as n,E as l,eQ as c,c as r}from"./houseplan-card-BOdiRDFR.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id}))}catch{this._spaces=[]}finally{this._spacesLoading=!1}}}get _lang(){return t(this.hass,this._config?.language)}get _schema(){const e=this._spaces||[];return[e.length?{name:"space",selector:{select:{mode:"dropdown",options:e}}}:{name:"space",selector:{text:{}}},{name:"title",selector:{text:{}}},{name:"fit",selector:{select:{mode:"dropdown",options:[{value:"content",label:s(this._lang,"editor.fit_content")},{value:"house",label:s(this._lang,"editor.fit_house")}]}}},{name:"show_button",selector:{boolean:{}}},{name:"button_label",selector:{text:{}}},{name:"button_target",selector:{text:{}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"light_pools",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}}]}render(){if(!this.hass||!this._config)return a;const e=o(this,i,t(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return l;this._loadSpaces();const h=this._lang,_={space:s(h,"editor.space"),title:s(h,"editor.title"),fit:s(h,"editor.framing"),show_button:s(h,"editor.show_button"),button_label:s(h,"editor.button_label"),button_target:s(h,"editor.button_target"),icon_size:s(h,"editor.icon_size"),show_temperature:s(h,"editor.show_temperature"),live_states:s(h,"editor.live_states"),light_pools:s(h,"editor.light_pools"),show_signal:s(h,"editor.show_signal")};return r`<ha-form
.hass=${this.hass}
.data=${{...this._config,fit:c(this._config.fit)}}
.schema=${this._schema}
.computeLabel=${e=>_[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`}_valueChanged(e){const t={...this._config||{},...e.detail.value};delete t.aspect_ratio;const s=new Event("config-changed",{bubbles:!0,composed:!0});s.detail={config:t},this.dispatchEvent(s)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-space-card-editor")||customElements.define("houseplan-space-card-editor",h);
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1e24262d325c178a2dcff7f9ded0cdbe6cca8fc53344f2ce137be568178fb802";import{c as a,A as e}from"./houseplan-card-EmxAmZMt.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-Cz5dApFg.js";import"./form-kit.styles-BFqEQFqK.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";import{c as a,A as e}from"./houseplan-card-BOdiRDFR.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-CcLP5B5v.js";import"./form-kit.styles-B6HsmMPA.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
<span class="summary-switch-caption"><strong>${b("summary.show_local")}</strong>
<small id="summary-local-show-hint">${b("summary.show_local_hint")}</small></span>
<input id="summary-local-show" data-summary-local-show type="checkbox"
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1e24262d325c178a2dcff7f9ded0cdbe6cca8fc53344f2ce137be568178fb802";import{eH as e,N as t,b2 as n,L as i,ae as o,ac as r,ca as s,eI as a,c9 as c,b1 as l,b0 as d,b3 as u,eJ as f,eK as m,cc as g,eL as v}from"./houseplan-card-EmxAmZMt.js";function y(e){if(!e.registry.authoritative)return null;const t=new Set,n=u(e.markers);for(const i of Object.values(e.registry.devices||{}))i?.id&&"service"!==i.entry_type&&!n.devices.has(i.id)&&i.area_id&&e.areaToSpace[i.area_id]&&t.add(i.id);for(const i of e.markers||[]){if(i.removed||"virtual"===i.binding)continue;const o=String(i.binding||"").indexOf(":");if(o<1)continue;const r=i.binding.slice(0,o),s=i.binding.slice(o+1),a="device"===r?e.registry.devices?.[s]:null,c="entity"===r?e.registry.entities?.[s]:null,l="device"===r?s:c?.device_id,d="device"===r?a?.area_id:c?.area_id||l&&e.registry.devices?.[l]?.area_id,u="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||d||"",f=u&&e.areaToSpace[u]||i.space||e.firstSpaceId;l&&e.registry.devices?.[l]&&e.spaceIds.has(f)&&(!n.devices.has(l)||"entity"===r&&n.liveEntities.has(s))&&t.add(l)}return t}function _(e,t){return e?.length?t?.length?v(e,t):e:t}function h(e,t){const n=l(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,o,r);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:d(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*p(l,d,u=h){try{let f=0;for(const m of d){const d=l.spaces.find(e=>String(e?.id)===m.id);if(!d)continue;const g=e(d,m),v=u(m,g);let y=null;for(const e of m.rooms){if(!e.id)continue;const a=t(e);if(!a)continue;const c=n(m.rooms,e.id,g.walls,g.openCuts,i,g.cellCm,o,r,v.roomGeom,v.multiWallNodes)||a;y=_(y,s(c,g.physicalBodies)),yield}const h=a(y||[],m.stairs);let p=h.next();for(;!p.done;)yield,p=h.next();y=p.value;const b=g.cellCm/o;f+=c(y)*b*b/1e4}return f}catch{return null}}function b(e,t,n=h){const i=p(e,t,n);let o=i.next();for(;!o.done;)o=i.next();return o.value}function S(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:g(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function C(e,t){const n=e?.states?.[t];if(!n)return null;const i=f(e,t);return i?m(i,String(n.attributes?.unit_of_measurement||"")):null}export{p as cleanFloorAreaSteps,y as representedHaDeviceIds,h as spaceWallGeometry,C as summaryEntityValue,S as summarySystemValue,b as totalCleanFloorAreaM2};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";import{e$ as e,O as t,b5 as n,M as i,af as o,ad as r,cf as s,f0 as a,ce as l,b4 as c,b3 as u,b6 as d,f1 as f,f2 as m,ch as g,f3 as v}from"./houseplan-card-BOdiRDFR.js";function y(e){if(!e.registry.authoritative)return null;const t=new Set,n=d(e.markers);for(const i of Object.values(e.registry.devices||{}))i?.id&&"service"!==i.entry_type&&!n.devices.has(i.id)&&i.area_id&&e.areaToSpace[i.area_id]&&t.add(i.id);for(const i of e.markers||[]){if(i.removed||"virtual"===i.binding)continue;const o=String(i.binding||"").indexOf(":");if(o<1)continue;const r=i.binding.slice(0,o),s=i.binding.slice(o+1),a="device"===r?e.registry.devices?.[s]:null,l="entity"===r?e.registry.entities?.[s]:null,c="device"===r?s:l?.device_id,u="device"===r?a?.area_id:l?.area_id||c&&e.registry.devices?.[c]?.area_id,d="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||u||"",f=d&&e.areaToSpace[d]||i.space||e.firstSpaceId;c&&e.registry.devices?.[c]&&e.spaceIds.has(f)&&(!n.devices.has(c)||"entity"===r&&n.liveEntities.has(s))&&t.add(c)}return t}function _(e,t){return e?.length?t?.length?v(e,t):e:t}function h(e,t){const n=c(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,o,r);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:u(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*p(c,u,d=h){try{let f=0;for(const m of u){const u=c.spaces.find(e=>String(e?.id)===m.id);if(!u)continue;const g=e(u,m),v=d(m,g);let y=null;for(const e of m.rooms){if(!e.id)continue;const a=t(e);if(!a)continue;const l=n(m.rooms,e.id,g.walls,g.openCuts,i,g.cellCm,o,r,v.roomGeom,v.multiWallNodes)||a;y=_(y,s(l,g.physicalBodies)),yield}const h=a(y||[],m.stairs);let p=h.next();for(;!p.done;)yield,p=h.next();y=p.value;const b=g.cellCm/o;f+=l(y)*b*b/1e4}return f}catch{return null}}function b(e,t,n=h){const i=p(e,t,n);let o=i.next();for(;!o.done;)o=i.next();return o.value}function S(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:g(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function C(e,t){const n=e?.states?.[t];if(!n)return null;const i=f(e,t);return i?m(i,String(n.attributes?.unit_of_measurement||"")):null}export{p as cleanFloorAreaSteps,y as representedHaDeviceIds,h as spaceWallGeometry,C as summaryEntityValue,S as summarySystemValue,b as totalCleanFloorAreaM2};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";var e={"furn.title":"Möbelbibliothek","furn.symbol":"Symbol","furn.group_furniture":"Möbel","furn.group_appliance":"Geräte","furn.group_sanitary":"Sanitär","furn.group_other":"Sonstiges","furn.width":"Breite","furn.depth":"Tiefe","furn.back_to_categories":"Alle Kategorien","furn.pick_hint":"Wählen Sie eine Kategorie und dann ein Symbol.","furn.place_hint":"Klicken Sie auf den Plan — das Element wird an der nächsten Wand platziert. Mit Shift wird es frei platziert.","furn.cat_air_conditioner":"Klimaanlagen","furn.cat_armchair":"Sessel","furn.cat_bathtub":"Badewannen","furn.cat_bed":"Betten","furn.cat_bidet":"Bidets","furn.cat_boiler":"Warmwasserbereiter","furn.cat_chair":"Stühle","furn.cat_coffee_table":"Couchtische","furn.cat_computer":"Computer","furn.cat_cooktop":"Kochfelder","furn.cat_dining_table":"Esstische","furn.cat_dishwasher":"Geschirrspüler","furn.cat_dryer":"Wäschetrockner","furn.cat_exercise":"Fitnessgerät","furn.cat_fireplace":"Kamine","furn.cat_fridge":"Kühlschränke","furn.cat_hood":"Dunstabzugshaube","furn.cat_kitchen_cabinet":"Küchenschränke","furn.cat_kitchen_sink":"Küchenspülen","furn.cat_nightstand":"Schränke","furn.cat_oven":"Backofen","furn.cat_plant":"Pflanzen","furn.cat_rug":"Teppiche","furn.cat_shelving":"Regale","furn.cat_shower":"Duschen","furn.cat_sink":"Waschbecken","furn.cat_sofa":"Sofas","furn.cat_stairs":"Treppen","furn.cat_toilet":"Toiletten","furn.cat_tv":"Fernseher","furn.cat_wardrobe":"Kleiderschränke","furn.cat_washer":"Waschmaschinen","furn.cat_work_table":"Schreibtische","furn.sym_exercise":"Fitnessgerät","furn.sym_computer":"Computer","furn.sym_hood":"Dunstabzugshaube","furn.sym_oven":"Backofen","furn.sym_sofa":"Zweisitzer-Sofa","furn.sym_sofa_three_seat":"Dreisitzer-Sofa","furn.sym_sofa_corner_right":"Ecksofa rechts","furn.sym_armchair":"Polstersessel","furn.sym_armchair_office":"Bürostuhl","furn.sym_coffee_table":"Rechteckiger Couchtisch","furn.sym_coffee_table_round":"Runder Couchtisch","furn.sym_coffee_table_oval":"Ovaler Couchtisch","furn.sym_coffee_table_rounded":"Abgerundeter Couchtisch","furn.sym_table_dining":"Rechteckiger Esstisch","furn.sym_table_round":"Rundtisch","furn.sym_table_dining_oval":"Ovaler Esstisch","furn.sym_table_dining_rounded":"Abgerundeter Esstisch","furn.sym_chair":"Stuhl","furn.sym_chair_bar":"Barhocker","furn.sym_desk":"Rechteckiger Schreibtisch","furn.sym_desk_corner":"Eckschreibtisch","furn.sym_bed_double":"Doppelbett","furn.sym_bed_single":"Einzelbett","furn.sym_nightstand":"Nachttisch","furn.sym_cabinet_tv":"TV-Schrank","furn.sym_cabinet_shoe":"Schuhschrank","furn.sym_cabinet_sink":"Waschtischunterschrank","furn.sym_wardrobe":"Kleiderschrank","furn.sym_bookshelf":"Bücherschrank","furn.sym_wall_unit":"Schrankwand","furn.sym_kitchen_floor":"Küchen-Unterschrank","furn.sym_kitchen_floor_corner":"Küchen-Eckunterschrank","furn.sym_kitchen_wall":"Küchen-Hängeschrank","furn.sym_kitchen_wall_corner":"Küchen-Eckhängeschrank","furn.sym_shelf_floor":"Standregal","furn.sym_shelf_wall":"Wandregal","furn.sym_fridge":"Kühlschrank","furn.sym_stove":"Vierflammiges Kochfeld","furn.sym_cooktop_two":"Zweiflammiges Kochfeld","furn.sym_dishwasher":"Geschirrspüler","furn.sym_washer":"Waschmaschine","furn.sym_dryer":"Wäschetrockner","furn.sym_tv":"Fernseher mit Standfuß","furn.sym_tv_wall":"Wandfernseher","furn.sym_ac":"Klimaanlage","furn.sym_water_heater":"Wassererhitzer","furn.sym_toilet":"Stand-WC","furn.sym_toilet_built_in":"Wand-WC","furn.sym_bathtub":"Rechteckige Badewanne","furn.sym_bathtub_corner":"Eckbadewanne","furn.sym_shower":"Dusche","furn.sym_sink":"Waschbecken","furn.sym_kitchen_sink":"Einzel-Küchenspüle","furn.sym_kitchen_sink_double":"Doppel-Küchenspüle","furn.sym_bidet":"Stand-Bidet","furn.sym_bidet_built_in":"Wand-Bidet","furn.sym_stairs":"Treppe","furn.sym_fireplace":"Kamin","furn.sym_plant":"Pflanze","furn.sym_rug":"Teppich"};const r="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";export{e as dictionary,r as fingerprint};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";var e={"furn.title":"Bibliothèque de mobilier","furn.symbol":"Symbole","furn.group_furniture":"Mobilier","furn.group_appliance":"Électroménager","furn.group_sanitary":"Sanitaires","furn.group_other":"Autre","furn.width":"Largeur","furn.depth":"Profondeur","furn.back_to_categories":"Toutes les catégories","furn.pick_hint":"Choisissez un symbole, puis cliquez sur le plan.","furn.place_hint":"Cliquez sur le plan — le meuble se place contre le mur le plus proche. Maj permet un placement libre.","furn.cat_air_conditioner":"Climatiseurs","furn.cat_armchair":"Fauteuils","furn.cat_bathtub":"Baignoires","furn.cat_bed":"Lits","furn.cat_bidet":"Bidets","furn.cat_boiler":"Chauffe-eau","furn.cat_chair":"Chaises","furn.cat_coffee_table":"Tables basses","furn.cat_computer":"Ordinateur","furn.cat_cooktop":"Plaques de cuisson","furn.cat_dining_table":"Tables à manger","furn.cat_dishwasher":"Lave-vaisselle","furn.cat_dryer":"Sèche-linge","furn.cat_exercise":"Équipement de sport","furn.cat_fireplace":"Cheminées","furn.cat_fridge":"Réfrigérateurs","furn.cat_hood":"Hotte","furn.cat_kitchen_cabinet":"Meubles de cuisine","furn.cat_kitchen_sink":"Éviers de cuisine","furn.cat_nightstand":"Meubles","furn.cat_oven":"Four","furn.cat_plant":"Plantes","furn.cat_rug":"Tapis","furn.cat_shelving":"Étagères","furn.cat_shower":"Douches","furn.cat_sink":"Lavabos","furn.cat_sofa":"Canapés","furn.cat_stairs":"Escaliers","furn.cat_toilet":"Toilettes","furn.cat_tv":"Téléviseurs","furn.cat_wardrobe":"Armoires","furn.cat_washer":"Lave-linge","furn.cat_work_table":"Bureaux","furn.sym_exercise":"Équipement de sport","furn.sym_computer":"Ordinateur","furn.sym_hood":"Hotte","furn.sym_oven":"Four","furn.sym_sofa":"Canapé","furn.sym_sofa_three_seat":"Canapé trois places","furn.sym_sofa_corner_right":"Canapé d'angle droit","furn.sym_armchair":"Fauteuil","furn.sym_armchair_office":"Chaise de bureau","furn.sym_coffee_table":"Table basse","furn.sym_coffee_table_round":"Table basse ronde","furn.sym_coffee_table_oval":"Table basse ovale","furn.sym_coffee_table_rounded":"Table basse arrondie","furn.sym_table_dining":"Table à manger","furn.sym_table_round":"Table ronde","furn.sym_table_dining_oval":"Table à manger ovale","furn.sym_table_dining_rounded":"Table à manger arrondie","furn.sym_chair":"Chaise","furn.sym_chair_bar":"Tabouret de bar","furn.sym_desk":"Bureau","furn.sym_desk_corner":"Bureau d'angle","furn.sym_bed_double":"Lit double","furn.sym_bed_single":"Lit simple","furn.sym_nightstand":"Table de chevet","furn.sym_cabinet_tv":"Meuble TV","furn.sym_cabinet_shoe":"Meuble à chaussures","furn.sym_cabinet_sink":"Meuble sous lavabo","furn.sym_wardrobe":"Armoire","furn.sym_bookshelf":"Bibliothèque","furn.sym_wall_unit":"Élément mural","furn.sym_kitchen_floor":"Module de cuisine bas","furn.sym_kitchen_floor_corner":"Module de cuisine bas d'angle","furn.sym_kitchen_wall":"Module de cuisine haut","furn.sym_kitchen_wall_corner":"Module de cuisine haut d'angle","furn.sym_shelf_floor":"Étagère au sol","furn.sym_shelf_wall":"Étagère murale","furn.sym_fridge":"Réfrigérateur","furn.sym_stove":"Cuisinière","furn.sym_cooktop_two":"Plaque à deux feux","furn.sym_dishwasher":"Lave-vaisselle","furn.sym_washer":"Lave-linge","furn.sym_dryer":"Sèche-linge","furn.sym_tv":"Téléviseur","furn.sym_tv_wall":"Téléviseur mural","furn.sym_ac":"Climatiseur","furn.sym_water_heater":"Chauffe-eau","furn.sym_toilet":"Toilettes","furn.sym_toilet_built_in":"Toilettes suspendues","furn.sym_bathtub":"Baignoire","furn.sym_bathtub_corner":"Baignoire d'angle","furn.sym_shower":"Douche","furn.sym_sink":"Lavabo","furn.sym_kitchen_sink":"Évier de cuisine","furn.sym_kitchen_sink_double":"Évier de cuisine double","furn.sym_bidet":"Bidet","furn.sym_bidet_built_in":"Bidet suspendu","furn.sym_stairs":"Escalier","furn.sym_fireplace":"Cheminée","furn.sym_plant":"Plante","furn.sym_rug":"Tapis"};const r="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";export{e as dictionary,r as fingerprint};
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1e24262d325c178a2dcff7f9ded0cdbe6cca8fc53344f2ce137be568178fb802";var e={title:"Zigbee-Verbindungen",toggle:"Zigbee-Verbindungen beim Darüberfahren über ein Gerät anzeigen",hint:"Es werden nur beobachtete direkte Nachbarn angezeigt. Beim Darüberfahren werden keine Daten abgerufen.",help:"Zeigt, mit welchen Zigbee-Geräten jedes Gerät direkt verbunden ist. Die Linienfarbe ist die Verbindungsqualität (LQI) auf derselben Skala wie der LQI-Wert des Geräts: rot bei 40 und darunter, grün bei 180 und darüber. Eine gestrichelte Linie bedeutet, dass die Qualität nicht gemeldet wurde. Ein Pfeil zeigt auf das nächste Gerät auf dem Weg zum Koordinator: ein Pfeil führt hinaus, eingehende Pfeile sind die Geräte, die über dieses Gerät routen. Eine Linie ohne Pfeil ist ein Ersatznachbar. Das ist der Routenbaum, den House Plan bildet, nicht der Weg eines Pakets in diesem Moment: eine Beschriftung am Pfeilende bedeutet, dass das Ziel nicht auf diesem Plan liegt, und ein fehlender Pfeil, dass die Route unbekannt ist. Die Daten stammen aus dem letzten Laden und veralten; Verbindungen sind nur mit der Maus sichtbar.",help_aria:"Hilfe: Zigbee-Verbindungen",admin_only:"Topologiedaten sind nur für Home-Assistant-Administratoren verfügbar.",save_first:"Speichere diese Einstellung, bevor Topologiedaten geladen werden.",zha:"ZHA",zha_read:"ZHA-Daten lesen",zha_hint:"Liest den ZHA-Topologiecache; es wird kein Funkscan gestartet.",z2m:"Zigbee2MQTT",z2m_topics:"Basistopics (eines pro Zeile)",z2m_update:"Karte aktualisieren",z2m_warning:"Ein Netzwerkscan kann 10 Sekunden bis 2 Minuten dauern und Zigbee vorübergehend verlangsamen.",status_idle:"Daten nicht geladen",status_loading:"Wird geladen…",status_ready:"Empfangen: {time}",status_stale:"Empfangen: {time} · veraltet",status_partial:"Empfangen: {time} · einige Knoten fehlen",status_no_links:"Empfangen: {time} · keine beobachteten Verbindungen gefunden",error_permission:"Administratorrechte sind erforderlich.",error_unsupported:"Dieser Anbieter oder die Home-Assistant-API ist nicht verfügbar.",error_timeout:"Der Anbieter hat nicht rechtzeitig geantwortet.",error_invalid_topic:"Prüfe das Zigbee2MQTT-Basistopic.",error_invalid_payload:"Der Anbieter hat nicht unterstützte Topologiedaten geliefert.",error_provider:"Topologiedaten konnten nicht geladen werden.",remote_count:"+{n} in anderen Bereichen",route_device_not_on_plan:"Gerät ist nicht im Plan",route_coordinator_not_on_plan:"Koordinator ist nicht im Plan",route_other_space:"anderer Bereich"};const n="1e24262d325c178a2dcff7f9ded0cdbe6cca8fc53344f2ce137be568178fb802";export{e as dictionary,n as fingerprint};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";var e={title:"Zigbee-Verbindungen",toggle:"Zigbee-Verbindungen beim Darüberfahren über ein Gerät anzeigen",hint:"Es werden nur beobachtete direkte Nachbarn angezeigt. Beim Darüberfahren werden keine Daten abgerufen.",help:"Zeigt, mit welchen Zigbee-Geräten jedes Gerät direkt verbunden ist. Die Linienfarbe ist die Verbindungsqualität (LQI) auf derselben Skala wie der LQI-Wert des Geräts: rot bei 40 und darunter, grün bei 180 und darüber. Eine gestrichelte Linie bedeutet, dass die Qualität nicht gemeldet wurde. Ein Pfeil zeigt auf das nächste Gerät auf dem Weg zum Koordinator: ein Pfeil führt hinaus, eingehende Pfeile sind die Geräte, die über dieses Gerät routen. Eine Linie ohne Pfeil ist ein Ersatznachbar. Das ist der Routenbaum, den House Plan bildet, nicht der Weg eines Pakets in diesem Moment: eine Beschriftung am Pfeilende bedeutet, dass das Ziel nicht auf diesem Plan liegt, und ein fehlender Pfeil, dass die Route unbekannt ist. Die Daten stammen aus dem letzten Laden und veralten; Verbindungen sind nur mit der Maus sichtbar.",help_aria:"Hilfe: Zigbee-Verbindungen",admin_only:"Topologiedaten sind nur für Home-Assistant-Administratoren verfügbar.",save_first:"Speichere diese Einstellung, bevor Topologiedaten geladen werden.",zha:"ZHA",zha_read:"ZHA-Daten lesen",zha_hint:"Liest den ZHA-Topologiecache; es wird kein Funkscan gestartet.",z2m:"Zigbee2MQTT",z2m_topics:"Basistopics (eines pro Zeile)",z2m_update:"Karte aktualisieren",z2m_warning:"Ein Netzwerkscan kann 10 Sekunden bis 2 Minuten dauern und Zigbee vorübergehend verlangsamen.",status_idle:"Daten nicht geladen",status_loading:"Wird geladen…",status_ready:"Empfangen: {time}",status_stale:"Empfangen: {time} · veraltet",status_partial:"Empfangen: {time} · einige Knoten fehlen",status_no_links:"Empfangen: {time} · keine beobachteten Verbindungen gefunden",error_permission:"Administratorrechte sind erforderlich.",error_unsupported:"Dieser Anbieter oder die Home-Assistant-API ist nicht verfügbar.",error_timeout:"Der Anbieter hat nicht rechtzeitig geantwortet.",error_invalid_topic:"Prüfe das Zigbee2MQTT-Basistopic.",error_invalid_payload:"Der Anbieter hat nicht unterstützte Topologiedaten geliefert.",error_provider:"Topologiedaten konnten nicht geladen werden.",remote_count:"+{n} in anderen Bereichen",route_device_not_on_plan:"Gerät ist nicht im Plan",route_coordinator_not_on_plan:"Koordinator ist nicht im Plan",route_other_space:"anderer Bereich"};const n="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";export{e as dictionary,n as fingerprint};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1e24262d325c178a2dcff7f9ded0cdbe6cca8fc53344f2ce137be568178fb802";var e={title:"Liens Zigbee",toggle:"Afficher les liens Zigbee au survol d’un appareil",hint:"Seuls les voisins directs observés sont affichés. Le survol ne lance aucune requête.",help:"Montre avec quels appareils Zigbee chaque appareil communique directement. La couleur de la ligne est la qualité du lien (LQI) sur la même échelle que l'indicateur LQI de l'appareil : rouge à 40 et en dessous, vert à 180 et au-dessus. Une ligne pointillée signifie que la qualité n'a pas été communiquée. Une flèche pointe vers l'appareil suivant sur le chemin du coordinateur : une flèche part, les flèches entrantes sont les appareils qui passent par celui-ci. Une ligne sans flèche est un voisin de secours. C'est l'arbre de routes que construit House Plan, pas le trajet d'un paquet à cet instant : une étiquette au bout d'une flèche signifie que sa cible n'est pas sur ce plan, et l'absence de flèche que la route est inconnue. Les données proviennent du dernier chargement et vieillissent ; les liens ne sont visibles qu'à la souris.",help_aria:"Aide : liens Zigbee",admin_only:"Les données de topologie sont réservées aux administrateurs Home Assistant.",save_first:"Enregistrez ce réglage avant de charger les données de topologie.",zha:"ZHA",zha_read:"Lire les données ZHA",zha_hint:"Lit la topologie en cache de ZHA sans lancer d’analyse radio.",z2m:"Zigbee2MQTT",z2m_topics:"Topics de base (un par ligne)",z2m_update:"Actualiser la carte",z2m_warning:"L’analyse de la carte réseau peut durer de 10 secondes à 2 minutes et réduire temporairement la réactivité de Zigbee.",status_idle:"Données non chargées",status_loading:"Chargement…",status_ready:"Reçu : {time}",status_stale:"Reçu : {time} · obsolète",status_partial:"Reçu : {time} · certains nœuds sont omis",status_no_links:"Reçu : {time} · aucun lien observé trouvé",error_permission:"Les droits administrateur sont requis.",error_unsupported:"Ce fournisseur ou l’API Home Assistant n’est pas disponible.",error_timeout:"Le fournisseur n’a pas répondu à temps.",error_invalid_topic:"Vérifiez le topic de base Zigbee2MQTT.",error_invalid_payload:"Le fournisseur a renvoyé des données de topologie non prises en charge.",error_provider:"Impossible de charger les données de topologie.",remote_count:"+{n} dans d’autres espaces",route_device_not_on_plan:"appareil absent du plan",route_coordinator_not_on_plan:"coordinateur absent du plan",route_other_space:"autre espace"};const s="1e24262d325c178a2dcff7f9ded0cdbe6cca8fc53344f2ce137be568178fb802";export{e as dictionary,s as fingerprint};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";var e={title:"Liens Zigbee",toggle:"Afficher les liens Zigbee au survol d’un appareil",hint:"Seuls les voisins directs observés sont affichés. Le survol ne lance aucune requête.",help:"Montre avec quels appareils Zigbee chaque appareil communique directement. La couleur de la ligne est la qualité du lien (LQI) sur la même échelle que l'indicateur LQI de l'appareil : rouge à 40 et en dessous, vert à 180 et au-dessus. Une ligne pointillée signifie que la qualité n'a pas été communiquée. Une flèche pointe vers l'appareil suivant sur le chemin du coordinateur : une flèche part, les flèches entrantes sont les appareils qui passent par celui-ci. Une ligne sans flèche est un voisin de secours. C'est l'arbre de routes que construit House Plan, pas le trajet d'un paquet à cet instant : une étiquette au bout d'une flèche signifie que sa cible n'est pas sur ce plan, et l'absence de flèche que la route est inconnue. Les données proviennent du dernier chargement et vieillissent ; les liens ne sont visibles qu'à la souris.",help_aria:"Aide : liens Zigbee",admin_only:"Les données de topologie sont réservées aux administrateurs Home Assistant.",save_first:"Enregistrez ce réglage avant de charger les données de topologie.",zha:"ZHA",zha_read:"Lire les données ZHA",zha_hint:"Lit la topologie en cache de ZHA sans lancer d’analyse radio.",z2m:"Zigbee2MQTT",z2m_topics:"Topics de base (un par ligne)",z2m_update:"Actualiser la carte",z2m_warning:"L’analyse de la carte réseau peut durer de 10 secondes à 2 minutes et réduire temporairement la réactivité de Zigbee.",status_idle:"Données non chargées",status_loading:"Chargement…",status_ready:"Reçu : {time}",status_stale:"Reçu : {time} · obsolète",status_partial:"Reçu : {time} · certains nœuds sont omis",status_no_links:"Reçu : {time} · aucun lien observé trouvé",error_permission:"Les droits administrateur sont requis.",error_unsupported:"Ce fournisseur ou l’API Home Assistant n’est pas disponible.",error_timeout:"Le fournisseur n’a pas répondu à temps.",error_invalid_topic:"Vérifiez le topic de base Zigbee2MQTT.",error_invalid_payload:"Le fournisseur a renvoyé des données de topologie non prises en charge.",error_provider:"Impossible de charger les données de topologie.",remote_count:"+{n} dans d’autres espaces",route_device_not_on_plan:"appareil absent du plan",route_coordinator_not_on_plan:"coordinateur absent du plan",route_other_space:"autre espace"};const s="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";export{e as dictionary,s as fingerprint};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1e24262d325c178a2dcff7f9ded0cdbe6cca8fc53344f2ce137be568178fb802";var e={title:"Связи Zigbee",toggle:"Показывать связи Zigbee при наведении на устройство",hint:"Показываются только наблюдаемые прямые соседи. Наведение ничего не запрашивает.",help:"Показывает, с какими устройствами Zigbee каждое связано напрямую. Цвет линии — качество связи (LQI) по той же шкале, что у показателя LQI устройства: красный при 40 и ниже, зелёный при 180 и выше. Пунктир — качество не сообщено. Стрелка ведёт к следующему устройству по пути к координатору: исходящая одна, входящие — те, кто ходит через это устройство. Линия без стрелки — запасной сосед. Это дерево маршрутов, которое строит House Plan, а не путь пакета в эту секунду: подпись на конце стрелки значит, что цель не на этом плане, а отсутствие стрелки — что путь неизвестен. Данные из последней загрузки и устаревают, связи видны только при работе мышью.",help_aria:"Справка: связи Zigbee",admin_only:"Данные топологии доступны только администраторам Home Assistant.",save_first:"Сохраните настройку перед загрузкой данных топологии.",zha:"ZHA",zha_read:"Прочитать данные ZHA",zha_hint:"Читает сохранённую топологию ZHA и не запускает радио-сканирование.",z2m:"Zigbee2MQTT",z2m_topics:"Базовые топики (по одному в строке)",z2m_update:"Обновить карту",z2m_warning:"Сканирование карты сети занимает от 10 секунд до 2 минут и временно может снизить отзывчивость Zigbee.",status_idle:"Данные не загружены",status_loading:"Загрузка…",status_ready:"Получено: {time}",status_stale:"Получено: {time} · данные устарели",status_partial:"Получено: {time} · часть узлов не показана",status_no_links:"Получено: {time} · наблюдаемые связи не найдены",error_permission:"Нужны права администратора.",error_unsupported:"Провайдер или API Home Assistant недоступен.",error_timeout:"Провайдер не ответил вовремя.",error_invalid_topic:"Проверьте базовый топик Zigbee2MQTT.",error_invalid_payload:"Провайдер вернул неподдерживаемые данные топологии.",error_provider:"Не удалось загрузить данные топологии.",remote_count:"+{n} в других пространствах",route_device_not_on_plan:"устройства нет на плане",route_coordinator_not_on_plan:"координатора нет на плане",route_other_space:"другое пространство"};const t="1e24262d325c178a2dcff7f9ded0cdbe6cca8fc53344f2ce137be568178fb802";export{e as dictionary,t as fingerprint};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";var e={title:"Связи Zigbee",toggle:"Показывать связи Zigbee при наведении на устройство",hint:"Показываются только наблюдаемые прямые соседи. Наведение ничего не запрашивает.",help:"Показывает, с какими устройствами Zigbee каждое связано напрямую. Цвет линии — качество связи (LQI) по той же шкале, что у показателя LQI устройства: красный при 40 и ниже, зелёный при 180 и выше. Пунктир — качество не сообщено. Стрелка ведёт к следующему устройству по пути к координатору: исходящая одна, входящие — те, кто ходит через это устройство. Линия без стрелки — запасной сосед. Это дерево маршрутов, которое строит House Plan, а не путь пакета в эту секунду: подпись на конце стрелки значит, что цель не на этом плане, а отсутствие стрелки — что путь неизвестен. Данные из последней загрузки и устаревают, связи видны только при работе мышью.",help_aria:"Справка: связи Zigbee",admin_only:"Данные топологии доступны только администраторам Home Assistant.",save_first:"Сохраните настройку перед загрузкой данных топологии.",zha:"ZHA",zha_read:"Прочитать данные ZHA",zha_hint:"Читает сохранённую топологию ZHA и не запускает радио-сканирование.",z2m:"Zigbee2MQTT",z2m_topics:"Базовые топики (по одному в строке)",z2m_update:"Обновить карту",z2m_warning:"Сканирование карты сети занимает от 10 секунд до 2 минут и временно может снизить отзывчивость Zigbee.",status_idle:"Данные не загружены",status_loading:"Загрузка…",status_ready:"Получено: {time}",status_stale:"Получено: {time} · данные устарели",status_partial:"Получено: {time} · часть узлов не показана",status_no_links:"Получено: {time} · наблюдаемые связи не найдены",error_permission:"Нужны права администратора.",error_unsupported:"Провайдер или API Home Assistant недоступен.",error_timeout:"Провайдер не ответил вовремя.",error_invalid_topic:"Проверьте базовый топик Zigbee2MQTT.",error_invalid_payload:"Провайдер вернул неподдерживаемые данные топологии.",error_provider:"Не удалось загрузить данные топологии.",remote_count:"+{n} в других пространствах",route_device_not_on_plan:"устройства нет на плане",route_coordinator_not_on_plan:"координатора нет на плане",route_other_space:"другое пространство"};const a="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";export{e as dictionary,a as fingerprint};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1e24262d325c178a2dcff7f9ded0cdbe6cca8fc53344f2ce137be568178fb802";import{n as t,c as n}from"./zigbee-topology-ZJgB0un9.js";import{aM as e}from"./houseplan-card-EmxAmZMt.js";import"./namespace-language-aBVqQLAD.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function m(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function h(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,h=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,h=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&m(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=m(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&h?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,h as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";import{n as t,c as n}from"./zigbee-topology-BG_a6cIb.js";import{aP as e}from"./houseplan-card-BOdiRDFR.js";import"./namespace-language-D86tJUeD.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function m(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function h(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,h=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,h=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&m(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=m(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&h?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,h as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="1e24262d325c178a2dcff7f9ded0cdbe6cca8fc53344f2ce137be568178fb802";try{await import("./houseplan-assets/houseplan-card-EmxAmZMt.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";try{await import("./houseplan-assets/houseplan-card-BOdiRDFR.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
File diff suppressed because one or more lines are too long
@@ -35,6 +35,7 @@ from .const import (
PLANS_URL,
VERSION,
)
from .led_strips import led_strip_link_report, plan_only_strips, unbind_strips
from .radar_validation import validate_marker_radars
from .store import HouseplanData
from .validation import (
@@ -294,6 +295,10 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
projected["decor"] = [
_project_plan_only_decor(shape) for shape in space.get("decor") or []
]
led_strips = plan_only_strips(space)
if led_strips is not None:
# #780: plan-only keeps the strip geometry and never a device link.
projected["led_strips"] = led_strips
if "stairs" in space:
projected["stairs"] = [
_pick_fields(stair, (
@@ -694,6 +699,12 @@ def parse_document(raw: bytes) -> dict[str, Any]:
# before an import token can be issued.
if model > 0:
config_candidate["model_version"] = model
# #780: the schema step unbinds strips whose marker is not in the
# document; count them before it does, for the import summary.
unbound_led_strips = (
led_strip_link_report(config_candidate)["unbound"]
if isinstance(config_candidate, dict) else 0
)
config = CONFIG_SCHEMA(config_candidate)
config.pop("model_version", None)
except (vol.Invalid, TypeError, ValueError) as err:
@@ -730,6 +741,10 @@ def parse_document(raw: bytes) -> dict[str, Any]:
**(document.get("transfer") or {}),
"dropped_marker_links": dropped_marker_links,
}
# Computed here, never trusted from the file.
document["transfer"].pop("unbound_led_strips", None)
if unbound_led_strips:
document["transfer"]["unbound_led_strips"] = unbound_led_strips
if plan_only:
_validate_plan_only_document(document, config, layout, placement)
if len(json.dumps(
@@ -1608,6 +1623,14 @@ def build_space_merge(
marker.pop("value_source", None)
dropped_marker_links += 1
# #780: an LED strip follows its marker through the same id remap; a
# marker that was skipped or virtualised by the duplicate policy is not
# transferred, so the strip arrives unbound (geometry kept, no device).
unbound_led_strips = unbind_strips(space, remap={
old_id: new_id for old_id, new_id in marker_map.items()
if old_id not in virtualized_targets
})
output_markers_by_id = {
str(marker.get("id")): marker for marker in output_markers
if marker.get("id") is not None
@@ -1709,6 +1732,7 @@ def build_space_merge(
"virtualized": virtualized,
"orphan_markers": len(output_markers) - len(marker_map),
"dropped_marker_links": dropped_marker_links,
"unbound_led_strips": unbound_led_strips,
"repaired_target_refs": repaired_target_refs,
"preserved_unresolved_refs": sum(
int(value) for value in reference_report["preservedUnresolved"].values()
@@ -1914,6 +1938,14 @@ def _materialize_import_candidate(
details = {
"dropped_marker_links": _transfer_dropped_marker_links(prepared),
}
# #780: strips unbound by the document itself (counted while parsing)
# plus those the transfer unbound (space merge) or the schema step will.
transfer = prepared.get("transfer") if isinstance(prepared.get("transfer"), dict) else {}
details["unbound_led_strips"] = (
int(details.get("unbound_led_strips", 0))
+ int(transfer.get("unbound_led_strips", 0) or 0)
+ led_strip_link_report(config)["unbound"]
)
try:
config = CONFIG_SCHEMA(config)
except vol.Invalid as err:
+315
View File
@@ -0,0 +1,315 @@
"""LED strips: stored shape, link normalisation and transfer rules (#780).
A strip is optional geometry of a space::
space.led_strips = [{"id": str, "points": [[x, y], ...],
"marker": str | None, "active": bool (optional)}]
``active`` is the representation of the bound marker: absent/``True`` draws
the strip, ``False`` keeps the shape hidden while the same marker is shown as
an ordinary icon. It is neither ``marker.hidden`` nor the power state.
The module is pure (voluptuous only), so the schema, the write-path
normalisation and the import/plan-only projections are exercised by the pure
pytest suite without Home Assistant.
Write-path order (ТЗ #780 §9):
1. structure, types and limits (``LED_STRIPS_SCHEMA`` inside ``SPACE_SCHEMA``);
2. per-space invariants and duplicate links — **before** any normalisation, so
two strips pointing at the same missing id are still a conflict;
3. normalisation: a link to a marker that is not live in the resulting config
becomes an unbound strip (``marker: None, active: True``) with its id and
points intact — a client that does not know about strips may delete a bound
marker and its save must not fail; a live marker with an empty ``space``
adopts the strip's space;
4. referential check: a live marker with a non-empty foreign ``space``
rejects the whole write.
"""
from __future__ import annotations
import copy
import math
from collections.abc import Iterator
from typing import Any
import voluptuous as vol
MAX_LED_STRIPS = 50
MAX_LED_POINTS = 50
LED_ID_MAX = 64
LED_MARKER_MAX = 500
CANVAS_LIMIT = 5000.0
LED_KEY = "led_strips"
def _coordinate(value: Any) -> float:
"""A stored coordinate: a real finite number, never a string or a bool."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise vol.Invalid("LED strip coordinate must be a number")
number = float(value)
if not math.isfinite(number):
raise vol.Invalid("LED strip coordinate must be finite")
if abs(number) > CANVAS_LIMIT:
raise vol.Invalid("LED strip coordinate is outside the canvas")
return number
def _strict_bool(value: Any) -> bool:
if not isinstance(value, bool):
raise vol.Invalid("LED strip active must be a boolean")
return value
def _marker_ref(value: Any) -> str | None:
if value is None:
return None
if not isinstance(value, str) or not value or len(value) > LED_MARKER_MAX:
raise vol.Invalid("LED strip marker must be a non-empty string or null")
return value
def _strip_id(value: Any) -> str:
if not isinstance(value, str) or not value or len(value) > LED_ID_MAX:
raise vol.Invalid("LED strip id must be a non-empty string")
return value
_POINT = vol.All([_coordinate], vol.Length(min=2, max=2))
LED_STRIP_SCHEMA = vol.Schema(
{
vol.Required("id"): _strip_id,
vol.Required("points"): vol.All(
list, vol.Length(min=2, max=MAX_LED_POINTS), [_POINT],
),
vol.Optional("marker", default=None): _marker_ref,
vol.Optional("active"): _strict_bool,
},
extra=vol.ALLOW_EXTRA,
)
LED_STRIPS_SCHEMA = vol.All(list, vol.Length(max=MAX_LED_STRIPS), [LED_STRIP_SCHEMA])
def _same(a: list[Any], b: list[Any]) -> bool:
return float(a[0]) == float(b[0]) and float(a[1]) == float(b[1])
def strip_is_closed(points: list[Any]) -> bool:
return len(points) >= 4 and _same(points[0], points[-1])
def polyline_length(points: list[Any]) -> float:
total = 0.0
for index in range(1, len(points)):
ax, ay = points[index - 1]
bx, by = points[index]
total += math.hypot(float(bx) - float(ax), float(by) - float(ay))
return total
def _distinct_vertices(points: list[Any]) -> int:
seen: set[tuple[float, float]] = set()
for point in points:
seen.add((float(point[0]), float(point[1])))
return len(seen)
def validate_strip_shape(strip: dict[str, Any]) -> None:
"""Geometry invariants that the type schema cannot express."""
points = strip["points"]
if _distinct_vertices(points) < 2 or not polyline_length(points) > 0:
raise vol.Invalid("LED strip needs at least two distinct points")
if _same(points[0], points[-1]) and len(points) > 2:
# A repeated first point closes the strip: three distinct vertices.
if _distinct_vertices(points[:-1]) < 3:
raise vol.Invalid("a closed LED strip needs three distinct vertices")
if strip.get("active") is False and not isinstance(strip.get("marker"), str):
raise vol.Invalid("a hidden LED strip shape must belong to a marker")
class LedStripLinkError(ValueError):
"""A write that cannot be normalised: duplicate or foreign link."""
code = "invalid_led_strip"
def __init__(self, reason: str) -> None:
# Marker ids may carry user-controlled HA identifiers: a stable
# message without the value, like DuplicateMarkerIdError.
super().__init__(reason)
self.reason = reason
def _live_markers(config: dict[str, Any]) -> dict[str, dict[str, Any]]:
live: dict[str, dict[str, Any]] = {}
for marker in config.get("markers") or []:
if not isinstance(marker, dict) or marker.get("removed") is True:
continue
marker_id = marker.get("id")
if isinstance(marker_id, str) and marker_id and marker_id not in live:
live[marker_id] = marker
return live
def _strips(config: dict[str, Any]) -> Iterator[tuple[dict[str, Any], dict[str, Any]]]:
for space in config.get("spaces") or []:
if not isinstance(space, dict):
continue
for strip in space.get(LED_KEY) or []:
if isinstance(strip, dict):
yield space, strip
def preserve_led_strips(config: dict[str, Any], previous: dict[str, Any] | None) -> int:
"""An ordinary writer that omits ``led_strips`` keeps the stored shapes (#780 r1 H1).
A previous frontend does not know the field and sends every space without
it; the omission is not a deletion. A new client deletes shapes by sending
an explicit list (``[]`` removes all of them). Applies to spaces that keep
their id; a space the writer removed takes its strips with it. Runs on the
ordinary write path before normalisation, so a preserved link to a marker
the same write deleted becomes an unbound strip by the usual rule.
Returns how many spaces received their stored shapes back.
"""
stored = {
space.get("id"): space[LED_KEY]
for space in (previous or {}).get("spaces") or []
if isinstance(space, dict) and isinstance(space.get(LED_KEY), list) and space[LED_KEY]
}
restored = 0
for space in config.get("spaces") or []:
if not isinstance(space, dict) or LED_KEY in space:
continue
shapes = stored.get(space.get("id"))
if shapes:
space[LED_KEY] = copy.deepcopy(shapes)
restored += 1
return restored
def led_strip_link_report(config: dict[str, Any]) -> dict[str, int]:
"""What the normaliser would change, without changing anything.
The write path answers the client with these counters so a new client
knows to re-read; an old client ignores the extra keys.
"""
live = _live_markers(config)
unbound = adopted = 0
for _space, strip in _strips(config):
ref = strip.get("marker")
if not isinstance(ref, str) or not ref:
continue
marker = live.get(ref)
if marker is None:
unbound += 1
elif marker.get("space") in (None, ""):
adopted += 1
return {"unbound": unbound, "space_adopted": adopted}
def normalize_led_strip_links(config: dict[str, Any]) -> dict[str, Any]:
"""Validate and normalise every strip of a whole configuration in place.
Runs after the type schema and the coordinate canonicalisation, so the
shape invariants judge the stored numbers.
"""
taken: set[str] = set()
for space in config.get("spaces") or []:
if not isinstance(space, dict):
continue
ids: set[str] = set()
for strip in space.get(LED_KEY) or []:
if strip["id"] in ids:
raise vol.Invalid("LED strip ids must be unique within a space")
ids.add(strip["id"])
validate_strip_shape(strip)
ref = strip.get("marker")
if isinstance(ref, str):
if ref in taken:
raise LedStripLinkError("a marker is bound to more than one LED strip")
taken.add(ref)
live = _live_markers(config)
for space, strip in _strips(config):
ref = strip.get("marker")
if not isinstance(ref, str):
strip["marker"] = None
continue
marker = live.get(ref)
if marker is None:
strip["marker"] = None
strip["active"] = True
continue
owner = marker.get("space")
if owner in (None, ""):
marker["space"] = space["id"]
elif owner != space["id"]:
raise LedStripLinkError("an LED strip is bound to a marker of another space")
return config
def config_led_strip_links(config: dict[str, Any]) -> dict[str, Any]:
"""voluptuous step for CONFIG_SCHEMA: ``LedStripLinkError`` → ``vol.Invalid``."""
try:
return normalize_led_strip_links(config)
except LedStripLinkError as err:
raise vol.Invalid(f"{LedStripLinkError.code}: {err.reason}") from err
def unbind_strips(space: dict[str, Any], keep: set[str] | None = None,
remap: dict[str, str] | None = None) -> int:
"""Transfer rule for imports/copies: remap a link or make the strip unbound.
``remap`` maps the source marker id to the id it received in the target;
a marker that was not transferred (skipped duplicate, plan-only, dropped)
leaves an unbound strip with ``active: True``. Returns how many strips
became unbound, for the import summary.
"""
unbound = 0
for strip in space.get(LED_KEY) or []:
if not isinstance(strip, dict):
continue
ref = strip.get("marker")
if not isinstance(ref, str) or not ref:
strip["marker"] = None
continue
target = (remap or {}).get(ref)
if target is None and keep is not None and ref in keep:
target = ref
if target is None:
strip["marker"] = None
strip["active"] = True
unbound += 1
else:
strip["marker"] = target
return unbound
def plan_only_strips(space: dict[str, Any]) -> list[dict[str, Any]] | None:
"""Plan-only export: geometry stays, device links never leak."""
if LED_KEY not in space:
return None
projected = []
for strip in space.get(LED_KEY) or []:
if not isinstance(strip, dict):
continue
projected.append({
"id": strip.get("id"),
"points": [list(point) for point in strip.get("points") or []],
"marker": None,
"active": True,
})
return projected
def led_strip_counts(config: dict[str, Any]) -> dict[str, int]:
"""Support-package counters: no coordinates, names or HA identifiers."""
total = unbound = hidden = 0
for _space, strip in _strips(config):
total += 1
if not isinstance(strip.get("marker"), str):
unbound += 1
elif strip.get("active") is False:
hidden += 1
return {"led_strips": total, "led_strips_unbound": unbound, "led_strips_hidden": hidden}
+1 -1
View File
@@ -20,5 +20,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.79.0-beta.1"
"version": "1.79.0"
}
@@ -22,6 +22,7 @@ from .const import (
MAX_SUPPORT_ATTACHMENT_BYTES,
PLAN_MODEL_VERSION,
)
from .led_strips import led_strip_counts
PACKAGE_FORMAT = "houseplan-support-package"
PACKAGE_VERSION = 1
@@ -457,10 +458,21 @@ def _summary(config: object, layout: object) -> dict[str, Any]:
"lifecycle": dict(sorted(lifecycles.items())),
"binding": dict(sorted(bindings.items())),
},
# #780: counters only — no coordinates, room names or HA identifiers.
"led_strips": _led_strip_summary(spaces),
"layout_entries": len(layout) if isinstance(layout, dict) else 0,
}
def _led_strip_summary(spaces: list[Any]) -> dict[str, int]:
counts = led_strip_counts({"spaces": [space for space in spaces if isinstance(space, dict)]})
return {
"total": counts["led_strips"],
"unbound": counts["led_strips_unbound"],
"hidden": counts["led_strips_hidden"],
}
def _binding_kind(value: object) -> str:
text = str(value or "")
if text == "virtual":
+14 -1
View File
@@ -17,6 +17,11 @@ from custom_components.houseplan.coordinate_canonicalization import (
canonicalize_layout_geometry,
canonicalize_position,
)
from custom_components.houseplan.led_strips import (
LED_STRIPS_SCHEMA,
config_led_strip_links,
preserve_led_strips,
)
from custom_components.houseplan.vacuum_routes import validate_marker_routes
# ---------- limits and extension sets ----------
@@ -1877,6 +1882,8 @@ SPACE_SCHEMA = vol.All(vol.Schema(
# (HP-1454-05): relying on a modern client to strip an unbounded legacy
# list is not a limit, it is a hope. `Remove` returns the key stripped.
vol.Remove("segments"): object,
# #780: optional LED strip shapes of the space (led_strips.py).
vol.Optional("led_strips"): LED_STRIPS_SCHEMA,
},
extra=vol.ALLOW_EXTRA,
), _space_geometry_invariants)
@@ -2268,13 +2275,16 @@ def prepare_ordinary_summary_candidate(
readable_entity_ids: set[str],
normalize,
) -> dict:
"""Apply the one summary-panel contract shared by ordinary config writers.
"""Apply the one preservation contract shared by ordinary config writers.
`normalize` owns the writer-specific schema/migration sequence. Preservation
must happen before it; change-aware reference validation must happen after
it. Authoritative full import/restore intentionally does not use this helper.
Preserved: the summary panel namespace (#437) and the LED strip shapes of a
space an older writer sent without the field (#780).
"""
preserve_summary_panel_namespace(candidate, previous)
preserve_led_strips(candidate, previous)
checked = normalize(candidate)
validate_summary_panel_references(checked, previous, readable_entity_ids)
return checked
@@ -2353,4 +2363,7 @@ CONFIG_SCHEMA = vol.All(
),
canonicalize_config_geometry,
_config_wall_segment_invariants,
# #780: after canonicalisation — shape invariants judge stored numbers;
# orphan links become unbound strips, foreign/duplicate links reject.
config_led_strip_links,
)
+13 -1
View File
@@ -70,6 +70,7 @@ from .import_export import (
revalidate_candidate,
)
from .junction_limits import JunctionLimitError, validate_junction_limits
from .led_strips import led_strip_link_report, preserve_led_strips
from .plans import (
QuotaError,
collect_attachments,
@@ -1681,6 +1682,14 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
radar_registry = radar_registry_evidence(hass)
# #780: what the write path will normalise in LED strip links (an
# orphan becomes unbound, an empty marker.space adopts the strip's
# space). Reported back so a new client re-reads; old ones ignore it.
# An older writer omits the field: its stored shapes are kept first
# (r1 H1), so the report also counts their links.
preserve_led_strips(msg["config"], data.get("config"))
led_report = led_strip_link_report(msg["config"])
def _validate_config_cpu():
def _normalize(candidate):
validate_wall_model_transition(candidate, data.get("config"))
@@ -1789,7 +1798,10 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
from .repairs import async_check_plan_files
hass.async_create_task(async_check_plan_files(hass, entry))
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
result: dict[str, Any] = {"ok": True, "rev": new_rev}
if any(led_report.values()):
result["led_strips"] = led_report
connection.send_result(msg["id"], result)
# ---------------- whole-plan maintenance ----------------
+80 -6
View File
@@ -22,7 +22,7 @@ const targetRoot = resolve(valueArg('target-root') ?? '.');
const profile = valueArg('profile') ?? 'large-house-v1';
if (![
'large-house-v1', 'large-house-isometric-v1', ISOMETRIC_STAGE3_DENSE_PROFILE,
'large-house-plan-snap-v1', 'large-house-interaction-v1',
'large-house-plan-snap-v1', 'large-house-interaction-v1', 'large-house-isometric-backdrop-v1',
].includes(profile))
throw new Error(`unknown large-house profile: ${profile}`);
const stage3Dense = profile === ISOMETRIC_STAGE3_DENSE_PROFILE;
@@ -30,7 +30,8 @@ const allowStage2Base = process.argv.includes('--allow-stage2-base');
if (allowStage2Base && !stage3Dense)
throw new Error('--allow-stage2-base is valid only for the Stage 3 dense profile');
const requireStage3 = stage3Dense && !allowStage2Base;
const isometric = profile === 'large-house-isometric-v1' || stage3Dense;
const backdrop = profile === 'large-house-isometric-backdrop-v1';
const isometric = profile === 'large-house-isometric-v1' || stage3Dense || backdrop;
const planSnap = profile === 'large-house-plan-snap-v1';
const interaction = profile === 'large-house-interaction-v1';
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
@@ -42,7 +43,20 @@ const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-fa
const requiresInteraction = interaction && existsSync(resolve(targetRoot, 'src/live-viewport.ts'));
const requiresStairs = existsSync(resolve(targetRoot, 'src/stairs.ts'));
const fixture = stage3Dense ? makeIsometricStage3DenseFixture() : makeLargeHouseFixture();
const fixtureCounts = stage3Dense ? fixture.counts : LARGE_HOUSE_COUNTS;
if (backdrop) {
// #743: the 2.5D twin with a backdrop image on every floor, so the imagePlan
// path (#739 K1) is measured at all. One shipped picture (demo/srv/assets
// since v1.14.0, present in every base) under a URL of its own per floor, so
// each floor decodes its own image; plan_aspect 1 lays it over the whole
// plan square and leaves the room geometry alone. The variant lives here, as
// plan-snap's does: demo/fixtures/** and the bundle fingerprint stay as they are.
for (const space of fixture.config.spaces) {
space.plan_url = `/assets/f1.svg?${space.id}`;
space.plan_aspect = 1;
}
fixture.counts = { ...fixture.counts, backdrops: fixture.config.spaces.length };
}
const fixtureCounts = stage3Dense || backdrop ? fixture.counts : LARGE_HOUSE_COUNTS;
const sourceSha = (() => {
try {
return execFileSync('git', ['-C', targetRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
@@ -101,7 +115,7 @@ try {
const row = await page.evaluate(async ({
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
requiresWallFace, interaction, requiresInteraction, stage3Dense, requireStage3,
requiresIsoStructuralBuildCounter, requiresStairs, profile,
requiresIsoStructuralBuildCounter, requiresStairs, profile, backdrop,
}) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
@@ -164,6 +178,11 @@ try {
cleanFloor: card._cleanFloorCache?.size ?? 0,
glowClip: card._glowClipCache?.size ?? 0,
wallUnion: card._wallUnionCache ? 1 : 0,
// #744: the union is pooled per floor, and the inner contours are the
// second structural cache of a floor visit; 0/1 above cannot see a
// cold floor whose union is rebuilt while its clean floor stays warm.
wallUnionPool: card._wallUnionPool?.size ?? 0,
innerContour: card._innerContourCache?.size ?? 0,
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
isoGeometry: card._isoGeometryCache?.size ?? 0,
@@ -255,7 +274,8 @@ try {
if (snapshot.renderedOverlayCount < 1) failures.push('no raised overlay roots rendered');
if (snapshot.raisedOverlayCount !== snapshot.renderedOverlayCount)
failures.push('one or more Stage 4 overlay roots remained outside the low visual plane');
if (snapshot.nudgedOverlayCount < 1) failures.push('dense fixture produced no bounded nudge');
if (snapshot.nudgedOverlayCount !== 0)
failures.push('raised overlay nudged although #713 lifts every tile by one rise');
if (snapshot.raisedVacuumCount !== 0) failures.push('floor-bound vacuum was marked as raised');
if (missingKinds.length) failures.push(`missing overlay kinds: ${missingKinds.join(', ')}`);
for (const kind of expectedKinds) {
@@ -405,6 +425,10 @@ try {
&& card.renderRoot.querySelectorAll('[data-hp="stair"]').length !== fixture.counts.stairs) {
throw new Error(`${profile} did not render the bounded maximum stair collection`);
}
// #743 precondition (docs/TESTING.md rule 3): the fixture carries the
// layer the profile guards, so a stable frame without the backdrop fails.
if (backdrop && !card.renderRoot.querySelector('.stage svg image.hp-backdrop'))
throw new Error(`${profile} rendered no backdrop image`);
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
const bootDiag = {
updates: card.__diag.updates,
@@ -1120,6 +1144,24 @@ try {
card._settingsDialog = null;
await card.updateComplete;
// #735: every sample mounts a new card, and until here it has visited
// only floors 1 and 2. The cycle would otherwise pay a first visit to
// floor 3 (and, after the interaction editor series moves the config
// epoch, to floor 1 again) inside its window, so one cold floor build
// dominated a metric documented as warmed navigation. Visit every
// fixture floor once in cycle order outside every timed and Long Task
// window, then return to floor 2 so the cycle still starts with 2 -> 1.
for (let floor = 1; floor <= fixture.counts.floors; floor++) {
card._pickSpace(`perf-floor-${floor}`);
await card.updateComplete;
await frame();
}
card._pickSpace('perf-floor-2');
await card.updateComplete;
await frame();
const switchCycleCachesBefore = cacheSnapshot(card);
const switchCycleBuildsBefore = isoStructuralBuildCount(card);
const switchCycle = await duration(async () => {
for (let index = 0; index < 12; index++) {
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
@@ -1130,6 +1172,37 @@ try {
await new Promise((done) => setTimeout(done, 0));
}
});
// #735 guard: a warmed cycle builds nothing. Any grown hot cache or a
// structural 2.5D build inside the window means a cold floor visit
// leaked back into switchCycleMs. Caches an older base lacks read as 0
// and cannot grow; its build counter is null and is not judged.
const switchCycleCachesAfter = cacheSnapshot(card);
const switchCycleBuildsAfter = isoStructuralBuildCount(card);
const switchCycleBuilt = Object.keys(switchCycleCachesAfter)
.filter((key) => switchCycleCachesAfter[key] > switchCycleCachesBefore[key])
.map((key) => `${key} +${switchCycleCachesAfter[key] - switchCycleCachesBefore[key]}`);
if (switchCycleBuildsBefore != null && switchCycleBuildsAfter !== switchCycleBuildsBefore)
switchCycleBuilt.push(`isoStructuralBuilds +${switchCycleBuildsAfter - switchCycleBuildsBefore}`);
if (switchCycleBuilt.length)
throw new Error(`${profile} switchCycle built a floor inside the window: ${switchCycleBuilt.join(', ')}`);
// #743 structural probe, untimed and outside every Long Task window: six
// warm switches round the floors (the cycle ended on the last floor, so
// each one is a real switch). Each counts the update passes from the
// pick until updateComplete reports quiescence (resolves true). Counting
// right after the first await would miss the #739 K1 second pass: it
// starts after that promise resolves, in the same task. The wait is
// bounded so an endless update loop yields a large count instead of a
// hung job. evaluate.mjs judges the candidate; a base is only reported.
const floorSwitchPasses = [];
if (backdrop) {
for (let index = 0; index < 6; index++) {
const passesBefore = card.__diag.updates;
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
for (let wait = 0; wait < 20 && !(await card.updateComplete); wait++);
floorSwitchPasses.push(card.__diag.updates - passesBefore);
}
}
await forceGc();
const cacheBefore = cacheSnapshot(card);
@@ -1207,6 +1280,7 @@ try {
? null : afterStateIsoStructuralBuilds - steadyIsoStructuralBuilds,
},
} : {}),
...(backdrop ? { floorSwitchPasses: { supported: true, perSwitch: floorSwitchPasses } } : {}),
...(finalStage3 ? {
isoStageRevision: finalStage3.isoStageRevision,
stage3Diagnostics: {
@@ -1224,7 +1298,7 @@ try {
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
interaction, requiresInteraction, stage3Dense, requireStage3,
requiresIsoStructuralBuildCounter, requiresStairs, profile,
requiresIsoStructuralBuildCounter, requiresStairs, profile, backdrop,
});
// #520: диагностика печатается в лог прогона и в запись не попадает.
const { bootDiag, ...measured } = row;
+471
View File
@@ -0,0 +1,471 @@
#!/usr/bin/env node
/**
* #780 ТЗ §13.2: the `led-strips-v1` performance profile.
*
* The `large-house-v1` fixture (200 devices, 3 floors, 60 rooms, 100
* openings) with 10 or 50 of each floor's existing devices shown as LED
* strips that are on: `--size=10x5` (10 strips × 5 points) or `--size=50x50`
* (50 strips × 50 points); `--size=none` is the same build and plan without
* strips. No icon is added: the converted devices keep their ids, only their
* entity becomes a light. Light radius 30 cm (the strip default), Glow on,
* viewport 1440×1000, DPR 1, reduced motion. `--warmups` (≥1) samples are
* discarded, then `--samples` (≥7) are judged. Every sample is cold: a new
* browser with an empty cache mounts a new card; the warm metrics run in the
* same page after the module has loaded.
*
* Metrics (judged on median AND p95 against budgets-led-strips.json):
* firstStableRenderMs (mount → stripes and fields of every visible strip,
* cold import included), warmSpaceReadyMs (space switch with the module
* loaded → stable LED frame), stateUpdateMs (the interaction profile's method:
* hass update → every stripe in its new state), panZoomMs (the interaction
* profile's camera scenario, benchmark_large_house.mjs) and the longest Long
* Task of the camera — the scenario and the 100-step series, retained heap
* after 20 A→B→C→A cycles over a warm cycle (same GC protocol).
*
* Counters (exact, every sample): geometry/visibility recomputes over 100
* unrelated HA ticks, 100 pan/zoom steps and a colour-only change (0); the
* three LED caches of the shown space — shapes ≤ 50, visibility ≤ 50,
* compact visibility paths ≤ 2500 and retained path text ≤ 4 Mi characters
* (≤ 8 MiB UTF-16) — identical after every cycle (growth 0). The true fan
* count remains diagnostic: #788 removed lossy endpoint/vertex thinning;
* after disconnect 0 retained entries and 0 live LED timers/observers; a
* late import after disconnect restores nothing (one extra cold run with the
* runtime response delayed); LED chunk requests (none without strips, never
* the editor chunk in the View).
*
* `--merge=a.json,b.json` judges the rows of several partial reports of the
* same size as one run — the samples are the same cold samples, only the
* process that collected them differs (a runner with a short step limit);
* `--warmup-only` collects the discarded warm-up of such a run.
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { launch } from './serve.mjs';
import { makeLedStripsFixture } from './performance/led-strips-fixture.mjs';
import { ledChunkRequestName } from './performance/led-chunk-request.mjs';
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const warmupOnly = process.argv.includes('--warmup-only');
const samples = warmupOnly ? 0 : Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
const warmups = warmupOnly ? 1 : Math.max(0, Math.min(5, Number(valueArg('warmups') ?? 1)));
const size = valueArg('size') ?? '10x5';
const output = valueArg('output') ? resolve(valueArg('output')) : null;
const merge = valueArg('merge')?.split(',').filter(Boolean).map((file) => resolve(file)) ?? null;
const skipLateImport = process.argv.includes('--no-late-import');
const SIZES = { '10x5': [10, 5], '50x50': [50, 50], none: [0, 0] };
if (!SIZES[size]) throw new Error(`unknown size: ${size}`);
const [STRIPS, POINTS] = SIZES[size];
const PROFILE = 'led-strips-v1';
const CYCLES = Math.max(1, Math.min(20, Number(valueArg('cycles')) || 20));
const MIN_SAMPLES = 7;
const fixture = makeLedStripsFixture(STRIPS, POINTS);
const sourceSha = (() => {
try { return execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { return null; }
})();
const budgets = JSON.parse(readFileSync(new URL('./performance/budgets-led-strips.json', import.meta.url), 'utf8'));
/**
* Installed in the page before the card mounts anything: timers, frames and
* observers created from an LED chunk (its file name is in the creation
* stack) are tracked until they fire, are cleared or disconnected.
*/
function installLedLifecycleTracker() {
if (window.__ledLive) return;
const live = { timers: new Set(), frames: new Set(), observers: new Set() };
window.__ledLive = live;
const fromLed = () => /led-strip-/.test(new Error().stack || '');
const nativeTimeout = window.setTimeout, nativeClearTimeout = window.clearTimeout;
window.setTimeout = function setTimeout(fn, ms, ...rest) {
const mine = fromLed();
let id = 0;
id = nativeTimeout.call(window, typeof fn === 'function'
? (...args) => { live.timers.delete(id); return fn(...args); } : fn, ms, ...rest);
if (mine) live.timers.add(id);
return id;
};
window.clearTimeout = function clearTimeout(id) { live.timers.delete(id); return nativeClearTimeout.call(window, id); };
const nativeInterval = window.setInterval, nativeClearInterval = window.clearInterval;
window.setInterval = function setInterval(fn, ms, ...rest) {
const id = nativeInterval.call(window, fn, ms, ...rest);
if (fromLed()) live.timers.add(id);
return id;
};
window.clearInterval = function clearInterval(id) { live.timers.delete(id); return nativeClearInterval.call(window, id); };
const nativeFrame = window.requestAnimationFrame, nativeCancelFrame = window.cancelAnimationFrame;
window.requestAnimationFrame = function requestAnimationFrame(fn) {
const mine = fromLed();
let id = 0;
id = nativeFrame.call(window, (time) => { live.frames.delete(id); return fn(time); });
if (mine) live.frames.add(id);
return id;
};
window.cancelAnimationFrame = function cancelAnimationFrame(id) { live.frames.delete(id); return nativeCancelFrame.call(window, id); };
for (const name of ['ResizeObserver', 'MutationObserver', 'IntersectionObserver']) {
const Native = window[name];
if (!Native) continue;
window[name] = class extends Native {
constructor(callback) {
super(callback);
if (fromLed()) live.observers.add(this);
}
disconnect() { live.observers.delete(this); return super.disconnect(); }
};
}
}
/** The page side of one mount: the same fixture host as before, shared by the sample and the late-import run. */
function pageHost() {
return `
window.__ledHost = (fixture) => {
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', title: 'LED strips', icon_size: 3.4 });
let states = fixture.states;
const hassBase = {
language: 'en', locale: { language: 'en' },
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas,
callWS: async (message) => {
if (message.type === 'houseplan/config/get') return { config: structuredClone(fixture.config), rev: 1, can_write: true };
if (message.type === 'houseplan/layout/get') return { layout: structuredClone(fixture.layout), rev: 1 };
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities);
return { ok: true };
},
callService: async () => undefined,
connection: { subscribeEvents: async () => () => undefined, subscribeMessage: async () => () => undefined },
localize: () => null,
formatEntityState: (state) => state.state,
config: { unit_system: { length: 'km' } },
};
return {
card,
get states() { return states; },
setStates(next) { states = next; card.hass = { ...hassBase, states }; },
};
};
window.__ledStats = async (card) => {
const url = performance.getEntriesByType('resource').map((entry) => entry.name)
.find((name) => /led-strip-runtime-[^/]+\\.js/.test(name));
if (!url) return { shapes: 0, visibility: 0, sources: 0, visibilityPaths: 0, pathChars: 0, recomputes: 0, loaded: false };
const runtime = await import(url);
return { ...runtime.ledStats(card), loaded: true };
};
window.__ledLiveCounts = () => ({
timers: window.__ledLive.timers.size, frames: window.__ledLive.frames.size,
observers: window.__ledLive.observers.size,
});
`;
}
async function sample() {
const { page, browser } = await launch({ width: 1440, height: 1000 }, 1,
['--enable-precise-memory-info', '--js-flags=--expose-gc']);
const chromium = await browser.version();
await page.emulateMedia({ reducedMotion: 'reduce' });
const requests = [];
page.on('request', (request) => {
const name = ledChunkRequestName(request.url());
if (name) requests.push(name);
});
try {
await page.evaluate(installLedLifecycleTracker);
await page.evaluate(pageHost());
const row = await page.evaluate(async ({ fixture, strips, cycles }) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
const until = async (predicate, timeout = 30000) => {
const started = performance.now();
while (!predicate()) {
if (performance.now() - started > timeout) throw new Error('led-strips-v1 timed out');
await sleep(5);
}
};
const longTasks = () => {
const entries = [];
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
observer.observe({ type: 'longtask', buffered: false });
return async () => {
await sleep(0);
entries.push(...observer.takeRecords());
observer.disconnect();
return Number(Math.max(0, ...entries.map((entry) => entry.duration)).toFixed(2));
};
};
const gc = async () => { if (globalThis.gc) { globalThis.gc(); await frame(); globalThis.gc(); await frame(); } };
window.__card?.remove?.();
localStorage.clear();
const host = window.__ledHost(fixture);
const { card } = host;
const root = () => card.renderRoot;
const count = (selector) => root()?.querySelectorAll(selector).length ?? 0;
const ledStable = (state = 'on') => count(`[data-led-strip][data-state="${state}"]`) === strips
&& (state !== 'on' || count('.led-fields [data-led-field]') === strips);
const stats = () => window.__ledStats(card);
const started = performance.now();
document.getElementById('host').replaceChildren(card);
host.setStates(host.states);
await until(() => card._loadOk && card._model?.length === 3);
if (strips) await until(() => ledStable());
else { await until(() => card._booting === false); await frame(); }
await frame();
const firstStableRenderMs = Number((performance.now() - started).toFixed(2));
const warm = performance.now();
card._pickSpace('perf-floor-2');
await card.updateComplete;
if (strips) await until(() => ledStable());
await frame();
const warmSpaceReadyMs = Number((performance.now() - warm).toFixed(2));
const states = host.states;
const floorLights = Object.keys(states).filter((id) => id.startsWith('light.perf_led_1_'));
const stateStarted = performance.now();
host.setStates({ ...states, ...Object.fromEntries(floorLights.map((id) => [id, { ...states[id], state: 'off' }])) });
await card.updateComplete;
if (strips) await until(() => ledStable('off'));
await frame();
const stateUpdateMs = Number((performance.now() - stateStarted).toFixed(2));
host.setStates({ ...states, ...Object.fromEntries(floorLights.map((id) => [id, { ...states[id], state: 'on' }])) });
await card.updateComplete;
if (strips) await until(() => ledStable());
await frame();
const before = await stats();
// 100 unrelated HA ticks.
const sensor = Object.keys(host.states).find((id) => id.startsWith('sensor.'));
for (let tick = 0; tick < 100; tick++) {
host.setStates({ ...host.states, [sensor]: { ...host.states[sensor], state: String(20 + (tick % 10) / 10) } });
await card.updateComplete;
}
await frame();
const afterTicks = await stats();
// The camera: the interaction profile's scenario (one wheel step, its
// render and frame — benchmark_large_house.mjs), then 100 steps whose
// Long Tasks are judged too and whose recomputes are counted.
const stage = root().querySelector('.stage');
const rect = stage.getBoundingClientRect();
const wheel = (deltaY) => stage.dispatchEvent(new WheelEvent('wheel', {
deltaY, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, bubbles: true, cancelable: true,
}));
const stopPan = longTasks();
const panStarted = performance.now();
wheel(-120);
await card.updateComplete;
await frame();
const panZoomMs = Number((performance.now() - panStarted).toFixed(2));
const panZoomLongTaskMaxMs = await stopPan();
const stopSeries = longTasks();
// One wheel step per frame, as a real wheel or trackpad delivers them.
for (let step = 0; step < 100; step++) {
wheel(step % 2 ? 60 : -60);
await card.updateComplete;
await frame();
}
await frame();
const cameraSeriesLongTaskMaxMs = await stopSeries();
const afterCamera = await stats();
// A colour-only change rebuilds no geometry or visibility.
host.setStates({ ...host.states, ...Object.fromEntries(floorLights.map((id) => [id,
{ ...host.states[id], attributes: { ...host.states[id].attributes, rgb_color: [255, 120, 80] } }])) });
await card.updateComplete;
await frame();
const afterColour = await stats();
// 20 cycles A→B→C→A after one warm cycle; the three caches identical after each.
const cycle = async () => {
for (const id of ['perf-floor-1', 'perf-floor-2', 'perf-floor-3', 'perf-floor-1']) {
card._pickSpace(id);
await card.updateComplete;
if (strips) await until(() => ledStable());
}
await frame();
const { shapes, visibility, sources, visibilityPaths, pathChars } = await stats();
return { shapes, visibility, sources, visibilityPaths, pathChars };
};
await cycle();
await gc();
const heapBefore = performance.memory?.usedJSHeapSize ?? null;
const cycleStats = [];
for (let index = 0; index < cycles; index++) cycleStats.push(await cycle());
await gc();
const heapAfter = performance.memory?.usedJSHeapSize ?? null;
const liveBeforeDisconnect = window.__ledLiveCounts();
card.remove();
await frame();
await sleep(600);
const afterDisconnect = await stats();
const liveAfterDisconnect = window.__ledLiveCounts();
const key = (entry) => `${entry.shapes}/${entry.visibility}/${entry.sources}/${entry.visibilityPaths}/${entry.pathChars}`;
return {
firstStableRenderMs, warmSpaceReadyMs, stateUpdateMs, panZoomMs, panZoomLongTaskMaxMs, cameraSeriesLongTaskMaxMs,
retainedHeapBytes: heapBefore == null || heapAfter == null ? null : Math.max(0, heapAfter - heapBefore),
counters: strips ? {
recomputesOnHaTicks: afterTicks.recomputes - before.recomputes,
recomputesOnCamera: afterCamera.recomputes - afterTicks.recomputes,
recomputesOnColour: afterColour.recomputes - afterCamera.recomputes,
shapes: Math.max(...cycleStats.map((entry) => entry.shapes)),
visibility: Math.max(...cycleStats.map((entry) => entry.visibility)),
sources: Math.max(...cycleStats.map((entry) => entry.sources)),
visibilityPaths: Math.max(...cycleStats.map((entry) => entry.visibilityPaths)),
pathChars: Math.max(...cycleStats.map((entry) => entry.pathChars)),
cacheGrowthOverCycles: new Set(cycleStats.map(key)).size - 1,
} : null,
disconnect: {
retained: afterDisconnect.shapes + afterDisconnect.visibility + afterDisconnect.sources
+ afterDisconnect.visibilityPaths + afterDisconnect.pathChars,
liveBefore: liveBeforeDisconnect,
live: liveAfterDisconnect.timers + liveAfterDisconnect.frames + liveAfterDisconnect.observers,
},
};
}, { fixture, strips: STRIPS, cycles: CYCLES });
row.ledRequests = [...new Set(requests)].sort();
return { row, chromium };
} finally {
await browser.close();
}
}
/**
* The late import: the card is removed while the runtime chunk is still on
* the wire (its response is held back); when it lands nothing is rendered,
* cached or scheduled for the gone card.
*/
async function lateImport() {
const { page, browser } = await launch({ width: 1440, height: 1000 }, 1, []);
try {
let held = false;
let runtimeUrl = null;
await page.route(/led-strip-runtime-[^/]+\.js/, async (route) => {
held = true;
runtimeUrl = route.request().url();
await new Promise((done) => setTimeout(done, 1500));
await route.fallback();
});
await page.evaluate(installLedLifecycleTracker);
await page.evaluate(pageHost());
const result = await page.evaluate(async ({ fixture }) => {
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
window.__card?.remove?.();
const host = window.__ledHost(fixture);
const { card } = host;
document.getElementById('host').replaceChildren(card);
host.setStates(host.states);
const started = performance.now();
while (!(card._loadOk && card._model?.length === 3) && performance.now() - started < 20000) await sleep(10);
await sleep(300);
const before = card.renderRoot.querySelectorAll('[data-led-strip]').length;
card.remove();
window.__lateCard = card;
await sleep(3000);
return { stripesBeforeLoad: before, stripesAfterLoad: card.renderRoot.querySelectorAll('[data-led-strip]').length };
}, { fixture });
// The chunk the card asked for, by its URL: the same module instance.
const late = await page.evaluate(async (url) => {
const runtime = url ? await import(url) : null;
return { stats: runtime ? { ...runtime.ledStats(window.__lateCard), loaded: true } : { shapes: 0, visibility: 0, sources: 0, visibilityPaths: 0, pathChars: 0, loaded: false },
live: window.__ledLiveCounts() };
}, runtimeUrl);
return { ...result, ...late, held };
} finally {
await browser.close();
}
}
let rows = [];
let chromium = null;
let late = null;
let discarded = 0;
if (merge) {
for (const file of merge) {
const part = JSON.parse(readFileSync(file, 'utf8'));
if (part.profile !== PROFILE || part.size !== size) throw new Error(`${file} is not ${PROFILE} ${size}`);
rows.push(...part.rows);
discarded += part.warmups;
chromium ??= part.chromium;
late ??= part.lateImport ?? null;
}
} else {
for (let iteration = 0; iteration < warmups + samples; iteration++) {
const result = await sample();
chromium ??= result.chromium;
if (iteration >= warmups) rows.push(result.row);
}
discarded = warmups;
if (STRIPS && !skipLateImport && !warmupOnly) late = await lateImport();
}
if (warmupOnly) {
// A discarded warm-up sample for a later --merge: nothing is judged here.
const text = `${JSON.stringify({ profile: PROFILE, size, sourceSha, chromium, samples: 0, warmups: 1, rows: [], partial: true }, null, 2)}\n`;
if (output) { mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, text); }
console.log(text);
process.exit(0);
}
const metric = (name) => {
const values = rows.map((row) => row[name]).filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
const at = (q) => values[Math.min(values.length - 1, Math.ceil(q * values.length) - 1)];
return { median: values.length ? at(0.5) : null, p95: values.length ? at(0.95) : null, samples: values };
};
const METRICS = ['firstStableRenderMs', 'warmSpaceReadyMs', 'stateUpdateMs', 'panZoomMs', 'panZoomLongTaskMaxMs',
'cameraSeriesLongTaskMaxMs', 'retainedHeapBytes'];
const report = {
profile: PROFILE, size, strips: STRIPS, points: POINTS, sourceSha, chromium,
samples: rows.length, warmups: discarded, cycles: CYCLES, viewport: { width: 1440, height: 1000 }, dpr: 1,
merged: merge ? merge.length : undefined,
metrics: Object.fromEntries(METRICS.map((name) => [name, metric(name)])),
counters: rows[0]?.counters ?? null,
counterSamples: rows.map((row) => row.counters),
disconnectSamples: rows.map((row) => row.disconnect),
lateImport: late,
ledRequests: [...new Set(rows.flatMap((row) => row.ledRequests))].sort(),
rows,
};
const failures = [];
const partial = !merge && (samples < MIN_SAMPLES || warmups < 1);
if (!partial && (report.samples < MIN_SAMPLES || report.warmups < 1)) {
failures.push(`${report.samples} samples after ${report.warmups} warmups: the profile needs ≥ ${MIN_SAMPLES} after ≥ 1`);
}
const limits = budgets.sizes[size] || {};
for (const [name, limit] of Object.entries(limits)) {
for (const stat of ['median', 'p95']) {
const value = report.metrics[name]?.[stat];
if (value == null) { if (name !== 'retainedHeapBytes') failures.push(`${name} ${stat} missing`); continue; }
if (value > limit) failures.push(`${name} ${stat} ${value} > ${limit}`);
}
}
for (const [index, disconnect] of report.disconnectSamples.entries()) {
if (disconnect.retained !== 0) failures.push(`sample ${index}: ${disconnect.retained} LED cache entries retained after disconnect`);
if (disconnect.live !== 0) failures.push(`sample ${index}: ${disconnect.live} LED timers/frames/observers alive after disconnect`);
}
if (STRIPS) {
const caches = budgets.caches;
for (const counters of report.counterSamples) {
for (const key of ['recomputesOnHaTicks', 'recomputesOnCamera', 'recomputesOnColour', 'cacheGrowthOverCycles']) {
if (counters[key] !== 0) failures.push(`${key} = ${counters[key]}, expected 0`);
}
for (const key of ['shapes', 'visibility', 'visibilityPaths', 'pathChars']) {
if (!Number.isFinite(counters[key])) failures.push(`${key} cache metric missing`);
else if (counters[key] > caches[key]) failures.push(`${key} cache ${counters[key]} > ${caches[key]}`);
}
}
if (late) {
if (!late.held || !late.stats.loaded) failures.push('late import: the runtime response was never held or never landed');
if (late.stripesAfterLoad !== late.stripesBeforeLoad) failures.push('late import rendered the gone card');
const retained = late.stats.shapes + late.stats.visibility + late.stats.sources
+ late.stats.visibilityPaths + late.stats.pathChars;
if (retained) failures.push(`late import restored ${retained} cache entries`);
if (late.live.timers + late.live.frames + late.live.observers) failures.push('late import left LED timers/observers');
} else if (!skipLateImport) failures.push('late import not measured');
if (!report.ledRequests.includes('led-strip-runtime') || !report.ledRequests.includes('led-strip-field'))
failures.push(`LED chunks not loaded: ${report.ledRequests.join(', ')}`);
} else if (report.ledRequests.length) failures.push(`no strips but LED chunks requested: ${report.ledRequests.join(', ')}`);
if (report.ledRequests.includes('led-strip-editor')) failures.push('the View loaded the LED editor chunk');
report.failures = failures;
report.partial = partial || undefined;
const text = `${JSON.stringify(report, null, 2)}\n`;
if (output) { mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, text); }
console.log(JSON.stringify({ ...report, rows: undefined }, null, 2));
if (failures.length) { console.error(`led-strips-v1 ${size}: ${failures.length} failure(s)`); process.exit(1); }
+66 -4
View File
@@ -136,7 +136,53 @@ const applianceSpace = {
decor: [],
};
const runtime = (includeAppliance = false) => {
// #780: the LED strip reference scene — the designer's four strips on a
// synthetic plan with the reference floor colour #868D94: a free straight
// strip, a strip on the inner face of the bottom wall, a polyline along the
// wall faces around a corner and a closed rectangle. The colours are scene
// values of the four light sources, not a product palette.
const ledRooms = [
{ id: 'led-living', name: 'LED living', area: 'golden_led_living',
poly: [[0.06, 0.08], [0.56, 0.08], [0.56, 0.62], [0.06, 0.62]] },
{ id: 'led-studio', name: 'LED studio', area: 'golden_led_studio',
poly: [[0.56, 0.08], [0.94, 0.08], [0.94, 0.62], [0.56, 0.62]] },
];
// Inner faces of the 20 cm walls (half thickness = 10 cm = 2 cells of 1/240).
const LED_FACE = 2 / 240;
export const LED_REFERENCE_STRIPS = Object.freeze([
{ id: 'led-free', marker: 'demo-led-strip', color: [128, 213, 255],
points: [[0.14, 0.26], [0.44, 0.26]] },
{ id: 'led-wall', marker: 'demo-led-strip-wall', color: [230, 128, 255],
points: [[0.12, round(0.62 - LED_FACE)], [0.50, round(0.62 - LED_FACE)]] },
{ id: 'led-corner', marker: 'demo-led-strip-corner', color: [255, 234, 128],
points: [[0.64, round(0.08 + LED_FACE)], [round(0.94 - LED_FACE), round(0.08 + LED_FACE)],
[round(0.94 - LED_FACE), 0.42]] },
{ id: 'led-loop', marker: 'demo-led-strip-loop', color: [88, 255, 88],
points: [[0.66, 0.30], [0.84, 0.30], [0.84, 0.52], [0.66, 0.52], [0.66, 0.30]] },
]);
const ledSpace = {
id: 'golden-led',
title: 'LED strips',
plan_url: null,
view_box: [0, 0, 1, 0.7],
cell_cm: 5,
settings: {
fill_mode: 'custom', custom_fill: { c: '#868D94', a: 1 }, glow_enabled: true,
show_borders: true, show_names: false, sun_rays: false, bg_mode: 'static',
},
rooms: ledRooms,
walls: wallsFor('led', ledRooms, 20),
openings: [
{ id: 'led-door', type: 'door', x: 0.56, y: 0.53, angle: 90, length: 0.10 },
],
partitions: [],
wall_columns: [],
decor: [],
led_strips: LED_REFERENCE_STRIPS.map(({ id, marker, points }) => ({ id, marker, points })),
};
const runtime = (includeAppliance = false, includeLed = false) => {
const devices = {};
const entities = {};
const states = {
@@ -199,6 +245,15 @@ const runtime = (includeAppliance = false) => {
unit_of_measurement: 'lqi',
});
if (includeLed) {
for (const room of ledRooms) areas[room.area] = { area_id: room.area, name: room.name };
for (const strip of LED_REFERENCE_STRIPS) {
add(strip.marker, 'light', ledRooms[strip.id === 'led-free' || strip.id === 'led-wall' ? 0 : 1].area,
0.5, 0.5, 'on', { rgb_color: strip.color, brightness: 255 });
layout[strip.marker] = { s: 'golden-led', x: 0.5, y: 0.35 };
}
}
if (includeAppliance) {
const washerId = 'golden-washer';
devices[washerId] = {
@@ -247,16 +302,23 @@ export const VISUAL_MATRIX_COUNTS = Object.freeze({
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
});
export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) => ({
export const makeVisualMatrixFixture = ({ applianceLifecycle = false, ledStrips = false } = {}) => ({
config: {
spaces: [
structuredClone(geometrySpace), structuredClone(lightingSpace),
...(applianceLifecycle ? [structuredClone(applianceSpace)] : []),
...(ledStrips ? [structuredClone(ledSpace)] : []),
],
// A persisted marker is part of the fixture contract for scenarios that
// override per-source Glow controls. The device/layout alone are not a
// saved marker configuration and must not be silently treated as one.
markers: [{ id: 'golden-light-two', binding: 'device:golden-light-two' }],
markers: [
{ id: 'golden-light-two', binding: 'device:golden-light-two' },
// #780: a strip link writes the marker's explicit space (ТЗ §5).
...(ledStrips ? LED_REFERENCE_STRIPS.map(({ marker }) => ({
id: marker, binding: `device:${marker}`, space: 'golden-led',
})) : []),
],
settings: {
glow_radius_cm: 360,
north_deg: 0,
@@ -269,7 +331,7 @@ export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) =>
},
},
},
...runtime(applianceLifecycle),
...runtime(applianceLifecycle, ledStrips),
counts: applianceLifecycle ? {
...VISUAL_MATRIX_COUNTS,
spaces: VISUAL_MATRIX_COUNTS.spaces + 1,
+33 -21
View File
@@ -1,27 +1,29 @@
{
"schema": 3,
"matrixVersion": 69,
"acceptedAt": "2026-09-30T13:51:08.299Z",
"sourceFingerprint": "f347f87c368130ae0a1c2983ca57116cd437335d6aeea2b92afe0b8beda6f0a0",
"matrixVersion": 71,
"acceptedAt": "2026-10-03T15:57:17.589Z",
"sourceFingerprint": "6cd33bbad3c9a6a101afc1d501a8959df6ad58b693802489873660c043677fa7",
"chromium": "151.0.7922.34",
"capturedOn": "linux",
"acceptedOn": "linux",
"acceptedOn": "win32",
"capture": {
"platform": "linux",
"arch": "x64",
"chromium": "151.0.7922.34",
"buildFingerprint": "f347f87c368130ae0a1c2983ca57116cd437335d6aeea2b92afe0b8beda6f0a0",
"buildFingerprint": "6cd33bbad3c9a6a101afc1d501a8959df6ad58b693802489873660c043677fa7",
"ci": {
"repository": "Matysh/houseplan-card",
"run": 36721827715,
"run": 37134548622,
"attempt": 1,
"sha": "c2c806fae3be53b2a0c0efa793490cdf54898dda"
"sha": "c142e4f7b5b3ffb46d3a2b88222a9d697e8144b1"
}
},
"localAttestation": null,
"foreignCapture": null,
"foreignCapture": {
"reason": "Owner-authorized #788 acceptance on Windows of Linux CI artifact Validate 37134548622; capture is not Windows (#455)"
},
"witnesses": {
"count": 130,
"count": 101,
"floor": 10
},
"scenarios": {
@@ -80,6 +82,8 @@
"day-cycle-night-dark": "39a6a959676295eb4ead02703533641f57901ad0ce1359933e1d47f48867196a",
"day-cycle-night-moon-gibbous-dark": "89ba8cdc95316a670486900b2b1aaf7b4c8889b388b54d16e3a645f1e5d41020",
"day-cycle-dusk-moon-crescent-south-dark": "03022fc1a57e05b9ec6ace88224d5bded6ee2335bb6b5715089edaf224ce0a4e",
"static-bg-moon-gibbous-white-light": "f23cd2ca25a0e18a2d3e6df762e2b0b04f1e89b77bee20fdded895bf9a155112",
"static-bg-moon-crescent-south-dark": "bb7d6a2f80741ce77c39a2258c6a28bbd1cc158ff316bb063a2d85ee63d01059",
"geometry-plan-editor-dark": "5e7365ef5820a9c2ea51abd3be0c56900de7932e3da4f78fc8103f307a5fcf65",
"safe-resize-handles-clamp-light": "80b3cf9c755dbb77deceaf6deb4569bd1570093509b37e96beb32dd2a07d40da",
"safe-resize-handles-clamp-dark": "dd32a1034f349b27717a89516b91a85b916e70080cfeffb127b0b51a24683bff",
@@ -110,17 +114,17 @@
"opening-symbol-diagonal-partition-dark": "fe264aa0a5926d904ea0a2d15e5f0567a536c9251a72bf96a287d7bf9c480aa1",
"opening-symbol-flip-pairs-light": "693fe97bae8375c6a16244fbb4ddaa1ed447732382a45d4629e1711e0aa0d45a",
"isometric-opening-symbol-parity-dark": "639e19dddf6d0a8e4f145b6850b0ad5d20d83100fd769e2aad678eb093a33067",
"geometry-devices-editor-dark": "1cc18ac3250a0cc20981b090ef7f964555b237e085e3ddbe10cc7b6081342c8c",
"geometry-devices-editor-dark": "0744447d86d071d5ad1c66e44ce3fdde40b6f06b36bdab59c5a2b2cd1da72292",
"device-inbox-desktop-en-light": "3a1229f1f5134c329ee457a62e555896ffd664d1d6cbd217e4e8248dc0c07dc7",
"device-inbox-desktop-ru-dark": "2b0981e7cc134a8f2cfe107c58833e33b862a00a89cadc5033f3b633562f9379",
"device-inbox-narrow-ru-dark": "7694119b368baf49fe8c0aeeec46a75bd77c0987266e4a64f5d13a2099e5f098",
"geometry-decor-editor-dark": "0fddb5ba477ecad15827215d04d22e9ba1adfacbbfc853712b1e76832182fa5f",
"device-inbox-narrow-ru-dark": "694b84a02e4122b973229488440e19e177b3d77dd8e9eb423304c709ffed6ad5",
"geometry-decor-editor-dark": "4077eb3f49182b5075f8cfd3c488cd6affa0fa26f7977ebb1e94cf57baabdc4e",
"tray-wide-selection-en": "e544ee6b4cde64aae188bac9be9e52c1232a3de8e98e1f68af42d8c7064f0a38",
"tray-wide-tool-ru": "d3c49be18142f280c21062612cf06dc8df6e5a076c53aeb2d62da8e3975c8003",
"tray-medium-group-en": "6ba416e0ca3cb2fba4a420703318e62d8651053d76ae1936a37537ab486af4a8",
"tray-medium-selection-ru": "d5a82d474f75f438c06a340897a9e009e7df0c95c5ef07edd3952f0fe43a0f40",
"tray-narrow-palette-en": "d2147c10ec130bead031db16090b0638188a548163ca8dfbfa86e68e71865051",
"furniture-categories-light": "8ffc0d18edc76dad428190f7c041ce9fdf9c5cc71a8329116bc1c7ae040a7fd8",
"furniture-categories-light": "4fdc9db78f6199abe86d1f982e9b1d0b71316f4ee0cbb8a1966742b141e0e0be",
"furniture-variants-dark": "368f863d070b8f2f02cf657731aab0e7d80bdf5a817193709bb2838d429ac4e1",
"furniture-variants-light": "a702b38b8bd42919a5ed8753b87489545eb6d6538726e5cc3cb856a586834974",
"furniture-placement-preview-light": "8a78103d7cdd2808496f6d73b8a65859c4e6b57ee53a0c18f4d90580945673ba",
@@ -183,15 +187,15 @@
"large-house-zoom-040-dark": "84f9e93b5fd25caaf031dc7758553c29459d4dda31e086b7d79e904c9b3d6230",
"large-house-zoom-250-dark": "fa901d097023320a599c1c80be9f9c65a0ce6245bb7182bc0f3d90d679668c17",
"large-house-warm-remount-dark": "6ed51a1c76cd7c62875eadafc169f7c6fc25604e64edabd9594a3664c886987d",
"device-dialog-desktop-en": "f5d660f9244712f016c02c560a4bd301f3729edd4ca1e63b3d2cf0fab9c71802",
"device-dialog-desktop-en": "1a3d6c9c77b263a438eb896f24282af6998ec3e478267423b5c2fbebda75c9b5",
"device-dialog-mobile-ru": "2486302565fb8dadb7758384e592b059df45f6f8b92195738e5a0bf62758c713",
"device-dialog-desktop-de": "972d56b0a0307ad7a061175aa0f6147c3170fc64eadb88bafd038835fd2ce331",
"device-dialog-desktop-de": "082969eec2b7fd472cd65a9ae162430a07bc0e72661a3b5eaae689f490369efa",
"room-temperature-dialog-desktop-en": "cea714dbe38185ea3d936fdb7cb71a0205feb74687d3ade517aed28407abd4a9",
"room-temperature-dialog-mobile-ru": "5ca1c4f18eab3fb4c0667f76b0d0340df94f24e0457baca89234e0ef159f62d6",
"room-discard-dialog-mobile-ru": "9c85af0cdbd2842808ef5ac8fdb306f621a726b0ac77c1449722c85399b4cb3f",
"room-discard-dialog-mobile-ru": "311cc2da1d85191ca26e72928a86f0846b021be85ed5ba83dd02dfe04397c10a",
"german-view-mobile-light": "1c14ef6e97f0f7c616fe61ad72f4dfa7d94c605efc3d8b26931016c8c13a4282",
"toggle-entity-dialog-desktop-en": "075a714a4f013c99614c4941b1202d9ae411e6382f72cb0d0a98e0f669b3388b",
"toggle-entity-dialog-mobile-ru": "c5bf5077a33c1acfa5f744e0ab1c15ce6c2147bc3622a7e8754d972aeedb555b",
"toggle-entity-dialog-desktop-en": "4a8de7f02995b992cb2e8110305280db4899f6fe9bbe967bb43398a5b479a694",
"toggle-entity-dialog-mobile-ru": "e6e11b5a4f0181d0115199d5c3eac93fa2db19c73bf743713a0821ce6d9fc5d9",
"device-help-popover-light-ru": "7645a419f8214dacc6b97be894643d12b2a0486342e568b0b3ee42d74b8cf878",
"decor-color-popover-mobile-ru": "bdf90e0e265d41d032db948a737eeedd6e60cdacc9f85c9356aa809894654960",
"decor-color-popover-desktop-en": "46b38745da10d4467b510c5a257102395a797e0d3229e7ce8c89e4bbf3b937e7",
@@ -200,8 +204,8 @@
"settings-help-zoom-200-ru-dark": "7be1e0c156ae3a8f71d6b55fc75e5a20e5a4404efee674fa4bebdb82004daa80",
"support-desktop-empty-light-en": "55ffd7a63b8e4d65b8cb695a7b166e0c4bb2e719ce925b8d28b8e8595897750d",
"support-desktop-preview-dark-en": "326c1b1ecbe71e0bdaed6445137ee4145418317bf5d5faf10faa297707fdb924",
"support-phone-validation-light-ru": "acc540f9459c9dc38981ccb70e75a5415f17c849d3840340648448884bd2ab31",
"support-phone-success-dark-en": "b2edd4415043d673d22dfe89ee16b9c190e7f3f9537371f01665e8e41444a00e",
"support-phone-validation-light-ru": "85baca107a2620c3d36c09ca9d4f72e029cf41661970983560efc4066f2e5688",
"support-phone-success-dark-en": "dcf2cb53e904dd3196052f5e0001b8aee712db9c9c0e4f8cd2982645e4bf7dbd",
"support-relay-error-light-en": "adf30f96a926a1730c9ad34d18b4234d9b19140f11d56c8fb7508976f366c11d",
"support-tablet-preview-dark-ru": "a69ba86bf069d9beba94eedf6cc3b385b026caeee66348e4ef42c1a072b6df6a",
"device-ripple-color-popover-mobile-ru": "8fc31025d32d87e5d3d3df896937d182577cd5169d520e7cf5fc267e7d080776",
@@ -214,6 +218,14 @@
"optimize-orphan-references-dark-en": "6a9ab05787bc789af6181e6f2abca19d3cf92ac166aac661ad8deb7c7a3c88bb",
"optimize-orphan-references-light-ru": "332e56edb151a5a820fc307d69ffd4060348978572e48704b0a2c14ba9802720",
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0",
"led-strip-design-reference-off-light": "1a558111dbeb1962b10f47baad20b4689248a347510261152f0d6196906044fd",
"led-strip-design-reference-on-light": "16bfb46f2a90c57e95f1307e375e04e5809d41ae677e3b1e1eb56d7e0bc1d8ec",
"lighting-led-strip-glow-dark": "254fe1e39c5bb7bbb7e67527f69c767c8bdb6ed959986a8085b18adb152001d6",
"led-strip-long-zigzag-glow-light": "cd0847c03b6b397c93fa923dcd06b30d7d4a8f6c6db2f006b9f3e20de0e66e91",
"led-strip-endcaps-zoom-light": "57703b53bd9d2eaae1b3142903a8b574e5d12b761b9b7240543ee2463c44c74d",
"led-strip-rectangle-door-zoom-light": "549e9e9110cc3b01537d07bcf6ab89896069a49183a27c22904b89c99e35347c",
"led-strip-off-light": "cbb4f4794c871d592917653e8b5713d87a6be6dfe470b2161da760c2bb3cebdc",
"iso-led-strip-dark": "572728e34f77f911f0ece247c5b7de6653e9c0dfafe04a0ab198b88644081a58"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 380 KiB

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 363 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 KiB

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 68 KiB

+45 -3
View File
@@ -43,7 +43,9 @@ const VERSION_RELOAD_ATTEMPT_KEY = 'houseplan-card:version-reload-target:v1';
const fixtureFor = (scenario) => scenario.fixture === 'large'
? makeLargeHouseFixture({ includeStairs: false })
: makeVisualMatrixFixture({ applianceLifecycle: !!scenario.applianceLifecycle });
: makeVisualMatrixFixture({
applianceLifecycle: !!scenario.applianceLifecycle, ledStrips: !!scenario.ledStrips,
});
const themeVars = {
dark: {
@@ -626,6 +628,17 @@ export function prepareGoldenFixture(scenario) {
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
return space;
};
if (scenario.ledStripOverride) {
const space = requireSpace();
if (space.id !== 'golden-led' || !Array.isArray(scenario.ledStripOverride.points)
|| scenario.ledStripOverride.points.length < 2) {
throw new Error(`invalid golden ledStripOverride: ${scenario.id}`);
}
space.led_strips = [{
id: 'led-owner-repro', marker: 'demo-led-strip-loop',
points: structuredClone(scenario.ledStripOverride.points),
}];
}
if (scenario.stage3Fixture) {
const contract = scenario.stage3Fixture;
const space = requireSpace();
@@ -686,7 +699,7 @@ export function prepareGoldenFixture(scenario) {
throw new Error(`golden deviceName references missing device: ${scenario.deviceId || '<empty>'}`);
fixture.devices[scenario.deviceId].name = scenario.deviceName;
}
if (scenario.fillMode || scenario.bgMode || typeof scenario.glowEnabled === 'boolean'
if (scenario.fillMode || scenario.bgMode || scenario.bgColor || typeof scenario.glowEnabled === 'boolean'
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean'
|| typeof scenario.showNames === 'boolean'
|| typeof scenario.northDeg === 'number') {
@@ -695,6 +708,8 @@ export function prepareGoldenFixture(scenario) {
...(space.settings || {}),
...(scenario.fillMode ? { fill_mode: scenario.fillMode } : {}),
...(scenario.bgMode ? { bg_mode: scenario.bgMode } : {}),
// #718: the scene colour of a static background, deterministic in both themes.
...(scenario.bgColor ? { bg_color: scenario.bgColor } : {}),
...(typeof scenario.glowEnabled === 'boolean' ? { glow_enabled: scenario.glowEnabled } : {}),
...(typeof scenario.sunRays === 'boolean' ? { sun_rays: scenario.sunRays } : {}),
...(typeof scenario.showBorders === 'boolean' ? { show_borders: scenario.showBorders } : {}),
@@ -933,6 +948,23 @@ export async function prepareGoldenScenario(page, scenario) {
await card.updateComplete;
await frame();
};
/**
* #718 AC8: General settings grow a status line under the moon switch once
* the lazy moon chunk judged the opening. Wait for it, so the frame does
* not depend on the chunk's timing; at phone width it wraps, it never
* scrolls the dialog sideways.
*/
const moonStatusSettled = async (card) => {
await until(() => !!card.renderRoot.querySelector('hp-dialog [data-moon-status]'));
await card.updateComplete;
await frame();
const dialog = card.renderRoot.querySelector('hp-dialog');
const body = dialog?.querySelector('.body');
if (innerWidth <= 390 && (!dialog || dialog.scrollWidth > dialog.clientWidth + 1
|| (body && body.scrollWidth > body.clientWidth + 1))) {
throw new Error(`golden general-settings dialog overflows horizontally: ${scenario.id}`);
}
};
const settleCamera = async (card) => {
await until(() => !card._cameraTransition?.active, 1500);
await card.updateComplete;
@@ -2022,6 +2054,7 @@ export async function prepareGoldenScenario(page, scenario) {
} else if (scenario.dialog === 'general-color') {
card._openSettingsDialog();
await card.updateComplete;
await moonStatusSettled(card);
const dialog = card.renderRoot.querySelector('hp-dialog');
const picker = [...(dialog?.querySelectorAll('hp-color-opacity') || [])]
.find((item) => item.label === card._t('gs.light_on'));
@@ -2035,6 +2068,7 @@ export async function prepareGoldenScenario(page, scenario) {
} else if (scenario.dialog === 'general-help') {
card._openSettingsDialog();
await card.updateComplete;
await moonStatusSettled(card);
const dialog = card.renderRoot.querySelector('hp-dialog');
const help = dialog?.querySelector(`hp-help[data-help-key="${scenario.openHelp}"]`);
await help?.updateComplete;
@@ -2216,7 +2250,15 @@ export async function prepareGoldenScenario(page, scenario) {
// The moon chunk is lazy: wait for it, then for the expected phase.
await until(() => card.renderRoot.querySelector('.hp-moon')?.dataset.moonVisible === 'true');
const moon = card.renderRoot.querySelector('.hp-moon');
if (moon.dataset.moonK !== scenario.moon.k || moon.parentElement !== card.renderRoot.querySelector('.hp-day-cycle-env')) {
// #718: over "Follow the Sun" the moon is in the environment; over a
// static background in its own sky layer, the first child of the scene.
const stage = card.renderRoot.querySelector('.stage');
const parent = scenario.bgMode === 'daynight'
? card.renderRoot.querySelector('.hp-day-cycle-env')
: card.renderRoot.querySelector('.hp-moon-sky');
const staticSky = scenario.bgMode === 'daynight' || (stage?.firstElementChild === parent
&& !card.renderRoot.querySelector('.hp-day-cycle-env'));
if (moon.dataset.moonK !== scenario.moon.k || !parent || moon.parentElement !== parent || !staticSky) {
throw new Error(`golden moon contract did not render: ${scenario.id} (k ${moon.dataset.moonK})`);
}
await frame();
+54 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 69;
export const GOLDEN_MATRIX_VERSION = 71;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -266,6 +266,8 @@ export const STAGE6_ACCEPTANCE_SCENARIOS = Object.freeze([
},
]);
const LED_STRIPS_OFF = Object.fromEntries(['light.demo_led_strip', 'light.demo_led_strip_wall', 'light.demo_led_strip_corner', 'light.demo_led_strip_loop'].map((id) => [id, { state: 'off' }]));
export const GOLDEN_SCENARIOS = Object.freeze([
...STAGE6_ACCEPTANCE_SCENARIOS,
// #663 AC3/AC10/AC12: both stair geometries, both rise directions and
@@ -562,6 +564,20 @@ export const GOLDEN_SCENARIOS = Object.freeze([
moon: { clock: '2026-10-14T09:00:00Z', latitude: -33.87, longitude: 151.21, k: '0.14' },
stateOverrides: { 'sun.sun': { attributes: { azimuth: 265, elevation: -2, rising: false } } },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
// #718 AC8: the moon with a static background — its own sky layer at the
// top-left of the scene, the plan over part of the disc, the chosen scene
// colour around the plan unchanged. White in the light theme, dark grey in
// the dark one; the same art and lit side as over "Follow the Sun".
{ id: 'static-bg-moon-gibbous-white-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
bgMode: 'static', bgColor: '#ffffff',
moon: { clock: '2026-10-21T18:00:00Z', latitude: 55.75, longitude: 37.62, k: '0.79' },
stateOverrides: { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } },
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'static-bg-moon-crescent-south-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
bgMode: 'static', bgColor: '#202126',
moon: { clock: '2026-10-14T09:00:00Z', latitude: -33.87, longitude: 151.21, k: '0.14' },
stateOverrides: { 'sun.sun': { attributes: { azimuth: 265, elevation: -2, rising: false } } },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'geometry-plan-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'safe-resize-handles-clamp-light', fixture: 'visual', space: 'golden-safe-resize',
@@ -1300,4 +1316,41 @@ export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'card-editor-invalid-default-floor-dark-ru', fixture: 'visual',
cardEditorInvalidDefaultFloor: 'removed-floor', language: 'ru', theme: 'dark',
viewport: { width: 900, height: 760 }, ...page },
// #780: LED strips. The two design references keep all four strips of the
// designer's composition at once (free, wall face, corner polyline, closed
// loop) on the reference floor #868D94; the others pin the dark Glow field,
// the off stripe in the light theme and the raised 2.5D stripe.
{ id: 'led-strip-design-reference-off-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', stateOverrides: LED_STRIPS_OFF, language: 'en', theme: 'light',
viewport: { width: 1000, height: 760 }, ...stage },
{ id: 'led-strip-design-reference-on-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', language: 'en', theme: 'light', viewport: { width: 1000, height: 760 }, ...stage },
{ id: 'lighting-led-strip-glow-dark', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', fillMode: 'none', language: 'en', theme: 'dark', viewport: { width: 1000, height: 760 }, ...stage },
{ id: 'led-strip-long-zigzag-glow-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', fillMode: 'none', language: 'en', theme: 'light', viewport: { width: 1000, height: 760 },
// A free diagonal joins a wall-face run and returns to free floor. Before
// #785 the active visibility clip kept only the wall fans and dropped both
// free diagonals because SVG clip paths ignore open-path strokes.
ledStripOverride: { points: [[0.12, 0.15], [0.551667, 0.35], [0.551667, 0.55], [0.16, 0.45]] }, ...stage },
{ id: 'led-strip-endcaps-zoom-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', fillMode: 'none', language: 'en', theme: 'light', viewport: { width: 1000, height: 760 },
// #786: at 4× the free end and the end inside one Glow radius of masonry
// must keep the same smooth circular rim; the wall still clips its side.
zoom: 4, zoomCenter: [500, 380],
ledStripOverride: { points: [[0.43, 0.35], [0.535, 0.35]] }, ...stage },
{ id: 'led-strip-rectangle-door-zoom-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', fillMode: 'none', language: 'en', theme: 'light', viewport: { width: 1000, height: 760 },
// #787: four stored corners must remain four straight visible sides. The
// right side crosses led-door: the opening is optically open, but neither
// the stripe nor its emitter line may step back to the wall axis there.
zoom: 1.15, zoomCenter: [310, 350],
ledStripOverride: { points: [[0.068333, 0.088333], [0.551667, 0.088333],
[0.551667, 0.611667], [0.068333, 0.611667], [0.068333, 0.088333]] }, ...stage },
{ id: 'led-strip-off-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', fillMode: 'none', stateOverrides: LED_STRIPS_OFF, language: 'en', theme: 'light',
viewport: { width: 1000, height: 760 }, ...stage },
{ id: 'iso-led-strip-dark', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', projection: 'iso', fillMode: 'none', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 760 }, ...stage },
]);
+132 -3
View File
@@ -22,8 +22,10 @@ value, LQI and new-device badges plus contact/lock-bound door, window and gate
examples. The base invocation explicitly allows a Stage 2 renderer so an older
`dev` SHA remains measurable; the candidate invocation fails closed unless it
reports effective Iso, the Stage 3 revision, all required raised overlay kinds,
at least one real nudge, a bounded shared material-definition set and zero
structural rebuilds during HA, opening and hover/focus updates. The two extra
no nudged overlay root (since #713 every raised tile is lifted by the one shared
wall-top rise, so a nudge means the #651 placement search came back), a bounded
shared material-definition set and zero structural rebuilds during HA, opening
and hover/focus updates. The two extra
timing windows use limits no softer than the historical HA-update budget. This
does not change the fixture, measured windows or budget of
`large-house-isometric-v1`, which remains the #124 regression witness.
@@ -64,11 +66,61 @@ high-tail guard rather than a population estimate:
- model readiness and first stable render;
- space switch, HA state update, pan/zoom and opening the settings dialog;
- a shared-wall room-resize preview which is cancelled before persistence;
- a twelve-switch navigation cycle;
- a twelve-switch navigation cycle; since #735 every fixture floor is visited
once before its window, and a floor build inside the window (any grown hot
cache or a 2.5D structural build) fails the sample;
- Long Tasks for every measured window;
- heap growth after four additional navigation rounds with forced GC;
- hot-cache size and growth after the same warmed cycles.
Issue #735 moved the first visit to each floor out of the navigation cycle.
Every sample mounts a new card that had seen only floors 1 and 2, so the cycle
used to pay one cold floor-3 build inside its window (in
`large-house-interaction-v1` also a floor-1 rebuild, because the editor series
moves the config epoch that keys the clean-floor cache). That single step was
about half of `switchCycleMs` and hid the warmed switches the metric is
meant to describe; a cold floor visit stays measured by `spaceSwitchMs`. The
change is a level step, not a regression or a speed-up of the card: base and
candidate are still measured by the candidate runner in the same run, so the
comparison is unaffected, but absolute `switchCycleMs` history and the
profile's Long Task sums before and after #735 are not comparable. Local
diagnostics showed the median falling by roughly 1.6–2.8 times (for example
`large-house-v1` about 2.0 s to 0.9 s); the exact-SHA Full Performance medians
on both sides of the change are recorded in #735. Budgets and `hardMaxMs` did
not change in #735; #747 then brought the `switchCycleMs` ceilings down to the
warmed level (see "CI contracts" below).
Issue #743 adds `large-house-isometric-backdrop-v1`, the 2.5D twin of
`large-house-isometric-v1` with a loaded plan picture on every floor. Before it
no Full Performance profile walked the backdrop (`imagePlan`) path, so the
double render of #739 K1 — every warm floor switch cleared the ready paper,
inserted the first-frame veil, probed the card background and asked for a
second full update — was invisible to CI both by time and structurally; a
temporary probe found it. The runner derives the fixture as it does for
plan-snap: `large-house-v1` plus `plan_url: '/assets/f1.svg?<space id>'` and
`plan_aspect: 1` on each floor. One shipped picture (in `demo/srv/assets` since
v1.14.0, so every base has it) under a URL of its own per floor decodes once per
floor; it covers the whole plan square and the room geometry is unchanged.
`demo/fixtures/**` and the bundle fingerprint stay as they are, and the report's
fixture counts gain `backdrops: 3`. A sample fails unless its first stable frame
has the backdrop `<image>` in the stage SVG (`docs/TESTING.md` rule 3). The
profile runs every window of the historical isometric profile; after the
`switchCycle` window and its #735 guard, before forced GC and outside every
timed and Long Task window, a structural probe makes six warm switches round the
floors and counts the `performUpdate` passes of each, from the pick until
`updateComplete` resolves `true`. Counting has to run to that quiescence: the K1
second pass starts after the first `updateComplete` has resolved, in the same
task, so a count taken right after the first `await` reads one pass on both
sides. Late updates after the frame are not counted. Each row reports
`floorSwitchPasses: { supported, perSwitch }`, and `evaluate.mjs` rejects a
candidate unless every element is 1 (`candidate: a warm 2.5D floor switch with a
backdrop took <n> update passes`, #739 AC2 and `docs/ISOMETRIC.md`). The base is
reported, not judged: bases before #739, v1.78.0 among them, take two passes.
`budgets-large-house-isometric-backdrop.json` is a copy of the historical
isometric budget that differs only in `profile`, so the twin shares every
ceiling (as #160 does); the historical profile keeps its meaning as the #124
witness. The profile runs in Full Performance only, not in the Validate smoke.
Every report is tied to the source fingerprint embedded by Rollup. A stale
bundle is a hard failure.
@@ -126,6 +178,42 @@ wide margin. Occasional hosted runners are about a quarter faster, which a
ceiling does not mind. Smaller growth stays the job of the full workflow's
base-relative comparison.
The `switchCycleMs` ceilings are 950 ms for the flat family (`budgets.json`,
plan-snap, interaction and the interaction smoke) and 1550 ms for the 2.5D
family (both isometric profiles and the isometric smoke), one number per family
(#747). Since #735 the metric is the warmed twelve-switch cycle, and the former
7000 and 8000 ms sat 7.6–10.2 times above the #735 medians. The series is every
Full Performance run after #735, both sides with 7 samples — the base is
measured by the candidate runner, so it is warm too: 36821241343 (#735, base
`76558bf2`), 36838891001 (#740), 36838952536 (#742) and 36839009721 (#739), the
last three against `dev` `7ff2b5ae`. Flat medians span 666.9–812.7 ms (maximum:
`large-house-v1`, the base of 36838891001). 2.5D medians span 766.5–1333.9 ms;
the maximum is `isometric-stage3-dense-v1` in 36838952536, whose base on the
same runner read 1249.7 ms against 849.9–982.4 ms in the other runs — runner
noise of 27–47 %, which the series is meant to contain. Each ceiling is the
first multiple of 50 ms at or above 1.15 × that maximum and no higher than 1.2 ×
(the #692 band, which #675 also falls into): +16.9 % and +16.2 %. Doubling the
level fails by a wide margin. The price of one number per family is wider
headroom for the faster profiles (`large-house-isometric-v1`, maximum 1135.7 ms,
gets +36 %); it keeps the plan-snap and interaction contracts of "every original
ceiling" and the dense twin equal to the historical profile. Smaller growth
stays the job of the full workflow's base-relative comparison (unchanged: 0.35
flat, 0.2 2.5D, 250 ms noise allowance), and a floor built inside the window
already fails the sample through the #735 structural guard, not through time. No
3-sample `performance_smoke` median is in the series yet: those profiles join
Validate only on a `src/**` diff, so the first beta candidate after #747 is
their check.
The 2.5D View toggle (`viewToggleMs`) is reported by the isometric profiles but
budgeted by none of them (#720, owner decision in #694 on 2026-09-30). Switching
Flat ↔ 2.5D is a one-off General settings change, not something View or kiosk
does in use. Since #649 the runner also measures different operations across
the comparison: a full config reload with `volumetric_view` for the candidate,
a per-device projection flip for bundles before #649 (v1.77.0: 73.8 ms against
195.7 ms for v1.79.0-beta.1, run 36742783749). The 2.5D scene build stays gated
by `modelReadyMs`, `firstStableRenderMs` and `spaceSwitchMs`, a UI freeze by
`longTask.maxSingleMs`.
The dedicated `performance.yml` workflow is the full comparison. It runs on
every `main` promotion, weekly and on manual dispatch for an important beta or
performance-sensitive change. It checks out the candidate and its base SHA,
@@ -239,6 +327,7 @@ Build and copy a fresh demo bundle first, then run:
```bash
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
npm run benchmark:large-house-isometric -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-local.json
npm run benchmark:large-house-isometric-backdrop -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-backdrop-local.json
npm run benchmark:isometric-stage3-dense -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-stage3-local.json
npm run benchmark:large-house-plan-snap -- --samples=7 --warmups=1 --output=artifacts/performance/plan-snap-local.json
npm run benchmark:large-house-interaction -- --samples=7 --warmups=1 --output=artifacts/performance/interaction-local.json
@@ -335,3 +424,43 @@ they must be reviewed against the first paired Ubuntu artifacts before the
feature is promoted from beta. Same-runner relative checks in the full workflow
remain the primary regression signal; the candidate-only smoke only guards
against catastrophic failures.
## `led-strips-v1` (#780)
`demo/benchmark_led_strips.mjs` (`npm run benchmark:led-strips -- --size=…`)
measures the LED strips on the `large-house-v1` fixture: on every floor 10 or 50
existing devices become lights shown as strips that are on — `10x5` (10 strips
× 5 points) or `50x50` — without adding a device or an icon
(`performance/led-strips-fixture.mjs`); `none` is the same build and plan
without strips. Glow on, strip radius 30 cm, viewport 1440×1000, DPR 1,
reduced motion; every sample is cold (a new browser and card), seven samples
after one warm-up. The runner fails on its own against
`budgets-led-strips.json` — the ТЗ table, median and p95: `firstStableRenderMs`
3400/5000, `warmSpaceReadyMs` 1500, `stateUpdateMs` 1000/1500, `panZoomMs` 500,
the longest pan/zoom Long Task 150 ms — of the interaction profile's camera
scenario and of a 100-step series, one wheel step per frame — retained heap
after 20 A→B→C→A cycles 64 MiB — and on exact counters: zero field geometry
recomputes over 100 unrelated HA ticks, 100 camera steps and a colour-only
change; the three caches of the shown space (`ledStats` of the runtime chunk)
— shapes ≤ 50, visibility ≤ 50, compact visibility batches ≤ 2500 and
retained SVG path text ≤ 4 Mi characters — identical
after every cycle; after disconnect 0 retained entries and 0 live LED
timers/frames/observers (tracked by creation stack in the page); one extra
cold run holds the runtime response, removes the card meanwhile and requires
that the landed chunk renders, caches and schedules nothing for it; the LED
chunks loaded with strips and none without them, never the editor chunk in
the View. A runner with a short step limit may collect the samples in parts
(`--warmup-only`, `--samples=N --no-late-import`) and judge them with
`--merge=…` — the same cold samples, the same gates, the minimum of seven
samples after one warm-up enforced on the merged set. Base predates the
strips, so there is no relative comparison; the zero-LED View remains judged
by the relative `large-house-interaction-v1` profile. Exact-SHA Linux output
of the full performance workflow (`led-strips` matrix entry) is the gate
evidence; a local report is diagnostic. Since #788 the actual emitter-fan
count remains reported but is not capped by dropping required endpoints or
corners: that old limit hid visible holes. Five fans are retained per compact
batch; all batches render as one positive-winding clip path per strip. The
4 Mi-character bound (at most 8 MiB UTF-16 text) explicitly measures cached
paths, excluding the joined Lit/DOM clip string. Timing and the 64 MiB
warm-cycle heap-growth limit are unchanged; that is not an absolute browser
or GPU memory bound.
@@ -61,7 +61,7 @@
},
"switchCycleMs": {
"stat": "median",
"hardMaxMs": 7000
"hardMaxMs": 950
}
},
"longTasks": {

Some files were not shown because too many files have changed in this diff Show More