Compare commits

..
Author SHA1 Message Date
overseer 1ff41c02bd Runbook: lead the TouchHub setup with the zero-config flow
The integration now registers its panel dashboard itself on setup
(storage-mode dashboard at /nestquest running the custom:nestquest-party
strategy), so the runbook's primary path no longer asks users to build
views: install, add the config entry, set the API base URL and panel
service token in the options flow, and the dashboard simply appears with
Panel-layout views generated from the roster. The hand-built two-view
instructions move under a manual alternative, and the two constraints
(Panel layout — Masonry squeezes the 1080px card into a column — and
TouchHub Auto-Return greater than idle_return_seconds, default 40s) get
their own subsection stating which path gets each for free.
2026-09-23 11:39:34 -04:00
dev_agent 5339b64751 Panel transitions: assert the motion contract and hard rules (#178) 2026-09-23 11:32:49 -04:00
dev_agent 881f48de43 Navigation: crest tap to child log and idle return (#177) 2026-09-23 11:10:17 -04:00
dev_agent 6d5a730cc7 Auto-register the dashboard on setup (#176) 2026-09-23 10:55:09 -04:00
dev_agent 6d570e1c0e Dashboard strategy generating the views (#175) 2026-09-23 10:06:05 -04:00
dev_agent 316503cf73 Feature 20: Cards auto-discover children from the household roster (#174) 2026-09-23 09:40:52 -04:00
7 changed files with 44 additions and 254 deletions
+21 -18
View File
@@ -21,6 +21,8 @@ missed instances, and ``admin_instances`` includes them (D-009).
"""
from __future__ import annotations
import re
import unicodedata
from collections.abc import Callable, Iterable
from typing import Any
@@ -32,7 +34,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from .const import DOMAIN
from .coordinator import (
@@ -49,12 +50,9 @@ from .core.snapshot import instance_payload
#: sensors reuse the same helper.
MAX_STATE_LENGTH = 255
#: Entity-id length cap the roster applies to HA's slugify: HA's
#: slugifier itself does NOT truncate, but the entity registry derives
#: entity ids as ``<domain>.<slug>`` and truncates the full entity_id at
#: MAX_LENGTH_STATE_ENTITY_ID (64), so for this module's ``sensor.``
#: entities the slugged object id alone may not exceed
#: 64 - len("sensor.").
#: Entity-id length cap the slug mirror applies: HA truncates the
#: entity_id (``sensor.`` + slug) at MAX_LENGTH_STATE_ENTITY_ID (64),
#: so the slugged object id alone may not exceed 64 - len("sensor.").
_MAX_SLUG_LENGTH = 64 - len("sensor.")
@@ -110,18 +108,23 @@ def _next_open_quest(
def _slugify(text: str) -> str:
"""Slugify a display name the way Home Assistant derives entity ids.
Runs ``homeassistant.util.slugify`` — the real slugifier HA runs
entity names through, which transliterates every script to ASCII
through unidecode (so non-Latin names survive: ``京子`` slugs to
``jing_zi``) before folding to lowercase and turning every run of
non-alphanumerics into one underscore, answering HA's ``unknown``
sentinel when a name slugs to nothing — then caps the result at
the object-id length the entity registry enforces (the registry
truncates the full ``sensor.`` + slug entity_id at 64 characters,
so the slug alone is capped like this; HA's slugify does not
truncate by itself).
Mirrors ``homeassistant.util.slugify`` (the unicode path HA runs
entity names through) for accented-Latin names: NFKD-normalize,
fold to ASCII (accents drop out with their combining marks),
lowercase, and turn every run of non-alphanumerics into one
underscore, capped at HA's entity-id length like the registry does.
The documented divergence from HA's vendored slugifier is unidecode:
HA transliterates any script to ASCII (so e.g. non-Latin names
survive), this mirror drops those characters instead, and a name
that then slugs to nothing falls through to HA's ``unknown``
sentinel — the same answer HA's wrapper gives. (The harness is
mock-only — no real HA to import the helper from — and entity ids
are registry identity, so the mirror is deliberate.)
"""
return slugify(text)[:_MAX_SLUG_LENGTH]
slug = unicodedata.normalize("NFKD", text)
slug = slug.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-z0-9]+", "_", slug.lower()).strip("_")
return (slug or "unknown")[:_MAX_SLUG_LENGTH]
def _child_roster(children: Iterable[ChildDaySnapshot]) -> list[dict[str, Any]]:
-24
View File
@@ -51,30 +51,6 @@ class CyclePromptTests(unittest.TestCase):
self.assertNotIn("The persistent user service runs", output)
self.assertNotIn("---\n\n---\n\n# 27", output)
def test_task_complete_feature_triggers_one_guarded_promotion(self):
output = RENDER(TEMPLATE, PROFILE)
self.assertIn(
"feature becomes task-complete and its reviewed merge into `dev` is verified",
output,
)
self.assertIn("Task completion inside an unmerged feature branch does not trigger", output)
self.assertIn("Do not create a duplicate promotion", output)
self.assertIn("Allow only one active promotion pull request to `main`", output)
self.assertIn("if it was closed without merge, recalculate", output)
self.assertIn("sole active orchestrator for the project", output)
self.assertIn("including controller exclusivity, reconciliation", output)
self.assertIn("RELEASE_PREPARATION_POLICY = EXCLUDED", output)
self.assertIn("Active promotion branch and pull request", output)
self.assertIn("An active promotion that has not reached `RELEASE_READY`", output)
self.assertIn("RELEASE_PREPARATION_POLICY:\n", output)
self.assertIn("A ready promotion is a local checkpoint", output)
self.assertIn("continue that work while the ready promotion waits", output)
self.assertLess(
output.index("Begin an automatically triggered, requested, or scheduled release"),
output.index("Advance to the next eligible feature"),
)
self.assertIn("Only Joshua may merge into `main`", output)
if __name__ == "__main__":
unittest.main()
-1
View File
@@ -21,7 +21,6 @@ dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24",
"pyyaml>=6",
"unidecode>=1.4.0",
"uvicorn>=0.30",
"voluptuous>=0.14",
]
+21 -79
View File
@@ -150,15 +150,6 @@ Possible scopes:
- `PROJECT_QUEUE`
- `RELEASE_PREPARATION`
Also set `RELEASE_PREPARATION_POLICY` at startup:
- `AUTO` by default.
- `EXCLUDED` only when the user's launch request explicitly says not to prepare
a release or `{{INTEGRATION_BRANCH}}`-to-`{{RELEASE_BRANCH}}` promotion.
Record both `MISSION_SCOPE` and `RELEASE_PREPARATION_POLICY` in durable run
state. Do not infer exclusion merely from `SINGLE_TASK` or `SINGLE_FEATURE`.
If the user explicitly names one task or feature, respect that scope.
If the user launches the orchestrator for general/autonomous {{PROJECT_NAME}} development without limiting it to one feature, use:
@@ -180,7 +171,6 @@ Maintain a durable orchestration checkpoint using {{WORK_TRACKER}} wherever its
Track at minimum:
- `MISSION_SCOPE`
- `RELEASE_PREPARATION_POLICY`
- Current feature
- Current controller phase
- Active development agents
@@ -195,9 +185,6 @@ Track at minimum:
- Blocked items
- Tests/checks status
- Last verified repository state
- Active promotion branch and pull request
- Triggering feature and exact `{{INTEGRATION_BRANCH}}` commit for each automatic promotion
- Completed features pending the next promotion
- `NEXT_ACTION`
After every material state transition:
@@ -267,8 +254,8 @@ Choose the highest-priority actionable transition in this order:
8. Remediate feature-level review findings.
9. Obtain feature approval.
10. Merge an approved feature into `{{INTEGRATION_BRANCH}}`.
11. Begin an automatically triggered, requested, or scheduled release when appropriate. The automatic post-feature trigger is in scope for both `SINGLE_FEATURE` and `PROJECT_QUEUE` unless `RELEASE_PREPARATION_POLICY = EXCLUDED`.
12. Advance to the next eligible feature when mission scope permits.
11. Advance to the next eligible feature when mission scope permits.
12. Begin a requested/scheduled release when appropriate.
13. Continue unrelated work around locally blocked items.
14. Escalate only when the defined escalation rules require it.
@@ -601,10 +588,9 @@ Immediately transition to `FEATURE_INTEGRATION`.
A merged feature is a transition.
It is terminal only when `MISSION_SCOPE = SINGLE_FEATURE` and no authorized
post-feature release transition is pending. Preparing the automatic promotion
for that completed feature is part of `SINGLE_FEATURE` scope when
`RELEASE_PREPARATION_POLICY = AUTO`.
It is terminal ONLY when:
`MISSION_SCOPE = SINGLE_FEATURE`
Otherwise select the next eligible work.
@@ -651,19 +637,13 @@ The orchestrator itself does not perform the merge.
When `MISSION_SCOPE = PROJECT_QUEUE` and a feature is successfully merged into `{{INTEGRATION_BRANCH}}`:
1. Reconcile {{WORK_TRACKER}}.
2. Evaluate the automatic, requested, and scheduled release triggers.
2. Check for a requested/scheduled release.
3. Identify remaining incomplete features.
4. Determine which features are eligible based on dependencies and priority.
5. Select the next eligible feature.
6. Initialize it.
7. Continue the development loop.
If an automatic promotion becomes `RELEASE_READY` while another eligible
feature remains, keep that promotion waiting for {{RELEASE_OWNER_NAME}} and
continue the project queue. A ready promotion is a local checkpoint in this
case, not a global terminal condition. Features completed while it remains open
are recorded as pending for the next promotion under Section 18.
Do not return to the user simply because one feature completed.
If several features are available, choose using:
@@ -678,52 +658,27 @@ If several features are available, choose using:
# 18. RELEASE RULES
Completed features accumulate in `{{INTEGRATION_BRANCH}}`. With no active
promotion, each verified feature completion starts release preparation. While
one promotion is active, later completed features accumulate for the next
promotion.
Completed features accumulate in `{{INTEGRATION_BRANCH}}`.
Prepare a release when any of these conditions is true:
Prepare a release only when:
- {{RELEASE_OWNER_NAME}} requests it; OR
- {{WORK_TRACKER}} schedules it; OR
- `RELEASE_PREPARATION_POLICY = AUTO`, and a feature becomes task-complete and its reviewed merge into `{{INTEGRATION_BRANCH}}` is verified.
For the automatic trigger, a feature is task-complete only when:
- Every required feature task is `done`, or deliberately `canceled` with recorded evidence.
- No task review, remediation, approval, or merge remains pending.
- The feature acceptance criteria and integrated checks pass.
- Independent approval applies to the exact feature head.
- The approver's merge of that head into `{{INTEGRATION_BRANCH}}` is verified.
- {{WORK_TRACKER}} reflects the completed feature and exact repository references.
Task completion inside an unmerged feature branch does not trigger a release.
The verified feature-to-`{{INTEGRATION_BRANCH}}` merge is the triggering transition.
Before creating a release branch or pull request:
1. Verify this session is the sole active orchestrator for the project. `{{TERMINAL_LAUNCHER}}` must enforce that exclusivity. If sole ownership cannot be verified, do not create a release branch or pull request; escalate instead.
2. Reconcile open release records, release branches, and promotion pull requests.
3. Do not create a duplicate promotion for an already-covered `{{INTEGRATION_BRANCH}}` commit.
4. Allow only one active promotion pull request to `{{RELEASE_BRANCH}}`. If an earlier promotion is still open and does not contain the newly completed feature, record the feature as pending for the next automatic promotion. Resume it after the active promotion is merged or closed; if it was closed without merge, recalculate the next promotion from the current validated `{{INTEGRATION_BRANCH}}` head.
5. Record the triggering feature and exact `{{INTEGRATION_BRANCH}}` commit so the automatic trigger runs once for that merge.
- {{WORK_TRACKER}} schedules it.
Release process:
1. Enforce every pre-creation requirement above, including controller exclusivity, reconciliation, duplicate and active-promotion checks, and trigger recording.
2. Reconcile release scope.
3. Confirm included features are complete.
4. Create release branch from exact validated `{{INTEGRATION_BRANCH}}`.
5. Run complete release checks.
6. Open release PR → `{{RELEASE_BRANCH}}`.
7. Obtain fresh independent {{REVIEWER_VENDOR}} review.
8. Remediate findings through development agents.
9. Incorporate every release correction back into `{{INTEGRATION_BRANCH}}`.
10. Revalidate.
11. Assign final release PR to `{{HOST_ACCOUNT}}`.
12. Update {{WORK_TRACKER}}.
13. Stop before {{RELEASE_BRANCH}} merge.
1. Reconcile release scope.
2. Confirm included features are complete.
3. Create release branch from exact validated `{{INTEGRATION_BRANCH}}`.
4. Run complete release checks.
5. Open release PR → `{{RELEASE_BRANCH}}`.
6. Obtain fresh independent {{REVIEWER_VENDOR}} review.
7. Remediate findings through development agents.
8. Incorporate every release correction back into `{{INTEGRATION_BRANCH}}`.
9. Revalidate.
10. Assign final release PR to `{{HOST_ACCOUNT}}`.
11. Update {{WORK_TRACKER}}.
12. Stop before {{RELEASE_BRANCH}} merge.
Only {{RELEASE_OWNER_NAME}} may merge into `{{RELEASE_BRANCH}}`.
@@ -769,23 +724,13 @@ For `PROJECT_QUEUE`, this additionally requires:
- No eligible incomplete feature remains.
- No task/review/remediation/merge is pending.
- No release preparation, release review, or release remediation is pending.
- Every in-scope automatic trigger is either incorporated into the active
`RELEASE_READY` promotion or durably recorded for the next promotion.
- No development agent is active.
- No approver is active.
An active promotion that has not reached `RELEASE_READY` makes
`MISSION_COMPLETE` false.
## `RELEASE_READY`
Release PR has passed required gates and is assigned to `{{HOST_ACCOUNT}}`, awaiting {{RELEASE_OWNER_NAME}}'s {{RELEASE_BRANCH}} merge.
`RELEASE_READY` is globally terminal only when no other in-scope engineering
action is executable. In `PROJECT_QUEUE`, if another eligible incomplete
feature remains, continue that work while the ready promotion waits.
## `GLOBAL_ESCALATION`
All remaining in-scope work is blocked by decisions/access that require {{RELEASE_OWNER_NAME}}.
@@ -833,12 +778,9 @@ Output a machine-readable continuation checkpoint:
```text
ORCHESTRATOR_STATE: CONTINUE
MISSION_SCOPE:
RELEASE_PREPARATION_POLICY:
CONTROLLER_PHASE:
CURRENT_FEATURE:
ACTIVE_TASKS:
ACTIVE_PROMOTION:
PENDING_PROMOTION_FEATURES:
BLOCKED_ITEMS:
LAST_VERIFIED_STATE:
NEXT_ACTION:
-68
View File
@@ -24,20 +24,16 @@ from __future__ import annotations
import asyncio
import importlib.util
import inspect
import re
import sys
import tempfile
import unicodedata
import weakref
from dataclasses import dataclass
from html.entities import name2codepoint
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from unidecode import unidecode as _unidecode
from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for
@@ -50,7 +46,6 @@ _HA_MODULES = (
"homeassistant.config_entries",
"homeassistant.data_entry_flow",
"homeassistant.exceptions",
"homeassistant.util",
"homeassistant.helpers",
"homeassistant.helpers.config_validation",
"homeassistant.helpers.entity",
@@ -99,7 +94,6 @@ for _parent, _child in (
("homeassistant", "data_entry_flow"),
("homeassistant", "core"),
("homeassistant", "exceptions"),
("homeassistant", "util"),
("homeassistant", "helpers"),
("homeassistant.helpers", "config_validation"),
("homeassistant.helpers", "entity"),
@@ -224,68 +218,6 @@ _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
_exceptions_mock.Unauthorized = Unauthorized
# ---------------------------------------------------------------------------
# homeassistant.util.slugify: a faithful stand-in for the real slugifier.
#
# Real HA (2024.6) wraps python-slugify's frozen legacy pipeline and answers
# its "unknown" sentinel when a non-empty name slugs to nothing. The stub
# mirrors that pipeline step for step — quotes to dashes, NFKD-normalize,
# TRANSLITERATE through unidecode (the backend python-slugify's "auto" mode
# prefers, and a dev dependency here), decode HTML entities, NFKD again,
# lowercase, drop quotes, fold runs of non-[a-z0-9-] to one dash, collapse
# and strip dashes, then map dashes to the requested separator — and was
# verified byte-for-byte against real python-slugify across Latin, Cyrillic,
# CJK and Greek household-name shapes. Transliteration is the point: HA
# derives the per-child entity ids from a name like "京子" as
# "nestquest_jing_zi_…", so the roster's slugs must transliterate too,
# never dropping characters into "unknown". Deliberately NOT truncating:
# real slugify caps nothing; the entity registry truncates the full
# entity_id instead.
# ---------------------------------------------------------------------------
_HA_QUOTE_PATTERN = re.compile(r"[']+")
_HA_CHAR_ENTITY_PATTERN = re.compile(r"&(%s);" % "|".join(name2codepoint))
_HA_DECIMAL_PATTERN = re.compile(r"&#(\d+);")
_HA_HEX_PATTERN = re.compile(r"&#x([\da-fA-F]+);")
_HA_NUMBERS_PATTERN = re.compile(r"(?<=\d),(?=\d)")
_HA_DISALLOWED_CHARS_PATTERN = re.compile(r"[^-a-zA-Z0-9]+")
_HA_DUPLICATE_DASH_PATTERN = re.compile(r"-{2,}")
def _ha_slugify(text: str | None, *, separator: str = "_") -> str:
"""Faithful homeassistant.util.slugify stand-in (see section above)."""
if text == "" or text is None:
return ""
text = str(text)
text = _HA_QUOTE_PATTERN.sub("-", text)
text = unicodedata.normalize("NFKD", text)
text = _unidecode(text)
text = _HA_CHAR_ENTITY_PATTERN.sub(
lambda match: chr(name2codepoint[match.group(1)]), text
)
try:
text = _HA_DECIMAL_PATTERN.sub(
lambda match: chr(int(match.group(1))), text
)
except (ValueError, OverflowError):
pass
try:
text = _HA_HEX_PATTERN.sub(
lambda match: chr(int(match.group(1), 16)), text
)
except (ValueError, OverflowError):
pass
text = unicodedata.normalize("NFKD", text)
text = text.lower()
text = _HA_QUOTE_PATTERN.sub("", text)
text = _HA_NUMBERS_PATTERN.sub("", text)
text = _HA_DISALLOWED_CHARS_PATTERN.sub("-", text)
text = _HA_DUPLICATE_DASH_PATTERN.sub("-", text).strip("-")
return "unknown" if text == "" else text.replace("-", separator)
_ha_mock("homeassistant.util").slugify = _ha_slugify
@dataclass
class DeviceInfo:
"""Stand-in for homeassistant.helpers.entity.DeviceInfo.
+2 -53
View File
@@ -12,7 +12,6 @@ from __future__ import annotations
import datetime
import pytest
from homeassistant.util import slugify
from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry
@@ -638,9 +637,8 @@ async def test_empty_household_publishes_an_empty_roster(
def test_slugify_mirrors_home_assistant_entity_id_derivation() -> None:
"""The roster slugs match homeassistant.util.slugify — the real
slugifier HA derives entity ids from, composed with the entity-id
length cap the entity registry applies to ``sensor.`` ids."""
"""The slug mirror matches homeassistant.util.slugify for the names
children are registered with (verified against HA's own slugify)."""
assert _slugify("Ada") == "ada"
assert _slugify("Mary Jane") == "mary_jane"
assert _slugify("Mary-Jane") == "mary_jane"
@@ -654,52 +652,3 @@ def test_slugify_mirrors_home_assistant_entity_id_derivation() -> None:
# The registry caps the entity_id at 64 characters, so the slugged
# object id alone is capped at 64 - len("sensor.").
assert _slugify("a" * 80) == "a" * (64 - len("sensor."))
def test_slugify_transliterates_non_latin_names_like_home_assistant() -> None:
"""HA's slugifier transliterates every script through unidecode,
so a non-Latin child name slugs to its romanization — never to the
``unknown`` sentinel — and the roster publishes the same slug HA's
entity-id derivation produces for that name."""
assert _slugify("Дана") == "dana"
assert _slugify("京子") == "jing_zi"
assert _slugify("Zoë") == "zoe"
async def test_non_latin_child_names_resolve_auto_discovery(
hass, make_entry
) -> None:
"""A non-Latin child name slugs through the real slugifier, so the
roster slug IS the object id of the per-child sensor's entity_id
and the cards' auto-discovery resolves that child.
HA derives the per-child entity id from the entity NAME ("NestQuest
京子 quests due today") with the same slugifier the roster uses, so
the two must agree exactly; the reviewed mirror dropped non-Latin
characters (slugging "京子" to "unknown"), so the cards built
"sensor.nestquest_unknown_…" ids that no entity had."""
payload = _payload(
[
_child(ADA, "Дана", [_instance(7, ADA)]),
_child(BO, "京子", [_instance(8, BO)]),
]
)
entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload)
roster = hass.entities[
"nestquest_household_quests_due_today"
].extra_state_attributes["child_roster"]
assert roster == [
{"child_id": ADA, "name": "Дана", "slug": "dana"},
{"child_id": BO, "name": "京子", "slug": "jing_zi"},
]
for roster_entry in roster:
due_entity = _sensor(hass, roster_entry["child_id"], "quests_due_today")
assert due_entity.name == (
f"NestQuest {roster_entry['name']} quests due today"
)
# The entity id HA's registry derives for this entity (from the
# same slugifier) embeds exactly the slug the roster publishes:
# the card builds sensor.nestquest_<slug>_quests_due_today.
assert slugify(due_entity.name) == (
f"nestquest_{roster_entry['slug']}_quests_due_today"
)
Generated
-11
View File
@@ -385,7 +385,6 @@ dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pyyaml" },
{ name = "unidecode" },
{ name = "uvicorn" },
{ name = "voluptuous" },
]
@@ -401,7 +400,6 @@ dev = [
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.24" },
{ name = "pyyaml", specifier = ">=6" },
{ name = "unidecode", specifier = ">=1.4.0" },
{ name = "uvicorn", specifier = ">=0.30" },
{ name = "voluptuous", specifier = ">=0.14" },
]
@@ -691,15 +689,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "unidecode"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" },
]
[[package]]
name = "uvicorn"
version = "0.53.0"