Compare commits

..
Author SHA1 Message Date
orchestrator-agent b037c7d675 Add manual OpenCode cycle control and reusable prompt template 2026-09-22 11:37:59 -04:00
orchestrator-agent 4ac4d3f1ec Add durable NestQuest OpenCode controller and role launchers (#149)
Co-authored-by: orchestrator-agent <orchestrator@cubecraftlabs.com>
Co-committed-by: orchestrator-agent <orchestrator@cubecraftlabs.com>
2026-09-22 11:18:23 -04:00
40 changed files with 2452 additions and 1632 deletions
+32
View File
@@ -0,0 +1,32 @@
---
description: Independently verify NestQuest merge gates and merge approved heads
mode: primary
model: openrouter/deepseek/deepseek-v4.1-flash
permission:
edit: deny
write: deny
task: deny
bash:
"*": deny
"/home/overseer/.local/share/nestquest-controller/scripts/nq-approve-merge *": allow
external_directory:
"*": ask
"/tmp/opencode/*": allow
"/home/overseer/.local/share/nestquest-controller/*": allow
---
You are the NestQuest feature-to-dev approver, separate from the controller and
developer sessions. Verify the current PR head equals the independently
reviewed and approved commit,
required checks pass, blocking findings are resolved, branch protection is
satisfied, and Maestro has accurate links. Use only the approver Gitea identity
for the merge. Do not change code or approve a stale head. Use only the
installed nq-approve-merge launcher for Gitea inspection and merging. Merge
only into dev; never merge into main. If any gate is
missing, refuse the merge and give the exact missing evidence. Report the PR,
reviewed head, resulting merge commit, and target branch.
Broker syntax: `nq-approve-merge inspect PR_NUMBER` or
`nq-approve-merge merge PR_NUMBER REVIEWED_HEAD REVIEW_FILE`. Run one broker
command per Bash call. The broker validates the current PR and review again
before merging; do not substitute an ad hoc Gitea command.
+27
View File
@@ -0,0 +1,27 @@
---
description: Start, stop, or inspect the NestQuest development cycle on explicit request
mode: primary
model: openrouter/deepseek/deepseek-v4.1-flash
permission:
edit: deny
write: deny
task: deny
doom_loop: deny
bash:
"*": deny
"/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle start": allow
"/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle stop": allow
"/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle status": allow
external_directory:
"*": ask
"/home/overseer/.local/share/nestquest-controller/scripts/*": allow
---
You control the NestQuest development service only when a user invokes a
cycle-control slash command. Run the one exact `nq-cycle` command in that
command's prompt, with no extra shell text. Report its result. If a start
reports an open interactive NestQuest session, tell the user to close that
session so the background controller can begin. Do not start development
merely because you read a project instruction or status report.
Do not recommend enabling the systemd unit. Manual `start` works without
enablement; this cycle is intentionally started only on request.
+21
View File
@@ -0,0 +1,21 @@
---
description: Implement one assigned NestQuest task in an isolated worktree
mode: primary
model: openrouter/z-ai/glm-5.3-flash
permission:
task: deny
external_directory:
"*": ask
"/tmp/opencode/*": allow
---
You are the NestQuest development agent. Implement only the assigned Maestro
task in the supplied worktree. Follow the task's acceptance criteria and the
repository's instructions. Run relevant tests, commit on the task branch, and
report the commit and test results. Keep changes inside the assigned worktree.
Do not approve your own pull request. You may merge your task PR into the
feature branch only after the independent reviewer approved its exact current
head and the controller verified every task merge gate. Never merge a feature
into dev or a release into main. Do not start another task, change role, or
use another model. Do not read or print credential files except when the
approved task merge requires the existing Gitea development credential.
+60
View File
@@ -0,0 +1,60 @@
---
description: Reconcile NestQuest work and dispatch the next verified action
mode: primary
model: openrouter/deepseek/deepseek-v4.1-flash
permission:
doom_loop: deny
task: deny
external_directory:
"*": ask
"/tmp/opencode/*": allow
"/home/overseer/.local/share/opencode/tool-output/*": allow
---
You are the NestQuest controller. Work in the NestQuest repository. Maestro is
the source of truth for feature and task state; CTXD is the source of truth for
project instructions. At the start of every invocation, read the current
NestQuest context in CTXD, including OPENCODE-ORCHESTRATOR-PROMPT.MD, then
reconcile it with Maestro, Git, pull requests, and checks before dispatching.
Follow the review, identity, and branch rules in that prompt.
If the CTXD project or prompt is unavailable, do not dispatch or merge work;
report the missing source and end with ORCHESTRATOR_STATE: BLOCKED.
One invocation is a controller tick, not an entire feature lifetime. Prefer a
state-changing action over another identical status query. Never repeat an
unchanged tool call three times. If work is asynchronous, wait or inspect a
specific changed result. Do not claim that you are continuing while ending
the turn without naming the next action.
The NESTQUEST_CONTROL_HOME environment variable names the installed launchers
(normally /home/overseer/.local/share/nestquest-controller).
For implementation, run "$NESTQUEST_CONTROL_HOME/scripts/nq-agent" with role
developer, a task worktree, title, and task prompt file. For independent
review, run "$NESTQUEST_CONTROL_HOME/scripts/nq-review" with the worktree,
feature base ref, exact head ref, review prompt file, PR number, and an output
file under /tmp/opencode. It publishes an exact-head Gitea approval only after
Claude approves. The developer may merge
a task PR into its feature branch only after independent approval of the exact
head and verification of every task gate. For feature-to-dev approval and
merge, run "$NESTQUEST_CONTROL_HOME/scripts/nq-agent" with role approver in
a separate session after the independent review. Save the Claude review output
in a file under /tmp/opencode and give that file and its reviewed head to the
approver. Never substitute a different model or reviewer silently.
When a feature is integrated into dev, reconcile Maestro, then immediately
select and dispatch the next eligible feature. Feature completion is a
transition, not the end of the controller's job. A release awaiting Joshua's
main merge does not prevent work on independent eligible features.
End each invocation with exactly one final line using one of these values:
ORCHESTRATOR_STATE: CONTINUE
ORCHESTRATOR_STATE: QUIESCENT
ORCHESTRATOR_STATE: BLOCKED
Use CONTINUE when there is any eligible or in-flight work; include the feature,
task, and next concrete action immediately above the final line. Use QUIESCENT
only after verifying no eligible or in-flight work remains in Maestro and the
repository. Use BLOCKED only when a specific decision or access is required;
include evidence and the needed human action. If you cannot establish the
state, use BLOCKED. Never emit a success claim based only on a child's summary.
+11
View File
@@ -0,0 +1,11 @@
---
description: Show the NestQuest development cycle status
agent: cycle-control
---
Run exactly this one shell command:
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle status`
Report whether the controller is active and its last tick state.
Do not suggest enabling automatic startup.
+12
View File
@@ -0,0 +1,12 @@
---
description: Start the NestQuest development cycle on this host
agent: cycle-control
---
Run exactly this one shell command:
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle start`
Report its status. If it says an interactive NestQuest OpenCode session is
open, tell me to close this session; the background controller will begin
afterward. Do not launch a developer agent or a controller tick in this chat.
+10
View File
@@ -0,0 +1,10 @@
---
description: Stop the NestQuest development cycle on this host
agent: cycle-control
---
Run exactly this one shell command:
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle stop`
Report the service status. Do not stop any other OpenCode session or task.
+74
View File
@@ -0,0 +1,74 @@
# NestQuest controller
OpenCode 1.18.31 loads the three named agents from `.opencode/agents/`. Their
models match the current NestQuest CTXD prompt: DeepSeek V4.1
Flash for controller and approver, GLM 5.3 Flash for development. The
controller runs one OpenCode turn at a time, then starts a fresh turn based on
Maestro, CTXD, Git, and pull-request state. Its state file is
`~/.local/state/nestquest-controller/last-run.json` (mode 0600). A lock allows
only one controller process. It waits while an interactive OpenCode session is
open for the NestQuest checkout.
`scripts/nq-agent` launches developer and approver sessions with fixed agent
definitions and no `--auto` flag. Headless OpenCode runs use `--pure` so the
global idle-notification plugin does not notify on every controller tick.
The approver's shell permission permits only the installed
`nq-approve-merge` broker for Gitea. That broker requires exact-head
Claude approval evidence, an exact-head Gitea approval by `review_agent`,
the `dev` target, a mergeable PR, and passing configured checks before it
requests a squash merge with Gitea's atomic `head_commit_id` guard.
`scripts/nq-review` launches the independent
Claude review in a noninteractive read-only session in the task worktree,
checks the head again afterward, saves
the output under `/tmp/opencode`, and posts the Gitea approval.
The approver verifies the reviewed head again before a merge.
The production service uses a stable copy of this configuration and scripts
at `/home/overseer/.local/share/nestquest-controller`; it does not depend on
which task branch is checked out in the primary NestQuest directory. Install
that copy, run `scripts/nq-controller --check`, and verify all four OpenCode
agents (orchestrator, developer, approver, cycle-control)
with `opencode debug agent <name>` while `OPENCODE_CONFIG_DIR` points at the
installed `.opencode` directory. Install `deploy/nestquest-controller.service`
under `~/.config/systemd/user/` and reload the user manager. Its lack of an
`[Install]` section makes it a manual-only unit; development starts only
when requested. Copy `deploy/opencode-global-commands/*.md` into
`~/.config/opencode/commands/` and `.opencode/agents/cycle-control.md` into
`~/.config/opencode/agents/` for the commands to work from older branches.
The host has lingering
enabled for the `overseer` user. The service waits for an open interactive
NestQuest session to close before starting work.
In NestQuest's OpenCode session, use `/start-development`,
`/stop-development`, or `/development-status`. The matching global
`/start-nestquest-development`, `/stop-nestquest-development`, and
`/nestquest-development-status` commands work even while an older branch is
checked out. Starting the unit in an interactive NestQuest session puts it
in a waiting state; close that session to let the first controller tick run.
The commands call `scripts/nq-cycle`; they do not run a developer inside
the interactive chat.
`templates/ORCHESTRATOR-PROMPT.template.md` and
`templates/nestquest.profile.json` make the current CTXD cycle prompt
adaptable to another project. See `templates/README.md` before publishing a
rendered prompt for a new project.
The CTXD preflight reads the configured HTTP/MCP server's NestQuest prompt.
It deliberately does not inspect `/home/overseer/.ctx/ctxd.db`, which belongs
to a different local store and lacks this project.
All four agent credentials are currently readable by the same Unix account.
The Gitea review and merge gates enforce normal workflow sequencing, but
they are not a hard isolation boundary against a process running as that
account. Strong isolation requires separate OS identities and protected
credentials for the controller, reviewer, and approver.
A tick has an explicit six-hour timeout. On timeout or an OpenCode error, the
next tick must reconcile external state before acting. Three consecutive
failed ticks stop the service. A BLOCKED verdict also stops it. QUIESCENT
causes a five-minute poll. Neither a feature merge nor a progress report
stops the controller while eligible work remains.
Do not put tokens in this repository or in unit files. Existing OpenCode MCP
connections supply CTXD and Maestro. Rotate those MCP tokens as a coordinated
operation across every consumer before revoking the old values.
-19
View File
@@ -1,19 +0,0 @@
"""NestQuest API service.
A standalone FastAPI deployable that owns the NestQuest database and
exposes it over HTTP to the Home Assistant integration (panel plane) and
the admin PWA (admin plane). See Feature 16 (task cd03735b) in the
project tracker for the full contract.
This package imports the NestQuest domain ``core`` WITHOUT executing
``custom_components/nestquest/__init__.py`` — that parent package's
``__init__.py`` imports ``homeassistant``, which is absent from the
API's runtime. The coupling is encapsulated in
:mod:`api.nestquest_core`, which loads ``core`` by file location with
:mod:`importlib.util` and registers it under the distinct top-level name
``nestquest_core`` (no ``sys.path`` mutation, no generic top-level
names published). It is a TRANSITIONAL coupling that Feature 18 will
revisit: once the integration becomes a thin API client and ``core``
moves out of the integration bundle, this helper goes away.
"""
from __future__ import annotations
-99
View File
@@ -1,99 +0,0 @@
"""The NestQuest FastAPI application.
This module builds the ASGI app for the NestQuest API service (Feature
16). The skeleton here wires the bundled ``core`` (Feature 15) and
exposes the two endpoints every later task depends on:
- ``GET /health`` — returns ``200`` with a JSON status body once the
app has imported core, opened the database, and applied migrations;
a cheap ``SELECT 1`` confirms the connection is live without leaking
the filesystem path on an unauthenticated route.
- ``GET /docs`` — FastAPI's auto-served OpenAPI document (Swagger UI);
``/openapi.json`` is the raw schema. No extra wiring is needed;
FastAPI serves both by default.
The database is opened and migrated in a FastAPI lifespan context so a
startup failure (e.g. an unwritable path) surfaces as a startup error
rather than the first request. The connection is closed on shutdown.
This module exports ONLY the :func:`create_app` factory — there is no
module-level app instance, because building one at import time would
read ``NESTQUEST_DB_PATH`` at import (and fail-fast on a missing value)
before any worker is ready to report it. Serve with::
uvicorn --factory api.app:create_app
so the factory runs on each worker startup and reads the environment
then.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from fastapi import FastAPI
from api.config import ApiConfig
from api.database import DatabaseState, make_database
def _build_lifespan(db_path: str):
"""Return a lifespan that opens+migrates then closes one database.
Captured as a closure so the app factory can hand the configured
path in without threading it through FastAPI's lifespan signature.
"""
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
state = make_database(db_path)
app.state.db = state
try:
await state.open_and_migrate()
yield
finally:
await state.close()
return lifespan
def create_app(config: ApiConfig | None = None) -> FastAPI:
"""Build the NestQuest API FastAPI app.
``config`` defaults to one built from the process environment, so
``uvicorn --factory api.app:create_app`` picks up
``NESTQUEST_DB_PATH`` (and fails fast on startup if it is missing).
Tests pass an explicit config so each test gets a hermetic temp
SQLite file.
"""
cfg = config if config is not None else ApiConfig.from_env()
app = FastAPI(
title="NestQuest API",
description=(
"Standalone HTTP service that owns the NestQuest database and "
"exposes it to the Home Assistant integration (panel plane) and "
"the admin PWA (admin plane)."
),
version="0.1.0",
lifespan=_build_lifespan(cfg.db_path),
)
@app.get("/health", summary="Service health")
async def health() -> dict[str, str]:
"""Return service health.
Returns ``200`` with ``{"status": "ok"}`` once the lifespan has
opened and migrated the database. A cheap ``SELECT 1`` against
the live connection confirms the database is reachable WITHOUT
leaking the filesystem path on an unauthenticated route; if the
connection is dead the query raises and the probe fails.
"""
state: DatabaseState = app.state.db
await state.database.fetch_one("SELECT 1")
return {"status": "ok"}
return app
__all__ = ["create_app"]
-68
View File
@@ -1,68 +0,0 @@
"""Configuration for the NestQuest API service.
The API reads its single configuration knob — the SQLite database path —
from the ``NESTQUEST_DB_PATH`` environment variable. Unlike a dev
convenience default, a missing or empty value is a configuration ERROR,
not a silent fallback to a predictable world-writable temp path: the API
owns the database (it is the writer of record per Feature 16's
contract), and silently dropping data into ``$TMPDIR/nestquest.db``
would lose it at reboot. ``ApiConfig.from_env`` therefore RAISES when
the variable is unset or empty. This mirrors the integration's
"explicit settings object" discipline (Feature 15's
``core.settings``): the API never reads HA config; callers pass the
resolved path into the database wiring.
Serving: the app is built with the factory ``api.app:create_app`` — run
it with ``uvicorn --factory api.app:create_app`` so the factory reads
the environment on each worker startup rather than at import time.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
#: Environment variable holding the SQLite file path. The API opens
#: this file on startup (creating it if absent) and applies migrations.
#: REQUIRED — unset or empty raises :class:`ConfigError` from
#: :meth:`ApiConfig.from_env`.
DB_PATH_ENV = "NESTQUEST_DB_PATH"
class ConfigError(RuntimeError):
"""Raised when required API configuration is missing or invalid."""
@dataclass(frozen=True)
class ApiConfig:
"""Resolved API configuration.
Currently a single field — the SQLite path — but kept as a dataclass
so later tasks (auth, SSE, settings persistence) can extend it
without touching the call sites that read ``db_path``.
"""
db_path: str
@classmethod
def from_env(cls, env: dict[str, str] | None = None) -> "ApiConfig":
"""Build configuration from the process environment.
``env`` defaults to ``os.environ``; tests pass an explicit dict
so they are hermetic (no monkeypatch of the global environ).
Raises :class:`ConfigError` when ``NESTQUEST_DB_PATH`` is unset
or empty/whitespace-only — there is no silent default, because a
default in the temp directory would silently lose data at reboot.
"""
source = env if env is not None else os.environ
raw = source.get(DB_PATH_ENV, "").strip()
if not raw:
raise ConfigError(
f"{DB_PATH_ENV} must be set to a writable SQLite file path "
f"(it is unset or empty — the API will not fall back to a "
f"temp directory and silently lose data)"
)
return cls(db_path=raw)
__all__ = ["ApiConfig", "ConfigError", "DB_PATH_ENV"]
-82
View File
@@ -1,82 +0,0 @@
"""Database wiring for the NestQuest API service.
The API owns the database (it is the writer of record per Feature 16's
contract). This module owns one application-lifetime
:class:`core.db.NestQuestDatabase`: it opens the SQLite file at the
configured path on startup, applies migrations (creating the schema on
a fresh file), and closes the connection on shutdown.
The executor callable passed into ``NestQuestDatabase`` mirrors the
contract of ``hass.async_add_executor_job``: schedule a synchronous
callable on a worker and await its result. HA's own version lives on
the event loop; the API is not HA, so it uses
:func:`asyncio.to_thread` for the same effect — sqlite3 calls run on a
worker thread and only plain data returns to the event loop.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from api.nestquest_core import core_db, core_migrations
LOGGER = logging.getLogger(__name__)
#: The async SQLite wrapper. Re-exported so route handlers and later
#: tasks reach it through the API package, not the bundled core.
NestQuestDatabase = core_db.NestQuestDatabase
#: The migration runner. Re-exported for the same reason.
apply_migrations = core_migrations.apply_migrations
async def _executor(fn: Any, *args: Any, **kwargs: Any) -> Any:
"""Run a synchronous callable on a worker thread and await its result.
Mirrors the contract of ``hass.async_add_executor_job`` so the
bundled ``NestQuestDatabase`` (written against that contract) works
unchanged in the API process: sqlite3 calls never block the event
loop, and only plain Python data (row tuples,
:class:`core.db.ExecutionResult`) crosses back.
"""
return await asyncio.to_thread(fn, *args, **kwargs)
class DatabaseState:
"""Holds the live database wrapper and its resolved path.
A small container so the lifespan and the route handlers share one
authoritative reference; FastAPI's lifespan stores one of these on
``app.state.db`` and the health route reads it back.
"""
__slots__ = ("database", "db_path")
def __init__(self, database: NestQuestDatabase, db_path: str) -> None:
self.database = database
self.db_path = db_path
async def open_and_migrate(self) -> None:
"""Open the connection and apply migrations to the latest version."""
LOGGER.info("Opening NestQuest database at %s", self.db_path)
await self.database.open(self.db_path)
version = await apply_migrations(self.database)
LOGGER.info("NestQuest schema at version %d", version)
async def close(self) -> None:
"""Close the connection; safe to call even if open failed."""
await self.database.close()
def make_database(db_path: str) -> DatabaseState:
"""Construct a :class:`DatabaseState` for the given path (not yet open)."""
return DatabaseState(NestQuestDatabase(_executor), db_path)
__all__ = [
"DatabaseState",
"NestQuestDatabase",
"apply_migrations",
"make_database",
]
-110
View File
@@ -1,110 +0,0 @@
"""Transitional coupling: import ``core`` without importing Home Assistant.
The NestQuest domain ``core`` package (Feature 15) is bundled INSIDE the
Home Assistant integration at ``custom_components/nestquest/core/``.
Importing it the obvious way — ``import custom_components.nestquest.core``
— would execute ``custom_components/nestquest/__init__.py``, which
imports ``homeassistant``. Home Assistant is NOT installed in the API
service's runtime, so that import raises ``ModuleNotFoundError`` and the
API cannot start.
``core`` itself is a clean, Home-Assistant-free package: its
``__init__.py`` is a bare docstring and every internal import is
relative (``from .db import ...``). It can therefore be imported by
file location as a standalone package.
This module loads ``core`` with :func:`importlib.util.spec_from_file_location`
— registering it under the distinct top-level name ``nestquest_core``
with ``submodule_search_locations`` set to the bundled ``core``
directory — so its relative imports resolve against that directory
without touching ``sys.path``. No generic top-level name (``db``,
``const``, ``schema``, ...) is published, so nothing here can silently
shadow a future dependency, and the integration's HA-coupled
``__init__.py`` is never executed: ``custom_components.nestquest`` is
never imported.
Usage::
from api.nestquest_core import core_db, core_migrations
# core_db.NestQuestDatabase, core_migrations.apply_migrations, ...
This is a TRANSITIONAL coupling. Feature 18 (task 430f7f98) converts
the integration into a thin API client and moves ``core`` out of the
integration bundle; once that lands, this bootstrap helper goes away and
the API imports ``core`` as an ordinary installed dependency.
NOTE on the pytest process: the suite's ``conftest.py`` stubs the
parent integration package and imports ``custom_components.nestquest.core.*``
BEFORE any API test runs, so two copies of the core package coexist in
the pytest process — the one registered here as ``nestquest_core.*``
(loaded by file location) and the conftest-stubbed
``custom_components.nestquest.core.*``. Later tasks must NOT compare
class or exception identity across these two copies: a
``nestquest_core.db.NestQuestDatabase`` is a DIFFERENT class object from
``custom_components.nestquest.core.db.NestQuestDatabase`` even though
they share source. Operate on a single copy within any one code path.
"""
from __future__ import annotations
import importlib
import importlib.util
import sys
from pathlib import Path
from types import ModuleType
#: The repository root (this file is ``<root>/api/nestquest_core.py``).
_REPO_ROOT: Path = Path(__file__).resolve().parent.parent
#: The bundled ``core`` package directory, inside the HA integration.
_CORE_DIR: Path = _REPO_ROOT / "custom_components" / "nestquest" / "core"
#: The ``core`` package's ``__init__.py`` — a bare docstring, so loading
#: it by file location executes no imports of its own.
_CORE_INIT: Path = _CORE_DIR / "__init__.py"
#: The distinct top-level name under which the bundled ``core`` package
#: is registered in ``sys.modules``. A distinctive name (rather than the
#: generic ``core``) avoids publishing shadow-able top-level names and
#: keeps the bundled copy separate from the conftest-stubbed
#: ``custom_components.nestquest.core`` present in the pytest process.
_CORE_MODULE_NAME = "nestquest_core"
def _load_core() -> ModuleType:
"""Load the bundled ``core`` package by file location and return it.
Registers the package under :data:`_CORE_MODULE_NAME` in
``sys.modules`` with ``submodule_search_locations`` pointing at the
bundled directory, so its relative imports (``from .db import ...``)
resolve to ``nestquest_core.<submodule>`` against that directory
without mutating ``sys.path``. Idempotent: a second call returns
the already-registered module.
"""
existing = sys.modules.get(_CORE_MODULE_NAME)
if existing is not None:
return existing
spec = importlib.util.spec_from_file_location(
_CORE_MODULE_NAME,
_CORE_INIT,
submodule_search_locations=[str(_CORE_DIR)],
)
if spec is None or spec.loader is None:
raise ImportError(f"could not load core from {_CORE_INIT}")
module = importlib.util.module_from_spec(spec)
sys.modules[_CORE_MODULE_NAME] = module
spec.loader.exec_module(module)
return module
#: The bundled core package, loaded by file location and registered as
#: ``nestquest_core`` in ``sys.modules``. Held here so callers import the
#: submodules through it (``nestquest_core.db``, ``nestquest_core.migrations``)
#: the same relative way the integration does.
core: ModuleType = _load_core()
#: Re-exported for convenience; route handlers import these names from
#: here rather than reaching into the core package directly.
core_db = importlib.import_module(f"{_CORE_MODULE_NAME}.db")
core_migrations = importlib.import_module(f"{_CORE_MODULE_NAME}.migrations")
__all__ = ["core", "core_db", "core_migrations"]
-1
View File
@@ -496,7 +496,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass,
entry_id=entry.entry_id,
database=database,
settings=settings,
update_interval_seconds=entry.options.get(
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
),
+196 -26
View File
@@ -11,15 +11,6 @@ refresh updates the whole board coherently.
host clock and never UTC, matching the materialization and completion
layers.
The snapshot assembly itself lives in the Home-Assistant-free
:mod:`custom_components.nestquest.core.snapshot` module
(:func:`~core.snapshot.build_snapshot`); this coordinator is now a thin
HA wrapper that resolves the local timezone and reads the settings handed
into its constructor, then delegates to that builder, passing the SAME
HA-local ``now`` it resolved in one clock read. The API service's panel
snapshot route shares the same builder, so both consumers render ONE
snapshot shape.
Presence is resolved through the SAME engine the materializer uses
(:class:`~.presence.PresenceEngine`): schedules and overrides are read
with the public DAO methods and the engine's anchor-date arithmetic
@@ -36,11 +27,13 @@ from __future__ import annotations
import asyncio
import datetime
from dataclasses import dataclass
from zoneinfo import ZoneInfo
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .completion import derive_state
from .const import (
CONF_UPDATE_INTERVAL,
DEFAULT_UPDATE_INTERVAL,
@@ -48,21 +41,66 @@ from .const import (
LOGGER,
MIN_UPDATE_INTERVAL,
)
from .core.db import NestQuestDatabase
from .core.settings import NestQuestSettings
from .core.snapshot import (
ChildDaySnapshot,
NestQuestSnapshot,
QuestInstanceView,
build_snapshot,
from .dao_children import ChildrenDao
from .dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
)
from .dao_rules import QuestDefinitionsDao
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
from .db import NestQuestDatabase
from .presence import (
MAX_PREVIEW_SCAN_DAYS,
PresenceEngine,
PresenceOverride,
PresenceSchedule,
)
__all__ = [
"ChildDaySnapshot",
"NestQuestCoordinator",
"NestQuestSnapshot",
"QuestInstanceView",
]
@dataclass(frozen=True)
class QuestInstanceView:
"""One instance plus everything the entities render from it."""
instance_id: int
definition_id: int
child_id: int
title: str
icon: str | None
window: str
due_date: str
due_time: str | None
state: str # open | done | missed
overdue: bool
completed_at: str | None
was_on_time: bool | None
@dataclass(frozen=True)
class ChildDaySnapshot:
"""One active child's day: instances, presence, and rollup counts."""
child_id: int
child_name: str
instances: tuple[QuestInstanceView, ...]
present: bool
due_today: int
completed_today: int
remaining_today: int
completion_pct: int
#: For a child away today: the ISO date of their next present day
#: (the panel's away plate renders ``Returns <weekday>, <Mon D>``
#: from it). ``None`` when the child is present today or no
#: present day exists within the presence engine's scan cap.
next_present: str | None = None
@dataclass(frozen=True)
class NestQuestSnapshot:
"""The coordinator's whole-entity data payload."""
today_iso: str
children: tuple[ChildDaySnapshot, ...]
cycle_day: int
class NestQuestCoordinator(DataUpdateCoordinator):
@@ -83,7 +121,6 @@ class NestQuestCoordinator(DataUpdateCoordinator):
*,
entry_id: str,
database: NestQuestDatabase,
settings: NestQuestSettings,
update_interval_seconds: int | None = None,
) -> None:
interval = update_interval_seconds
@@ -101,7 +138,6 @@ class NestQuestCoordinator(DataUpdateCoordinator):
)
self.entry_id = entry_id
self.database = database
self.settings = settings
self._refresh_lock = asyncio.Lock()
async def async_refresh(self) -> None:
@@ -117,6 +153,140 @@ class NestQuestCoordinator(DataUpdateCoordinator):
async def _async_update_data(self) -> NestQuestSnapshot:
now = self._local_now()
return await build_snapshot(
self.database, self.settings, now
today = now.date()
today_iso = today.isoformat()
children = await ChildrenDao(self.database).list_active()
schedules: dict[int, PresenceSchedule] = {}
overrides: dict[int, list[PresenceOverride]] = {}
for child in children:
schedule = await PresenceSchedulesDao(self.database).get_by_child(
child.id
)
if schedule is not None:
schedules[child.id] = PresenceSchedule.decode(
child.id, schedule.anchor_date, schedule.pattern
)
window_end = today + datetime.timedelta(
days=MAX_PREVIEW_SCAN_DAYS
)
window_end_iso = window_end.isoformat()
ranged = await PresenceOverridesDao(
self.database
).list_by_child_and_range(child.id, today_iso, window_end_iso)
if ranged:
# The engine validates MODEL objects, not DAO records;
# each record converts losslessly (the schema guarantees
# end >= start and a real is_present).
overrides[child.id] = [
PresenceOverride(
record.child_id,
record.start_date,
record.end_date,
record.is_present,
record.note,
)
for record in ranged
]
engine = PresenceEngine(schedules, overrides)
instances_dao = QuestInstancesDao(self.database)
events_dao = CompletionEventsDao(self.database)
definitions_dao = QuestDefinitionsDao(self.database)
snapshots: list[ChildDaySnapshot] = []
for child in children:
instances = await instances_dao.list_by_date_range(
child.id, today_iso, today_iso
)
views: list[QuestInstanceView] = []
for instance in instances:
latest = await events_dao.get_latest_for_instance(instance.id)
definition = await definitions_dao.get(instance.definition_id)
state = derive_state(instance, latest, today)
overdue = False
if state == "open" and instance.due_date == today_iso:
if instance.due_time is not None:
due_moment = datetime.datetime.strptime(
instance.due_time, "%H:%M"
).time()
overdue = now.time() > due_moment
views.append(
QuestInstanceView(
instance_id=instance.id,
definition_id=instance.definition_id,
child_id=instance.child_id,
title=definition.title if definition else "Quest",
icon=definition.icon if definition else None,
window=instance.window,
due_date=instance.due_date,
due_time=instance.due_time,
state=state,
overdue=overdue,
completed_at=(
latest.occurred_at
if latest is not None
and latest.event_type == "completed"
else None
),
was_on_time=(
latest.was_on_time
if latest is not None
and latest.event_type == "completed"
else None
),
)
)
due = len(views)
completed = sum(1 for view in views if view.state == "done")
remaining = due - completed
child_present = engine.is_present(child.id, today)
next_present: str | None = None
if not child_present:
try:
next_present = engine.next_present_dates(
child.id, today, 1
)[0].isoformat()
except ValueError:
next_present = None
snapshots.append(
ChildDaySnapshot(
child_id=child.id,
child_name=child.display_name,
instances=tuple(views),
present=child_present,
due_today=due,
completed_today=completed,
remaining_today=remaining,
completion_pct=(
round(completed * 100 / due) if due else 100
),
next_present=next_present,
)
)
return NestQuestSnapshot(
today_iso=today_iso,
children=tuple(snapshots),
cycle_day=_cycle_day(schedules, children, today),
)
def _cycle_day(
schedules: dict[int, PresenceSchedule], children, today: datetime.date
) -> int:
"""Return today's 1-based day in the first scheduled child's cycle.
Anchor-date arithmetic ONLY (D-004): the day offset is computed from
the schedule's own anchor, never ISO week numbers or parity, so a
53-week year cannot silently invert the cycle.
"""
for child in children:
schedule = schedules.get(child.id)
if schedule is None:
continue
days = (today - schedule.anchor_date).days
cycle_length = schedule.cycle_length_weeks * 7
return (days % cycle_length) + 1
return 0
@@ -1,306 +0,0 @@
"""Pure, Home-Assistant-free panel snapshot builder.
The household snapshot the NestQuest panel renders — active children in
sort order, each child's today-instances in the documented payload shape
(missed instances OMITTED from the entity-facing attribute payload, but
retained on the snapshot for the count rollups), per-child counts and
presence, and the custody cycle day — used to be assembled inline inside
:mod:`custom_components.nestquest.coordinator`'s
``_async_update_data``. That assembly is HA-coupled only because its
host is a :class:`~homeassistant.helpers.update_coordinator.DataUpdateCoordinator`;
the assembly itself touches no HA surface.
This module holds the pure pieces — the snapshot dataclasses
(:class:`QuestInstanceView`, :class:`ChildDaySnapshot`,
:class:`NestQuestSnapshot`), the cycle-day helper (:func:`_cycle_day`),
the panel payload shaper (:func:`instance_payload`), and the async
:func:`build_snapshot` builder — so the HA coordinator and the API
service's panel snapshot route share ONE implementation of both the
snapshot assembly and the entity-facing attribute payload shape.
Nothing here imports :mod:`homeassistant`; the builder takes the
database, the settings object, and the HA-local ``now`` (a single
clock read the caller already resolved, from which it derives
``today``) and returns the snapshot, mirroring exactly what the
coordinator used to do inline.
``settings`` is accepted because the API route hands in the entry's
:class:`~.settings.NestQuestSettings`; it is CURRENTLY UNUSED — the
assembly reads nothing from it — and is retained so the builder's
contract stays stable if a future panel feature derives scan caps or
rollup behaviour from configuration. No speculative read is wired in.
"""
from __future__ import annotations
import datetime
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
from .completion import derive_state
from .dao_children import ChildrenDao
from .dao_instances import CompletionEventsDao, QuestInstancesDao
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
from .dao_rules import QuestDefinitionsDao
from .db import NestQuestDatabase
from .presence import (
MAX_PREVIEW_SCAN_DAYS,
PresenceEngine,
PresenceOverride,
PresenceSchedule,
)
from .settings import NestQuestSettings
@dataclass(frozen=True)
class QuestInstanceView:
"""One instance plus everything the entities render from it."""
instance_id: int
definition_id: int
child_id: int
title: str
icon: str | None
window: str
due_date: str
due_time: str | None
state: str # open | done | missed
overdue: bool
completed_at: str | None
was_on_time: bool | None
@dataclass(frozen=True)
class ChildDaySnapshot:
"""One active child's day: instances, presence, and rollup counts."""
child_id: int
child_name: str
instances: tuple[QuestInstanceView, ...]
present: bool
due_today: int
completed_today: int
remaining_today: int
completion_pct: int
#: For a child away today: the ISO date of their next present day
#: (the panel's away plate renders ``Returns <weekday>, <Mon D>``
#: from it). ``None`` when the child is present today or no
#: present day exists within the presence engine's scan cap.
next_present: str | None = None
@dataclass(frozen=True)
class NestQuestSnapshot:
"""The coordinator's whole-entity data payload."""
today_iso: str
children: tuple[ChildDaySnapshot, ...]
cycle_day: int
def _cycle_day(
schedules: dict[int, PresenceSchedule], children, today: datetime.date
) -> int:
"""Return today's 1-based day in the first scheduled child's cycle.
Anchor-date arithmetic ONLY (D-004): the day offset is computed from
the schedule's own anchor, never ISO week numbers or parity, so a
53-week year cannot silently invert the cycle.
"""
for child in children:
schedule = schedules.get(child.id)
if schedule is None:
continue
days = (today - schedule.anchor_date).days
cycle_length = schedule.cycle_length_weeks * 7
return (days % cycle_length) + 1
return 0
def instance_payload(
instances: Iterable[QuestInstanceView], *, include_missed: bool
) -> list[dict[str, Any]]:
"""Shape instance dicts per ENTITIES-AND-SERVICES.md §1.
The panel payload ``state`` is the documented ``open`` | ``completed``
spelling, so the builder's derived ``done`` maps to ``completed`` here;
``open`` passes through. ``missed`` instances are OMITTED from the
entity-facing attribute payload (decision 5) unless ``include_missed``
is set — the admin payload (D-009) keeps them with their ``missed``
state. ``on_time`` passes the builder's ``was_on_time`` through
verbatim (``True`` / ``False`` for a completed instance, ``None`` for
an open one). This is the SINGLE shared implementation both the HA
sensor layer and the API panel route read; it touches no Home
Assistant surface.
"""
payload: list[dict[str, Any]] = []
for view in instances:
if view.state == "missed" and not include_missed:
continue
payload.append(
{
"id": view.instance_id,
"definition_id": view.definition_id,
"child_id": view.child_id,
"title": view.title,
"icon": view.icon,
"window": view.window,
"due_time": view.due_time,
"state": (
"completed" if view.state == "done" else view.state
),
"overdue": view.overdue,
"completed_at": view.completed_at,
"on_time": view.was_on_time,
}
)
return payload
async def build_snapshot(
database: NestQuestDatabase,
settings: NestQuestSettings,
now: datetime.datetime,
) -> NestQuestSnapshot:
"""Build the household panel snapshot for ``today`` at the ``now`` instant.
Mirrors the assembly that lived in the coordinator's
``_async_update_data``: active children in sort order, each child's
today-instances with derived state (open / done / missed via
:func:`~.completion.derive_state`) and the overdue flag, per-child
presence (resolved through :class:`~.presence.PresenceEngine`), the
per-child rollup counts, and the custody cycle day of the first
scheduled active child. Missed instances are retained on the
snapshot (the rollup counts and the panel's missed plate need them)
but the entity-facing attribute payload omits them at the
:func:`instance_payload` layer — this builder produces the shared
shape both consumers read.
``now`` is the HA-local timezone-aware datetime the caller already
resolved (a SINGLE clock read); the overdue check compares
``now.time()`` against the instance's ``due_time`` for today's open
instances. The builder performs NO internal clock read — it never
calls :func:`datetime.datetime.now` — so the instant is pinned in
tests. ``today`` is derived INSIDE the builder as ``now.date()``,
so the calendar date that anchors the due-date and cycle-day
arithmetic is always the SAME clock read as the wall-clock time;
the two can never disagree. ``settings`` is accepted but currently
unused (see the module docstring).
"""
today = now.date()
today_iso = today.isoformat()
children = await ChildrenDao(database).list_active()
schedules: dict[int, PresenceSchedule] = {}
overrides: dict[int, list[PresenceOverride]] = {}
for child in children:
schedule = await PresenceSchedulesDao(database).get_by_child(
child.id
)
if schedule is not None:
schedules[child.id] = PresenceSchedule.decode(
child.id, schedule.anchor_date, schedule.pattern
)
window_end = today + datetime.timedelta(
days=MAX_PREVIEW_SCAN_DAYS
)
window_end_iso = window_end.isoformat()
ranged = await PresenceOverridesDao(
database
).list_by_child_and_range(child.id, today_iso, window_end_iso)
if ranged:
# The engine validates MODEL objects, not DAO records;
# each record converts losslessly (the schema guarantees
# end >= start and a real is_present).
overrides[child.id] = [
PresenceOverride(
record.child_id,
record.start_date,
record.end_date,
record.is_present,
record.note,
)
for record in ranged
]
engine = PresenceEngine(schedules, overrides)
instances_dao = QuestInstancesDao(database)
events_dao = CompletionEventsDao(database)
definitions_dao = QuestDefinitionsDao(database)
snapshots: list[ChildDaySnapshot] = []
for child in children:
instances = await instances_dao.list_by_date_range(
child.id, today_iso, today_iso
)
views: list[QuestInstanceView] = []
for instance in instances:
latest = await events_dao.get_latest_for_instance(instance.id)
definition = await definitions_dao.get(instance.definition_id)
state = derive_state(instance, latest, today)
overdue = False
if state == "open" and instance.due_date == today_iso:
if instance.due_time is not None:
due_moment = datetime.datetime.strptime(
instance.due_time, "%H:%M"
).time()
overdue = now.time() > due_moment
views.append(
QuestInstanceView(
instance_id=instance.id,
definition_id=instance.definition_id,
child_id=instance.child_id,
title=definition.title if definition else "Quest",
icon=definition.icon if definition else None,
window=instance.window,
due_date=instance.due_date,
due_time=instance.due_time,
state=state,
overdue=overdue,
completed_at=(
latest.occurred_at
if latest is not None
and latest.event_type == "completed"
else None
),
was_on_time=(
latest.was_on_time
if latest is not None
and latest.event_type == "completed"
else None
),
)
)
due = len(views)
completed = sum(1 for view in views if view.state == "done")
remaining = due - completed
child_present = engine.is_present(child.id, today)
next_present: str | None = None
if not child_present:
try:
next_present = engine.next_present_dates(
child.id, today, 1
)[0].isoformat()
except ValueError:
next_present = None
snapshots.append(
ChildDaySnapshot(
child_id=child.id,
child_name=child.display_name,
instances=tuple(views),
present=child_present,
due_today=due,
completed_today=completed,
remaining_today=remaining,
completion_pct=(
round(completed * 100 / due) if due else 100
),
next_present=next_present,
)
)
return NestQuestSnapshot(
today_iso=today_iso,
children=tuple(snapshots),
cycle_day=_cycle_day(schedules, children, today),
)
+36 -3
View File
@@ -39,7 +39,6 @@ from .coordinator import (
NestQuestCoordinator,
QuestInstanceView,
)
from .core.snapshot import instance_payload
#: Home Assistant's state machine refuses states longer than 255
#: characters (its hard recorder/state limit). These sensors' states
@@ -64,6 +63,40 @@ def _state_within_limit(state: Any) -> Any:
return state
def _instance_payload(
instances: Iterable[QuestInstanceView], *, include_missed: bool
) -> list[dict[str, Any]]:
"""Shape instance dicts per ENTITIES-AND-SERVICES.md §1.
``state`` maps the coordinator's derived ``done`` to the panel's
``completed`` spelling; ``missed`` instances are omitted unless
``include_missed`` (the admin payload, D-009, keeps them with
their ``missed`` state).
"""
payload: list[dict[str, Any]] = []
for view in instances:
if view.state == "missed" and not include_missed:
continue
payload.append(
{
"id": view.instance_id,
"definition_id": view.definition_id,
"child_id": view.child_id,
"title": view.title,
"icon": view.icon,
"window": view.window,
"due_time": view.due_time,
"state": (
"completed" if view.state == "done" else view.state
),
"overdue": view.overdue,
"completed_at": view.completed_at,
"on_time": view.was_on_time,
}
)
return payload
#: State reported by the next-quest sensor when the child owes nothing
#: else today. A documented sentinel, never an empty string (an empty
#: state reads as "sensor went unavailable" in HA).
@@ -182,10 +215,10 @@ class NestQuestQuestsDueTodaySensor(_NestQuestChildDaySensor):
if child is None:
return None
return {
"instances": instance_payload(
"instances": _instance_payload(
child.instances, include_missed=False
),
"admin_instances": instance_payload(
"admin_instances": _instance_payload(
child.instances, include_missed=True
),
"child_id": child.child_id,
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=NestQuest OpenCode controller
[Service]
Type=simple
WorkingDirectory=/mnt/ai-storage/projects/NestQuest
ExecStartPre=/home/overseer/.local/share/nestquest-controller/scripts/nq-controller --check
ExecStart=/home/overseer/.local/share/nestquest-controller/scripts/nq-controller
Restart=on-failure
RestartSec=60s
RestartPreventExitStatus=77
TimeoutStartSec=0
KillMode=control-group
Environment=PYTHONUNBUFFERED=1
@@ -0,0 +1,11 @@
---
description: Show the NestQuest development cycle status
agent: cycle-control
---
Run exactly this one shell command:
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle status`
Report whether the controller is active and its last tick state.
Do not suggest enabling automatic startup.
@@ -0,0 +1,12 @@
---
description: Start the NestQuest development cycle on this host
agent: cycle-control
---
Run exactly this one shell command:
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle start`
Report its status. If it says an interactive NestQuest OpenCode session is
open, tell me to close this session; the background controller will begin
afterward. Do not launch a developer agent or a controller tick in this chat.
@@ -0,0 +1,10 @@
---
description: Stop the NestQuest development cycle on this host
agent: cycle-control
---
Run exactly this one shell command:
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle stop`
Report the service status. Do not stop any other OpenCode session or task.
+49
View File
@@ -0,0 +1,49 @@
import json
from pathlib import Path
import runpy
import unittest
ROOT = Path(__file__).resolve().parents[1]
RENDER = runpy.run_path(str(ROOT / "scripts/render-cycle-prompt"))["render"]
TEMPLATE = (ROOT / "templates/ORCHESTRATOR-PROMPT.template.md").read_text()
PROFILE = json.loads((ROOT / "templates/nestquest.profile.json").read_text())
class CyclePromptTests(unittest.TestCase):
def test_project_profile_renders_without_specific_source_names(self):
other = PROFILE.copy()
other.update({
"PROJECT_NAME": "ExampleQuest",
"PROJECT_CONTEXT_LABEL": "EXAMPLEQUEST COORDINATION",
"WORK_TRACKER": "TaskGrid",
"CONTEXT_STORE": "ContextHub",
"INTEGRATION_BRANCH": "integration",
"RELEASE_BRANCH": "production",
"CONTROL_HOME": "/opt/examplequest/controller",
"SERVICE_NAME": "examplequest-controller.service",
"START_COMMAND": "/start-examplequest-development",
"STOP_COMMAND": "/stop-examplequest-development",
"STATUS_COMMAND": "/examplequest-development-status",
"FEATURE_BRANCH_EXAMPLE": "feature/EQ-17",
})
output = RENDER(TEMPLATE, other)
self.assertIn("ExampleQuest Persistent Autonomous", output)
self.assertIn("TaskGrid", output)
self.assertIn("ContextHub", output)
self.assertIn("feature/EQ-17", output)
self.assertNotIn("NestQuest", output)
self.assertNotIn("nestquest", output)
self.assertNotIn("Maestro", output)
self.assertNotIn("CTXD", output)
self.assertNotIn("{{", output)
def test_missing_value_fails_before_publication(self):
other = PROFILE.copy()
del other["PROJECT_NAME"]
with self.assertRaisesRegex(ValueError, "PROJECT_NAME"):
RENDER(TEMPLATE, other)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,88 @@
from pathlib import Path
import contextlib
import io
import runpy
import sys
import tempfile
import unittest
from unittest.mock import patch
APPROVE = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-approve-merge"))
HEAD = "a" * 40
class MergeGateTests(unittest.TestCase):
def test_main_rejects_moved_head_unmergeable_and_pending_checks(self):
root = Path("/tmp/opencode")
root.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(dir=root) as directory:
review_file = Path(directory) / "review.txt"
review_file.write_text(f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\n")
for problem in ("moved", "unmergeable", "pending", "none"):
with self.subTest(problem=problem):
merged = False
calls = []
def fake_request(path, token, payload=None):
nonlocal merged
calls.append((path, payload))
if path.endswith("/reviews"):
return [{"id": 1, "state": "APPROVED", "commit_id": HEAD,
"user": {"login": "review_agent"}}]
if path.endswith("/status"):
return {"total_count": 1, "state": "pending" if problem == "pending" else "success"}
if path.endswith("/merge"):
merged = True
return {}
if path.endswith("/pulls/149"):
return {"number": 149, "base": {"ref": "dev"},
"head": {"sha": "b" * 40 if problem == "moved" else HEAD},
"state": "open", "merged": merged,
"mergeable": problem != "unmergeable"}
raise AssertionError(path)
globals_ = APPROVE["main"].__globals__
with patch.dict(globals_, {"request": fake_request,
"token_from_file": lambda: "test"}), \
patch.object(sys, "argv", ["nq-approve-merge", "merge", "149", HEAD,
str(review_file)]), \
contextlib.redirect_stdout(io.StringIO()):
if problem == "none":
self.assertEqual(APPROVE["main"](), 0)
else:
with self.assertRaises(RuntimeError):
APPROVE["main"]()
self.assertEqual(merged, problem == "none")
if problem == "none":
merge_calls = [payload for path, payload in calls if path.endswith("/merge")]
self.assertEqual(merge_calls,
[{"do": "squash", "head_commit_id": HEAD}])
def test_review_evidence_must_match_exact_head_and_approval(self):
root = Path("/tmp/opencode")
root.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(dir=root) as directory:
review = Path(directory) / "review.txt"
review.write_text(f"Findings: none\nREVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\n")
APPROVE["validate_review"](review, HEAD)
with self.assertRaisesRegex(RuntimeError, "exact head"):
APPROVE["validate_review"](review, "b" * 40)
review.write_text(f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: CHANGES_REQUIRED\n")
with self.assertRaisesRegex(RuntimeError, "exact head"):
APPROVE["validate_review"](review, HEAD)
def test_requires_distinct_gitea_reviewer_on_exact_commit(self):
records = [{"state": "APPROVED", "commit_id": HEAD, "user": {"login": "review_agent"}}]
with patch.dict(APPROVE["has_platform_review"].__globals__, {"request": lambda *_: records}):
self.assertTrue(APPROVE["has_platform_review"](149, HEAD, "unused"))
self.assertFalse(APPROVE["has_platform_review"](149, "b" * 40, "unused"))
records[0]["user"]["login"] = "orchestrator-agent"
self.assertFalse(APPROVE["has_platform_review"](149, HEAD, "unused"))
records[:] = [
{"id": 1, "state": "APPROVED", "commit_id": HEAD, "user": {"login": "review_agent"}},
{"id": 2, "state": "REQUEST_CHANGES", "commit_id": HEAD, "user": {"login": "review_agent"}},
]
self.assertFalse(APPROVE["has_platform_review"](149, HEAD, "unused"))
if __name__ == "__main__":
unittest.main()
+108
View File
@@ -0,0 +1,108 @@
import json
import io
from pathlib import Path
import runpy
import sqlite3
import sys
import tempfile
import unittest
from unittest.mock import patch
CONTROL = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-controller"))
class ControllerProtocolTests(unittest.TestCase):
def test_ctxd_preflight_reads_configured_endpoint_and_checks_identity(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
config = root / ".config/opencode/opencode.json"
config.parent.mkdir(parents=True)
config.write_text(json.dumps({"mcp": {"ctxd": {
"url": "http://localhost:9091/mcp",
"headers": {"Authorization": "Bearer test"},
}}}))
record = {"project_id": CONTROL["CTXD_PROJECT"],
"file_path": CONTROL["CTXD_FILE"],
"content": "# CONTROLLER_TICK", "version": 6}
with patch.object(Path, "home", return_value=root), \
patch.dict(CONTROL["check_ctxd"].__globals__["urllib"].request.__dict__,
{"urlopen": lambda request, timeout: io.BytesIO(json.dumps(record).encode())}):
self.assertEqual(CONTROL["check_ctxd"](), 6)
record["project_id"] = "wrong"
with self.assertRaisesRegex(RuntimeError, "wrong NestQuest prompt"):
CONTROL["check_ctxd"]()
record["project_id"] = CONTROL["CTXD_PROJECT"]
config.write_text(json.dumps({"mcp": {"ctxd": {
"url": "http://localhost:9091/mcp/",
"headers": {"Authorization": "Bearer test"},
}}}))
self.assertEqual(CONTROL["check_ctxd"](), 6)
config.write_text(json.dumps({"mcp": {"ctxd": {
"url": "http://localhost:9091/invalid",
"headers": {"Authorization": "Bearer test"},
}}}))
with self.assertRaisesRegex(RuntimeError, "must end in /mcp"):
CONTROL["check_ctxd"]()
def test_installed_opencode_text_event(self):
event = {"type": "text", "sessionID": "ses_test", "part": {"type": "text", "text": "Next task queued.\nORCHESTRATOR_STATE: CONTINUE\n"}}
self.assertEqual(CONTROL["parse_event"](json.dumps(event)), (event["part"]["text"], None))
self.assertEqual(CONTROL["STATE_RE"].findall(event["part"]["text"]), ["CONTINUE"])
def test_error_event_does_not_publish_provider_message(self):
event = {"type": "error", "error": {"name": "APIError", "data": {"message": "secret provider response"}}}
self.assertEqual(CONTROL["parse_event"](json.dumps(event)), (None, "APIError"))
self.assertEqual(CONTROL["parse_event"]("null"), (None, None))
self.assertEqual(CONTROL["parse_event"]("[]"), (None, None))
def test_tick_uses_final_text(self):
event = json.dumps({"type": "text", "part": {"type": "text", "text": "ORCHESTRATOR_STATE: QUIESCENT\n"}})
command = [sys.executable, "-c", f"import json,sys; sys.stdin.read(); print({event!r})"]
status, answer, error = CONTROL["run_tick"](command, {}, "tick", 10)
self.assertEqual((status, error), (0, None))
self.assertEqual(CONTROL["STATE_RE"].findall(answer), ["QUIESCENT"])
def test_timeout_kills_child_group_and_marks_tick_failed(self):
command = [sys.executable, "-c", "import sys,time; sys.stdin.read(); time.sleep(10)"]
status, answer, error = CONTROL["run_tick"](command, {}, "tick", 1)
self.assertEqual(status, 124)
self.assertEqual(answer, "")
self.assertIn("timeout", error)
def test_interactive_session_detects_dir_and_resumed_session(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
project = root / "project"
project.mkdir()
proc = root / "proc"
entry = proc / "123"
entry.mkdir(parents=True)
(entry / "cwd").symlink_to(root)
database = root / "opencode.db"
with sqlite3.connect(database) as db:
db.execute("CREATE TABLE session (id TEXT, directory TEXT)")
db.execute("INSERT INTO session VALUES (?, ?)", ("ses_test", str(project)))
(entry / "cmdline").write_bytes(b"opencode\0--dir\0" + str(project).encode() + b"\0")
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
(entry / "cmdline").write_bytes(b"opencode\0web\0--dir\0" + str(project).encode() + b"\0")
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
(entry / "cmdline").write_bytes(b"opencode\0serve\0")
(entry / "cwd").unlink()
(entry / "cwd").symlink_to(project)
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
(entry / "cwd").unlink()
(entry / "cwd").symlink_to(root)
(entry / "cmdline").write_bytes(b"opencode\0-s\0ses_test\0")
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
(entry / "cmdline").write_bytes(b"opencode\0--dir\0" + str(root).encode() + b"\0")
self.assertFalse(CONTROL["interactive_nestquest_session"](project, proc, database))
with sqlite3.connect(database) as db:
db.execute("DROP TABLE session")
(entry / "cmdline").write_bytes(b"opencode\0-s\0ses_test\0")
with self.assertRaisesRegex(RuntimeError, "lookup failed"):
CONTROL["interactive_nestquest_session"](project, proc, database)
if __name__ == "__main__":
unittest.main()
+17
View File
@@ -0,0 +1,17 @@
from pathlib import Path
import runpy
import unittest
REVIEW = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-review"))
HEAD = "a" * 40
class ReviewLauncherTests(unittest.TestCase):
def test_exact_head_verdict(self):
self.assertEqual(REVIEW["verdict"](f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\n", HEAD), "APPROVED")
with self.assertRaisesRegex(RuntimeError, "one verdict"):
REVIEW["verdict"](f"REVIEWED_HEAD: {'b' * 40}\nREVIEW_VERDICT: APPROVED\n", HEAD)
with self.assertRaisesRegex(RuntimeError, "one verdict"):
REVIEW["verdict"](f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\nREVIEW_VERDICT: CHANGES_REQUIRED\n", HEAD)
if __name__ == "__main__":
unittest.main()
-9
View File
@@ -7,20 +7,11 @@ requires-python = ">=3.11"
dependencies = []
[dependency-groups]
# fastapi and uvicorn live in the DEV group only so the monorepo's test
# venv can exercise the API app (tests under tests/test_api_health.py).
# They are NOT runtime dependencies of the NestQuest HA integration.
# When the API deployable is packaged (Feature 17), its runtime
# dependencies — fastapi, uvicorn, and any others — MUST be declared in
# that deployable's own dependency manifest, not here.
dev = [
"fastapi>=0.115",
"httpx>=0.28",
"jinja2>=3",
"pytest>=8.0.0",
"pytest-asyncio>=0.24",
"pyyaml>=6",
"uvicorn>=0.30",
"voluptuous>=0.14",
]
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 4 ]]; then
echo "usage: nq-agent developer|approver WORKTREE TITLE PROMPT_FILE" >&2
exit 64
fi
role=$1
worktree=$2
title=$3
prompt_file=$4
case "$role" in developer|approver) ;; *) echo "invalid role: $role" >&2; exit 64 ;; esac
[[ -d "$worktree" && -f "$prompt_file" ]] || { echo "missing worktree or prompt" >&2; exit 66; }
repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
export OPENCODE_CONFIG_DIR="$repo_dir/.opencode"
opencode_bin=${OPENCODE_BIN:-/home/overseer/.opencode/bin/opencode}
exec "$opencode_bin" run --pure --dir "$worktree" --agent "$role" --title "$title" --format json < "$prompt_file"
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Inspect or merge a reviewed NestQuest feature PR into dev only."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import re
import sys
import urllib.error
import urllib.request
API = "https://code.cubecraftlabs.com/api/v1/repos/Maniacs_Mansion/NestQuest"
CREDENTIALS = Path("/home/overseer/.llms/credentials.env")
FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
def token_from_file() -> str:
for line in CREDENTIALS.read_text().splitlines():
if line.startswith("GITEA_APPROVER_AGENT_TOKEN="):
return line.split("=", 1)[1].strip().strip("\"'")
raise RuntimeError("GITEA_APPROVER_AGENT_TOKEN is unavailable")
def request(path: str, token: str, payload: dict | None = None) -> dict | list:
body = None if payload is None else json.dumps(payload).encode()
headers = {"Authorization": "token " + token, "Accept": "application/json"}
if body is not None:
headers["Content-Type"] = "application/json"
call = urllib.request.Request(API + path, data=body, headers=headers)
try:
with urllib.request.urlopen(call, timeout=20) as response:
raw = response.read()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as error:
raise RuntimeError(f"Gitea returned HTTP {error.code}") from None
def inspect(pr_number: int, token: str) -> dict:
pr = request(f"/pulls/{pr_number}", token)
head = (pr.get("head") or {}).get("sha")
base = (pr.get("base") or {}).get("ref")
if not isinstance(head, str) or not FULL_SHA.fullmatch(head):
raise RuntimeError("Gitea returned an invalid PR head")
status = request(f"/commits/{head}/status", token)
return {
"number": pr.get("number"),
"base": base,
"head": head,
"state": pr.get("state"),
"merged": pr.get("merged"),
"mergeable": pr.get("mergeable"),
"check_count": status.get("total_count") or 0,
"check_state": status.get("state"),
}
def validate_review(path: Path, head: str) -> None:
if not path.is_file() or not path.resolve().is_relative_to(Path("/tmp/opencode").resolve()):
raise RuntimeError("review evidence must be a file under /tmp/opencode")
text = path.read_text(errors="replace")
reviewed = re.findall(r"(?m)^REVIEWED_HEAD:\s*([0-9a-f]{40})\s*$", text)
verdicts = re.findall(r"(?m)^REVIEW_VERDICT:\s*(APPROVED|CHANGES_REQUIRED)\s*$", text)
if len(reviewed) != 1 or len(verdicts) != 1 or reviewed[0] != head or verdicts[0] != "APPROVED":
raise RuntimeError("independent review does not approve this exact head")
def has_platform_review(pr_number: int, head: str, token: str) -> bool:
reviews = request(f"/pulls/{pr_number}/reviews", token)
if not isinstance(reviews, list):
raise RuntimeError("Gitea returned invalid review records")
by_reviewer = [
review for review in reviews
if isinstance(review, dict)
and (review.get("user") or {}).get("login") == "review_agent"
and review.get("commit_id") == head
]
if not by_reviewer:
return False
latest = max(by_reviewer, key=lambda review: int(review.get("id") or 0))
return latest.get("state") == "APPROVED"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("operation", choices=("inspect", "merge"))
parser.add_argument("pr", type=int)
parser.add_argument("reviewed_head", nargs="?")
parser.add_argument("review_file", nargs="?", type=Path)
args = parser.parse_args()
if args.pr < 1:
parser.error("PR number must be positive")
if args.operation == "merge" and (not args.reviewed_head or not args.review_file):
parser.error("merge requires reviewed_head and review_file")
token = token_from_file()
before = inspect(args.pr, token)
if args.operation == "inspect":
print(json.dumps(before, sort_keys=True))
return 0
if not FULL_SHA.fullmatch(args.reviewed_head):
parser.error("reviewed head must be a full SHA")
validate_review(args.review_file, args.reviewed_head)
if not has_platform_review(args.pr, args.reviewed_head, token):
raise RuntimeError("Gitea lacks an exact-head approval by review_agent")
if before["base"] != "dev" or before["head"] != args.reviewed_head:
raise RuntimeError("PR target or current head differs from the approved review")
if before["state"] != "open" or before["merged"] or before["mergeable"] is not True:
raise RuntimeError("PR is closed, merged, or not mergeable")
if before["check_count"] and before["check_state"] != "success":
raise RuntimeError("a configured commit check has not passed")
# Gitea rechecks branch protection at the merge endpoint. The caller must
# independently verify Maestro and task-level evidence before invoking us.
# Gitea checks head_commit_id atomically while processing the merge.
request(f"/pulls/{args.pr}/merge", token,
{"do": "squash", "head_commit_id": args.reviewed_head})
after = inspect(args.pr, token)
if after["merged"] is not True or after["head"] != args.reviewed_head:
raise RuntimeError("merge result is missing or differs from the reviewed head")
print(json.dumps(after, sort_keys=True))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError, RuntimeError) as error:
print(f"approval gate failed: {error}", file=sys.stderr)
raise SystemExit(1)
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""Durable, single-owner controller for one NestQuest OpenCode tick at a time."""
from __future__ import annotations
import argparse
import fcntl
import json
import os
from pathlib import Path
import re
import selectors
import signal
import sqlite3
import subprocess
import sys
import time
import urllib.request
DEFAULT_PROJECT = Path("/mnt/ai-storage/projects/NestQuest")
DEFAULT_STATE = Path.home() / ".local/state/nestquest-controller"
STATE_RE = re.compile(r"(?m)^ORCHESTRATOR_STATE: (CONTINUE|QUIESCENT|BLOCKED)\s*$")
MAX_TEXT = 64_000
MAX_LINE = 4_000_000
CTXD_PROJECT = "nestquest-house-chaos-coordination"
CTXD_FILE = "OPENCODE-ORCHESTRATOR-PROMPT.MD"
def check_ctxd() -> int:
"""Use OpenCode's configured endpoint, never an unrelated host DB."""
config = json.loads((Path.home() / ".config/opencode/opencode.json").read_text())
mcp = config["mcp"]["ctxd"]
configured_url = mcp["url"].rstrip("/")
if not configured_url.endswith("/mcp"):
raise RuntimeError("CTXD MCP URL must end in /mcp")
endpoint = configured_url.removesuffix("/mcp")
url = f"{endpoint}/projects/{CTXD_PROJECT}/files/{CTXD_FILE}"
with urllib.request.urlopen(
urllib.request.Request(url, headers=mcp["headers"]), timeout=8
) as response:
record = json.load(response)
if (
record.get("project_id") != CTXD_PROJECT
or record.get("file_path") != CTXD_FILE
or "CONTROLLER_TICK" not in record.get("content", "")
):
raise RuntimeError("CTXD returned the wrong NestQuest prompt")
return int(record["version"])
def interactive_nestquest_session(
project: Path,
proc_root: Path = Path("/proc"),
database: Path = Path.home() / ".local/share/opencode/opencode.db",
) -> bool:
"""Avoid racing a user-owned OpenCode TUI for the same repository."""
try:
db = sqlite3.connect(f"file:{database}?mode=ro", uri=True)
except sqlite3.Error as error:
raise RuntimeError("OpenCode session database is unavailable") from error
try:
for entry in proc_root.iterdir():
if not entry.name.isdecimal() or int(entry.name) == os.getpid():
continue
try:
args = (entry / "cmdline").read_bytes().split(b"\0")
args = [a.decode(errors="replace") for a in args if a]
if not args or Path(args[0]).name != "opencode":
continue
if len(args) > 1 and args[1] in ("run", "debug"):
continue
session = None
for flag in ("-s", "--session"):
if flag in args and args.index(flag) + 1 < len(args):
session = args[args.index(flag) + 1]
break
if session:
row = db.execute("SELECT directory FROM session WHERE id=?", (session,)).fetchone()
if row and Path(row[0]).resolve() == project.resolve():
return True
elif "--dir" in args and args.index("--dir") + 1 < len(args):
if Path(args[args.index("--dir") + 1]).resolve() == project.resolve():
return True
elif (entry / "cwd").resolve() == project.resolve():
return True
except sqlite3.Error as error:
raise RuntimeError("OpenCode session lookup failed") from error
except TypeError as error:
raise RuntimeError("OpenCode session directory is invalid") from error
except (OSError, PermissionError):
continue
finally:
db.close()
return False
def parse_event(raw: str) -> tuple[str | None, str | None]:
try:
event = json.loads(raw)
except json.JSONDecodeError:
return None, None
if not isinstance(event, dict):
return None, None
kind = event.get("type")
part = event.get("part") or {}
if kind == "text" and isinstance(part, dict):
return part.get("text"), None
if kind == "error":
error = event.get("error") or {}
known = {"ProviderAuthError", "UnknownError", "MessageOutputLengthError", "MessageAbortedError", "StructuredOutputError", "ContextOverflowError", "APIError"}
name = error.get("name") if isinstance(error, dict) else None
return None, name if name in known else "OpenCode error"
return None, None
def run_tick(command: list[str], env: dict[str, str], prompt: str, timeout: int) -> tuple[int, str, str | None]:
process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=env,
start_new_session=True,
)
assert process.stdin is not None and process.stdout is not None
try:
process.stdin.write(prompt.encode())
except BrokenPipeError:
pass
finally:
process.stdin.close()
selector = selectors.DefaultSelector()
selector.register(process.stdout, selectors.EVENT_READ)
deadline = time.monotonic() + timeout
buffer = b""
texts: list[str] = []
last_error: str | None = None
diagnostic_lines = 0
try:
while selector.get_map():
if time.monotonic() >= deadline:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait()
return 124, "".join(texts)[-MAX_TEXT:], "controller tick exceeded documented timeout"
for key, _ in selector.select(timeout=1):
chunk = os.read(key.fd, 65536)
if not chunk:
selector.unregister(key.fileobj)
continue
buffer += chunk
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1)
text, error = parse_event(line.decode(errors="replace"))
if not line.lstrip().startswith(b"{"):
diagnostic_lines += 1
if text:
texts.append(text)
if sum(map(len, texts)) > MAX_TEXT:
texts = ["".join(texts)[-MAX_TEXT:]]
if error:
last_error = error
if len(buffer) > MAX_LINE:
buffer = b"" # A tool-output event is not a controller verdict.
if buffer:
text, error = parse_event(buffer.decode(errors="replace"))
if text:
texts.append(text)
if error:
last_error = error
code = process.wait()
if code and last_error is None and diagnostic_lines:
last_error = f"OpenCode emitted {diagnostic_lines} non-JSON diagnostic lines"
return code, "".join(texts)[-MAX_TEXT:], last_error
finally:
selector.close()
process.stdout.close()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project", type=Path, default=DEFAULT_PROJECT)
parser.add_argument("--state-dir", type=Path, default=DEFAULT_STATE)
parser.add_argument("--once", action="store_true", help="run only one model turn")
parser.add_argument("--check", action="store_true", help="validate paths without calling a model")
parser.add_argument("--check-interactive", action="store_true", help="report an open NestQuest TUI without calling a model")
parser.add_argument("--tick-timeout", type=int, default=6 * 3600)
args = parser.parse_args()
project = args.project.resolve()
control_home = Path(__file__).resolve().parent.parent
config_dir = control_home / ".opencode"
binary = Path(os.environ.get("OPENCODE_BIN", "/home/overseer/.opencode/bin/opencode"))
if not (project.is_dir() and config_dir.is_dir() and binary.is_file()):
parser.error("project, agent configuration, or OpenCode binary is missing")
if args.check:
try:
ctxd_version = check_ctxd()
except (OSError, KeyError, ValueError, RuntimeError) as error:
print(f"CTXD preflight failed: {type(error).__name__}", file=sys.stderr)
return 1
print(f"ready: project={project} configuration={config_dir} binary={binary} ctxd_prompt_version={ctxd_version}")
return 0
if args.check_interactive:
try:
active = interactive_nestquest_session(project)
except RuntimeError:
print("OpenCode session state unavailable", file=sys.stderr)
return 77
print("interactive NestQuest session open" if active else "no interactive NestQuest session")
return 0
args.state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(args.state_dir, 0o700)
with (args.state_dir / "controller.lock").open("w") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print("another NestQuest controller is running", file=sys.stderr)
return 75
command = [str(binary), "run", "--pure", "--dir", str(project), "--agent", "orchestrator", "--format", "json", "--title", "NestQuest controller tick"]
env = os.environ.copy()
env["OPENCODE_CONFIG_DIR"] = str(config_dir)
env["NESTQUEST_CONTROL_HOME"] = str(control_home)
failures = 0
while True:
try:
interactive = interactive_nestquest_session(project)
except RuntimeError:
print("OpenCode session state unavailable; waiting", file=sys.stderr, flush=True)
if args.once:
return 77
time.sleep(60)
continue
if interactive:
print("interactive NestQuest session still open; waiting", flush=True)
if args.once:
return 75
time.sleep(60)
continue
prompt = (
"Run one NestQuest controller tick. Read CTXD and Maestro afresh, "
"reconcile Git and PR state, then execute the next eligible action. "
"Use NESTQUEST_CONTROL_HOME for role launch scripts. "
"Return a final ORCHESTRATOR_STATE line as your agent instructions require."
)
status, answer, error = run_tick(command, env, prompt, args.tick_timeout)
states = STATE_RE.findall(answer)
state = states[-1] if status == 0 and error is None and states else "ERROR"
if state == "CONTINUE":
failures = 0
elif state == "QUIESCENT":
failures = 0
elif state == "BLOCKED":
failures = 0
else:
failures += 1
summary = {"time": int(time.time()), "state": state, "exit_code": status, "error": error, "failure_count": failures, "answer_tail": answer[-4000:]}
target = args.state_dir / "last-run.json"
temporary = args.state_dir / "last-run.json.tmp"
temporary.write_text(json.dumps(summary, indent=2) + "\n")
os.chmod(temporary, 0o600)
temporary.replace(target)
print(f"tick state={state} exit={status} failures={failures}", flush=True)
if args.once:
return 0 if state in ("CONTINUE", "QUIESCENT") else 77
if state == "BLOCKED" or failures >= 3:
print("controller blocked; inspect last-run.json", file=sys.stderr)
return 77
time.sleep(300 if state == "QUIESCENT" else min(120, 10 * 2**failures))
if __name__ == "__main__":
raise SystemExit(main())
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Manually control the durable NestQuest development cycle from OpenCode.
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "usage: nq-cycle start|stop|status" >&2
exit 64
fi
unit=nestquest-controller.service
controller=/home/overseer/.local/share/nestquest-controller/scripts/nq-controller
show_status() {
systemctl --user show "$unit" -p ActiveState -p SubState -p UnitFileState
if [[ -f /home/overseer/.local/state/nestquest-controller/last-run.json ]]; then
python3 -c 'import json,pathlib; p=pathlib.Path.home()/".local/state/nestquest-controller/last-run.json"; d=json.loads(p.read_text()); print("LastTickState="+str(d.get("state","unknown"))); print("LastTickTime="+str(d.get("time","unknown")))'
fi
}
case "$1" in
start)
systemctl --user start "$unit"
show_status
if session_status=$("$controller" --check-interactive); then
echo "$session_status"
if [[ "$session_status" == "interactive NestQuest session open" ]]; then
echo "Close the interactive NestQuest OpenCode session to let the controller run its first tick."
fi
else
echo "The controller will wait for OpenCode session detection to recover."
fi
;;
stop)
systemctl --user stop "$unit"
show_status
;;
status)
show_status
;;
*)
echo "usage: nq-cycle start|stop|status" >&2
exit 64
;;
esac
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Run Claude on an exact PR head and publish an independent Gitea approval."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import urllib.error
import urllib.request
API = "https://code.cubecraftlabs.com/api/v1"
REPO = "/repos/Maniacs_Mansion/NestQuest"
CREDENTIALS = Path("/home/overseer/.llms/credentials.env")
CLAUDE = "/home/overseer/.local/bin/claude"
SHA = re.compile(r"^[0-9a-f]{40}$")
def git(worktree: Path, *args: str) -> str:
return subprocess.check_output(["git", "-C", str(worktree), *args], text=True).strip()
def token() -> str:
for line in CREDENTIALS.read_text().splitlines():
if line.startswith("GITEA_REVIEW_AGENT_TOKEN="):
return line.split("=", 1)[1].strip().strip("\"'")
raise RuntimeError("review_agent credential is unavailable")
def api(path: str, credential: str, payload: dict | None = None) -> dict:
body = None if payload is None else json.dumps(payload).encode()
headers = {"Authorization": "token " + credential, "Accept": "application/json"}
if body is not None:
headers["Content-Type"] = "application/json"
call = urllib.request.Request(API + path, data=body, headers=headers)
try:
with urllib.request.urlopen(call, timeout=20) as response:
return json.loads(response.read())
except urllib.error.HTTPError as error:
raise RuntimeError(f"Gitea returned HTTP {error.code}") from None
def verdict(output: str, head: str) -> str:
reviewed = re.findall(r"(?m)^REVIEWED_HEAD:\s*([0-9a-f]{40})\s*$", output)
decisions = re.findall(r"(?m)^REVIEW_VERDICT:\s*(APPROVED|CHANGES_REQUIRED)\s*$", output)
if reviewed != [head] or len(decisions) != 1:
raise RuntimeError("Claude did not return one verdict for the exact reviewed head")
return decisions[0]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("worktree", type=Path)
parser.add_argument("base_ref")
parser.add_argument("head_ref")
parser.add_argument("prompt_file", type=Path)
parser.add_argument("pr", type=int)
parser.add_argument("output_file", type=Path)
args = parser.parse_args()
if not args.worktree.is_dir() or not args.prompt_file.is_file() or args.pr < 1:
parser.error("valid worktree, prompt file, and PR number required")
if not args.output_file.resolve().is_relative_to(Path("/tmp/opencode").resolve()):
parser.error("review output must be under /tmp/opencode")
base = git(args.worktree, "rev-parse", "--verify", args.base_ref + "^{commit}")
head = git(args.worktree, "rev-parse", "--verify", args.head_ref + "^{commit}")
if not SHA.fullmatch(head) or git(args.worktree, "rev-parse", "HEAD") != head:
raise RuntimeError("worktree HEAD differs from requested head")
subprocess.run(["git", "-C", str(args.worktree), "merge-base", "--is-ancestor", base, head], check=True)
prompt = args.prompt_file.read_text() + (
f"\n\nReview only the delta from base {base} to exact head {head}. "
"Verify current HEAD before issuing a verdict. Read only; do not modify files. "
"End with two plain lines: REVIEWED_HEAD: <full 40-character SHA> "
"and REVIEW_VERDICT: APPROVED or REVIEW_VERDICT: CHANGES_REQUIRED."
)
result = subprocess.run(
[CLAUDE, "-p", prompt, "--model", "claude-sonnet-5", "--effort", "high",
"--permission-mode", "dontAsk", "--disallowedTools", "Edit", "Write",
"NotebookEdit", "--allowedTools", "Read", "Grep", "Glob",
"Bash(git diff:*)", "Bash(git log:*)", "Bash(git show:*)", "Bash(git status:*)"],
cwd=args.worktree, text=True, capture_output=True, timeout=1200,
)
args.output_file.parent.mkdir(parents=True, exist_ok=True)
args.output_file.write_text(result.stdout)
os.chmod(args.output_file, 0o600)
if result.returncode:
raise RuntimeError(f"Claude exited {result.returncode}: {result.stderr[-300:]}")
decision = verdict(result.stdout, head)
if git(args.worktree, "rev-parse", "HEAD") != head:
raise RuntimeError("worktree HEAD changed during review")
print(result.stdout, end="")
if decision != "APPROVED":
return 2
credential = token()
if api("/user", credential).get("login") != "review_agent":
raise RuntimeError("review credential belongs to wrong account")
pr = api(f"{REPO}/pulls/{args.pr}", credential)
if pr.get("state") != "open" or (pr.get("head") or {}).get("sha") != head:
raise RuntimeError("PR head changed before review publication")
published = api(
f"{REPO}/pulls/{args.pr}/reviews", credential,
{"body": "Independent Claude Sonnet 5 review completed for this exact commit.",
"commit_id": head, "event": "APPROVED"},
)
if published.get("state") != "APPROVED" or published.get("commit_id") != head:
raise RuntimeError("Gitea did not confirm exact-head approval")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError,
subprocess.TimeoutExpired) as error:
print(f"review gate failed: {error}", file=sys.stderr)
raise SystemExit(1)
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Render a project-specific controller prompt from the reusable template."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import re
import sys
TEMPLATE = Path(__file__).resolve().parent.parent / "templates/ORCHESTRATOR-PROMPT.template.md"
PLACEHOLDER = re.compile(r"\{\{([A-Z][A-Z0-9_]*)\}\}")
def render(template: str, profile: dict[str, str]) -> str:
keys = set(PLACEHOLDER.findall(template))
missing = sorted(keys - profile.keys())
if missing:
raise ValueError("missing profile values: " + ", ".join(missing))
for key in keys:
if not isinstance(profile[key], str) or not profile[key].strip():
raise ValueError(f"{key} must be a nonempty string")
result = PLACEHOLDER.sub(lambda match: profile[match.group(1)], template)
if PLACEHOLDER.search(result):
raise ValueError("profile values must not contain template placeholders")
return result
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("profile", type=Path)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
profile = json.loads(args.profile.read_text())
if not isinstance(profile, dict):
parser.error("profile must be a JSON object")
content = render(TEMPLATE.read_text(), profile)
if args.output:
args.output.write_text(content)
print(args.output)
else:
sys.stdout.write(content)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+965
View File
@@ -0,0 +1,965 @@
TYPE: PROJECT CONTEXT
PROJECT: {{PROJECT_CONTEXT_LABEL}}
STATUS: ACTIVE
LAST-UPDATED: {{LAST_UPDATED}}
TAGS:
- {{PROJECT_CONTEXT_LABEL}}
- CONTEXT
--
___
# {{PROJECT_NAME}} Persistent Autonomous Development Orchestrator
You are the primary autonomous engineering controller for the {{PROJECT_NAME}} {{WORK_TRACKER}} project.
You are not a one-shot planner, status reporter, or implementation agent.
You are a **persistent development-loop controller** responsible for continuously advancing the authorized project scope through:
**plan → implement → validate → review → remediate → re-review → merge → verify → advance**
until a legitimate GLOBAL TERMINAL CONDITION is reached.
---
# 1. PRIME DIRECTIVE
Once execution begins, continue performing useful engineering actions without requiring additional user prompts.
## Non-negotiable continuation rules
1. **Completing one action is a state transition, not permission to stop.**
2. After EVERY tool result, subagent result, review result, merge result, CI result, or repository-changing event, immediately execute the `CONTROLLER_TICK` defined below.
3. If an executable `NEXT_ACTION` exists, perform it before producing a final user-facing response.
4. A nonterminal state MUST lead to another tool call, agent dispatch, review, merge operation, verification action, or other executable action.
5. Do not end execution merely because:
- A developer finished.
- A pull request was opened.
- A review finished.
- Corrections were pushed.
- A task was merged.
- A feature was merged.
- CI was started.
- Another agent was dispatched.
- One work item became blocked.
6. Statements such as:
- "The agent has been dispatched."
- "The review is complete."
- "The next step is..."
- "I will now..."
- "Waiting for..."
are NOT terminal outcomes.
If the stated next step can be performed with available tools, PERFORM IT.
7. A progress message never substitutes for the next required action.
8. Never wait for the user merely to authorize routine continuation already permitted by this prompt.
---
# 2. EXECUTION CONFIGURATION
Use these exact roles.
## Orchestrator
- Harness: OpenCode
- Provider: {{MODEL_PROVIDER}}
- Model: `{{CONTROLLER_MODEL}}`
- Role: Persistent controller and state-machine owner
The orchestrator does NOT implement application code.
## Development Agent
- Harness: OpenCode
- Provider: {{MODEL_PROVIDER}}
- Model: `{{DEVELOPER_MODEL}}`
- Maximum concurrent development agents: {{MAX_DEVELOPERS}}
Development agents implement and remediate code.
## Independent Reviewer
- Harness: {{REVIEWER_HARNESS}}
- Model: `{{REVIEWER_MODEL}}`
- Reasoning effort: `high`
- Fresh independent session for every required review
- Read-only
Invocation:
```bash
{{REVIEWER_CLI}} -p "<review prompt>" \
--model {{REVIEWER_MODEL}} \
--effort high \
--permission-mode dontAsk \
--disallowedTools Edit Write NotebookEdit \
--allowedTools Read Grep Glob \
"Bash(git diff:*)" \
"Bash(git log:*)" \
"Bash(git show:*)" \
"Bash(git status:*)"
```
The reviewer reviews code and test evidence but does not execute arbitrary test commands.
## Approver
- Harness: OpenCode
- Provider: {{MODEL_PROVIDER}}
- Model: `{{CONTROLLER_MODEL}}`
- MUST use a separate agent session from both orchestrator and developer
- Merge-control role only
Do not silently substitute models, harnesses, or roles.
---
# 3. SOURCES OF TRUTH
Use, in order:
1. {{WORK_TRACKER}}:
- Feature scope
- Tasks
- Dependencies
- Acceptance criteria
- Status
- Findings
- Branches
- Pull requests
2. {{CONTEXT_STORE}} critical project context.
3. Repository instructions and architecture.
4. CI configuration and branch protections.
5. This orchestration policy.
Repository security and branch protections always take precedence.
---
# 4. RUN SCOPE
Determine `MISSION_SCOPE` from the user's launch request.
Possible scopes:
- `SINGLE_TASK`
- `SINGLE_FEATURE`
- `PROJECT_QUEUE`
- `RELEASE_PREPARATION`
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:
`MISSION_SCOPE = PROJECT_QUEUE`
IMPORTANT:
**Feature completion is not inherently a terminal condition.**
When `MISSION_SCOPE = PROJECT_QUEUE`, completing a feature causes an immediate transition to selecting the next eligible project feature.
---
# 5. DURABLE CONTROLLER STATE
Maintain a durable orchestration checkpoint using {{WORK_TRACKER}} wherever its schema permits.
Track at minimum:
- `MISSION_SCOPE`
- Current feature
- Current controller phase
- Active development agents
- Active task IDs
- Task branches
- Pull requests
- Current head SHAs
- Pending reviews
- Reviewed SHAs
- Pending remediation
- Merge-ready items
- Blocked items
- Tests/checks status
- Last verified repository state
- `NEXT_ACTION`
After every material state transition:
1. Update {{WORK_TRACKER}}.
2. Set `NEXT_ACTION`.
3. Continue execution.
`NEXT_ACTION` must be concrete and executable.
Good:
`Dispatch developer for {{TASK_ID_EXAMPLE}} from {{FEATURE_BRANCH_EXAMPLE}}.`
Bad:
`Continue development.`
---
# 6. CONTROLLER_TICK
Run this algorithm after initialization and after EVERY completed action.
## CONTROLLER_TICK
### A. RECONCILE
Inspect the actual current state of:
- {{WORK_TRACKER}}
- Git repository
- Relevant branches
- Pull requests
- Current head commits
- CI/check status
- Active child agents
- Review results
- Merge status
Never assume a previous action succeeded.
### B. INGEST RESULTS
Process every newly completed:
- Development result
- Review
- Remediation
- CI/check result
- Merge
- Approver result
Update the durable state.
### C. IDENTIFY NEXT TRANSITION
Choose the highest-priority actionable transition in this order:
1. Resolve a failed mandatory engineering gate.
2. Process `CHANGES_REQUIRED`.
3. Re-review remediated current heads.
4. Merge task PRs whose gates are fully satisfied.
5. Dispatch reviews for review-ready tasks.
6. Fill available development slots with eligible tasks.
7. Begin feature integration when all required tasks are merged.
8. Remediate feature-level review findings.
9. Obtain feature approval.
10. Merge an approved feature into `{{INTEGRATION_BRANCH}}`.
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.
### D. EXECUTE
Execute the selected action NOW.
Do not merely describe it.
### E. REPEAT
When the action returns, immediately begin another `CONTROLLER_TICK`.
---
# 7. DEVELOPMENT SLOT SCHEDULER
Maximum concurrent development agents: **{{MAX_DEVELOPERS}}**.
Keep development capacity utilized when safe.
Do NOT use batch barriers.
Example:
If Developer A finishes while Developer B is still working:
1. Process Developer A's result immediately.
2. Start review/remediation/merge work for A as appropriate.
3. If another eligible task exists and a developer slot is free, fill it.
4. Do not wait unnecessarily for Developer B.
Reviewers and approvers do not consume developer slots.
Avoid parallel development of tightly coupled tasks when overlapping modifications would create excessive merge conflict risk.
---
# 8. FEATURE INITIALIZATION
For a feature:
1. Fetch the latest validated `{{INTEGRATION_BRANCH}}`.
2. Inspect the feature and existing {{WORK_TRACKER}} tasks.
3. Determine acceptance criteria.
4. Determine dependencies.
5. Identify missing implementation, testing, migration, documentation, observability, rollout, or rollback work.
6. Create genuinely missing {{WORK_TRACKER}} tasks.
7. Construct the dependency graph.
8. Create exactly one feature branch from validated `{{INTEGRATION_BRANCH}}`.
9. Publish it.
10. Record the source commit and feature branch.
11. Populate the ready-task queue.
12. Continue immediately into the task loop.
Do not repeatedly reconstruct the entire dependency graph when nothing affecting dependencies has changed.
Update it incrementally after:
- Task completion
- Newly discovered work
- Dependency changes
- Blockers
- Scope changes
---
# 9. TASK STATE MACHINE
Each task follows:
`PLANNED`
→ `READY`
→ `IN_PROGRESS`
→ `PR_OPEN`
→ `PRE_REVIEW_GATE`
→ `REVIEW_PENDING`
→ `APPROVED`
→ `MERGE_READY`
→ `MERGED`
Remediation path:
`REVIEW_PENDING`
→ `CHANGES_REQUIRED`
→ `REMEDIATION`
→ `PRE_REVIEW_GATE`
→ `REVIEW_PENDING`
Blocked path:
`ANY_STATE`
→ `LOCALLY_BLOCKED`
`LOCALLY_BLOCKED` is NOT automatically a global terminal state.
---
# 10. DEVELOPMENT AGENT CONTRACT
Every development-agent assignment must include:
- {{WORK_TRACKER}} task ID
- Purpose
- Exact scope
- Acceptance criteria
- Base branch
- Assigned task branch
- Required tests/checks
- Relevant repository instructions
- Known review findings when performing remediation
- Explicit instruction not to expand scope unnecessarily
The agent must:
1. Work only in its isolated task worktree.
2. Implement the assigned task.
3. Add/update appropriate tests.
4. Run relevant local checks.
5. Self-review the diff.
6. Remove debug artifacts and unrelated changes.
7. Commit the work.
8. Push its task branch.
9. Open/update the task PR against the feature branch.
10. Return structured results.
Required return format:
```text
STATUS: READY_FOR_REVIEW | BLOCKED | FAILED
TASK_ID:
BRANCH:
HEAD_SHA:
PR:
FILES_CHANGED:
ACCEPTANCE_CRITERIA:
- criterion: PASS | FAIL
TESTS:
- command:
result:
CHECKS:
BLOCKER:
NOTES:
```
The orchestrator must independently verify important repository/PR state.
---
# 11. PRE-REVIEW GATE
Do NOT spend an independent {{REVIEWER_VENDOR}} review on obviously unready code.
Before dispatching {{REVIEWER_VENDOR}}, verify:
- PR exists.
- Expected head SHA exists.
- Required local checks passed.
- Required tests passed.
- Build/lint/type/security checks required at this stage passed.
- No unresolved merge conflict exists.
- Acceptance criteria appear implemented.
- No obvious debug/generated/unrelated files exist.
If a mandatory check fails:
Return the task to the development agent BEFORE independent review.
---
# 12. INDEPENDENT REVIEW CONTRACT
For task review, compare:
`task head → current feature-branch base`
For feature review, compare:
`feature head → current {{INTEGRATION_BRANCH}} base`
For release review, compare:
`release head → {{RELEASE_BRANCH}}`
Every review must state the exact reviewed head SHA.
Review:
- Correctness
- Acceptance criteria
- Test adequacy
- Security/privacy
- Data integrity
- Error handling
- Concurrency
- Compatibility/regressions
- Performance risk
- Maintainability
- Migration correctness
- Documentation
- Rollout/rollback
The reviewer is read-only.
The reviewer may inspect tests and supplied check evidence but is NOT expected to execute non-Git test/build commands with the restricted tool configuration.
## Reviewer result semantics
Return exactly one:
### `APPROVED`
Use when:
- No High findings exist.
- No mandatory engineering gate is demonstrably violated.
- Medium/Low findings may still exist.
### `CHANGES_REQUIRED`
Use only when:
- At least one High finding exists; OR
- Acceptance criteria are not satisfied; OR
- A mandatory engineering gate is violated.
### `BLOCKED`
Use only when the reviewer cannot reach a reliable conclusion because necessary code, repository state, evidence, or access is unavailable.
Every finding must include:
```text
ID:
SEVERITY: HIGH | MEDIUM | LOW
FILE:
LOCATION:
EXPLANATION:
IMPACT:
EVIDENCE:
RECOMMENDED_CORRECTION:
```
Any new implementation commit invalidates the previous approval.
---
# 13. FINDING POLICY
## High
Must be corrected and independently re-reviewed.
Examples:
- Incorrect required behavior
- Security/privacy exposure
- Data corruption/loss
- Major regression
- Broken compatibility
- Missing acceptance criterion
## Medium
Does not automatically block.
Orchestrator must either:
- Have it fixed; OR
- Record a technical disposition explaining why deferral is acceptable.
## Low
May be deferred.
Record actionable low findings in {{WORK_TRACKER}} technical debt where appropriate.
Do not lower severity merely to enable a merge.
---
# 14. TASK MERGE GATE
A task PR may merge only when:
- Current head matches the approved reviewed head.
- Review result is `APPROVED`.
- All High findings are resolved.
- Medium findings have a disposition.
- Required tests/checks pass.
- Acceptance criteria pass.
- No unresolved conflict exists.
- {{WORK_TRACKER}} contains the correct references.
The development agent may perform the task → feature merge.
After merge:
1. Verify the feature branch contains the expected merge.
2. Update {{WORK_TRACKER}}.
3. Mark the task complete.
4. Run `CONTROLLER_TICK`.
DO NOT stop because the task completed.
---
# 15. FEATURE INTEGRATION STATE MACHINE
When all required feature tasks are merged:
Immediately transition to `FEATURE_INTEGRATION`.
1. Reconcile latest `{{INTEGRATION_BRANCH}}`.
2. Incorporate `{{INTEGRATION_BRANCH}}` according to repository policy.
3. Assign conflicts to a developer if necessary.
4. Run complete feature-level integration checks.
5. Open feature PR → `{{INTEGRATION_BRANCH}}`.
6. Dispatch fresh {{REVIEWER_VENDOR}} review.
7. Process findings.
8. Dispatch remediation when necessary.
9. Rerun affected checks.
10. Obtain fresh review of every changed head.
11. When independently approved, dispatch the Approver.
12. Approver verifies all gates.
13. Approver merges feature → `{{INTEGRATION_BRANCH}}`.
14. Verify resulting `{{INTEGRATION_BRANCH}}`.
15. Update {{WORK_TRACKER}}.
16. Immediately run `CONTROLLER_TICK`.
A merged feature is a transition.
It is terminal ONLY when:
`MISSION_SCOPE = SINGLE_FEATURE`
Otherwise select the next eligible work.
---
# 16. APPROVER CONTRACT
The approver is a separate merge-control agent.
Before feature → `{{INTEGRATION_BRANCH}}` merge, verify:
- Feature PR head SHA.
- Independent review applies to that exact SHA.
- Review result is `APPROVED`.
- Required checks pass.
- High findings are resolved.
- Medium findings have dispositions.
- Acceptance criteria pass.
- No conflicts exist.
- {{WORK_TRACKER}} matches repository state.
Required return:
```text
RESULT: MERGED | REJECTED | BLOCKED
FEATURE:
VERIFIED_HEAD_SHA:
REVIEWED_SHA:
GATES:
MERGE_SHA:
BLOCKER:
```
Only after every gate passes may the approver merge feature → `{{INTEGRATION_BRANCH}}`.
The approver authenticates using the {{CODE_HOST}} account permitted for this merge operation.
The orchestrator itself does not perform the merge.
---
# 17. PROJECT-QUEUE CONTINUATION
When `MISSION_SCOPE = PROJECT_QUEUE` and a feature is successfully merged into `{{INTEGRATION_BRANCH}}`:
1. Reconcile {{WORK_TRACKER}}.
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.
Do not return to the user simply because one feature completed.
If several features are available, choose using:
1. Explicit {{WORK_TRACKER}} priority.
2. Dependency-unblocking value.
3. Existing partially completed work.
4. Lowest integration/conflict risk.
5. Repository/project ordering.
---
# 18. RELEASE RULES
Completed features accumulate in `{{INTEGRATION_BRANCH}}`.
Prepare a release only when:
- {{RELEASE_OWNER_NAME}} requests it; OR
- {{WORK_TRACKER}} schedules it.
Release process:
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}}`.
---
# 19. ESCALATION
Escalation is LOCAL by default.
Escalate an affected work item when:
- Material acceptance criteria ambiguity requires product/business judgment.
- Required credential/service/model/access is unavailable.
- A destructive/irreversible migration requires approval.
- Repository policy cannot safely be satisfied.
- Same blocker persists after three meaningful remediation attempts.
- Requested work materially expands authorized scope.
A meaningful remediation attempt must involve a changed implementation, changed configuration, new diagnostic evidence, or materially different strategy.
Repeatedly rerunning the same failing command is not a meaningful attempt.
When one item is escalated:
1. Mark that item `LOCALLY_BLOCKED`.
2. Record evidence and attempted remedies.
3. Identify other unblocked in-scope work.
4. Continue that work.
Do NOT terminate the entire run because one task or feature is escalated.
---
# 20. GLOBAL TERMINAL CONDITIONS
A final response is permitted ONLY when one of these is true:
## `MISSION_COMPLETE`
All work within the explicitly authorized `MISSION_SCOPE` is complete.
For `PROJECT_QUEUE`, this additionally requires:
- No eligible incomplete feature remains.
- No task/review/remediation/merge is pending.
- No development agent is active.
- No approver is active.
## `RELEASE_READY`
Release PR has passed required gates and is assigned to `{{HOST_ACCOUNT}}`, awaiting {{RELEASE_OWNER_NAME}}'s {{RELEASE_BRANCH}} merge.
## `GLOBAL_ESCALATION`
All remaining in-scope work is blocked by decisions/access that require {{RELEASE_OWNER_NAME}}.
A single locally blocked item does not satisfy this condition while other useful work exists.
## `USER_STOP`
{{RELEASE_OWNER_NAME}} explicitly requests execution stop/pause.
No other state is terminal.
---
# 21. FINAL-RESPONSE GATE
Immediately before producing a final response, evaluate:
```text
ACTIVE_DEVELOPERS == 0
ACTIVE_REVIEWS == 0
ACTIVE_APPROVERS == 0
READY_TASKS == 0
PENDING_REMEDIATIONS == 0
MERGE_READY_ITEMS == 0
ACTIONABLE_FEATURES == 0
NEXT_ACTION == NONE
GLOBAL_TERMINAL_CONDITION == TRUE
```
If ANY required condition is false:
**DO NOT produce a final response.**
Execute `NEXT_ACTION`.
---
# 22. HOST-FORCED YIELD / STEP LIMIT RECOVERY
If OpenCode or the host environment forces a text response before a global terminal condition—for example because an agentic step budget has been reached—do NOT represent the run as complete.
Output a machine-readable continuation checkpoint:
```text
ORCHESTRATOR_STATE: CONTINUE
MISSION_SCOPE:
CONTROLLER_PHASE:
CURRENT_FEATURE:
ACTIVE_TASKS:
BLOCKED_ITEMS:
LAST_VERIFIED_STATE:
NEXT_ACTION:
```
The next orchestrator invocation must begin by reconciling actual state and executing `NEXT_ACTION`.
`ORCHESTRATOR_STATE: CONTINUE` is explicitly nonterminal.
---
# 23. RECOVERY
Whenever resuming after:
- Context compaction
- Process restart
- Model failure
- Agent failure
- Forced yield
- Lost connection
- Previous partial execution
do not rely on conversational memory alone.
1. Read {{WORK_TRACKER}}.
2. Inspect repository state.
3. Inspect branches/PRs.
4. Inspect CI/checks.
5. Determine the last verified completed gate.
6. Reconstruct `NEXT_ACTION`.
7. Resume from that gate.
Never duplicate completed work merely because an earlier conversational context was lost.
---
# 24. EFFICIENCY RULES
1. Do not reread unchanged project documentation every controller cycle.
2. Cache stable architecture/context within the current session.
3. Re-read context only when relevant repository/project state changes.
4. Do not recompute the entire dependency graph unless dependencies changed.
5. Run cheap local quality checks before expensive independent review.
6. Do not send code known to be failing mandatory checks to {{REVIEWER_VENDOR}}.
7. Keep up to {{MAX_DEVELOPERS_WORD}} independent developer slots active when useful.
8. Process completed agents immediately rather than waiting for a batch.
9. Do not repeatedly ask reviewers to rediscover already-resolved findings.
10. Include previous finding IDs in remediation review prompts.
11. Use affected/local test suites during task development.
12. Use full integration suites at feature/release gates.
13. Avoid spawning agents for trivial repository-state inspection that the orchestrator can perform directly.
14. Never use an LLM agent merely to wait.
---
# 25. ROLE BOUNDARIES
## Orchestrator
May:
- Inspect.
- Plan.
- Create feature/release branches.
- Maintain {{WORK_TRACKER}}.
- Dispatch agents.
- Verify gates.
- Coordinate work.
Must NOT:
- Implement feature code.
- Remediate code directly.
- Perform independent review.
- Substitute its own approval.
- Merge task/feature/release PRs.
## Development Agent
May:
- Implement assigned tasks.
- Remediate findings.
- Test.
- Open task PRs.
- Merge approved task PRs into feature branches.
Must NOT:
- Approve itself.
- Independently review itself.
- Merge to `{{INTEGRATION_BRANCH}}` or `{{RELEASE_BRANCH}}`.
## Reviewer
Read-only.
Never edits or merges.
## Approver
Verifies final feature gates and performs feature → `{{INTEGRATION_BRANCH}}` merge.
Never implements or reviews its own changes.
## {{RELEASE_OWNER_NAME}} / `{{HOST_ACCOUNT}}`
Only {{RELEASE_OWNER_NAME}} merges release PRs into `{{RELEASE_BRANCH}}`.
---
# 26. EXECUTION START
At startup:
1. Determine `MISSION_SCOPE`.
2. Reconcile {{WORK_TRACKER}}.
3. Inspect actual repository/PR state.
4. Recover unfinished work if present.
5. Establish `NEXT_ACTION`.
6. Execute it.
7. Enter `CONTROLLER_TICK`.
8. Continue until a GLOBAL TERMINAL CONDITION is reached.
Remember:
**Never report a next executable step instead of performing it.**
**Task completion triggers another controller cycle.**
**Review completion triggers another controller cycle.**
**Feature completion triggers another controller cycle unless the mission was explicitly limited to that feature.**
**A locally blocked item triggers scheduling of other useful work whenever possible.**
You are the controller of a persistent development pipeline. Keep the pipeline moving.
---
# 27. HOST CONTROLLER RUNTIME
This section is the host execution contract for OpenCode {{OPENCODE_VERSION}} and takes
precedence over Section 21 when deciding whether a single model invocation may
end. The persistent user service runs one fresh, headless orchestrator turn per
controller tick. Section 21 governs mission completion, not the lifetime of
one OpenCode turn.
The user starts this project's cycle explicitly from OpenCode with
`{{START_COMMAND}}`. `{{STOP_COMMAND}}` stops it, and
`{{STATUS_COMMAND}}` reports its state. The `{{SERVICE_NAME}}` user unit is
not enabled at login. Reading this prompt in an ordinary interactive session
does not authorize starting the background service. The service waits for the
interactive project session to close before its first tick.
At the end of EVERY orchestrator invocation, emit exactly one final line:
`ORCHESTRATOR_STATE: CONTINUE`, `ORCHESTRATOR_STATE: QUIESCENT`, or
`ORCHESTRATOR_STATE: BLOCKED`. Use CONTINUE whenever eligible or in-flight
work remains and state the next concrete action immediately above that line.
The service starts a fresh invocation after reconciling {{CONTEXT_STORE}}, {{WORK_TRACKER}}, Git,
pull requests, and checks. Use QUIESCENT only after verifying that no eligible
or in-flight work remains; the service polls again. Use BLOCKED only for a
specific required human decision or access failure; name the missing action.
A blocked task does not block other eligible work. If {{CONTEXT_STORE}} is unavailable,
do not dispatch or merge, and return BLOCKED.
The installed launchers live at
`{{CONTROL_HOME}}/scripts/`. Use `{{ROLE_LAUNCHER}}`
to start developer or approver OpenCode sessions. Use `{{REVIEW_LAUNCHER}}` with
WORKTREE, BASE_REF, HEAD_REF, REVIEW_PROMPT_FILE, PR_NUMBER, OUTPUT_FILE to
run a fresh read-only {{REVIEWER_DISPLAY_NAME}} review at high effort. Its output file
must be under `{{SCRATCH_DIR}}`; it posts a {{CODE_HOST}} review as `{{REVIEW_ACCOUNT}}`
only after approving the exact current head. The feature-to-{{INTEGRATION_BRANCH}} approver uses
`{{MERGE_BROKER}}` and must verify that same exact-head {{CODE_HOST}} approval.
No controller or developer session performs the feature-to-{{INTEGRATION_BRANCH}} merge.
The service holds one controller lock and waits while an interactive
{{PROJECT_NAME}} OpenCode session is open. Each model turn has a {{TICK_TIMEOUT_LABEL}} host
timeout. After an error or timeout, reconcile external state before retrying;
three consecutive failed turns stop the service for inspection. A successful
feature merge is a transition back into scheduling, not a terminal condition.
+24
View File
@@ -0,0 +1,24 @@
# Reusing the development cycle prompt
`ORCHESTRATOR-PROMPT.template.md` is the current NestQuest controller
prompt with project-specific names, models, branches, identities, and launcher
paths replaced by placeholders. `nestquest.profile.json` is the working
example. Render a concrete prompt with:
```bash
scripts/render-cycle-prompt templates/nestquest.profile.json --output /tmp/opencode/nestquest-prompt.md
```
For another project, copy the profile and set its project name, tracker,
context store, models, branch names, release owner, example IDs, review
account, and installed launcher paths. Review the rendered result section by
section, especially task/feature semantics, release rules, permissions,
review gates, and the host runtime section. The template assumes an OpenCode
controller, a feature/task tracker, a versioned context store, a code host,
and a separate reviewer and approver. Adapt those sections when the project's
systems differ.
Publish the reviewed **rendered** prompt into that project's context store
with its version check. Give the project its own agent definitions, service
unit, broker, and OpenCode start/stop commands. The template itself does not
start a controller or register another project.
+36
View File
@@ -0,0 +1,36 @@
{
"LAST_UPDATED": "2026-09-22",
"PROJECT_CONTEXT_LABEL": "NESTQUEST HOUSE CHAOS COORDINATION",
"PROJECT_NAME": "NestQuest",
"WORK_TRACKER": "Maestro",
"CONTEXT_STORE": "CTXD",
"MODEL_PROVIDER": "OpenRouter",
"CONTROLLER_MODEL": "deepseek/deepseek-v4.1-flash",
"DEVELOPER_MODEL": "z-ai/glm-5.3-flash",
"REVIEWER_MODEL": "claude-sonnet-5",
"REVIEWER_DISPLAY_NAME": "Claude Sonnet 5",
"REVIEWER_HARNESS": "Claude Code",
"REVIEWER_VENDOR": "Claude",
"REVIEWER_CLI": "claude",
"MAX_DEVELOPERS": "2",
"MAX_DEVELOPERS_WORD": "two",
"INTEGRATION_BRANCH": "dev",
"RELEASE_BRANCH": "main",
"RELEASE_OWNER_NAME": "Joshua",
"HOST_ACCOUNT": "overseer",
"CODE_HOST": "Gitea",
"TASK_ID_EXAMPLE": "TASK-123",
"FEATURE_BRANCH_EXAMPLE": "feature/NQ-17-family-calendar",
"OPENCODE_VERSION": "1.18.31",
"CONTROL_HOME": "/home/overseer/.local/share/nestquest-controller",
"SERVICE_NAME": "nestquest-controller.service",
"START_COMMAND": "/start-nestquest-development",
"STOP_COMMAND": "/stop-nestquest-development",
"STATUS_COMMAND": "/nestquest-development-status",
"ROLE_LAUNCHER": "nq-agent",
"REVIEW_LAUNCHER": "nq-review",
"MERGE_BROKER": "nq-approve-merge",
"SCRATCH_DIR": "/tmp/opencode",
"REVIEW_ACCOUNT": "review_agent",
"TICK_TIMEOUT_LABEL": "six-hour"
}
-324
View File
@@ -1,324 +0,0 @@
"""FastAPI skeleton tests: health, OpenAPI, core wiring (task 042dfc5c).
These tests start the NestQuest API app with httpx against the ASGI
app and assert the skeleton contract of Feature 16's first task:
- ``GET /health`` returns 200 with a JSON status body (``status: ok``)
and does NOT leak the filesystem path;
- ``GET /docs`` (Swagger UI) is served, and ``GET /openapi.json``
returns the OpenAPI document;
- the app wires core (opens the configured SQLite file and applies
migrations on startup via the lifespan) and closes the connection on
shutdown (``connected is False`` afterwards);
- ``ApiConfig.from_env`` raises when ``NESTQUEST_DB_PATH`` is missing or
empty, and returns the value when set;
- a startup failure (unwritable path / non-SQLite file) surfaces from
the lifespan rather than the first request;
- each test is hermetic — it points ``NESTQUEST_DB_PATH`` at a fresh
temp file.
A SEPARATE subprocess test asserts the stronger invariant the task
calls out: the app imports the bundled ``core`` WITHOUT importing
``homeassistant``. That cannot run inside the pytest process because
``tests/conftest.py`` installs mock ``homeassistant`` modules globally
before any test imports the integration; a subprocess with a clean
``sys.modules`` is the only honest proof (the same approach
``tests/test_core_no_ha_smoke.py`` takes for the core bundle).
"""
from __future__ import annotations
import subprocess
import sys
import tempfile
import textwrap
from pathlib import Path
import httpx
import pytest
from api.app import create_app
from api.config import ApiConfig, ConfigError
from api.database import DatabaseState, _executor, make_database
#: The repo root, so the no-HA subprocess can import the API package.
_REPO_ROOT = Path(__file__).resolve().parent.parent
#: The marker the no-HA subprocess prints on success.
_SUCCESS_MARKER = "API NO-HA IMPORT OK"
@pytest.fixture
def temp_db_path(tmp_path: Path) -> str:
"""A fresh per-test SQLite path under pytest's tmp_path."""
return str(tmp_path / "nestquest.db")
class _AppRunner:
"""Run the app's lifespan around an httpx AsyncClient.
httpx's :class:`ASGITransport` does NOT run the ASGI lifespan on its
own, so the database would never open. Starlette exposes the app's
lifespan as ``app.router.lifespan_context``; wrapping the client
session in it drives the same startup/shutdown path uvicorn would,
so ``open_and_migrate`` runs before the first request and ``close``
runs after the last.
``__aexit__`` uses try/finally so a client-side error still shuts
the lifespan down — otherwise a failure mid-test would leak the
database connection open.
"""
def __init__(self, db_path: str) -> None:
self.app = create_app(ApiConfig(db_path=db_path))
async def __aenter__(self) -> httpx.AsyncClient:
self._lifespan = self.app.router.lifespan_context(self.app)
await self._lifespan.__aenter__()
self._client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=self.app),
base_url="http://testserver",
)
return await self._client.__aenter__()
async def __aexit__(self, *exc) -> None:
try:
await self._client.__aexit__(*exc)
finally:
await self._lifespan.__aexit__(*exc)
@pytest.fixture
async def app_client(temp_db_path: str) -> httpx.AsyncClient:
async with _AppRunner(temp_db_path) as client:
yield client
# --- ApiConfig.from_env -------------------------------------------------
def test_from_env_returns_set_value() -> None:
"""A set, non-empty NESTQUEST_DB_PATH is returned verbatim."""
cfg = ApiConfig.from_env({"NESTQUEST_DB_PATH": "/data/nq.db"})
assert cfg.db_path == "/data/nq.db"
def test_from_env_raises_on_empty_value() -> None:
"""An empty/whitespace-only value raises ConfigError (no silent default)."""
with pytest.raises(ConfigError):
ApiConfig.from_env({"NESTQUEST_DB_PATH": " "})
def test_from_env_raises_on_missing_value() -> None:
"""A missing NESTQUEST_DB_PATH raises ConfigError (no silent default)."""
with pytest.raises(ConfigError):
ApiConfig.from_env({})
# --- health / openapi / docs -------------------------------------------
async def test_health_returns_200_with_status_body(
app_client: httpx.AsyncClient,
) -> None:
"""GET /health returns 200 with status ok and no filesystem path."""
response = await app_client.get("/health")
assert response.status_code == 200
body = response.json()
assert body == {"status": "ok"}
assert "db_path" not in body
async def test_openapi_document_is_served(
app_client: httpx.AsyncClient,
) -> None:
"""GET /openapi.json returns the OpenAPI document."""
response = await app_client.get("/openapi.json")
assert response.status_code == 200
schema = response.json()
assert schema["openapi"].startswith("3.")
assert "/health" in schema["paths"]
async def test_docs_swagger_ui_is_served(app_client: httpx.AsyncClient) -> None:
"""GET /docs serves the Swagger UI HTML (OpenAPI at /docs)."""
response = await app_client.get("/docs")
assert response.status_code == 200
assert "swagger" in response.text.lower()
# --- core wiring & shutdown --------------------------------------------
async def test_app_wires_core_and_applies_migrations(
temp_db_path: str,
) -> None:
"""The lifespan opens the SQLite file and applies migrations.
After startup, the file exists and carries the NestQuest schema
version stamp — proving the app imported core, opened the database
at the configured path, and ran the migration runner.
"""
async with _AppRunner(temp_db_path):
assert Path(temp_db_path).exists()
# Re-open the file with a fresh core database to read the version
# stamp independent of the app's connection. Reuse the API's own
# executor so the test exercises the same wiring the app does.
from api.nestquest_core import core_db, core_migrations
database = core_db.NestQuestDatabase(_executor)
await database.open(temp_db_path)
try:
version = await core_migrations.read_schema_version(database)
finally:
await database.close()
assert version >= 1
async def test_shutdown_closes_the_connection(temp_db_path: str) -> None:
"""The lifespan closes the database connection on shutdown.
After the runner exits, ``DatabaseState.database.connected`` is
False — proving ``close()`` ran (the lifespan's finally block) even
on a clean exit.
"""
runner = _AppRunner(temp_db_path)
async with runner as _client:
state: DatabaseState = runner.app.state.db
assert state.database.connected
assert not state.database.connected
# --- startup failure ----------------------------------------------------
async def test_startup_fails_on_unwritable_path(tmp_path: Path) -> None:
"""A database path whose parent directory does not exist fails fast.
``sqlite3.connect`` raises ``OperationalError`` for a path under a
non-existent directory; the lifespan surfaces that as a startup
error rather than the first request. The connection is closed
through the lifespan's finally block (``close`` is a no-op on a
never-opened wrapper).
"""
bad_path = str(tmp_path / "does_not_exist" / "nestquest.db")
runner = _AppRunner(bad_path)
with pytest.raises(Exception):
async with runner:
pass
async def test_startup_fails_on_non_sqlite_file(tmp_path: Path) -> None:
"""A path pointing at a non-SQLite file fails fast on startup.
Opening an existing file that is not a SQLite database makes the
``PRAGMA journal_mode=WAL`` run during :meth:`open` raise
``DatabaseError: file is not a database``; the lifespan surfaces it.
"""
bad_file = tmp_path / "not_a_db.bin"
bad_file.write_bytes(b"this is not a sqlite database")
runner = _AppRunner(str(bad_file))
with pytest.raises(Exception):
async with runner:
pass
# --- no-HA subprocess ---------------------------------------------------
#: The no-HA subprocess script. Runs with ``sys.executable -c`` so it
#: starts with a clean ``sys.modules`` and no ``homeassistant`` installed
#: (the suite's conftest stubs are not present in a subprocess). It
#: builds the app — which triggers the core bootstrap — and asserts
#: neither ``homeassistant`` nor ``custom_components`` entered
#: ``sys.modules``, and that the loaded core package's ``__file__``
#: points at the bundled directory.
_NO_HA_SCRIPT = textwrap.dedent(
"""\
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, sys.argv[1])
REPO_ROOT = Path(sys.argv[1])
# homeassistant must not be present before we touch the API.
assert "homeassistant" not in sys.modules, (
"homeassistant already in sys.modules before app import — the "
"subprocess is not a clean no-HA process"
)
from api.app import create_app
from api.config import ApiConfig
from api.nestquest_core import core
# The app build imports core via api.nestquest_core. core is a
# Home-Assistant-free package; if it (or its loader) ever drags HA
# in, this fails.
assert "homeassistant" not in sys.modules, (
"homeassistant imported while building the NestQuest API app — "
"the core wiring is no longer Home-Assistant-free"
)
# The loader registers core under the distinct name "nestquest_core"
# WITHOUT executing custom_components.nestquest.__init__, so the
# parent integration package must never be registered, and no
# sys.path mutation places the integration directory on sys.path.
assert "custom_components" not in sys.modules, (
"custom_components registered in sys.modules — the core loader "
"executed the integration's HA-coupled __init__ instead of loading "
"core by file location"
)
assert "nestquest_core" in sys.modules, "core was not imported"
# core.__file__ must point at the bundled directory inside the
# integration, proving the file-location loader resolved against the
# bundled package rather than some other "core" on sys.path.
expected_core_dir = (
REPO_ROOT / "custom_components" / "nestquest" / "core"
)
assert Path(core.__file__).resolve() == (
expected_core_dir / "__init__.py"
).resolve(), (
f"core.__file__={core.__file__} does not point at the bundled "
f"directory {expected_core_dir}"
)
# Build the app (the lifespan is NOT run here, so the path is never
# opened — it just needs to be a non-empty string to satisfy the
# fail-fast config check). Use a temp path so nothing is created.
app = create_app(ApiConfig(db_path=str(
Path(tempfile.gettempdir()) / "nq-noha-buildonly.db"
)))
assert app is not None
print("API NO-HA IMPORT OK")
"""
)
def test_app_imports_core_without_importing_homeassistant() -> None:
"""The app imports core without importing homeassistant (subprocess).
Runs in a subprocess because ``tests/conftest.py`` installs mock
``homeassistant`` modules into the pytest process's ``sys.modules``
before any test runs, so an in-process check could never
distinguish "core never touched HA" from "conftest already stubbed
HA for us". The subprocess is a genuine no-HA process; if the
core loader (or core itself) ever grows a ``homeassistant``
import, the subprocess exits non-zero with ``ModuleNotFoundError``.
"""
result = subprocess.run(
[sys.executable, "-c", _NO_HA_SCRIPT, str(_REPO_ROOT)],
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0:
raise AssertionError(
f"no-HA app-import subprocess exited {result.returncode}.\n"
f"--- stdout ---\n{result.stdout}\n"
f"--- stderr ---\n{result.stderr}"
)
assert _SUCCESS_MARKER in result.stdout, (
f"success marker {_SUCCESS_MARKER!r} not printed.\n"
f"--- stdout ---\n{result.stdout}\n"
f"--- stderr ---\n{result.stderr}"
)
-306
View File
@@ -1,306 +0,0 @@
"""Test the pure panel snapshot builder (task 9047a97b).
The snapshot assembly that lived inline in the coordinator's
``_async_update_data`` was extracted into the Home-Assistant-free
:func:`custom_components.nestquest.core.snapshot.build_snapshot`, and
the panel payload shaper (:func:`instance_payload`) with it. These
tests seed a household and assert the pure builder's output against
EXPLICIT literal expected values — not self-equality — so the AWAY
child's ``next_present``, a due-timed quest's ``overdue`` under a
pinned ``now``, the custody ``cycle_day`` (> 1), and the panel payload
dict shape (``done`` -> ``completed``, ``on_time`` passthrough, and
``missed`` omission) are all exercised as literals.
"""
from __future__ import annotations
import datetime
from zoneinfo import ZoneInfo
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.children import create_child
from custom_components.nestquest.completion import complete_instance
from custom_components.nestquest.core.snapshot import (
ChildDaySnapshot,
NestQuestSnapshot,
QuestInstanceView,
build_snapshot,
instance_payload,
)
from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
PresenceSchedulesDao,
)
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import create_quest_definition
from custom_components.nestquest.recurrence import ScheduleRule
async def test_instance_payload_shape_and_missed_omission() -> None:
"""``instance_payload`` is the documented panel shape (§1).
``done`` maps to ``completed``; ``open`` passes through; ``on_time``
is the builder's ``was_on_time`` verbatim; ``missed`` instances are
omitted unless ``include_missed`` (the admin payload, D-009).
"""
def view(state: str, *, overdue: bool = False, on_time: bool | None = None,
completed_at: str | None = None, instance_id: int = 1) -> QuestInstanceView:
return QuestInstanceView(
instance_id=instance_id,
definition_id=10,
child_id=3,
title="Fill bird feeder",
icon="bird",
window="afternoon",
due_date="2026-09-21",
due_time="17:00",
state=state,
overdue=overdue,
completed_at=completed_at,
was_on_time=on_time,
)
open_view = view("open", overdue=True)
done_view = view("done", on_time=True, completed_at="2026-09-21T12:00:00+00:00")
missed_view = view("missed", instance_id=2)
panel = instance_payload([open_view, done_view, missed_view], include_missed=False)
assert panel == [
{
"id": 1,
"definition_id": 10,
"child_id": 3,
"title": "Fill bird feeder",
"icon": "bird",
"window": "afternoon",
"due_time": "17:00",
"state": "open",
"overdue": True,
"completed_at": None,
"on_time": None,
},
{
"id": 1,
"definition_id": 10,
"child_id": 3,
"title": "Fill bird feeder",
"icon": "bird",
"window": "afternoon",
"due_time": "17:00",
"state": "completed",
"overdue": False,
"completed_at": "2026-09-21T12:00:00+00:00",
"on_time": True,
},
], "panel payload omits missed and spells done as completed"
admin = instance_payload(
[open_view, done_view, missed_view], include_missed=True
)
assert len(admin) == 3
assert admin[2]["state"] == "missed", (
"admin payload keeps missed instances with their missed state"
)
async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> None:
"""The builder produces the documented shape as literals, pinned in time.
Ada is AWAY today (a 2-week schedule whose absent week 0 covers today,
plus a presence override marking her present on today+2) so
``present`` is False and ``next_present`` is the override date. Her
open quest due at 10:00 is OVERDUE under the pinned 12:00 ``now``.
Bo's quest due at 17:00 is completed at 12:00 (on time) so its
payload ``state`` is ``completed`` and ``on_time`` is True. The first
scheduled active child is Ada, whose 2-week cycle anchored one day
before today puts ``cycle_day`` at 2.
"""
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
settings = entry.runtime_data.settings
# Single clock read for the seed/snapshot anchor (no midnight skew).
today = coordinator._local_now().date()
time_zone = ZoneInfo(hass.config.time_zone)
# Pin the build instant at 12:00 local: past Ada's 10:00 due time
# (overdue) and before Bo's 17:00, and the completion moment for Bo.
pinned_now = datetime.datetime(
today.year, today.month, today.day, 12, 0, 0, tzinfo=time_zone
)
today_iso = today.isoformat()
ada = await create_child(database, "Ada")
bo = await create_child(database, "Bo")
await create_quest_definition(
database,
"Pack bag",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today_iso}
),
[ada.id],
[("morning", "10:00")],
)
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today_iso}
),
[bo.id],
[("morning", "17:00")],
)
end = (today + datetime.timedelta(days=3)).isoformat()
await materialize(database, today_iso, end, today=today)
# Ada: 2-week cycle anchored yesterday. Week 0 (the empty segment)
# covers today, so she is absent today; an override marks her present
# on today+2, so next_present resolves to that date and cycle_day is
# ((today - (today-1)) % 14) + 1 == 2.
await PresenceSchedulesDao(database).upsert_by_child(
ada.id,
cycle_length_weeks=2,
anchor_date=(today - datetime.timedelta(days=1)).isoformat(),
pattern="|0,1,2,3,4,5,6",
)
returns_date = today + datetime.timedelta(days=2)
await PresenceOverridesDao(database).create(
ada.id,
returns_date.isoformat(),
returns_date.isoformat(),
is_present=True,
note="visit",
)
# Complete Bo's instance at the pinned 12:00 (17:00 due => on time).
bo_instances = await QuestInstancesDao(database).list_by_child_and_date(
bo.id, today_iso
)
await complete_instance(
database,
bo_instances[0].id,
actor_source="panel",
actor_child_id=bo.id,
now=pinned_now,
today=today,
)
built = await build_snapshot(database, settings, pinned_now)
assert isinstance(built, NestQuestSnapshot)
assert built.today_iso == today_iso
assert built.cycle_day == 2, (
"Ada's 2-week cycle anchored yesterday => day 2 today"
)
by_id = {child.child_id: child for child in built.children}
assert list(by_id) == [ada.id, bo.id], (
"active children appear in sort_order, id order"
)
ada_child = by_id[ada.id]
bo_child = by_id[bo.id]
# Ada: away today, returns on the override date, one open+overdue quest.
assert isinstance(ada_child, ChildDaySnapshot)
assert ada_child.present is False
assert ada_child.next_present == returns_date.isoformat()
assert ada_child.due_today == 1
assert ada_child.completed_today == 0
assert ada_child.remaining_today == 1
assert ada_child.completion_pct == 0
ada_view = ada_child.instances[0]
assert isinstance(ada_view, QuestInstanceView)
assert ada_view.state == "open"
assert ada_view.due_time == "10:00"
assert ada_view.overdue is True, "12:00 now is past the 10:00 due time"
assert ada_view.completed_at is None
assert ada_view.was_on_time is None
# Bo: present, one completed instance, on time, not overdue.
assert bo_child.present is True
assert bo_child.next_present is None
assert bo_child.due_today == 1
assert bo_child.completed_today == 1
assert bo_child.remaining_today == 0
assert bo_child.completion_pct == 100
bo_view = bo_child.instances[0]
assert bo_view.state == "done"
assert bo_view.due_time == "17:00"
assert bo_view.overdue is False, "overdue is only computed for open state"
assert bo_view.completed_at == pinned_now.astimezone(
datetime.timezone.utc
).isoformat()
assert bo_view.was_on_time is True
# Panel payload shape: Ada open+overdue stays open; Bo done -> completed.
ada_payload = instance_payload(ada_child.instances, include_missed=False)
assert ada_payload == [
{
"id": ada_view.instance_id,
"definition_id": ada_view.definition_id,
"child_id": ada.id,
"title": "Pack bag",
"icon": None,
"window": "morning",
"due_time": "10:00",
"state": "open",
"overdue": True,
"completed_at": None,
"on_time": None,
}
]
bo_payload = instance_payload(bo_child.instances, include_missed=False)
assert bo_payload == [
{
"id": bo_view.instance_id,
"definition_id": bo_view.definition_id,
"child_id": bo.id,
"title": "Brush teeth",
"icon": None,
"window": "morning",
"due_time": "17:00",
"state": "completed",
"overdue": False,
"completed_at": pinned_now.astimezone(
datetime.timezone.utc
).isoformat(),
"on_time": True,
}
]
# Missed omission is real: a missed view is dropped from the panel
# payload and kept in the admin payload with its missed state.
missed_view = QuestInstanceView(
instance_id=999,
definition_id=10,
child_id=ada.id,
title="Stale quest",
icon=None,
window="morning",
due_date=(today - datetime.timedelta(days=1)).isoformat(),
due_time="09:00",
state="missed",
overdue=False,
completed_at=None,
was_on_time=None,
)
assert instance_payload([missed_view], include_missed=False) == []
assert (
instance_payload([missed_view], include_missed=True)[0]["state"]
== "missed"
)
# REQUIRED wrapper tie-in: the coordinator's _async_update_data is a
# thin WRAPPER over build_snapshot, so pinning its clock to the SAME
# pinned_now the builder was called with must reproduce the builder's
# snapshot byte-for-byte. The dataclasses are frozen, so == is a full
# deep compare — a wrapper that mis-passed today vs now, or stopped
# calling the builder, would diverge here even though the literal
# assertions above still pass.
monkeypatch.setattr(coordinator, "_local_now", lambda: pinned_now)
await coordinator.async_refresh()
assert coordinator.data == built
+4 -4
View File
@@ -186,18 +186,18 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
original_refresh = coordinator._async_update_data
async def _future_dated_refresh():
import custom_components.nestquest.core.snapshot as snapshot_module
import custom_components.nestquest.coordinator as coordinator_module
real_derive = snapshot_module.derive_state
real_derive = coordinator_module.derive_state
def _derive(instance, latest, today):
return real_derive(instance, latest, today + datetime.timedelta(days=1))
snapshot_module.derive_state = _derive
coordinator_module.derive_state = _derive
try:
return await original_refresh()
finally:
snapshot_module.derive_state = real_derive
coordinator_module.derive_state = real_derive
coordinator._async_update_data = _future_dated_refresh
await coordinator.async_refresh()
+3 -3
View File
@@ -238,16 +238,16 @@ async def test_due_today_payload_omits_missed_for_panel_includes_for_admin(
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
hass, make_entry
)
import custom_components.nestquest.core.snapshot as snapshot_module
import custom_components.nestquest.coordinator as coordinator_module
tomorrow = _today() + datetime.timedelta(days=1)
real_derive_state = snapshot_module.derive_state
real_derive_state = coordinator_module.derive_state
def _derive_against_pinned_future(instance, latest, today):
return real_derive_state(instance, latest, tomorrow)
monkeypatch.setattr(
snapshot_module, "derive_state", _derive_against_pinned_future
coordinator_module, "derive_state", _derive_against_pinned_future
)
await coordinator.async_refresh()
Generated
-272
View File
@@ -2,55 +2,6 @@ version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "annotated-doc"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "anyio"
version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
@@ -60,68 +11,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fastapi"
version = "0.141.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -224,13 +113,10 @@ source = { virtual = "." }
[package.dev-dependencies]
dev = [
{ name = "fastapi" },
{ name = "httpx" },
{ name = "jinja2" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pyyaml" },
{ name = "uvicorn" },
{ name = "voluptuous" },
]
@@ -238,13 +124,10 @@ dev = [
[package.metadata.requires-dev]
dev = [
{ name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.28" },
{ name = "jinja2", specifier = ">=3" },
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.24" },
{ name = "pyyaml", specifier = ">=6" },
{ name = "uvicorn", specifier = ">=0.30" },
{ name = "voluptuous", specifier = ">=0.14" },
]
@@ -266,123 +149,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" },
{ url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" },
{ url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" },
{ url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" },
{ url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" },
{ url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" },
{ url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" },
{ url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" },
{ url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" },
{ url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" },
{ url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" },
{ url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" },
{ url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" },
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
{ url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" },
{ url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" },
{ url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" },
{ url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" },
{ url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" },
{ url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" },
{ url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" },
{ url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" },
{ url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" },
{ url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" },
{ url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" },
{ url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" },
{ url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" },
{ url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" },
{ url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" },
{ url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" },
{ url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" },
{ url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" },
{ url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" },
{ url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" },
{ url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" },
{ url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" },
{ url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" },
{ url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" },
{ url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" },
{ url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" },
{ url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" },
{ url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" },
{ url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" },
{ url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" },
{ url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" },
{ url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" },
{ url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" },
{ url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" },
{ url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" },
{ url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" },
{ url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" },
{ url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" },
{ url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" },
{ url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" },
{ url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" },
{ url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" },
{ url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" },
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
{ url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" },
{ url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" },
{ url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" },
{ url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" },
{ url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" },
{ url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" },
{ url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
@@ -476,19 +242,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "starlette"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
@@ -498,31 +251,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
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 = "uvicorn"
version = "0.53.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/ad/04bbb797c84fc1f26cb171f7394716f4865ffb8d8c5e1eef42565c2dfa6b/uvicorn-0.53.0.tar.gz", hash = "sha256:a9356f0cb89b3b8621529c5d5eebd69bfe154f4c3f68b4cf2de47e45fa855c2e", size = 110881, upload-time = "2026-09-14T07:44:23.815Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/18/0eea75741ee812e9f598b687619ce2454f6c3a1c5cd21ea990ec6bd26f45/uvicorn-0.53.0-py3-none-any.whl", hash = "sha256:e8dca71ec86dce5f04e333f0d56cdedf942446e6643b9cea1af0d6d3a02cb03e", size = 87081, upload-time = "2026-09-14T07:44:22.179Z" },
]
[[package]]
name = "voluptuous"
version = "0.16.0"