From 0ea2788943dda2f985f8b6e8d8b0236463d6b6d3 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 24 Feb 2026 17:47:49 +0100 Subject: [PATCH 001/198] =?UTF-8?q?Add=20state-hub=20v0.1=20=E2=80=94=20lo?= =?UTF-8?q?cal-first=20state=20service=20for=20the=20Custodian?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the first live layer of the Custodian cognitive infrastructure: PostgreSQL schema, FastAPI REST API, FastMCP stdio server, and Observable Framework telemetry dashboard. - state-hub/: full stack (docker-compose, FastAPI, Alembic, MCP server, dashboard) - 5 DB tables: topics, workstreams, tasks, decisions, progress_events - 11 MCP tools + 5 resources registered in .mcp.json - Observable dashboard: Overview, Workstreams, Decisions, Progress pages - CLAUDE.md: session protocol (get_state_summary / add_progress_event ritual) - ~/.claude/CLAUDE.md: global cross-project reference to the hub - scripts/pull_image.py: WSL2 TLS-resilient Docker image downloader Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 13 + Makefile | 35 + alembic.ini | 39 + api/config.py | 16 + api/database.py | 24 + api/main.py | 32 + api/models/__init__.py | 15 + api/models/base.py | 26 + api/models/decision.py | 63 + api/models/progress_event.py | 43 + api/models/task.py | 57 + api/models/topic.py | 48 + api/models/workstream.py | 46 + api/routers/__init__.py | 0 api/routers/decisions.py | 110 + api/routers/progress.py | 50 + api/routers/state.py | 132 + api/routers/tasks.py | 83 + api/routers/topics.py | 77 + api/routers/workstreams.py | 80 + api/schemas/__init__.py | 15 + api/schemas/decision.py | 58 + api/schemas/progress_event.py | 32 + api/schemas/state.py | 58 + api/schemas/task.py | 51 + api/schemas/topic.py | 47 + api/schemas/workstream.py | 38 + dashboard/observablehq.config.js | 13 + dashboard/package-lock.json | 4184 ++++++++++++++++++++ dashboard/package.json | 14 + dashboard/src/data/decisions.json.py | 15 + dashboard/src/data/progress.json.py | 16 + dashboard/src/data/summary.json.py | 31 + dashboard/src/data/workstreams.json.py | 15 + dashboard/src/decisions.md | 70 + dashboard/src/index.md | 122 + dashboard/src/progress.md | 77 + dashboard/src/workstreams.md | 67 + infra/docker-compose.yml | 33 + mcp_server/__init__.py | 0 mcp_server/server.py | 360 ++ migrations/__init__.py | 0 migrations/env.py | 56 + migrations/versions/0001_initial_schema.py | 248 ++ pyproject.toml | 32 + scripts/pull_image.py | 185 + scripts/seed.py | 97 + uv.lock | 1644 ++++++++ 48 files changed, 8567 insertions(+) create mode 100644 .env.example create mode 100644 Makefile create mode 100644 alembic.ini create mode 100644 api/config.py create mode 100644 api/database.py create mode 100644 api/main.py create mode 100644 api/models/__init__.py create mode 100644 api/models/base.py create mode 100644 api/models/decision.py create mode 100644 api/models/progress_event.py create mode 100644 api/models/task.py create mode 100644 api/models/topic.py create mode 100644 api/models/workstream.py create mode 100644 api/routers/__init__.py create mode 100644 api/routers/decisions.py create mode 100644 api/routers/progress.py create mode 100644 api/routers/state.py create mode 100644 api/routers/tasks.py create mode 100644 api/routers/topics.py create mode 100644 api/routers/workstreams.py create mode 100644 api/schemas/__init__.py create mode 100644 api/schemas/decision.py create mode 100644 api/schemas/progress_event.py create mode 100644 api/schemas/state.py create mode 100644 api/schemas/task.py create mode 100644 api/schemas/topic.py create mode 100644 api/schemas/workstream.py create mode 100644 dashboard/observablehq.config.js create mode 100644 dashboard/package-lock.json create mode 100644 dashboard/package.json create mode 100644 dashboard/src/data/decisions.json.py create mode 100644 dashboard/src/data/progress.json.py create mode 100644 dashboard/src/data/summary.json.py create mode 100644 dashboard/src/data/workstreams.json.py create mode 100644 dashboard/src/decisions.md create mode 100644 dashboard/src/index.md create mode 100644 dashboard/src/progress.md create mode 100644 dashboard/src/workstreams.md create mode 100644 infra/docker-compose.yml create mode 100644 mcp_server/__init__.py create mode 100644 mcp_server/server.py create mode 100644 migrations/__init__.py create mode 100644 migrations/env.py create mode 100644 migrations/versions/0001_initial_schema.py create mode 100644 pyproject.toml create mode 100644 scripts/pull_image.py create mode 100644 scripts/seed.py create mode 100644 uv.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b279dce --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Copy to .env and fill in values before running +POSTGRES_DB=custodian +POSTGRES_USER=custodian +POSTGRES_PASSWORD=changeme + +DATABASE_URL=postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian + +# pgAdmin (optional, only used with --profile tools) +PGADMIN_EMAIL=admin@local.dev +PGADMIN_PASSWORD=admin + +# API +API_BASE=http://127.0.0.1:8000 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3ac7d45 --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +.PHONY: install db db-tools migrate seed api dashboard check start clean + +COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env + +install: + uv sync + +db: + $(COMPOSE) up -d postgres + +db-tools: + $(COMPOSE) --profile tools up -d + +migrate: + uv run alembic upgrade head + +seed: + uv run python scripts/seed.py + +api: + uv run uvicorn api.main:app --reload --host 127.0.0.1 --port 8000 + +dashboard: + cd dashboard && npm run dev + +check: + curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool + +start: db + sleep 3 + $(MAKE) migrate + $(MAKE) api + +clean: + $(COMPOSE) down -v diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..801d441 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,39 @@ +[alembic] +script_location = migrations +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = postgresql+psycopg2://custodian:changeme@127.0.0.1:5432/custodian + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/api/config.py b/api/config.py new file mode 100644 index 0000000..51c3cc7 --- /dev/null +++ b/api/config.py @@ -0,0 +1,16 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + database_url: str = "postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian" + api_base: str = "http://127.0.0.1:8000" + debug: bool = False + + +settings = Settings() diff --git a/api/database.py b/api/database.py new file mode 100644 index 0000000..9e21ab3 --- /dev/null +++ b/api/database.py @@ -0,0 +1,24 @@ +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from api.config import settings + +engine = create_async_engine( + settings.database_url, + echo=settings.debug, + pool_pre_ping=True, + pool_size=10, + max_overflow=20, +) + +async_session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +async def get_session() -> AsyncGenerator[AsyncSession, None]: + async with async_session_factory() as session: + yield session diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..04f57bd --- /dev/null +++ b/api/main.py @@ -0,0 +1,32 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from api.database import engine +from api.routers import decisions, progress, state, tasks, topics, workstreams + + +@asynccontextmanager +async def lifespan(app: FastAPI): + yield + await engine.dispose() + + +app = FastAPI( + title="Custodian State Hub", + description="Local-first state API for the Custodian agent system.", + version="0.1.0", + lifespan=lifespan, +) + +app.include_router(topics.router) +app.include_router(workstreams.router) +app.include_router(tasks.router) +app.include_router(decisions.router) +app.include_router(progress.router) +app.include_router(state.router) + + +@app.get("/", include_in_schema=False) +async def root(): + return {"service": "state-hub", "docs": "/docs"} diff --git a/api/models/__init__.py b/api/models/__init__.py new file mode 100644 index 0000000..455f824 --- /dev/null +++ b/api/models/__init__.py @@ -0,0 +1,15 @@ +from api.models.base import Base +from api.models.topic import Topic, TopicStatus, Domain +from api.models.workstream import Workstream, WorkstreamStatus +from api.models.task import Task, TaskStatus, TaskPriority +from api.models.decision import Decision, DecisionType, DecisionStatus +from api.models.progress_event import ProgressEvent + +__all__ = [ + "Base", + "Topic", "TopicStatus", "Domain", + "Workstream", "WorkstreamStatus", + "Task", "TaskStatus", "TaskPriority", + "Decision", "DecisionType", "DecisionStatus", + "ProgressEvent", +] diff --git a/api/models/base.py b/api/models/base.py new file mode 100644 index 0000000..4dfbd1c --- /dev/null +++ b/api/models/base.py @@ -0,0 +1,26 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +def new_uuid() -> uuid.UUID: + return uuid.uuid4() diff --git a/api/models/decision.py b/api/models/decision.py new file mode 100644 index 0000000..de807d4 --- /dev/null +++ b/api/models/decision.py @@ -0,0 +1,63 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import CheckConstraint, DateTime, Enum, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class DecisionType(str, enum.Enum): + made = "made" + pending = "pending" + + +class DecisionStatus(str, enum.Enum): + open = "open" + resolved = "resolved" + escalated = "escalated" + superseded = "superseded" + + +class Decision(Base, TimestampMixin): + __tablename__ = "decisions" + __table_args__ = ( + CheckConstraint( + "topic_id IS NOT NULL OR workstream_id IS NOT NULL", + name="ck_decisions_topic_or_workstream", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + topic_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True + ) + workstream_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True + ) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + decision_type: Mapped[DecisionType] = mapped_column( + Enum(DecisionType), nullable=False, default=DecisionType.pending + ) + status: Mapped[DecisionStatus] = mapped_column( + Enum(DecisionStatus), nullable=False, default=DecisionStatus.open + ) + rationale: Mapped[str | None] = mapped_column(Text, nullable=True) + decided_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + deadline: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + escalation_note: Mapped[str | None] = mapped_column(Text, nullable=True) + superseded_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("decisions.id", ondelete="SET NULL"), nullable=True + ) + + topic: Mapped["Topic | None"] = relationship("Topic", back_populates="decisions") # noqa: F821 + workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="decisions") # noqa: F821 + progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821 + "ProgressEvent", back_populates="decision", lazy="selectin" + ) diff --git a/api/models/progress_event.py b/api/models/progress_event.py new file mode 100644 index 0000000..f52b8b5 --- /dev/null +++ b/api/models/progress_event.py @@ -0,0 +1,43 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, new_uuid + + +class ProgressEvent(Base): + """Append-only event log. No updated_at. No DELETE endpoint (constitution Β§5).""" + + __tablename__ = "progress_events" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + topic_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True + ) + workstream_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True + ) + task_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="RESTRICT"), nullable=True, index=True + ) + decision_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("decisions.id", ondelete="RESTRICT"), nullable=True, index=True + ) + event_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + summary: Mapped[str] = mapped_column(Text, nullable=False) + detail: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + author: Mapped[str | None] = mapped_column(String(100), nullable=True) + session_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False, index=True + ) + + topic: Mapped["Topic | None"] = relationship("Topic", back_populates="progress_events") # noqa: F821 + workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="progress_events") # noqa: F821 + task: Mapped["Task | None"] = relationship("Task", back_populates="progress_events") # noqa: F821 + decision: Mapped["Decision | None"] = relationship("Decision", back_populates="progress_events") # noqa: F821 diff --git a/api/models/task.py b/api/models/task.py new file mode 100644 index 0000000..b829b04 --- /dev/null +++ b/api/models/task.py @@ -0,0 +1,57 @@ +import enum +import uuid +from datetime import date + +from sqlalchemy import Date, Enum, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class TaskStatus(str, enum.Enum): + todo = "todo" + in_progress = "in_progress" + blocked = "blocked" + done = "done" + cancelled = "cancelled" + + +class TaskPriority(str, enum.Enum): + low = "low" + medium = "medium" + high = "high" + critical = "critical" + + +class Task(Base, TimestampMixin): + __tablename__ = "tasks" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + workstream_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=False, index=True + ) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[TaskStatus] = mapped_column( + Enum(TaskStatus), nullable=False, default=TaskStatus.todo + ) + priority: Mapped[TaskPriority] = mapped_column( + Enum(TaskPriority), nullable=False, default=TaskPriority.medium + ) + assignee: Mapped[str | None] = mapped_column(String(100), nullable=True) + due_date: Mapped[date | None] = mapped_column(Date, nullable=True) + blocking_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + parent_task_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True + ) + + workstream: Mapped["Workstream"] = relationship("Workstream", back_populates="tasks") # noqa: F821 + subtasks: Mapped[list["Task"]] = relationship( + "Task", foreign_keys=[parent_task_id], lazy="selectin" + ) + progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821 + "ProgressEvent", back_populates="task", lazy="selectin" + ) diff --git a/api/models/topic.py b/api/models/topic.py new file mode 100644 index 0000000..2a06026 --- /dev/null +++ b/api/models/topic.py @@ -0,0 +1,48 @@ +import enum +import uuid + +from sqlalchemy import Enum, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class TopicStatus(str, enum.Enum): + active = "active" + paused = "paused" + archived = "archived" + + +class Domain(str, enum.Enum): + custodian = "custodian" + railiance = "railiance" + markitect = "markitect" + coulomb_social = "coulomb_social" + personhood = "personhood" + foerster_capabilities = "foerster_capabilities" + + +class Topic(Base, TimestampMixin): + __tablename__ = "topics" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + domain: Mapped[Domain] = mapped_column(Enum(Domain), nullable=False) + status: Mapped[TopicStatus] = mapped_column( + Enum(TopicStatus), nullable=False, default=TopicStatus.active + ) + + workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821 + "Workstream", back_populates="topic", lazy="selectin" + ) + decisions: Mapped[list["Decision"]] = relationship( # noqa: F821 + "Decision", back_populates="topic", lazy="selectin" + ) + progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821 + "ProgressEvent", back_populates="topic", lazy="selectin" + ) diff --git a/api/models/workstream.py b/api/models/workstream.py new file mode 100644 index 0000000..d2ddbb7 --- /dev/null +++ b/api/models/workstream.py @@ -0,0 +1,46 @@ +import enum +import uuid +from datetime import date + +from sqlalchemy import Date, Enum, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class WorkstreamStatus(str, enum.Enum): + active = "active" + blocked = "blocked" + completed = "completed" + archived = "archived" + + +class Workstream(Base, TimestampMixin): + __tablename__ = "workstreams" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + topic_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=False, index=True + ) + slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[WorkstreamStatus] = mapped_column( + Enum(WorkstreamStatus), nullable=False, default=WorkstreamStatus.active + ) + owner: Mapped[str | None] = mapped_column(String(100), nullable=True) + due_date: Mapped[date | None] = mapped_column(Date, nullable=True) + + topic: Mapped["Topic"] = relationship("Topic", back_populates="workstreams") # noqa: F821 + tasks: Mapped[list["Task"]] = relationship( # noqa: F821 + "Task", back_populates="workstream", lazy="selectin" + ) + decisions: Mapped[list["Decision"]] = relationship( # noqa: F821 + "Decision", back_populates="workstream", lazy="selectin" + ) + progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821 + "ProgressEvent", back_populates="workstream", lazy="selectin" + ) diff --git a/api/routers/__init__.py b/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/routers/decisions.py b/api/routers/decisions.py new file mode 100644 index 0000000..cd676d9 --- /dev/null +++ b/api/routers/decisions.py @@ -0,0 +1,110 @@ +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.decision import Decision, DecisionStatus, DecisionType +from api.schemas.decision import DecisionCreate, DecisionRead, DecisionUpdate + +router = APIRouter(prefix="/decisions", tags=["decisions"]) + +_FINANCIAL_LEGAL_KEYWORDS = ( + "financ", "legal", "payment", "purchas", "contract", "commit", + "obligation", "external representation", +) + + +def _needs_escalation(body: DecisionCreate) -> str | None: + if body.decision_type != DecisionType.pending: + return None + text = f"{body.title} {body.description or ''}".lower() + for kw in _FINANCIAL_LEGAL_KEYWORDS: + if kw in text: + return ( + "Auto-escalated per constitution Β§4: this pending decision touches " + "financial or legal territory and requires explicit human approval before action." + ) + return None + + +@router.get("/", response_model=list[DecisionRead]) +async def list_decisions( + topic_id: uuid.UUID | None = None, + workstream_id: uuid.UUID | None = None, + status: DecisionStatus | None = None, + decision_type: DecisionType | None = None, + session: AsyncSession = Depends(get_session), +) -> list[Decision]: + q = select(Decision) + if topic_id: + q = q.where(Decision.topic_id == topic_id) + if workstream_id: + q = q.where(Decision.workstream_id == workstream_id) + if status: + q = q.where(Decision.status == status) + if decision_type: + q = q.where(Decision.decision_type == decision_type) + q = q.order_by(Decision.created_at) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=DecisionRead, status_code=status.HTTP_201_CREATED) +async def create_decision( + body: DecisionCreate, + session: AsyncSession = Depends(get_session), +) -> Decision: + data = body.model_dump() + note = _needs_escalation(body) + if note: + data["escalation_note"] = note + data["status"] = DecisionStatus.escalated + decision = Decision(**data) + session.add(decision) + await session.commit() + await session.refresh(decision) + return decision + + +@router.get("/{decision_id}", response_model=DecisionRead) +async def get_decision( + decision_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Decision: + decision = await session.get(Decision, decision_id) + if decision is None: + raise HTTPException(status_code=404, detail="Decision not found") + return decision + + +@router.patch("/{decision_id}", response_model=DecisionRead) +async def update_decision( + decision_id: uuid.UUID, + body: DecisionUpdate, + session: AsyncSession = Depends(get_session), +) -> Decision: + decision = await session.get(Decision, decision_id) + if decision is None: + raise HTTPException(status_code=404, detail="Decision not found") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(decision, field, value) + await session.commit() + await session.refresh(decision) + return decision + + +@router.delete("/{decision_id}", response_model=DecisionRead) +async def supersede_decision( + decision_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Decision: + decision = await session.get(Decision, decision_id) + if decision is None: + raise HTTPException(status_code=404, detail="Decision not found") + decision.status = DecisionStatus.superseded + await session.commit() + await session.refresh(decision) + return decision diff --git a/api/routers/progress.py b/api/routers/progress.py new file mode 100644 index 0000000..2ca9309 --- /dev/null +++ b/api/routers/progress.py @@ -0,0 +1,50 @@ +import uuid +from datetime import datetime + +from fastapi import APIRouter, Depends, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.progress_event import ProgressEvent +from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead + +router = APIRouter(prefix="/progress", tags=["progress"]) + + +@router.get("/", response_model=list[ProgressEventRead]) +async def list_progress( + topic_id: uuid.UUID | None = None, + workstream_id: uuid.UUID | None = None, + task_id: uuid.UUID | None = None, + event_type: str | None = None, + since: datetime | None = None, + limit: int = 100, + session: AsyncSession = Depends(get_session), +) -> list[ProgressEvent]: + q = select(ProgressEvent) + if topic_id: + q = q.where(ProgressEvent.topic_id == topic_id) + if workstream_id: + q = q.where(ProgressEvent.workstream_id == workstream_id) + if task_id: + q = q.where(ProgressEvent.task_id == task_id) + if event_type: + q = q.where(ProgressEvent.event_type == event_type) + if since: + q = q.where(ProgressEvent.created_at >= since) + q = q.order_by(ProgressEvent.created_at.desc()).limit(limit) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=ProgressEventRead, status_code=status.HTTP_201_CREATED) +async def append_progress( + body: ProgressEventCreate, + session: AsyncSession = Depends(get_session), +) -> ProgressEvent: + event = ProgressEvent(**body.model_dump()) + session.add(event) + await session.commit() + await session.refresh(event) + return event diff --git a/api/routers/state.py b/api/routers/state.py new file mode 100644 index 0000000..fd9dbed --- /dev/null +++ b/api/routers/state.py @@ -0,0 +1,132 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse +from sqlalchemy import func, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session, engine +from api.models.decision import Decision, DecisionStatus, DecisionType +from api.models.progress_event import ProgressEvent +from api.models.task import Task, TaskStatus +from api.models.topic import Topic, TopicStatus +from api.models.workstream import Workstream, WorkstreamStatus +from api.schemas.decision import DecisionRead +from api.schemas.progress_event import ProgressEventRead +from api.schemas.state import ( + DecisionTotals, + StateSummary, + TaskTotals, + Totals, + TopicTotals, + WorkstreamTotals, +) +from api.schemas.task import TaskRead +from api.schemas.topic import TopicWithWorkstreams +from api.schemas.workstream import WorkstreamRead + +router = APIRouter(prefix="/state", tags=["state"]) + + +@router.get("/summary", response_model=StateSummary) +async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSummary: + # Run all queries sequentially on one session. + # AsyncSession does not support concurrent operations (no gather on same session). + + topics_rows = await session.execute( + select(Topic).where(Topic.status != TopicStatus.archived).order_by(Topic.created_at) + ) + topics = list(topics_rows.scalars().all()) + + blocking_rows = await session.execute( + select(Decision) + .where(Decision.decision_type == DecisionType.pending) + .where(Decision.status.in_([DecisionStatus.open, DecisionStatus.escalated])) + .order_by(Decision.deadline.asc().nullslast(), Decision.created_at) + ) + blocking = list(blocking_rows.scalars().all()) + + blocked_rows = await session.execute( + select(Task).where(Task.status == TaskStatus.blocked).order_by(Task.created_at) + ) + blocked = list(blocked_rows.scalars().all()) + + recent_rows = await session.execute( + select(ProgressEvent).order_by(ProgressEvent.created_at.desc()).limit(20) + ) + recent = list(recent_rows.scalars().all()) + + open_ws_rows = await session.execute( + select(Workstream) + .where(Workstream.status.in_([WorkstreamStatus.active, WorkstreamStatus.blocked])) + .order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at) + ) + open_ws = list(open_ws_rows.scalars().all()) + + # Totals β€” one GROUP BY per table + topic_counts = {r[0]: r[1] for r in await session.execute( + select(Topic.status, func.count()).group_by(Topic.status) + )} + ws_counts = {r[0]: r[1] for r in await session.execute( + select(Workstream.status, func.count()).group_by(Workstream.status) + )} + task_counts = {r[0]: r[1] for r in await session.execute( + select(Task.status, func.count()).group_by(Task.status) + )} + dec_counts = {r[0]: r[1] for r in await session.execute( + select(Decision.status, func.count()).group_by(Decision.status) + )} + + totals = Totals( + topics=TopicTotals( + active=topic_counts.get(TopicStatus.active, 0), + paused=topic_counts.get(TopicStatus.paused, 0), + archived=topic_counts.get(TopicStatus.archived, 0), + total=sum(topic_counts.values()), + ), + workstreams=WorkstreamTotals( + active=ws_counts.get(WorkstreamStatus.active, 0), + blocked=ws_counts.get(WorkstreamStatus.blocked, 0), + completed=ws_counts.get(WorkstreamStatus.completed, 0), + archived=ws_counts.get(WorkstreamStatus.archived, 0), + total=sum(ws_counts.values()), + ), + tasks=TaskTotals( + todo=task_counts.get(TaskStatus.todo, 0), + in_progress=task_counts.get(TaskStatus.in_progress, 0), + blocked=task_counts.get(TaskStatus.blocked, 0), + done=task_counts.get(TaskStatus.done, 0), + cancelled=task_counts.get(TaskStatus.cancelled, 0), + total=sum(task_counts.values()), + ), + decisions=DecisionTotals( + open=dec_counts.get(DecisionStatus.open, 0), + resolved=dec_counts.get(DecisionStatus.resolved, 0), + escalated=dec_counts.get(DecisionStatus.escalated, 0), + superseded=dec_counts.get(DecisionStatus.superseded, 0), + total=sum(dec_counts.values()), + ), + ) + + return StateSummary( + generated_at=datetime.now(tz=timezone.utc), + totals=totals, + topics=[TopicWithWorkstreams.model_validate(t) for t in topics], + blocking_decisions=[DecisionRead.model_validate(d) for d in blocking], + blocked_tasks=[TaskRead.model_validate(t) for t in blocked], + recent_progress=[ProgressEventRead.model_validate(e) for e in recent], + open_workstreams=[WorkstreamRead.model_validate(w) for w in open_ws], + ) + + +@router.get("/health") +async def health_check() -> dict: + try: + async with engine.connect() as conn: + await conn.execute(text("SELECT 1")) + return {"status": "ok", "db": "connected"} + except Exception as exc: + return JSONResponse( + status_code=503, + content={"status": "error", "db": str(exc)}, + ) diff --git a/api/routers/tasks.py b/api/routers/tasks.py new file mode 100644 index 0000000..8c3de56 --- /dev/null +++ b/api/routers/tasks.py @@ -0,0 +1,83 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.task import Task, TaskStatus +from api.schemas.task import TaskCreate, TaskRead, TaskUpdate + +router = APIRouter(prefix="/tasks", tags=["tasks"]) + + +@router.get("/", response_model=list[TaskRead]) +async def list_tasks( + workstream_id: uuid.UUID | None = None, + status: TaskStatus | None = None, + assignee: str | None = None, + session: AsyncSession = Depends(get_session), +) -> list[Task]: + q = select(Task) + if workstream_id: + q = q.where(Task.workstream_id == workstream_id) + if status: + q = q.where(Task.status == status) + if assignee: + q = q.where(Task.assignee == assignee) + q = q.order_by(Task.created_at) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=TaskRead, status_code=status.HTTP_201_CREATED) +async def create_task( + body: TaskCreate, + session: AsyncSession = Depends(get_session), +) -> Task: + task = Task(**body.model_dump()) + session.add(task) + await session.commit() + await session.refresh(task) + return task + + +@router.get("/{task_id}", response_model=TaskRead) +async def get_task( + task_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Task: + task = await session.get(Task, task_id) + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + return task + + +@router.patch("/{task_id}", response_model=TaskRead) +async def update_task( + task_id: uuid.UUID, + body: TaskUpdate, + session: AsyncSession = Depends(get_session), +) -> Task: + task = await session.get(Task, task_id) + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(task, field, value) + await session.commit() + await session.refresh(task) + return task + + +@router.delete("/{task_id}", response_model=TaskRead) +async def cancel_task( + task_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Task: + task = await session.get(Task, task_id) + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + task.status = TaskStatus.cancelled + await session.commit() + await session.refresh(task) + return task diff --git a/api/routers/topics.py b/api/routers/topics.py new file mode 100644 index 0000000..660e5fa --- /dev/null +++ b/api/routers/topics.py @@ -0,0 +1,77 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.topic import Topic, TopicStatus +from api.schemas.topic import TopicCreate, TopicRead, TopicUpdate, TopicWithWorkstreams + +router = APIRouter(prefix="/topics", tags=["topics"]) + + +@router.get("/", response_model=list[TopicRead]) +async def list_topics( + status: TopicStatus | None = None, + session: AsyncSession = Depends(get_session), +) -> list[Topic]: + q = select(Topic) + if status: + q = q.where(Topic.status == status) + q = q.order_by(Topic.created_at) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=TopicRead, status_code=status.HTTP_201_CREATED) +async def create_topic( + body: TopicCreate, + session: AsyncSession = Depends(get_session), +) -> Topic: + topic = Topic(**body.model_dump()) + session.add(topic) + await session.commit() + await session.refresh(topic) + return topic + + +@router.get("/{topic_id}", response_model=TopicWithWorkstreams) +async def get_topic( + topic_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Topic: + topic = await session.get(Topic, topic_id) + if topic is None: + raise HTTPException(status_code=404, detail="Topic not found") + return topic + + +@router.patch("/{topic_id}", response_model=TopicRead) +async def update_topic( + topic_id: uuid.UUID, + body: TopicUpdate, + session: AsyncSession = Depends(get_session), +) -> Topic: + topic = await session.get(Topic, topic_id) + if topic is None: + raise HTTPException(status_code=404, detail="Topic not found") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(topic, field, value) + await session.commit() + await session.refresh(topic) + return topic + + +@router.delete("/{topic_id}", response_model=TopicRead) +async def archive_topic( + topic_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Topic: + topic = await session.get(Topic, topic_id) + if topic is None: + raise HTTPException(status_code=404, detail="Topic not found") + topic.status = TopicStatus.archived + await session.commit() + await session.refresh(topic) + return topic diff --git a/api/routers/workstreams.py b/api/routers/workstreams.py new file mode 100644 index 0000000..d602802 --- /dev/null +++ b/api/routers/workstreams.py @@ -0,0 +1,80 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.workstream import Workstream, WorkstreamStatus +from api.schemas.workstream import WorkstreamCreate, WorkstreamRead, WorkstreamUpdate + +router = APIRouter(prefix="/workstreams", tags=["workstreams"]) + + +@router.get("/", response_model=list[WorkstreamRead]) +async def list_workstreams( + topic_id: uuid.UUID | None = None, + status: WorkstreamStatus | None = None, + session: AsyncSession = Depends(get_session), +) -> list[Workstream]: + q = select(Workstream) + if topic_id: + q = q.where(Workstream.topic_id == topic_id) + if status: + q = q.where(Workstream.status == status) + q = q.order_by(Workstream.created_at) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED) +async def create_workstream( + body: WorkstreamCreate, + session: AsyncSession = Depends(get_session), +) -> Workstream: + ws = Workstream(**body.model_dump()) + session.add(ws) + await session.commit() + await session.refresh(ws) + return ws + + +@router.get("/{workstream_id}", response_model=WorkstreamRead) +async def get_workstream( + workstream_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Workstream: + ws = await session.get(Workstream, workstream_id) + if ws is None: + raise HTTPException(status_code=404, detail="Workstream not found") + return ws + + +@router.patch("/{workstream_id}", response_model=WorkstreamRead) +async def update_workstream( + workstream_id: uuid.UUID, + body: WorkstreamUpdate, + session: AsyncSession = Depends(get_session), +) -> Workstream: + ws = await session.get(Workstream, workstream_id) + if ws is None: + raise HTTPException(status_code=404, detail="Workstream not found") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(ws, field, value) + await session.commit() + await session.refresh(ws) + return ws + + +@router.delete("/{workstream_id}", response_model=WorkstreamRead) +async def archive_workstream( + workstream_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Workstream: + ws = await session.get(Workstream, workstream_id) + if ws is None: + raise HTTPException(status_code=404, detail="Workstream not found") + ws.status = WorkstreamStatus.archived + await session.commit() + await session.refresh(ws) + return ws diff --git a/api/schemas/__init__.py b/api/schemas/__init__.py new file mode 100644 index 0000000..16d68ee --- /dev/null +++ b/api/schemas/__init__.py @@ -0,0 +1,15 @@ +from api.schemas.topic import TopicCreate, TopicUpdate, TopicRead, TopicWithWorkstreams +from api.schemas.workstream import WorkstreamCreate, WorkstreamUpdate, WorkstreamRead +from api.schemas.task import TaskCreate, TaskUpdate, TaskRead +from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead +from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead +from api.schemas.state import StateSummary, Totals, TopicTotals, WorkstreamTotals, TaskTotals, DecisionTotals + +__all__ = [ + "TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams", + "WorkstreamCreate", "WorkstreamUpdate", "WorkstreamRead", + "TaskCreate", "TaskUpdate", "TaskRead", + "DecisionCreate", "DecisionUpdate", "DecisionRead", + "ProgressEventCreate", "ProgressEventRead", + "StateSummary", "Totals", "TopicTotals", "WorkstreamTotals", "TaskTotals", "DecisionTotals", +] diff --git a/api/schemas/decision.py b/api/schemas/decision.py new file mode 100644 index 0000000..6e52fe2 --- /dev/null +++ b/api/schemas/decision.py @@ -0,0 +1,58 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, model_validator + +from api.models.decision import DecisionStatus, DecisionType + + +class DecisionCreate(BaseModel): + topic_id: uuid.UUID | None = None + workstream_id: uuid.UUID | None = None + title: str + description: str | None = None + decision_type: DecisionType = DecisionType.pending + status: DecisionStatus = DecisionStatus.open + rationale: str | None = None + decided_by: str | None = None + decided_at: datetime | None = None + deadline: datetime | None = None + escalation_note: str | None = None + + @model_validator(mode="after") + def topic_or_workstream_required(self) -> "DecisionCreate": + if self.topic_id is None and self.workstream_id is None: + raise ValueError("At least one of topic_id or workstream_id must be set") + return self + + +class DecisionUpdate(BaseModel): + title: str | None = None + description: str | None = None + decision_type: DecisionType | None = None + status: DecisionStatus | None = None + rationale: str | None = None + decided_by: str | None = None + decided_at: datetime | None = None + deadline: datetime | None = None + escalation_note: str | None = None + superseded_by: uuid.UUID | None = None + + +class DecisionRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + topic_id: uuid.UUID | None = None + workstream_id: uuid.UUID | None = None + title: str + description: str | None = None + decision_type: DecisionType + status: DecisionStatus + rationale: str | None = None + decided_by: str | None = None + decided_at: datetime | None = None + deadline: datetime | None = None + escalation_note: str | None = None + superseded_by: uuid.UUID | None = None + created_at: datetime + updated_at: datetime diff --git a/api/schemas/progress_event.py b/api/schemas/progress_event.py new file mode 100644 index 0000000..1c16e51 --- /dev/null +++ b/api/schemas/progress_event.py @@ -0,0 +1,32 @@ +import uuid +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class ProgressEventCreate(BaseModel): + topic_id: uuid.UUID | None = None + workstream_id: uuid.UUID | None = None + task_id: uuid.UUID | None = None + decision_id: uuid.UUID | None = None + event_type: str + summary: str + detail: dict[str, Any] | None = None + author: str | None = None + session_id: str | None = None + + +class ProgressEventRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + topic_id: uuid.UUID | None = None + workstream_id: uuid.UUID | None = None + task_id: uuid.UUID | None = None + decision_id: uuid.UUID | None = None + event_type: str + summary: str + detail: dict[str, Any] | None = None + author: str | None = None + session_id: str | None = None + created_at: datetime diff --git a/api/schemas/state.py b/api/schemas/state.py new file mode 100644 index 0000000..0db6d39 --- /dev/null +++ b/api/schemas/state.py @@ -0,0 +1,58 @@ +from datetime import datetime + +from pydantic import BaseModel + +from api.schemas.decision import DecisionRead +from api.schemas.progress_event import ProgressEventRead +from api.schemas.task import TaskRead +from api.schemas.topic import TopicWithWorkstreams +from api.schemas.workstream import WorkstreamRead + + +class TopicTotals(BaseModel): + active: int = 0 + paused: int = 0 + archived: int = 0 + total: int = 0 + + +class WorkstreamTotals(BaseModel): + active: int = 0 + blocked: int = 0 + completed: int = 0 + archived: int = 0 + total: int = 0 + + +class TaskTotals(BaseModel): + todo: int = 0 + in_progress: int = 0 + blocked: int = 0 + done: int = 0 + cancelled: int = 0 + total: int = 0 + + +class DecisionTotals(BaseModel): + open: int = 0 + resolved: int = 0 + escalated: int = 0 + superseded: int = 0 + total: int = 0 + + +class Totals(BaseModel): + topics: TopicTotals + workstreams: WorkstreamTotals + tasks: TaskTotals + decisions: DecisionTotals + + +class StateSummary(BaseModel): + generated_at: datetime + totals: Totals + topics: list[TopicWithWorkstreams] + blocking_decisions: list[DecisionRead] + blocked_tasks: list[TaskRead] + recent_progress: list[ProgressEventRead] + open_workstreams: list[WorkstreamRead] diff --git a/api/schemas/task.py b/api/schemas/task.py new file mode 100644 index 0000000..bc95b0a --- /dev/null +++ b/api/schemas/task.py @@ -0,0 +1,51 @@ +import uuid +from datetime import date, datetime + +from pydantic import BaseModel, ConfigDict, model_validator + +from api.models.task import TaskPriority, TaskStatus + + +class TaskCreate(BaseModel): + workstream_id: uuid.UUID + title: str + description: str | None = None + status: TaskStatus = TaskStatus.todo + priority: TaskPriority = TaskPriority.medium + assignee: str | None = None + due_date: date | None = None + blocking_reason: str | None = None + parent_task_id: uuid.UUID | None = None + + +class TaskUpdate(BaseModel): + title: str | None = None + description: str | None = None + status: TaskStatus | None = None + priority: TaskPriority | None = None + assignee: str | None = None + due_date: date | None = None + blocking_reason: str | None = None + parent_task_id: uuid.UUID | None = None + + @model_validator(mode="after") + def blocking_reason_required_when_blocked(self) -> "TaskUpdate": + if self.status == TaskStatus.blocked and not self.blocking_reason: + raise ValueError("blocking_reason is required when status is blocked") + return self + + +class TaskRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + workstream_id: uuid.UUID + title: str + description: str | None = None + status: TaskStatus + priority: TaskPriority + assignee: str | None = None + due_date: date | None = None + blocking_reason: str | None = None + parent_task_id: uuid.UUID | None = None + created_at: datetime + updated_at: datetime diff --git a/api/schemas/topic.py b/api/schemas/topic.py new file mode 100644 index 0000000..39385c9 --- /dev/null +++ b/api/schemas/topic.py @@ -0,0 +1,47 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from api.models.topic import Domain, TopicStatus + + +class TopicCreate(BaseModel): + slug: str + title: str + description: str | None = None + domain: Domain + status: TopicStatus = TopicStatus.active + + +class TopicUpdate(BaseModel): + title: str | None = None + description: str | None = None + domain: Domain | None = None + status: TopicStatus | None = None + + +class WorkstreamStub(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + slug: str + title: str + status: str + owner: str | None = None + due_date: datetime | None = None + + +class TopicRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + slug: str + title: str + description: str | None = None + domain: Domain + status: TopicStatus + created_at: datetime + updated_at: datetime + + +class TopicWithWorkstreams(TopicRead): + workstreams: list[WorkstreamStub] = [] diff --git a/api/schemas/workstream.py b/api/schemas/workstream.py new file mode 100644 index 0000000..ae507b9 --- /dev/null +++ b/api/schemas/workstream.py @@ -0,0 +1,38 @@ +import uuid +from datetime import date, datetime + +from pydantic import BaseModel, ConfigDict + +from api.models.workstream import WorkstreamStatus + + +class WorkstreamCreate(BaseModel): + topic_id: uuid.UUID + slug: str + title: str + description: str | None = None + status: WorkstreamStatus = WorkstreamStatus.active + owner: str | None = None + due_date: date | None = None + + +class WorkstreamUpdate(BaseModel): + title: str | None = None + description: str | None = None + status: WorkstreamStatus | None = None + owner: str | None = None + due_date: date | None = None + + +class WorkstreamRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + topic_id: uuid.UUID + slug: str + title: str + description: str | None = None + status: WorkstreamStatus + owner: str | None = None + due_date: date | None = None + created_at: datetime + updated_at: datetime diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js new file mode 100644 index 0000000..1d8a895 --- /dev/null +++ b/dashboard/observablehq.config.js @@ -0,0 +1,13 @@ +export default { + root: "src", + title: "Custodian State Hub", + pages: [ + { name: "Overview", path: "/" }, + { name: "Workstreams", path: "/workstreams" }, + { name: "Decisions", path: "/decisions" }, + { name: "Progress", path: "/progress" }, + ], + theme: ["air", "near-midnight"], + head: ``, + footer: "Custodian State Hub β€” local-first, append-only, sovereignty-preserving.", +}; diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json new file mode 100644 index 0000000..64b2d2c --- /dev/null +++ b/dashboard/package-lock.json @@ -0,0 +1,4184 @@ +{ + "name": "custodian-state-hub-dashboard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "custodian-state-hub-dashboard", + "version": "0.1.0", + "dependencies": { + "@observablehq/framework": "^1.13.3" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-2.0.2.tgz", + "integrity": "sha512-x1KXOatwofR6ZAYzXRBL5wrdV0vwNxlTCK9NCuLqAzQYARqGcvFwiJA6A1ERuh+dgeA4Dxm3JBYictIes+SqUQ==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^2.3.1", + "is-potential-custom-element-name": "^1.0.1" + } + }, + "node_modules/@clack/core": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.3.5.tgz", + "integrity": "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.7.0.tgz", + "integrity": "sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA==", + "bundleDependencies": [ + "is-unicode-supported" + ], + "license": "MIT", + "dependencies": { + "@clack/core": "^0.3.3", + "is-unicode-supported": "*", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts/node_modules/is-unicode-supported": { + "version": "1.3.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@observablehq/framework": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/@observablehq/framework/-/framework-1.13.3.tgz", + "integrity": "sha512-rlKeVob043Eti9AtF6fJ+z4cGacGYwX9cgvVnpozA4dKRiQvNGiGdMt9OJrCa80KnbTt8k8pRoTCd0q+zAMu2Q==", + "license": "ISC", + "dependencies": { + "@clack/prompts": "^0.7.0", + "@observablehq/inputs": "^0.12.0", + "@observablehq/inspector": "^5.0.1", + "@observablehq/runtime": "^6.0.0", + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-virtual": "^3.0.2", + "@sindresorhus/slugify": "^2.2.1", + "acorn": "^8.11.2", + "acorn-walk": "^8.3.0", + "ci-info": "^4.0.0", + "cross-spawn": "^7.0.3", + "d3-array": "^3.2.4", + "d3-hierarchy": "^3.1.2", + "esbuild": "^0.20.1", + "fast-array-diff": "^1.1.0", + "fast-deep-equal": "^3.1.3", + "glob": "^10.3.10", + "gray-matter": "^4.0.3", + "he": "^1.2.0", + "highlight.js": "^11.8.0", + "is-docker": "^3.0.0", + "is-wsl": "^3.1.0", + "jsdom": "^23.2.0", + "jszip": "^3.10.1", + "markdown-it": "^14.0.0", + "markdown-it-anchor": "^8.6.7", + "mime": "^4.0.0", + "minisearch": "^6.3.0", + "open": "^10.1.0", + "picocolors": "^1.1.1", + "pkg-dir": "^8.0.0", + "resolve.exports": "^2.0.2", + "rollup": "^4.6.0", + "rollup-plugin-esbuild": "^6.1.0", + "semver": "^7.5.4", + "send": "^0.19.0", + "tar": "^6.2.0", + "tar-stream": "^3.1.6", + "tsx": "^4.7.1", + "untildify": "^5.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.14.2" + }, + "bin": { + "observable": "dist/bin/observable.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@observablehq/inputs": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@observablehq/inputs/-/inputs-0.12.0.tgz", + "integrity": "sha512-1ln7+PYe31cMx00K9awVbiCscQM0THnXRJ/AEzd+FfTA25Gu3KRWknAGECxU49QzHyKqiXpLl5LCg3XtYm70eQ==", + "license": "ISC", + "dependencies": { + "htl": "^0.3.1", + "isoformat": "^0.2.0" + }, + "engines": { + "node": ">=14.5.0" + } + }, + "node_modules/@observablehq/inspector": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@observablehq/inspector/-/inspector-5.0.1.tgz", + "integrity": "sha512-euwWxwDa6KccU4G3D2JBD7GI/2McJh/z7HHEzJKbj2TDa7zhI37eTbTxiwE9rgTWBagvVBel+hAmnJRYBYOv2Q==", + "license": "ISC", + "dependencies": { + "isoformat": "^0.2.0" + } + }, + "node_modules/@observablehq/runtime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@observablehq/runtime/-/runtime-6.0.0.tgz", + "integrity": "sha512-t3UXP69O0JK20HY/neF4/DDDSDorwo92As806Y1pNTgTmj1NtoPyVpesYzfH31gTFOFrXC2cArV+wLpebMk9eA==", + "license": "ISC" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "25.0.8", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.8.tgz", + "integrity": "sha512-ZEZWTK5n6Qde0to4vS9Mr5x/0UZoqCxPVR9KRUjU4kA2sO7GEUn1fop0DAwpO6z0Nw/kJON9bDmSxdWxO/TT1A==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "glob": "^8.0.3", + "is-reference": "1.2.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/minimatch": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.7.tgz", + "integrity": "sha512-FjiwU9HaHW6YB3H4a1sFudnv93lvydNjz2lmyUXR6IwKhGI+bgL3SOZrBGn6kvvX2pJvhEkGSGjyTHN47O4rqA==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", + "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-virtual": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-virtual/-/plugin-virtual-3.0.2.tgz", + "integrity": "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/slugify": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz", + "integrity": "sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==", + "license": "MIT", + "dependencies": { + "@sindresorhus/transliterate": "^1.0.0", + "escape-string-regexp": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/transliterate": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/transliterate/-/transliterate-1.6.0.tgz", + "integrity": "sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-array-diff": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-array-diff/-/fast-array-diff-1.1.0.tgz", + "integrity": "sha512-muSPyZa/yHCoDQhah9th57AmLENB1nekbrUoLAqOpQXdl1Kw8VbH24Syl5XLscaQJlx7KRU95bfTDPvVB5BJvw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/htl": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/htl/-/htl-0.3.1.tgz", + "integrity": "sha512-1LBtd+XhSc+++jpOOt0lCcEycXs/zTQSupOISnVAUmvGBpV7DH+C2M6hwV7zWYfpTMMg9ch4NO0lHiOTAMHdVA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "license": "MIT" + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/isoformat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/isoformat/-/isoformat-0.2.1.tgz", + "integrity": "sha512-tFLRAygk9NqrRPhJSnNGh7g7oaVWDwR0wKh/GM2LgmPa50Eg4UfyaCO4I8k6EqJHl1/uh2RAD6g06n5ygEnrjQ==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "23.2.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-23.2.0.tgz", + "integrity": "sha512-L88oL7D/8ufIES+Zjz7v0aes+oBMh2Xnh3ygWvL0OaICOomKEPKuPnIfBJekiXr+BHbbMjrWn/xqrDQuxFTeyA==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/dom-selector": "^2.0.1", + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.3", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.16.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it-anchor": { + "version": "8.6.7", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz", + "integrity": "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==", + "license": "Unlicense", + "peerDependencies": { + "@types/markdown-it": "*", + "markdown-it": "*" + } + }, + "node_modules/markdown-it/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "license": "CC0-1.0" + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, + "node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", + "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minisearch": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-6.3.0.tgz", + "integrity": "sha512-ihFnidEeU8iXzcVHy74dhkxh/dn8Dc08ERl0xwoMMGqp4+LvRSCgicb+zGqWthVokQKvCSxITlh3P08OzdTYCQ==", + "license": "MIT" + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-8.0.0.tgz", + "integrity": "sha512-4peoBq4Wks0riS0z8741NVv+/8IiTvqnZAr8QGgtdifrtpdXbNw/FxRS1l6NFqm4EMzuS0EDqNNx4XGaz8cuyQ==", + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-esbuild": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-6.2.1.tgz", + "integrity": "sha512-jTNOMGoMRhs0JuueJrJqbW8tOwxumaWYq+V5i+PD+8ecSCVkuX27tGW7BXqDgoULQ55rO7IdNxPcnsWtshz3AA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "es-module-lexer": "^1.6.0", + "get-tsconfig": "^4.10.0", + "unplugin-utils": "^0.2.4" + }, + "engines": { + "node": ">=14.18.0" + }, + "peerDependencies": { + "esbuild": ">=0.18.0", + "rollup": "^1.20.0 || ^2.0.0 || ^3.0.0 || ^4.0.0" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "license": "MIT" + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unplugin-utils": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.2.5.tgz", + "integrity": "sha512-gwXJnPRewT4rT7sBi/IvxKTjsms7jX7QIDLOClApuZwR49SXbrB1z2NLUZ+vDHyqCj/n58OzRRqaW+B8OZi8vg==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/untildify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-5.0.0.tgz", + "integrity": "sha512-bOgQLUnd2G5rhzaTvh1VCI9Fo6bC5cLTpH17T5aFfamyXFYDbbdzN6IXdeoc3jBS7T9hNTmJtYUzJCJ2Xlc9gA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000..b5487b4 --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,14 @@ +{ + "name": "custodian-state-hub-dashboard", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "observable preview", + "build": "observable build", + "clean": "rm -rf dist" + }, + "dependencies": { + "@observablehq/framework": "^1.13.3" + } +} diff --git a/dashboard/src/data/decisions.json.py b/dashboard/src/data/decisions.json.py new file mode 100644 index 0000000..c9c00ed --- /dev/null +++ b/dashboard/src/data/decisions.json.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Observable data loader: all decisions.""" +import json +import os +import urllib.request +import urllib.error + +API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") + +try: + with urllib.request.urlopen(f"{API_BASE}/decisions", timeout=10) as resp: + data = json.loads(resp.read()) + print(json.dumps(data)) +except urllib.error.URLError as e: + print(json.dumps({"error": str(e), "decisions": []})) diff --git a/dashboard/src/data/progress.json.py b/dashboard/src/data/progress.json.py new file mode 100644 index 0000000..32e6b3a --- /dev/null +++ b/dashboard/src/data/progress.json.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""Observable data loader: recent progress events (last 200).""" +import json +import os +import urllib.request +import urllib.error + +API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") + +try: + url = f"{API_BASE}/progress?limit=200" + with urllib.request.urlopen(url, timeout=10) as resp: + data = json.loads(resp.read()) + print(json.dumps(data)) +except urllib.error.URLError as e: + print(json.dumps({"error": str(e), "events": []})) diff --git a/dashboard/src/data/summary.json.py b/dashboard/src/data/summary.json.py new file mode 100644 index 0000000..4d6ab11 --- /dev/null +++ b/dashboard/src/data/summary.json.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Observable data loader: fetches /state/summary from the API.""" +import json +import os +import sys +import urllib.request +import urllib.error + +API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") + +try: + with urllib.request.urlopen(f"{API_BASE}/state/summary", timeout=10) as resp: + data = json.loads(resp.read()) + print(json.dumps(data)) +except urllib.error.URLError as e: + # Return empty structure so the dashboard can show an error state + print(json.dumps({ + "error": str(e), + "generated_at": None, + "totals": { + "topics": {"active": 0, "paused": 0, "archived": 0, "total": 0}, + "workstreams": {"active": 0, "blocked": 0, "completed": 0, "archived": 0, "total": 0}, + "tasks": {"todo": 0, "in_progress": 0, "blocked": 0, "done": 0, "cancelled": 0, "total": 0}, + "decisions": {"open": 0, "resolved": 0, "escalated": 0, "superseded": 0, "total": 0}, + }, + "topics": [], + "blocking_decisions": [], + "blocked_tasks": [], + "recent_progress": [], + "open_workstreams": [], + })) diff --git a/dashboard/src/data/workstreams.json.py b/dashboard/src/data/workstreams.json.py new file mode 100644 index 0000000..d3368cd --- /dev/null +++ b/dashboard/src/data/workstreams.json.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Observable data loader: all workstreams.""" +import json +import os +import urllib.request +import urllib.error + +API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") + +try: + with urllib.request.urlopen(f"{API_BASE}/workstreams", timeout=10) as resp: + data = json.loads(resp.read()) + print(json.dumps(data)) +except urllib.error.URLError as e: + print(json.dumps({"error": str(e), "workstreams": []})) diff --git a/dashboard/src/decisions.md b/dashboard/src/decisions.md new file mode 100644 index 0000000..7885a6e --- /dev/null +++ b/dashboard/src/decisions.md @@ -0,0 +1,70 @@ +--- +title: Decisions +--- + +# Decisions + +```js +const decisions = await FileAttachment("data/decisions.json").json(); +const data = Array.isArray(decisions) ? decisions : []; +const pending = data.filter(d => d.decision_type === "pending"); +const made = data.filter(d => d.decision_type === "made"); +``` + +```js +const tab = view(Inputs.select(["Pending", "Made"], { label: "View" })); +``` + +```js +const shown = tab === "Pending" ? pending : made; + +display(Inputs.table(shown.map(d => ({ + Title: d.title, + Status: d.status + (d.escalation_note ? " ⚠️" : ""), + Decided_by: d.decided_by ?? "β€”", + Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "β€”", + Rationale: (d.rationale ?? "").slice(0, 80), + Updated: new Date(d.updated_at).toLocaleDateString(), +})), { rows: 30 })); +``` + +## Resolution Velocity + +```js +import * as Plot from "npm:@observablehq/plot"; + +const resolved = made.filter(d => d.decided_at); +const byMonth = resolved.reduce((acc, d) => { + const m = d.decided_at.slice(0, 7); + acc[m] = (acc[m] ?? 0) + 1; + return acc; +}, {}); + +display(Plot.plot({ + title: "Decisions Resolved per Month", + x: { label: "Month", tickRotate: -30 }, + y: { label: "Count", grid: true }, + marks: [ + Plot.barY( + Object.entries(byMonth).map(([month, count]) => ({ month, count })), + { x: "month", y: "count", fill: "steelblue", tip: true } + ), + Plot.ruleY([0]), + ], + marginBottom: 60, + width: 700, +})); +``` + +```js +if (tab === "Pending" && pending.filter(d => d.escalation_note).length > 0) { + display(html`
+ ⚠️ Escalated decisions require human approval before any action is taken (constitution §4). +
    ${pending.filter(d => d.escalation_note).map(d => html`
  • ${d.title}: ${d.escalation_note}
  • `)}
+
`); +} +``` + + diff --git a/dashboard/src/index.md b/dashboard/src/index.md new file mode 100644 index 0000000..e3e45af --- /dev/null +++ b/dashboard/src/index.md @@ -0,0 +1,122 @@ +--- +title: Overview +--- + +# Custodian State Hub + +```js +const summary = await FileAttachment("data/summary.json").json(); +const totals = summary.totals ?? {}; +const ws = totals.workstreams ?? {}; +const tasks = totals.tasks ?? {}; +const decisions = totals.decisions ?? {}; +const topics = totals.topics ?? {}; +``` + +```js +if (summary.error) display(html`
⚠️ API unreachable: ${summary.error}. Run make api.
`); +``` + +## Status + +```js +display(html`
+
+

Active Workstreams

+

${ws.active ?? 0}

+ ${ws.blocked ?? 0} blocked +
+
+

Blocking Decisions

+

${(decisions.open ?? 0) + (decisions.escalated ?? 0)}

+ ${decisions.escalated ?? 0} escalated +
+
+

Blocked Tasks

+

${tasks.blocked ?? 0}

+ of ${tasks.total ?? 0} total +
+
+

Progress Events Today

+

${(summary.recent_progress ?? []).filter(e => e.created_at?.startsWith(new Date().toISOString().slice(0,10))).length}

+ last 20 shown below +
+
`); +``` + +## Tasks by Domain + +```js +import * as Plot from "npm:@observablehq/plot"; + +const tasksByDomain = []; +for (const topic of (summary.topics ?? [])) { + for (const ws of (topic.workstreams ?? [])) { + // workstream stubs don't include tasks in summary β€” show per-topic WS count as proxy + } + tasksByDomain.push({ domain: topic.domain, status: topic.status, count: (topic.workstreams ?? []).length }); +} + +display(Plot.plot({ + title: "Open Workstreams by Domain", + x: { label: "Domain" }, + y: { label: "Count", grid: true }, + marks: [ + Plot.barY(tasksByDomain, { x: "domain", y: "count", fill: "domain", tip: true }), + Plot.ruleY([0]), + ], + marginBottom: 80, + width: 700, +})); +``` + +## Blocking Decisions + +```js +const blocking = summary.blocking_decisions ?? []; +if (blocking.length === 0) { + display(html`

βœ“ No blocking decisions.

`); +} else { + display(Inputs.table(blocking.map(d => ({ + Title: d.title, + Status: d.status, + Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "β€”", + Escalated: d.escalation_note ? "⚠️" : "", + })))); +} +``` + +## Decisions Due Within 7 Days + +```js +const now = new Date(); +const in7 = new Date(now.getTime() + 7*24*60*60*1000); +const due = (summary.blocking_decisions ?? []).filter(d => d.deadline && new Date(d.deadline) <= in7); +if (due.length === 0) { + display(html`

No decisions due in next 7 days.

`); +} else { + display(Inputs.table(due.map(d => ({ + Title: d.title, + Deadline: new Date(d.deadline).toLocaleString(), + Status: d.status, + })))); +} +``` + +## Recent Activity + +```js +display(Inputs.table((summary.recent_progress ?? []).map(e => ({ + Time: new Date(e.created_at).toLocaleString(), + Type: e.event_type, + Author: e.author ?? "β€”", + Summary: e.summary, +})), { maxWidth: 900 })); +``` + + diff --git a/dashboard/src/progress.md b/dashboard/src/progress.md new file mode 100644 index 0000000..3b9f660 --- /dev/null +++ b/dashboard/src/progress.md @@ -0,0 +1,77 @@ +--- +title: Progress +--- + +# Progress Log + +*Append-only per constitution Β§5 β€” no deletions.* + +```js +const events = await FileAttachment("data/progress.json").json(); +const data = Array.isArray(events) ? events : []; +``` + +```js +const authorFilter = view(Inputs.select( + ["(all)", ...new Set(data.map(e => e.author ?? "unknown"))], + { label: "Author" } +)); +const typeFilter = view(Inputs.select( + ["(all)", ...new Set(data.map(e => e.event_type))], + { label: "Event type" } +)); +const sinceFilter = view(Inputs.date({ label: "Since" })); +``` + +```js +const filtered = data.filter(e => + (authorFilter === "(all)" || (e.author ?? "unknown") === authorFilter) && + (typeFilter === "(all)" || e.event_type === typeFilter) && + (!sinceFilter || new Date(e.created_at) >= sinceFilter) +); + +display(html`

${filtered.length} events shown (append-only, no deletions).

`); + +display(Inputs.table(filtered.map(e => ({ + Time: new Date(e.created_at).toLocaleString(), + Type: e.event_type, + Author: e.author ?? "β€”", + Summary: e.summary, +})), { rows: 50 })); +``` + +## Event Volume (Last 30 Days) + +```js +import * as Plot from "npm:@observablehq/plot"; + +const cutoff = new Date(); +cutoff.setDate(cutoff.getDate() - 30); + +const byDay = data + .filter(e => new Date(e.created_at) >= cutoff) + .reduce((acc, e) => { + const day = e.created_at.slice(0, 10); + acc[day] = (acc[day] ?? 0) + 1; + return acc; + }, {}); + +display(Plot.plot({ + title: "Progress Events per Day (30-day window)", + x: { label: "Date", tickRotate: -30 }, + y: { label: "Events", grid: true }, + marks: [ + Plot.areaY( + Object.entries(byDay).sort().map(([day, count]) => ({ day, count })), + { x: "day", y: "count", fill: "steelblue", fillOpacity: 0.3 } + ), + Plot.lineY( + Object.entries(byDay).sort().map(([day, count]) => ({ day, count })), + { x: "day", y: "count", stroke: "steelblue" } + ), + Plot.ruleY([0]), + ], + marginBottom: 60, + width: 750, +})); +``` diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md new file mode 100644 index 0000000..d30335a --- /dev/null +++ b/dashboard/src/workstreams.md @@ -0,0 +1,67 @@ +--- +title: Workstreams +--- + +# Workstreams + +```js +const workstreams = await FileAttachment("data/workstreams.json").json(); +const data = Array.isArray(workstreams) ? workstreams : []; +``` + +```js +const domainFilter = view(Inputs.select( + ["(all)", ...new Set(data.map(w => w.domain ?? "unknown"))], + { label: "Domain" } +)); +const statusFilter = view(Inputs.select( + ["(all)", "active", "blocked", "completed", "archived"], + { label: "Status" } +)); +const ownerFilter = view(Inputs.text({ label: "Owner contains" })); +``` + +```js +const filtered = data.filter(w => + (domainFilter === "(all)" || w.domain === domainFilter) && + (statusFilter === "(all)" || w.status === statusFilter) && + (!ownerFilter || (w.owner ?? "").toLowerCase().includes(ownerFilter.toLowerCase())) +); + +const STATUS_COLOR = { + active: "green", + blocked: "orange", + completed: "blue", + archived: "gray", +}; + +display(Inputs.table(filtered.map(w => ({ + Title: w.title, + Domain: w.domain, + Status: w.status, + Owner: w.owner ?? "β€”", + "Due": w.due_date ?? "β€”", + "Updated": new Date(w.updated_at).toLocaleDateString(), +})), { + rows: 20, +})); +``` + +```js +import * as Plot from "npm:@observablehq/plot"; + +display(Plot.plot({ + title: "Workstream Status Distribution", + marks: [ + Plot.barX( + Object.entries( + filtered.reduce((acc, w) => { acc[w.status] = (acc[w.status] ?? 0) + 1; return acc; }, {}) + ).map(([status, count]) => ({ status, count })), + { y: "status", x: "count", fill: "status", tip: true } + ), + Plot.ruleX([0]), + ], + marginLeft: 80, + width: 500, +})); +``` diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..3357b60 --- /dev/null +++ b/infra/docker-compose.yml @@ -0,0 +1,33 @@ +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + ports: + - "127.0.0.1:5432:5432" + environment: + POSTGRES_DB: ${POSTGRES_DB:-custodian} + POSTGRES_USER: ${POSTGRES_USER:-custodian} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - pg_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-custodian} -d ${POSTGRES_DB:-custodian}"] + interval: 5s + timeout: 5s + retries: 5 + + pgadmin: + image: dpage/pgadmin4:latest + profiles: ["tools"] + restart: unless-stopped + ports: + - "127.0.0.1:5050:80" + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@local.dev} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin} + depends_on: + postgres: + condition: service_healthy + +volumes: + pg_data: diff --git a/mcp_server/__init__.py b/mcp_server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcp_server/server.py b/mcp_server/server.py new file mode 100644 index 0000000..7337f25 --- /dev/null +++ b/mcp_server/server.py @@ -0,0 +1,360 @@ +"""Custodian State Hub MCP Server (stdio). + +Thin HTTP client over the FastAPI service β€” no direct DB access. +All business logic stays in the API; this layer is stateless. +""" +from __future__ import annotations + +import json +import os +import sys +from datetime import datetime +from typing import Any +from uuid import UUID + +import httpx +from fastmcp import FastMCP + +API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") + +mcp = FastMCP( + name="state-hub", + instructions=( + "Custodian State Hub: tracks topics, workstreams, tasks, decisions, and progress events. " + "Start every session with get_state_summary() for orientation. " + "All writes emit a progress_event automatically." + ), +) + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- + +def _client() -> httpx.Client: + return httpx.Client(base_url=API_BASE, timeout=30.0) + + +def _get(path: str, params: dict | None = None) -> Any: + with _client() as c: + r = c.get(path, params={k: v for k, v in (params or {}).items() if v is not None}) + r.raise_for_status() + return r.json() + + +def _post(path: str, body: dict) -> Any: + if not path.endswith("/"): + path = path + "/" + with _client() as c: + r = c.post(path, json={k: v for k, v in body.items() if v is not None}) + r.raise_for_status() + return r.json() + + +def _patch(path: str, body: dict) -> Any: + with _client() as c: + r = c.patch(path, json={k: v for k, v in body.items() if v is not None}) + r.raise_for_status() + return r.json() + + +# --------------------------------------------------------------------------- +# Resources +# --------------------------------------------------------------------------- + +@mcp.resource("state://summary") +def resource_summary() -> str: + """Full StateSummary JSON β€” primary orientation resource.""" + return json.dumps(_get("/state/summary"), indent=2) + + +@mcp.resource("state://topics") +def resource_topics() -> str: + """Active topics list.""" + return json.dumps(_get("/topics", {"status": "active"}), indent=2) + + +@mcp.resource("state://workstreams/{topic_slug}") +def resource_workstreams(topic_slug: str) -> str: + """Workstreams for a topic (by slug).""" + topics = _get("/topics", {"status": "active"}) + match = next((t for t in topics if t["slug"] == topic_slug), None) + if not match: + return json.dumps({"error": f"Topic '{topic_slug}' not found"}) + return json.dumps(_get("/workstreams", {"topic_id": match["id"]}), indent=2) + + +@mcp.resource("state://decisions/blocking") +def resource_blocking_decisions() -> str: + """All pending/escalated decisions.""" + return json.dumps( + _get("/decisions", {"decision_type": "pending", "status": "open"}), + indent=2, + ) + + +@mcp.resource("state://tasks/blocked") +def resource_blocked_tasks() -> str: + """All tasks with status=blocked.""" + return json.dumps(_get("/tasks", {"status": "blocked"}), indent=2) + + +# --------------------------------------------------------------------------- +# Query tools +# --------------------------------------------------------------------------- + +@mcp.tool() +def get_state_summary() -> str: + """Primary orientation tool. Call at the start of every session. + + Returns a full snapshot: topic/workstream/task/decision totals, blocking + decisions, blocked tasks, open workstreams, and the 20 most recent events. + """ + return json.dumps(_get("/state/summary"), indent=2) + + +@mcp.tool() +def get_topic(slug: str) -> str: + """Return a topic (with workstreams) by slug, plus its recent progress events.""" + topics = _get("/topics") + match = next((t for t in topics if t["slug"] == slug), None) + if not match: + return json.dumps({"error": f"Topic '{slug}' not found"}) + topic_detail = _get(f"/topics/{match['id']}") + recent = _get("/progress", {"topic_id": match["id"], "limit": 10}) + return json.dumps({"topic": topic_detail, "recent_progress": recent}, indent=2) + + +@mcp.tool() +def list_blocked_tasks(workstream_id: str | None = None) -> str: + """List all tasks with status=blocked, optionally filtered by workstream_id.""" + return json.dumps(_get("/tasks", {"status": "blocked", "workstream_id": workstream_id}), indent=2) + + +@mcp.tool() +def list_pending_decisions(topic_id: str | None = None) -> str: + """List pending decisions sorted by deadline (nulls last). + + Optionally filter by topic_id. Escalated decisions are included and + highlighted by their escalation_note. + """ + results = _get("/decisions", {"decision_type": "pending", "topic_id": topic_id}) + return json.dumps(results, indent=2) + + +@mcp.tool() +def get_recent_progress(limit: int = 20, since: str | None = None) -> str: + """Retrieve recent progress events to reconstruct session history. + + Args: + limit: max events to return (default 20) + since: ISO datetime string β€” only events after this timestamp + """ + return json.dumps(_get("/progress", {"limit": limit, "since": since}), indent=2) + + +# --------------------------------------------------------------------------- +# Mutate tools +# --------------------------------------------------------------------------- + +@mcp.tool() +def create_task( + workstream_id: str, + title: str, + priority: str = "medium", + description: str | None = None, + assignee: str | None = None, + due_date: str | None = None, +) -> str: + """Create a new task and emit a progress_event. + + Args: + workstream_id: UUID of the parent workstream + title: task title + priority: low | medium | high | critical + description: optional longer description + assignee: optional assignee name + due_date: optional ISO date string (YYYY-MM-DD) + """ + task = _post("/tasks", { + "workstream_id": workstream_id, + "title": title, + "priority": priority, + "description": description, + "assignee": assignee, + "due_date": due_date, + }) + _post("/progress", { + "workstream_id": workstream_id, + "task_id": task["id"], + "event_type": "task_created", + "summary": f"Task created: {title}", + "author": "custodian", + "detail": {"priority": priority, "assignee": assignee}, + }) + return json.dumps(task, indent=2) + + +@mcp.tool() +def update_task_status( + task_id: str, + status: str, + blocking_reason: str | None = None, +) -> str: + """Update a task's status. blocking_reason is required when status='blocked'. + + Args: + task_id: UUID of the task + status: todo | in_progress | blocked | done | cancelled + blocking_reason: required when status=blocked + """ + body: dict[str, Any] = {"status": status} + if blocking_reason: + body["blocking_reason"] = blocking_reason + task = _patch(f"/tasks/{task_id}", body) + _post("/progress", { + "task_id": task_id, + "workstream_id": task.get("workstream_id"), + "event_type": "task_status_changed", + "summary": f"Task status β†’ {status}: {task['title']}", + "author": "custodian", + "detail": {"blocking_reason": blocking_reason}, + }) + return json.dumps(task, indent=2) + + +@mcp.tool() +def record_decision( + title: str, + decision_type: str = "pending", + topic_id: str | None = None, + workstream_id: str | None = None, + description: str | None = None, + rationale: str | None = None, + decided_by: str | None = None, + deadline: str | None = None, +) -> str: + """Record a decision (made or pending). + + Pending decisions touching financial/legal topics are auto-escalated per + constitution Β§4. + + Args: + title: decision title + decision_type: made | pending + topic_id: optional topic UUID + workstream_id: optional workstream UUID (at least one required) + description: optional context + rationale: reasoning behind the decision + decided_by: person/agent who decided + deadline: ISO datetime string for when decision is needed + """ + decision = _post("/decisions", { + "title": title, + "decision_type": decision_type, + "topic_id": topic_id, + "workstream_id": workstream_id, + "description": description, + "rationale": rationale, + "decided_by": decided_by, + "deadline": deadline, + }) + _post("/progress", { + "topic_id": topic_id, + "workstream_id": workstream_id, + "decision_id": decision["id"], + "event_type": "decision_recorded", + "summary": f"Decision recorded ({decision_type}): {title}", + "author": "custodian", + "detail": {"status": decision.get("status"), "escalation_note": decision.get("escalation_note")}, + }) + return json.dumps(decision, indent=2) + + +@mcp.tool() +def resolve_decision( + decision_id: str, + rationale: str, + decided_by: str, +) -> str: + """Mark a decision as resolved. + + Args: + decision_id: UUID of the decision + rationale: final reasoning/outcome + decided_by: who resolved it + """ + decision = _patch(f"/decisions/{decision_id}", { + "status": "resolved", + "decision_type": "made", + "rationale": rationale, + "decided_by": decided_by, + "decided_at": datetime.utcnow().isoformat() + "Z", + }) + _post("/progress", { + "topic_id": decision.get("topic_id"), + "workstream_id": decision.get("workstream_id"), + "decision_id": decision_id, + "event_type": "decision_resolved", + "summary": f"Decision resolved by {decided_by}: {decision['title']}", + "author": "custodian", + "detail": {"rationale": rationale}, + }) + return json.dumps(decision, indent=2) + + +@mcp.tool() +def add_progress_event( + summary: str, + event_type: str = "note", + topic_id: str | None = None, + workstream_id: str | None = None, + task_id: str | None = None, + detail: dict | None = None, +) -> str: + """Append a progress event to the log. + + Args: + summary: human-readable summary of what happened + event_type: free-form label, e.g. note | milestone | blocker | insight + topic_id: optional topic UUID + workstream_id: optional workstream UUID + task_id: optional task UUID + detail: optional structured data (JSONB) + """ + event = _post("/progress", { + "topic_id": topic_id, + "workstream_id": workstream_id, + "task_id": task_id, + "event_type": event_type, + "summary": summary, + "author": "custodian", + "detail": detail, + }) + return json.dumps(event, indent=2) + + +@mcp.tool() +def update_workstream_status(workstream_id: str, status: str) -> str: + """Update a workstream's status. + + Args: + workstream_id: UUID of the workstream + status: active | blocked | completed | archived + """ + ws = _patch(f"/workstreams/{workstream_id}", {"status": status}) + _post("/progress", { + "workstream_id": workstream_id, + "topic_id": ws.get("topic_id"), + "event_type": "workstream_status_changed", + "summary": f"Workstream status β†’ {status}: {ws['title']}", + "author": "custodian", + }) + return json.dumps(ws, indent=2) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + mcp.run(transport="stdio") diff --git a/migrations/__init__.py b/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..a5c4968 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,56 @@ +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool +from sqlalchemy.ext.asyncio import AsyncEngine + +# Import all models so Alembic can detect them +from api.models import Base # noqa: F401 β€” registers all ORM classes +from api.models import ( # noqa: F401 + Decision, ProgressEvent, Task, Topic, Workstream, +) + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + +# Allow DATABASE_URL to override alembic.ini +db_url = os.environ.get("DATABASE_URL") +if db_url: + # Alembic sync driver: replace asyncpg with psycopg2 + sync_url = db_url.replace("postgresql+asyncpg://", "postgresql+psycopg2://") + config.set_main_option("sqlalchemy.url", sync_url) + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/versions/0001_initial_schema.py b/migrations/versions/0001_initial_schema.py new file mode 100644 index 0000000..68c2e66 --- /dev/null +++ b/migrations/versions/0001_initial_schema.py @@ -0,0 +1,248 @@ +"""initial schema + +Revision ID: 0001 +Revises: +Create Date: 2026-02-24 00:00:00.000000 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Enums + topic_status = postgresql.ENUM( + "active", "paused", "archived", name="topicstatus", create_type=True + ) + domain = postgresql.ENUM( + "custodian", "railiance", "markitect", "coulomb_social", "personhood", + "foerster_capabilities", name="domain", create_type=True + ) + workstream_status = postgresql.ENUM( + "active", "blocked", "completed", "archived", name="workstreamstatus", create_type=True + ) + task_status = postgresql.ENUM( + "todo", "in_progress", "blocked", "done", "cancelled", name="taskstatus", create_type=True + ) + task_priority = postgresql.ENUM( + "low", "medium", "high", "critical", name="taskpriority", create_type=True + ) + decision_type = postgresql.ENUM( + "made", "pending", name="decisiontype", create_type=True + ) + decision_status = postgresql.ENUM( + "open", "resolved", "escalated", "superseded", name="decisionstatus", create_type=True + ) + + # topics + op.create_table( + "topics", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("slug", sa.String(100), nullable=False, unique=True), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("domain", domain, nullable=False), + sa.Column("status", topic_status, nullable=False, server_default="active"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + ) + op.create_index("ix_topics_slug", "topics", ["slug"]) + + # workstreams + op.create_table( + "workstreams", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "topic_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("topics.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("slug", sa.String(100), nullable=False, unique=True), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("status", workstream_status, nullable=False, server_default="active"), + sa.Column("owner", sa.String(100), nullable=True), + sa.Column("due_date", sa.Date, nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + ) + op.create_index("ix_workstreams_slug", "workstreams", ["slug"]) + op.create_index("ix_workstreams_topic_id", "workstreams", ["topic_id"]) + + # tasks + op.create_table( + "tasks", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "workstream_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("workstreams.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("status", task_status, nullable=False, server_default="todo"), + sa.Column("priority", task_priority, nullable=False, server_default="medium"), + sa.Column("assignee", sa.String(100), nullable=True), + sa.Column("due_date", sa.Date, nullable=True), + sa.Column("blocking_reason", sa.Text, nullable=True), + sa.Column( + "parent_task_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("tasks.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + ) + op.create_index("ix_tasks_workstream_id", "tasks", ["workstream_id"]) + + # decisions + op.create_table( + "decisions", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "topic_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("topics.id", ondelete="RESTRICT"), + nullable=True, + ), + sa.Column( + "workstream_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("workstreams.id", ondelete="RESTRICT"), + nullable=True, + ), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("decision_type", decision_type, nullable=False, server_default="pending"), + sa.Column("status", decision_status, nullable=False, server_default="open"), + sa.Column("rationale", sa.Text, nullable=True), + sa.Column("decided_by", sa.String(100), nullable=True), + sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("deadline", sa.DateTime(timezone=True), nullable=True), + sa.Column("escalation_note", sa.Text, nullable=True), + sa.Column( + "superseded_by", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("decisions.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.CheckConstraint( + "topic_id IS NOT NULL OR workstream_id IS NOT NULL", + name="ck_decisions_topic_or_workstream", + ), + ) + op.create_index("ix_decisions_topic_id", "decisions", ["topic_id"]) + op.create_index("ix_decisions_workstream_id", "decisions", ["workstream_id"]) + + # progress_events + op.create_table( + "progress_events", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "topic_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("topics.id", ondelete="RESTRICT"), + nullable=True, + ), + sa.Column( + "workstream_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("workstreams.id", ondelete="RESTRICT"), + nullable=True, + ), + sa.Column( + "task_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("tasks.id", ondelete="RESTRICT"), + nullable=True, + ), + sa.Column( + "decision_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("decisions.id", ondelete="RESTRICT"), + nullable=True, + ), + sa.Column("event_type", sa.String(50), nullable=False), + sa.Column("summary", sa.Text, nullable=False), + sa.Column("detail", postgresql.JSONB, nullable=True), + sa.Column("author", sa.String(100), nullable=True), + sa.Column("session_id", sa.String(100), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + ) + op.create_index("ix_progress_events_topic_id", "progress_events", ["topic_id"]) + op.create_index("ix_progress_events_workstream_id", "progress_events", ["workstream_id"]) + op.create_index("ix_progress_events_task_id", "progress_events", ["task_id"]) + op.create_index("ix_progress_events_decision_id", "progress_events", ["decision_id"]) + op.create_index("ix_progress_events_event_type", "progress_events", ["event_type"]) + op.create_index("ix_progress_events_created_at", "progress_events", ["created_at"]) + + +def downgrade() -> None: + op.drop_table("progress_events") + op.drop_table("decisions") + op.drop_table("tasks") + op.drop_table("workstreams") + op.drop_table("topics") + + for name in [ + "topicstatus", "domain", "workstreamstatus", + "taskstatus", "taskpriority", "decisiontype", "decisionstatus", + ]: + op.execute(f"DROP TYPE IF EXISTS {name}") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..55cfd5f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "state-hub" +version = "0.1.0" +description = "Custodian State Hub β€” PostgreSQL + FastAPI + MCP server" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "sqlalchemy[asyncio]>=2.0.0", + "asyncpg>=0.30.0", + "alembic>=1.14.0", + "pydantic>=2.10.0", + "pydantic-settings>=2.7.0", + "httpx>=0.28.0", + "fastmcp>=2.0.0", + "python-dotenv>=1.0.0", + "psycopg2-binary>=2.9.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["api", "mcp_server"] + +[tool.uv] +dev-dependencies = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", + "httpx>=0.28.0", +] diff --git a/scripts/pull_image.py b/scripts/pull_image.py new file mode 100644 index 0000000..548a6aa --- /dev/null +++ b/scripts/pull_image.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +Pull a Docker Hub image via the registry v2 API using Python's SSL (OpenSSL), +then import it via `docker load`. Bypasses Docker's Go TLS client entirely. + +Usage: python pull_image.py [output.tar] + e.g: python pull_image.py postgres:16-alpine postgres.tar +""" +import json +import os +import sys +import tarfile +import tempfile +import urllib.request +import urllib.error +import hashlib + + +def get_token(repo: str) -> str: + url = f"https://auth.docker.io/token?service=registry.docker.io&scope=repository:{repo}:pull" + with urllib.request.urlopen(url, timeout=30) as r: + return json.loads(r.read())["token"] + + +class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler): + """Follow redirects but strip Authorization; keep Range and other headers.""" + def redirect_request(self, req, fp, code, msg, headers, newurl): + new_req = urllib.request.Request(newurl) + # Forward Range header (needed for chunked downloads) but NOT Authorization + for hdr in ("Range",): + val = req.get_header(hdr.capitalize()) + if val: + new_req.add_header(hdr, val) + return new_req + + +def _opener(): + return urllib.request.build_opener(_StripAuthOnRedirect()) + + +def registry_get(url: str, token: str, headers: dict | None = None) -> bytes: + """GET with Bearer auth; follows redirects WITHOUT auth (for S3/CDN blobs).""" + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}", **(headers or {})}) + with _opener().open(req, timeout=60) as r: + return r.read() + + +def pull_image(image: str, output_tar: str) -> None: + if ":" in image: + repo_name, tag = image.rsplit(":", 1) + else: + repo_name, tag = image, "latest" + + if "/" not in repo_name: + repo_name = f"library/{repo_name}" + + print(f"Authenticating for {repo_name}:{tag} ...") + token = get_token(repo_name) + + # Fetch manifest (prefer OCI, fall back to v2 schema2) + print("Fetching manifest ...") + manifest_url = f"https://registry-1.docker.io/v2/{repo_name}/manifests/{tag}" + manifest_bytes = registry_get( + manifest_url, + token, + headers={"Accept": "application/vnd.docker.distribution.manifest.v2+json," + "application/vnd.oci.image.manifest.v1+json"}, + ) + manifest = json.loads(manifest_bytes) + + # Handle manifest list (multi-arch) β€” pick linux/amd64 + media_type = manifest.get("mediaType", "") or manifest.get("schemaVersion", "") + if "list" in str(media_type) or manifest.get("manifests"): + print("Manifest list detected β€” selecting linux/amd64 ...") + for m in manifest["manifests"]: + plat = m.get("platform", {}) + if plat.get("os") == "linux" and plat.get("architecture") == "amd64": + digest = m["digest"] + manifest_bytes = registry_get( + f"https://registry-1.docker.io/v2/{repo_name}/manifests/{digest}", + token, + headers={"Accept": "application/vnd.docker.distribution.manifest.v2+json"}, + ) + manifest = json.loads(manifest_bytes) + break + + config_digest = manifest["config"]["digest"] + layers = manifest["layers"] + + with tempfile.TemporaryDirectory() as tmpdir: + # Download config blob + print("Downloading config ...") + config_data = registry_get( + f"https://registry-1.docker.io/v2/{repo_name}/blobs/{config_digest}", + token, + ) + config_filename = config_digest.replace("sha256:", "") + ".json" + config_path = os.path.join(tmpdir, config_filename) + with open(config_path, "wb") as f: + f.write(config_data) + + # Download each layer + layer_dirs = [] + for i, layer in enumerate(layers): + digest = layer["digest"] + size = layer["size"] + short = digest[7:19] + print(f"Downloading layer {i+1}/{len(layers)} ({short}..., {size//1024//1024}MB) ...") + + blob_url = f"https://registry-1.docker.io/v2/{repo_name}/blobs/{digest}" + req = urllib.request.Request(blob_url, headers={"Authorization": f"Bearer {token}"}) + + layer_dir = os.path.join(tmpdir, f"layer_{i}") + os.makedirs(layer_dir) + layer_tar = os.path.join(layer_dir, "layer.tar") + version_file = os.path.join(layer_dir, "VERSION") + json_file = os.path.join(layer_dir, "json") + + # Stream download with Range-request chunking so a TCP corruption + # only loses one 2MB chunk, not the whole download. + CHUNK_SIZE = 2 * 1024 * 1024 # 2MB per Range request + downloaded = 0 + with open(layer_tar, "wb") as f: + while downloaded < size: + end = min(downloaded + CHUNK_SIZE - 1, size - 1) + while True: + try: + range_req = urllib.request.Request( + blob_url, + headers={ + "Authorization": f"Bearer {token}", + "Range": f"bytes={downloaded}-{end}", + }, + ) + with _opener().open(range_req, timeout=60) as resp: + data = resp.read() + break + except Exception as exc: + print(f"\r retry at {downloaded//1024//1024}MB ({exc})...", end="", flush=True) + import time; time.sleep(1) + f.write(data) + downloaded += len(data) + pct = downloaded * 100 // size if size else 0 + print(f"\r {downloaded//1024//1024}MB / {size//1024//1024}MB ({pct}%)", end="", flush=True) + print() + + with open(version_file, "w") as f: + f.write("1.0") + with open(json_file, "w") as f: + json.dump({"id": digest.replace("sha256:", "")}, f) + + layer_dirs.append(f"layer_{i}/layer.tar") + + # Write manifest.json + manifest_json = [ + { + "Config": config_filename, + "RepoTags": [f"{repo_name.replace('library/', '')}:{tag}"], + "Layers": layer_dirs, + } + ] + manifest_path = os.path.join(tmpdir, "manifest.json") + with open(manifest_path, "w") as f: + json.dump(manifest_json, f) + + # Bundle into tar + print(f"Building {output_tar} ...") + with tarfile.open(output_tar, "w") as tar: + for name in [config_filename, "manifest.json"]: + tar.add(os.path.join(tmpdir, name), arcname=name) + for i in range(len(layers)): + for fname in ["layer.tar", "VERSION", "json"]: + path = os.path.join(tmpdir, f"layer_{i}", fname) + tar.add(path, arcname=f"layer_{i}/{fname}") + + print(f"Done. Load with: docker load -i {output_tar}") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: pull_image.py [output.tar]") + sys.exit(1) + image = sys.argv[1] + output = sys.argv[2] if len(sys.argv) > 2 else image.replace(":", "_").replace("/", "_") + ".tar" + pull_image(image, output) diff --git a/scripts/seed.py b/scripts/seed.py new file mode 100644 index 0000000..94066b8 --- /dev/null +++ b/scripts/seed.py @@ -0,0 +1,97 @@ +"""Seed the 6 canonical topics from canon/projects/.""" +import asyncio +import sys +from pathlib import Path + +# Allow running from state-hub/ root +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import async_session_factory, engine +from api.models.topic import Domain, Topic, TopicStatus + +TOPICS = [ + { + "slug": "custodian", + "title": "The Custodian", + "description": ( + "Master agent system: transgenerational cognitive infrastructure for " + "co-creating and stewarding knowledge across all domains." + ), + "domain": Domain.custodian, + }, + { + "slug": "railiance", + "title": "Railiance", + "description": ( + "DevOps & infrastructure reliability. Dependency for all other projects; " + "provides the deployment and operational backbone." + ), + "domain": Domain.railiance, + }, + { + "slug": "markitect", + "title": "Markitect", + "description": ( + "Knowledge artifact management: structured authoring, versioning, and " + "retrieval of canonical documents." + ), + "domain": Domain.markitect, + }, + { + "slug": "coulomb-social", + "title": "Coulomb.social", + "description": ( + "Co-creation marketplace experiment: connecting people around shared " + "projects and complementary capabilities." + ), + "domain": Domain.coulomb_social, + }, + { + "slug": "personhood", + "title": "Personhood", + "description": ( + "Rights and obligations framework: defining digital personhood, consent " + "models, and data sovereignty." + ), + "domain": Domain.personhood, + }, + { + "slug": "foerster-capabilities", + "title": "Foerster Capabilities", + "description": ( + "Agency capability taxonomy inspired by Heinz von Foerster: mapping the " + "space of possible cognitive and social actions." + ), + "domain": Domain.foerster_capabilities, + }, +] + + +async def seed() -> None: + async with async_session_factory() as session: + for data in TOPICS: + existing = await session.execute( + select(Topic).where(Topic.slug == data["slug"]) + ) + if existing.scalar_one_or_none() is not None: + print(f" skip (already exists): {data['slug']}") + continue + topic = Topic( + slug=data["slug"], + title=data["title"], + description=data["description"], + domain=data["domain"], + status=TopicStatus.active, + ) + session.add(topic) + print(f" insert: {data['slug']}") + await session.commit() + await engine.dispose() + print("Seed complete.") + + +if __name__ == "__main__": + asyncio.run(seed()) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..afb9d88 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1644 @@ +version = 1 +requires-python = ">=3.12" + +[[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539 }, +] + +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893 }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592 }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042 }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504 }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241 }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321 }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685 }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858 }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852 }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175 }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111 }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928 }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067 }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156 }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636 }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079 }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606 }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569 }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867 }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349 }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428 }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678 }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505 }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744 }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251 }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901 }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280 }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931 }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608 }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738 }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026 }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426 }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495 }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062 }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615 }, +] + +[[package]] +name = "authlib" +version = "1.6.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/6c/c88eac87468c607f88bc24df1f3b31445ee6fc9ba123b09e666adf687cd9/authlib-1.6.8.tar.gz", hash = "sha256:41ae180a17cf672bc784e4a518e5c82687f1fe1e98b0cafaeda80c8e4ab2d1cb", size = 165074 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/73/f7084bf12755113cd535ae586782ff3a6e710bfbe6a0d13d1c2f81ffbbfa/authlib-1.6.8-py2.py3-none-any.whl", hash = "sha256:97286fd7a15e6cfefc32771c8ef9c54f0ed58028f1322de6a2a7c969c3817888", size = 244116 }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658 }, +] + +[[package]] +name = "cachetools" +version = "7.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/07/56595285564e90777d758ebd383d6b0b971b87729bbe2184a849932a3736/cachetools-7.0.1.tar.gz", hash = "sha256:e31e579d2c5b6e2944177a0397150d312888ddf4e16e12f1016068f0c03b8341", size = 36126 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/9e/5faefbf9db1db466d633735faceda1f94aa99ce506ac450d232536266b32/cachetools-7.0.1-py3-none-any.whl", hash = "sha256:8f086515c254d5664ae2146d14fc7f65c9a4bce75152eb247e5a9c5e6d7b2ecf", size = 13484 }, +] + +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983 }, + { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012 }, + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979 }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900 }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978 }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832 }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087 }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900 }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271 }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048 }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529 }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097 }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983 }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519 }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572 }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963 }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361 }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230 }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043 }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446 }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101 }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948 }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422 }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499 }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928 }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302 }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320 }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487 }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049 }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793 }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300 }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244 }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828 }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926 }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773 }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013 }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593 }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354 }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480 }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584 }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443 }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437 }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289 }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637 }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742 }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528 }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993 }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855 }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635 }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038 }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181 }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482 }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497 }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819 }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230 }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909 }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287 }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728 }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287 }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291 }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539 }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199 }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131 }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072 }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170 }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741 }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728 }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001 }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637 }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487 }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514 }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349 }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667 }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980 }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143 }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674 }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801 }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755 }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539 }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794 }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160 }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123 }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220 }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050 }, +] + +[[package]] +name = "cyclopts" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/5c/88a4068c660a096bbe87efc5b7c190080c9e86919c36ec5f092cb08d852f/cyclopts-4.6.0.tar.gz", hash = "sha256:483c4704b953ea6da742e8de15972f405d2e748d19a848a4d61595e8e5360ee5", size = 162724 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/eb/1e8337755a70dc7d7ff10a73dc8f20e9352c9ad6c2256ed863ac95cd3539/cyclopts-4.6.0-py3-none-any.whl", hash = "sha256:0a891cb55bfd79a3cdce024db8987b33316aba11071e5258c21ac12a640ba9f2", size = 200518 }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094 }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896 }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196 }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 }, +] + +[[package]] +name = "fastapi" +version = "0.133.0" +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/c2/04/ab382c7c03dd545f2c964d06e87ad0d5faa944a2434186ad9c285f5d87e0/fastapi-0.133.0.tar.gz", hash = "sha256:b900a2bf5685cdb0647a41d5900bdeafc3a9e8a28ac08c6246b76699e164d60d", size = 373265 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/b4/023e75a2ec3f5440e380df6caf4d28edc0806d007193e6fb0707237886a4/fastapi-0.133.0-py3-none-any.whl", hash = "sha256:0a78878483d60702a1dde864c24ab349a1a53ef4db6b6f74f8cd4a2b2bc67d2f", size = 104787 }, +] + +[[package]] +name = "fastmcp" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "jsonref" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "pydantic", extra = ["email"] }, + { name = "pyperclip" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/6b/1a7ec89727797fb07ec0928e9070fa2f45e7b35718e1fe01633a34c35e45/fastmcp-3.0.2.tar.gz", hash = "sha256:6bd73b4a3bab773ee6932df5249dcbcd78ed18365ed0aeeb97bb42702a7198d7", size = 17239351 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/5a/f410a9015cfde71adf646dab4ef2feae49f92f34f6050fcfb265eb126b30/fastmcp-3.0.2-py3-none-any.whl", hash = "sha256:f513d80d4b30b54749fe8950116b1aab843f3c293f5cb971fc8665cb48dbb028", size = 606268 }, +] + +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358 }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217 }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792 }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250 }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875 }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467 }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001 }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081 }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331 }, + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120 }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238 }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219 }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268 }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774 }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277 }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455 }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961 }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221 }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650 }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295 }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163 }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371 }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160 }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181 }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713 }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034 }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437 }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617 }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189 }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225 }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581 }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907 }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857 }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010 }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280 }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004 }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655 }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440 }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186 }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192 }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694 }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889 }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180 }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596 }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268 }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517 }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337 }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743 }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619 }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714 }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909 }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831 }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631 }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910 }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960 }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865 }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777 }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065 }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481 }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010 }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425 }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 }, +] + +[[package]] +name = "jsonschema-path" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/da/1ebeb1c0ff579c330e200e8b06e6200653e3d0758136d8bd86762d63e7de/jsonschema_path-0.4.2.tar.gz", hash = "sha256:5f5ff183150030ea24bb51cf1ddac9bf5dbf030272e2792a7ffe8262f7eea2a5", size = 13417 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/10/96f8fe82137979fcd1e46fff243ce7d80cd03b9e1cee8f22476ce780f38c/jsonschema_path-0.4.2-py3-none-any.whl", hash = "sha256:9c3d88e727cc4f1a88e51dbbed4211dbcd815d27799d2685efd904435c3d39e7", size = 16702 }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160 }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509 }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622 }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029 }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374 }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980 }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990 }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784 }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588 }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041 }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543 }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113 }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911 }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658 }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066 }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639 }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569 }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284 }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801 }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769 }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642 }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612 }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200 }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973 }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619 }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029 }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408 }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005 }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048 }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821 }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606 }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043 }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747 }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341 }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073 }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661 }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069 }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670 }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598 }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261 }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835 }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733 }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672 }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819 }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426 }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 }, +] + +[[package]] +name = "mcp" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667 }, +] + +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381 }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356 }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 }, +] + +[[package]] +name = "pathable" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", size = 16655 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867 }, +] + +[[package]] +name = "platformdirs" +version = "4.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603 }, + { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509 }, + { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159 }, + { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234 }, + { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236 }, + { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083 }, + { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281 }, + { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010 }, + { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641 }, + { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940 }, + { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147 }, + { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572 }, + { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529 }, + { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242 }, + { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258 }, + { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295 }, + { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133 }, + { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383 }, + { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168 }, + { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712 }, + { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549 }, + { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215 }, + { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567 }, + { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755 }, + { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646 }, + { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701 }, + { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293 }, + { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184 }, + { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650 }, + { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663 }, + { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737 }, + { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643 }, + { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913 }, +] + +[[package]] +name = "py-key-value-aio" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291 }, +] + +[package.optional-dependencies] +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, +] + +[[package]] +name = "pydantic" +version = "2.12.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/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200 }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578 }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504 }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816 }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366 }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698 }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603 }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591 }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068 }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908 }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145 }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179 }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441 }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291 }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632 }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905 }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495 }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388 }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879 }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017 }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929 }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, +] + +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224 }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063 }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579 }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543 }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040 }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102 }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700 }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700 }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318 }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714 }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800 }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540 }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458 }, +] + +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567 }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086 }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053 }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763 }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951 }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622 }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492 }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080 }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680 }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589 }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289 }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737 }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120 }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782 }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463 }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868 }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887 }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904 }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945 }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783 }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021 }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589 }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025 }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895 }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799 }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731 }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027 }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020 }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139 }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224 }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645 }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443 }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375 }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850 }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812 }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841 }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149 }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843 }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507 }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949 }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790 }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217 }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806 }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341 }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768 }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099 }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192 }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080 }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841 }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670 }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005 }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112 }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049 }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661 }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606 }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126 }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371 }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298 }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604 }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391 }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868 }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747 }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795 }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330 }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194 }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340 }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765 }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834 }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470 }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630 }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148 }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030 }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570 }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532 }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554 }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/35/d16bfa235c8b7caba3730bba43e20b1e376d2224f407c178fbf59559f23e/sqlalchemy-2.0.46-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a9a72b0da8387f15d5810f1facca8f879de9b85af8c645138cba61ea147968c", size = 2153405 }, + { url = "https://files.pythonhosted.org/packages/06/6c/3192e24486749862f495ddc6584ed730c0c994a67550ec395d872a2ad650/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2347c3f0efc4de367ba00218e0ae5c4ba2306e47216ef80d6e31761ac97cb0b9", size = 3334702 }, + { url = "https://files.pythonhosted.org/packages/ea/a2/b9f33c8d68a3747d972a0bb758c6b63691f8fb8a49014bc3379ba15d4274/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9094c8b3197db12aa6f05c51c05daaad0a92b8c9af5388569847b03b1007fb1b", size = 3347664 }, + { url = "https://files.pythonhosted.org/packages/aa/d2/3e59e2a91eaec9db7e8dc6b37b91489b5caeb054f670f32c95bcba98940f/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37fee2164cf21417478b6a906adc1a91d69ae9aba8f9533e67ce882f4bb1de53", size = 3277372 }, + { url = "https://files.pythonhosted.org/packages/dd/dd/67bc2e368b524e2192c3927b423798deda72c003e73a1e94c21e74b20a85/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1e14b2f6965a685c7128bd315e27387205429c2e339eeec55cb75ca4ab0ea2e", size = 3312425 }, + { url = "https://files.pythonhosted.org/packages/43/82/0ecd68e172bfe62247e96cb47867c2d68752566811a4e8c9d8f6e7c38a65/sqlalchemy-2.0.46-cp312-cp312-win32.whl", hash = "sha256:412f26bb4ba942d52016edc8d12fb15d91d3cd46b0047ba46e424213ad407bcb", size = 2113155 }, + { url = "https://files.pythonhosted.org/packages/bc/2a/2821a45742073fc0331dc132552b30de68ba9563230853437cac54b2b53e/sqlalchemy-2.0.46-cp312-cp312-win_amd64.whl", hash = "sha256:ea3cd46b6713a10216323cda3333514944e510aa691c945334713fca6b5279ff", size = 2140078 }, + { url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268 }, + { url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511 }, + { url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881 }, + { url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559 }, + { url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728 }, + { url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295 }, + { url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076 }, + { url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533 }, + { url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208 }, + { url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292 }, + { url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497 }, + { url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079 }, + { url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216 }, + { url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208 }, + { url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994 }, + { url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990 }, + { url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215 }, + { url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867 }, + { url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202 }, + { url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296 }, + { url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008 }, + { url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137 }, + { url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882 }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + +[[package]] +name = "sse-starlette" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/00d280c03ffd39aaee0e86ec81e2d3b9253036a0f93f51d10503adef0e65/sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422", size = 27253 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf", size = 12763 }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +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/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272 }, +] + +[[package]] +name = "state-hub" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "alembic" }, + { name = "asyncpg" }, + { name = "fastapi" }, + { name = "fastmcp" }, + { name = "httpx" }, + { name = "psycopg2-binary" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.14.0" }, + { name = "asyncpg", specifier = ">=0.30.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "fastmcp", specifier = ">=2.0.0" }, + { name = "httpx", specifier = ">=0.28.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.0" }, + { name = "pydantic", specifier = ">=2.10.0" }, + { name = "pydantic-settings", specifier = ">=2.7.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.32.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.28.0" }, + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, +] + +[[package]] +name = "uvicorn" +version = "0.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783 }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936 }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769 }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413 }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307 }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970 }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343 }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611 }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811 }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562 }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890 }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472 }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051 }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067 }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423 }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437 }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101 }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360 }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790 }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783 }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548 }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065 }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384 }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730 }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745 }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769 }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374 }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485 }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813 }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816 }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186 }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812 }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196 }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657 }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042 }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410 }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209 }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321 }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783 }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279 }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405 }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976 }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506 }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936 }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147 }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007 }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280 }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056 }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162 }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909 }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389 }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964 }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114 }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264 }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877 }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176 }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577 }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425 }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826 }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208 }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315 }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869 }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919 }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845 }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027 }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615 }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836 }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099 }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626 }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519 }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078 }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664 }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154 }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510 }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408 }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968 }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096 }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040 }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847 }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072 }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104 }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112 }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365 }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038 }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328 }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915 }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152 }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583 }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880 }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261 }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693 }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364 }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039 }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323 }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975 }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203 }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653 }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920 }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255 }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689 }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406 }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085 }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328 }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044 }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279 }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711 }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982 }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915 }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381 }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737 }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268 }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486 }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331 }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501 }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062 }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356 }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085 }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531 }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598 }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276 }, +] From ad87153f2f1ea57efe8d429c4523d1e0ea28e23d Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 24 Feb 2026 22:22:53 +0100 Subject: [PATCH 002/198] =?UTF-8?q?Implement=20registration=20UX=20wishlis?= =?UTF-8?q?t=20W1=E2=80=93W6=20(260224)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W1: Document user-scope MCP config location in ~/.claude/CLAUDE.md β€” adds verification and re-registration commands, warns against settings.json (saves ~12K tokens per registration session). W2: scripts/register_project.sh + make register-project β€” 5-step automation: API health β†’ topic lookup β†’ MCP check β†’ CLAUDE.md from template β†’ progress event. W3: state-hub/scripts/project_claude_md.template β€” parameterised CLAUDE.md with {PROJECT_NAME}/{DOMAIN}/{TOPIC_ID} placeholders; used by register_project.sh. W4: Add custodian_topic_id + domain to all 6 canon project charters β€” lets agents grep for topic IDs without touching the API. W5: state-hub/mcp_server/TOOLS.md β€” compact 30-line tool reference card; replaces reading the full server.py (~350 lines). W6: Switch .mcp.json to absolute path + PYTHONPATH env so cwd is not required; add scripts/patch_mcp_cwd.py for post-registration fix. Update ~/.claude.json to match (cwd kept for belt-and-suspenders). W7 (SessionStart hook) deferred: no SessionStart hook type in Claude Code; PreToolUse with empty matcher fires before every tool call. Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 8 +- mcp_server/TOOLS.md | 57 ++++++++++++ scripts/patch_mcp_cwd.py | 42 +++++++++ scripts/project_claude_md.template | 32 +++++++ scripts/register_project.sh | 145 +++++++++++++++++++++++++++++ 5 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 mcp_server/TOOLS.md create mode 100644 scripts/patch_mcp_cwd.py create mode 100644 scripts/project_claude_md.template create mode 100755 scripts/register_project.sh diff --git a/Makefile b/Makefile index 3ac7d45..76e2555 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install db db-tools migrate seed api dashboard check start clean +.PHONY: install db db-tools migrate seed api dashboard check start clean register-project COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env @@ -31,5 +31,11 @@ start: db $(MAKE) migrate $(MAKE) api +## Register a project: make register-project DOMAIN=railiance PROJECT_PATH=/home/worsch/railiance +register-project: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required. Usage: make register-project DOMAIN= PROJECT_PATH="; exit 1) + @test -n "$(PROJECT_PATH)" || (echo "ERROR: PROJECT_PATH is required."; exit 1) + scripts/register_project.sh "$(DOMAIN)" "$(PROJECT_PATH)" + clean: $(COMPOSE) down -v diff --git a/mcp_server/TOOLS.md b/mcp_server/TOOLS.md new file mode 100644 index 0000000..eca80f8 --- /dev/null +++ b/mcp_server/TOOLS.md @@ -0,0 +1,57 @@ +# State Hub MCP β€” Tool Reference Card + +Quick reference for all 11 tools and 5 resources. Read this instead of `server.py`. + +## Query Tools (read-only) + +| Tool | Key Args | When to use | +|------|----------|-------------| +| `get_state_summary()` | β€” | **Session start.** Full snapshot: totals, blocking decisions, blocked tasks, open workstreams, last 20 events. | +| `get_topic(slug)` | `slug`: e.g. `"markitect"` | Deep-dive on one topic + its workstreams + recent events. | +| `list_blocked_tasks(workstream_id?)` | optional filter | Surface all impediments, optionally scoped to one workstream. | +| `list_pending_decisions(topic_id?)` | optional filter | Decisions holding up work, sorted by deadline. | +| `get_recent_progress(limit, since?)` | `limit` default 20; `since` ISO datetime | Reconstruct recent session history. | + +## Mutate Tools (each auto-emits a progress_event) + +| Tool | Key Args | Notes | +|------|----------|-------| +| `create_task(workstream_id, title, ...)` | `priority`: low/medium/high/critical; `assignee?`; `due_date?` | Creates task under a workstream. | +| `update_task_status(task_id, status, ...)` | `status`: todo/in_progress/blocked/done/cancelled; `blocking_reason` required when blocked | | +| `record_decision(title, ...)` | `decision_type`: made/pending; `topic_id?`; `workstream_id?`; `deadline?` | Financial/legal + pending β†’ auto-escalated per constitution Β§4. At least one of topic_id/workstream_id required. | +| `resolve_decision(decision_id, rationale, decided_by)` | all required | Marks decision resolved and records who decided. | +| `add_progress_event(summary, ...)` | `event_type`: note/milestone/blocker/insight; `topic_id?`; `workstream_id?`; `task_id?`; `detail?` | Append-only log entry. **Use at session end.** | +| `update_workstream_status(workstream_id, status)` | `status`: active/blocked/completed/archived | | + +## Resources (URI-addressable, read-only) + +| URI | Returns | +|-----|---------| +| `state://summary` | Full StateSummary JSON | +| `state://topics` | Active topics list | +| `state://workstreams/{topic_slug}` | Workstreams for a topic (by slug) | +| `state://decisions/blocking` | All pending decisions | +| `state://tasks/blocked` | All blocked tasks | + +## Domain Slugs + +`custodian` Β· `railiance` Β· `markitect` Β· `coulomb-social` Β· `personhood` Β· `foerster-capabilities` + +## Common Patterns + +```python +# New workstream (via API β€” no MCP tool yet): +# POST /workstreams/ {"topic_id": "...", "slug": "...", "title": "...", "status": "active", "owner": "..."} + +# Session start ritual: +get_state_summary() + +# Session end ritual: +add_progress_event( + summary="...", + event_type="note", # or milestone / insight / blocker + topic_id="", + workstream_id="", # optional + detail={"key": "value"}, # optional structured data +) +``` diff --git a/scripts/patch_mcp_cwd.py b/scripts/patch_mcp_cwd.py new file mode 100644 index 0000000..8724f89 --- /dev/null +++ b/scripts/patch_mcp_cwd.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +""" +Patch ~/.claude.json to add the cwd field to the state-hub MCP entry. + +claude mcp add-json silently drops the cwd field. Run this script after +any claude mcp add-json call to restore it. + +Usage: python3 scripts/patch_mcp_cwd.py +""" +import json +import os +from pathlib import Path + +CLAUDE_JSON = Path.home() / ".claude.json" +STATE_HUB_DIR = Path(__file__).resolve().parent.parent # state-hub/ + +def main() -> None: + if not CLAUDE_JSON.exists(): + print(f"ERROR: {CLAUDE_JSON} not found. Run 'claude mcp add-json' first.") + raise SystemExit(1) + + config = json.loads(CLAUDE_JSON.read_text()) + servers = config.setdefault("mcpServers", {}) + + if "state-hub" not in servers: + print("ERROR: 'state-hub' not found in ~/.claude.json. Run 'claude mcp add-json' first.") + raise SystemExit(1) + + entry = servers["state-hub"] + cwd_str = str(STATE_HUB_DIR) + + if entry.get("cwd") == cwd_str: + print(f"OK: cwd already set to {cwd_str}") + return + + entry["cwd"] = cwd_str + CLAUDE_JSON.write_text(json.dumps(config, indent=2) + "\n") + print(f"Patched: ~/.claude.json state-hub.cwd = {cwd_str}") + + +if __name__ == "__main__": + main() diff --git a/scripts/project_claude_md.template b/scripts/project_claude_md.template new file mode 100644 index 0000000..2ad0da2 --- /dev/null +++ b/scripts/project_claude_md.template @@ -0,0 +1,32 @@ +# {PROJECT_NAME} β€” Claude Code Instructions + +## Custodian State Hub Integration + +This project is tracked as the **{DOMAIN}** domain in the Custodian State Hub. +Hub topic ID: `{TOPIC_ID}` + +The State Hub runs locally at http://127.0.0.1:8000. The MCP server (`state-hub`) +exposes tools for reading and writing state without touching the API directly. + +### Session Protocol + +**At the start of every session:** +1. Call `get_state_summary()` β€” orients you to active workstreams, blocking decisions, + and recent progress. If it fails, the API is likely offline: + ``` + cd ~/the-custodian/state-hub && make api + ``` +2. Review any `blocking_decisions` entries for this project before starting work. + +**During work:** +- Use `create_task()` / `update_task_status()` to track concrete deliverables. +- Use `record_decision()` for any decision that affects direction or dependencies. +- Use `add_progress_event()` for notable events (milestones, blockers, insights). + +**At the end of every session:** +- Call `add_progress_event()` with a summary of what was accomplished or decided. + Include `topic_id: {TOPIC_ID}` and the relevant `workstream_id`. + +### Quick Reference + +See `~/the-custodian/state-hub/mcp_server/TOOLS.md` for a compact tool reference. diff --git a/scripts/register_project.sh b/scripts/register_project.sh new file mode 100755 index 0000000..4c80feb --- /dev/null +++ b/scripts/register_project.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# register_project.sh β€” register a new project with the Custodian State Hub +# +# Usage: scripts/register_project.sh +# domain: one of custodian|railiance|markitect|coulomb_social|personhood|foerster_capabilities +# project_path: absolute path to the project directory +# +# Example: +# scripts/register_project.sh railiance /home/worsch/railiance +# +# What it does: +# 1. Verify the API is reachable +# 2. Look up the topic ID for the domain +# 3. Check that state-hub is in ~/.claude.json; warn if missing +# 4. Write $project_path/CLAUDE.md from the template (skip if exists) +# 5. POST a progress event recording the registration + +set -euo pipefail + +DOMAIN="${1:-}" +PROJECT_PATH="${2:-}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STATE_HUB_DIR="$(dirname "$SCRIPT_DIR")" +API_BASE="${API_BASE:-http://127.0.0.1:8000}" + +# ── Validate args ────────────────────────────────────────────────────────────── +if [[ -z "$DOMAIN" || -z "$PROJECT_PATH" ]]; then + echo "Usage: $0 " + echo " domain: custodian|railiance|markitect|coulomb_social|personhood|foerster_capabilities" + echo " project_path: absolute path to project directory" + exit 1 +fi + +if [[ ! -d "$PROJECT_PATH" ]]; then + echo "ERROR: project_path does not exist: $PROJECT_PATH" + exit 1 +fi + +PROJECT_NAME="$(basename "$PROJECT_PATH")" + +# ── Step 1: API health check ─────────────────────────────────────────────────── +echo "==> Checking API at $API_BASE ..." +if ! curl -sf "$API_BASE/state/health" > /dev/null; then + echo "ERROR: State Hub API is not reachable." + echo " Start it: cd $STATE_HUB_DIR && make api" + echo " (requires postgres: make db first)" + exit 1 +fi +echo " API OK" + +# ── Step 2: Look up topic ID ─────────────────────────────────────────────────── +echo "==> Looking up topic for domain '$DOMAIN' ..." +TOPICS_JSON="$(curl -sf "$API_BASE/topics/?status=active")" + +TOPIC_ID="$(echo "$TOPICS_JSON" | python3 -c " +import json, sys +topics = json.load(sys.stdin) +match = next((t for t in topics if t.get('domain') == sys.argv[1]), None) +if not match: + print('NOT_FOUND') +else: + print(match['id']) +" "$DOMAIN")" + +if [[ "$TOPIC_ID" == "NOT_FOUND" ]]; then + echo "ERROR: No active topic found for domain '$DOMAIN'." + echo " Known domains: custodian railiance markitect coulomb_social personhood foerster_capabilities" + exit 1 +fi +echo " topic_id: $TOPIC_ID" + +# ── Step 3: Check MCP registration ──────────────────────────────────────────── +echo "==> Checking MCP server registration ..." +MCP_OK="$(python3 -c " +import json +from pathlib import Path +f = Path.home() / '.claude.json' +if not f.exists(): + print('MISSING_FILE') +else: + d = json.loads(f.read_text()) + servers = d.get('mcpServers', {}) + print('OK' if 'state-hub' in servers else 'NOT_REGISTERED') +")" + +if [[ "$MCP_OK" == "MISSING_FILE" ]]; then + echo "WARNING: ~/.claude.json not found. MCP server is not registered." + echo " To register:" + echo " MΠ‘PCFG=\$(cat $STATE_HUB_DIR/../.mcp.json | python3 -c \"import json,sys; print(json.dumps(json.load(sys.stdin)['mcpServers']['state-hub']))\")" + echo " claude mcp add-json -s user state-hub \"\$MCPCFG\"" + echo " python3 $SCRIPT_DIR/patch_mcp_cwd.py" +elif [[ "$MCP_OK" == "NOT_REGISTERED" ]]; then + echo "WARNING: 'state-hub' not found in ~/.claude.json." + echo " To register, see CLAUDE.md MCP Server Registration section." +else + echo " MCP OK" +fi + +# ── Step 4: Write CLAUDE.md ──────────────────────────────────────────────────── +CLAUDE_MD="$PROJECT_PATH/CLAUDE.md" +TEMPLATE="$SCRIPT_DIR/project_claude_md.template" + +if [[ -f "$CLAUDE_MD" ]]; then + echo "==> CLAUDE.md already exists at $CLAUDE_MD β€” skipping." +else + echo "==> Writing CLAUDE.md to $CLAUDE_MD ..." + sed \ + -e "s|{PROJECT_NAME}|$PROJECT_NAME|g" \ + -e "s|{DOMAIN}|$DOMAIN|g" \ + -e "s|{TOPIC_ID}|$TOPIC_ID|g" \ + "$TEMPLATE" > "$CLAUDE_MD" + echo " Written." +fi + +# ── Step 5: Record progress event ───────────────────────────────────────────── +echo "==> Recording registration event ..." +EVENT_JSON="$(python3 -c " +import json +payload = { + 'topic_id': '$TOPIC_ID', + 'event_type': 'milestone', + 'summary': 'Project registered with State Hub: $PROJECT_NAME ($DOMAIN)', + 'author': 'custodian', + 'detail': { + 'project_path': '$PROJECT_PATH', + 'claude_md': '$CLAUDE_MD', + 'domain': '$DOMAIN', + }, +} +print(json.dumps(payload)) +")" + +curl -sf -X POST "$API_BASE/progress/" \ + -H "Content-Type: application/json" \ + -d "$EVENT_JSON" > /dev/null + +echo " Event recorded." +echo "" +echo "Registration complete!" +echo " Project: $PROJECT_NAME" +echo " Domain: $DOMAIN" +echo " Topic ID: $TOPIC_ID" +echo " CLAUDE.md: $CLAUDE_MD" +echo "" +echo "Next: restart Claude Code for the MCP server to be available in this project." From 6492ae98919a4b41b6ba8c1ceee5e729904a2e84 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 24 Feb 2026 22:55:41 +0100 Subject: [PATCH 003/198] =?UTF-8?q?Add=20custodian=20CLI=20=E2=80=94=20reg?= =?UTF-8?q?ister-project=20and=20status=20subcommands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit custodian register-project [--domain DOMAIN] [--path PATH] Defaults path to cwd; auto-detects domain from project charter if --domain is omitted. Does: API health β†’ topic lookup β†’ MCP check β†’ CLAUDE.md from template β†’ progress event. custodian status Prints API health + summary totals + blocking decisions. Installed via: make install-cli (symlinks .venv/bin/custodian β†’ ~/.local/bin/) Entry point declared in pyproject.toml [project.scripts]. Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 10 ++- custodian_cli.py | 217 +++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 4 + 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 custodian_cli.py diff --git a/Makefile b/Makefile index 76e2555..3a5ce65 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,18 @@ -.PHONY: install db db-tools migrate seed api dashboard check start clean register-project +.PHONY: install install-cli db db-tools migrate seed api dashboard check start clean register-project COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env install: uv sync +## Symlink the custodian CLI into ~/.local/bin so it's on PATH system-wide +install-cli: install + mkdir -p ~/.local/bin + ln -sf "$(shell pwd)/.venv/bin/custodian" ~/.local/bin/custodian + @echo "Installed: custodian β†’ $$(readlink -f ~/.local/bin/custodian)" + @echo "Make sure ~/.local/bin is on your PATH:" + @echo " echo 'export PATH=\"\$$HOME/.local/bin:\$$PATH\"' >> ~/.bashrc && source ~/.bashrc" + db: $(COMPOSE) up -d postgres diff --git a/custodian_cli.py b/custodian_cli.py new file mode 100644 index 0000000..cabe266 --- /dev/null +++ b/custodian_cli.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +custodian β€” CLI for the Custodian State Hub. + +Usage: + custodian register-project [--domain DOMAIN] [--path PATH] + + Run from inside the project directory you want to connect. + --domain defaults to auto-detection from the project charter. + --path defaults to current working directory. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path + +STATE_HUB_DIR = Path(__file__).resolve().parent +API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000") +TEMPLATE = STATE_HUB_DIR / "scripts" / "project_claude_md.template" +PATCH_CWD = STATE_HUB_DIR / "scripts" / "patch_mcp_cwd.py" + +VALID_DOMAINS = [ + "custodian", "railiance", "markitect", + "coulomb_social", "personhood", "foerster_capabilities", +] + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +def _api_get(path: str) -> object: + url = API_BASE.rstrip("/") + path + try: + with urllib.request.urlopen(url, timeout=10) as r: + return json.loads(r.read()) + except urllib.error.URLError as e: + print(f"ERROR: Cannot reach API at {API_BASE}: {e}") + print(f" Start it: cd {STATE_HUB_DIR} && make api") + sys.exit(1) + + +def _api_post(path: str, body: dict) -> object: + url = API_BASE.rstrip("/") + path + data = json.dumps({k: v for k, v in body.items() if v is not None}).encode() + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()) + + +def _detect_domain(project_path: Path) -> str | None: + """Try to read domain from project charter frontmatter.""" + for charter in project_path.rglob("project_charter_v*.md"): + text = charter.read_text() + m = re.search(r"^domain:\s*(\S+)", text, re.MULTILINE) + if m: + return m.group(1).strip('"\'') + return None + + +def _check_mcp() -> bool: + claude_json = Path.home() / ".claude.json" + if not claude_json.exists(): + return False + config = json.loads(claude_json.read_text()) + return "state-hub" in config.get("mcpServers", {}) + + +# ── Subcommands ──────────────────────────────────────────────────────────────── + +def cmd_register(args: argparse.Namespace) -> None: + project_path = Path(args.path).resolve() + if not project_path.is_dir(): + print(f"ERROR: {project_path} is not a directory.") + sys.exit(1) + + project_name = project_path.name + + # ── Step 1: API health ───────────────────────────────────────────────────── + print(f"==> Checking API at {API_BASE} ...") + _api_get("/state/health") + print(" API OK") + + # ── Step 2: Domain ───────────────────────────────────────────────────────── + domain = args.domain + if not domain: + print("==> Auto-detecting domain from project charter ...") + domain = _detect_domain(project_path) + if domain: + print(f" Detected: {domain}") + else: + print(f"ERROR: Could not auto-detect domain. Pass --domain explicitly.") + print(f" Valid: {', '.join(VALID_DOMAINS)}") + sys.exit(1) + + if domain not in VALID_DOMAINS: + print(f"ERROR: Unknown domain '{domain}'. Valid: {', '.join(VALID_DOMAINS)}") + sys.exit(1) + + # ── Step 3: Topic ID lookup ──────────────────────────────────────────────── + print(f"==> Looking up topic for domain '{domain}' ...") + topics = _api_get("/topics/?status=active") + match = next((t for t in topics if t.get("domain") == domain), None) + if not match: + print(f"ERROR: No active topic found for domain '{domain}'.") + sys.exit(1) + topic_id = match["id"] + print(f" topic_id: {topic_id}") + + # ── Step 4: MCP check ────────────────────────────────────────────────────── + print("==> Checking MCP server registration ...") + if _check_mcp(): + print(" MCP OK") + else: + print("WARNING: 'state-hub' not in ~/.claude.json.") + print(f" See ~/.claude/CLAUDE.md β†’ MCP Server Registration section.") + + # ── Step 5: CLAUDE.md ────────────────────────────────────────────────────── + claude_md = project_path / "CLAUDE.md" + if claude_md.exists(): + print(f"==> CLAUDE.md already exists at {claude_md} β€” skipping.") + else: + print(f"==> Writing CLAUDE.md to {claude_md} ...") + content = TEMPLATE.read_text() + content = content.replace("{PROJECT_NAME}", project_name) + content = content.replace("{DOMAIN}", domain) + content = content.replace("{TOPIC_ID}", topic_id) + claude_md.write_text(content) + print(" Written.") + + # ── Step 6: Progress event ───────────────────────────────────────────────── + print("==> Recording registration event ...") + try: + _api_post("/progress/", { + "topic_id": topic_id, + "event_type": "milestone", + "summary": f"Project registered with State Hub: {project_name} ({domain})", + "author": "custodian", + "detail": { + "project_path": str(project_path), + "claude_md": str(claude_md), + "domain": domain, + }, + }) + print(" Event recorded.") + except Exception as e: + print(f" WARNING: Could not record progress event: {e}") + + print() + print("Registration complete!") + print(f" Project: {project_name}") + print(f" Domain: {domain}") + print(f" Topic ID: {topic_id}") + print(f" CLAUDE.md: {claude_md}") + print() + print("Next: restart Claude Code for the MCP server to be active in this project.") + + +def cmd_status(_args: argparse.Namespace) -> None: + """Quick status: API health + summary totals.""" + health = _api_get("/state/health") + print(f"API: {health.get('status', '?')} DB: {health.get('db', '?')}") + summary = _api_get("/state/summary") + t = summary["totals"] + print(f"Topics: {t['topics']['active']} active") + print(f"Workstreams: {t['workstreams']['active']} active, {t['workstreams']['blocked']} blocked") + print(f"Tasks: {t['tasks']['in_progress']} in-progress, {t['tasks']['todo']} todo, {t['tasks']['blocked']} blocked") + print(f"Decisions: {t['decisions']['open']} open, {t['decisions']['escalated']} escalated") + blocking = summary.get("blocking_decisions", []) + if blocking: + print(f"\nBlocking decisions ({len(blocking)}):") + for d in blocking: + deadline = d.get("deadline") or "no deadline" + print(f" [{deadline}] {d['title']}") + + +# ── Entry point ──────────────────────────────────────────────────────────────── + +def main() -> None: + parser = argparse.ArgumentParser( + prog="custodian", + description="Custodian State Hub CLI", + ) + sub = parser.add_subparsers(dest="command", required=True) + + # register-project + reg = sub.add_parser("register-project", help="Register a project with the State Hub") + reg.add_argument( + "--domain", + choices=VALID_DOMAINS, + default=None, + help="Project domain (auto-detected from charter if omitted)", + ) + reg.add_argument( + "--path", + default=os.getcwd(), + help="Project directory (defaults to current directory)", + ) + + # status + sub.add_parser("status", help="Show State Hub health and summary totals") + + args = parser.parse_args() + + if args.command == "register-project": + cmd_register(args) + elif args.command == "status": + cmd_status(args) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 55cfd5f..1e04d18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,12 +17,16 @@ dependencies = [ "psycopg2-binary>=2.9.0", ] +[project.scripts] +custodian = "custodian_cli:main" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["api", "mcp_server"] +artifacts = ["custodian_cli.py"] [tool.uv] dev-dependencies = [ From 935d8a6b835861a0eb51607a04ef8c66f3271b43 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 24 Feb 2026 23:00:20 +0100 Subject: [PATCH 004/198] Add documentation: root README and state-hub/README Root README covers: architecture, domain table with topic IDs, quick start, project registration, Claude Code integration, governance summary, roadmap, and design principles. state-hub/README covers: full setup guide, Makefile targets, DB schema with governance constraints, API summary (incl. /state/summary shape), MCP server config, custodian CLI reference, dashboard pages, and WSL2 known issues. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 242 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..1e225de --- /dev/null +++ b/README.md @@ -0,0 +1,242 @@ +# State Hub v0.1 + +The operational brain of the Custodian: a local PostgreSQL database, FastAPI REST service, FastMCP stdio server for Claude Code, Observable Framework dashboard, and a `custodian` CLI. + +--- + +## Stack + +| Layer | Technology | Port | +|-------|-----------|------| +| Database | PostgreSQL 16-alpine (Docker) | `127.0.0.1:5432` | +| API | FastAPI + SQLAlchemy 2.0 async + asyncpg | `127.0.0.1:8000` | +| MCP server | FastMCP stdio (Claude Code native) | stdio | +| Dashboard | Observable Framework | `127.0.0.1:3000` | +| CLI | `custodian` (Python, uv entry point) | β€” | + +All services bind to `127.0.0.1` only β€” nothing exposed to the network. + +--- + +## Setup + +### Prerequisites + +- Docker Engine (WSL2: see `CLAUDE.md` in repo root β†’ Docker Setup) +- Python 3.12+ with `uv` (`pip install uv`) +- Node.js 18+ (dashboard only) + +### First-time + +```bash +cd state-hub + +cp .env.example .env # edit POSTGRES_PASSWORD +make install # uv sync +make db # docker compose up postgres +make migrate # alembic upgrade head (creates 5 tables) +make seed # insert 6 canonical topics +make api # uvicorn :8000 --reload +``` + +### Shortcut + +```bash +make start # db + sleep + migrate + api +``` + +### Dashboard + +```bash +make dashboard # Observable dev server on :3000 +``` + +### CLI + +```bash +make install-cli # symlink .venv/bin/custodian β†’ ~/.local/bin +custodian status # API health + summary totals +custodian register-project # register cwd as a Custodian project +``` + +--- + +## Makefile Targets + +| Target | What it does | +|--------|-------------| +| `make install` | `uv sync` β€” install Python deps + entry points | +| `make install-cli` | Symlink `custodian` to `~/.local/bin` | +| `make db` | Start postgres container | +| `make db-tools` | Start postgres + pgadmin (http://127.0.0.1:5050) | +| `make migrate` | `alembic upgrade head` | +| `make seed` | Insert 6 canonical topics | +| `make api` | `uvicorn api.main:app --reload` | +| `make dashboard` | Observable dev server | +| `make check` | `curl /state/health` | +| `make start` | `db` + wait + `migrate` + `api` | +| `make register-project DOMAIN=x PROJECT_PATH=y` | Register a project | +| `make clean` | `docker compose down -v` (destroys DB volume) | + +--- + +## Database Schema + +Five tables in dependency order: + +``` +topics +└── workstreams + └── tasks (self-FK: parent_task_id) + └── progress_events +decisions (FK: topic_id, workstream_id β€” at least one required) + └── progress_events +``` + +### Enums + +| Enum | Values | +|------|--------| +| `topic_status` | `active` Β· `paused` Β· `archived` | +| `workstream_status` | `active` Β· `blocked` Β· `completed` Β· `archived` | +| `task_status` | `todo` Β· `in_progress` Β· `blocked` Β· `done` Β· `cancelled` | +| `task_priority` | `low` Β· `medium` Β· `high` Β· `critical` | +| `decision_type` | `made` Β· `pending` | +| `decision_status` | `open` Β· `resolved` Β· `escalated` Β· `superseded` | +| `domain` | `custodian` Β· `railiance` Β· `markitect` Β· `coulomb_social` Β· `personhood` Β· `foerster_capabilities` | + +### Governance constraints encoded in schema + +- No hard DELETE endpoints β€” only soft: `archived`, `cancelled`, `superseded` +- `progress_events` has no `updated_at` and no DELETE endpoint (append-only per constitution Β§5) +- `decisions` with financial/legal keywords + `pending` type β†’ auto-set `escalation_note` (Β§4) + +--- + +## API + +Interactive docs at http://127.0.0.1:8000/docs once the API is running. + +### Key endpoint: `/state/summary` + +Returns a full snapshot in one call β€” used by both the MCP server and dashboard: + +```json +{ + "generated_at": "...", + "totals": { + "topics": { "active": 6, "paused": 0, "archived": 0, "total": 6 }, + "workstreams": { "active": 1, "blocked": 0, "completed": 1, "total": 2 }, + "tasks": { "todo": 9, "in_progress": 0, "blocked": 0, "done": 11, "total": 20 }, + "decisions": { "open": 1, "resolved": 0, "escalated": 0, "total": 1 } + }, + "topics": [...], // topics with nested workstream stubs + "blocking_decisions": [...], // pending decisions only + "blocked_tasks": [...], + "recent_progress": [...], // last 20 events + "open_workstreams": [...] +} +``` + +### Router summary + +| Prefix | Operations | +|--------|-----------| +| `/topics` | CRUD (soft-delete: `archived`) | +| `/workstreams` | CRUD (soft-delete: `archived`) | +| `/tasks` | CRUD (soft-delete: `cancelled`); `PATCH` updates status | +| `/decisions` | CRUD (soft-delete: `superseded`); auto-escalation | +| `/progress` | `GET` list + `POST` append β€” no DELETE | +| `/state/summary` | Full snapshot | +| `/state/health` | DB connectivity check | + +--- + +## MCP Server + +Registered in `~/.claude.json` at user scope. Config in `.mcp.json` (repo root). + +Uses absolute path + `PYTHONPATH` so `cwd` is not required: +```json +{ + "command": "/home/worsch/the-custodian/state-hub/.venv/bin/python", + "args": ["/home/worsch/the-custodian/state-hub/mcp_server/server.py"], + "env": { "PYTHONPATH": "/home/worsch/the-custodian/state-hub", "API_BASE": "http://127.0.0.1:8000" } +} +``` + +See `mcp_server/TOOLS.md` for the full tool reference card (30 lines, faster than reading `server.py`). + +### Tools at a glance + +**Query** (read-only): `get_state_summary` Β· `get_topic` Β· `list_blocked_tasks` Β· `list_pending_decisions` Β· `get_recent_progress` + +**Mutate** (each auto-emits a progress event): `create_task` Β· `update_task_status` Β· `record_decision` Β· `resolve_decision` Β· `add_progress_event` Β· `update_workstream_status` + +**Resources**: `state://summary` Β· `state://topics` Β· `state://workstreams/{topic_slug}` Β· `state://decisions/blocking` Β· `state://tasks/blocked` + +--- + +## `custodian` CLI + +Installed into `.venv/bin/custodian` by `uv sync`; symlinked to `~/.local/bin` by `make install-cli`. + +``` +custodian register-project [--domain DOMAIN] [--path PATH] +``` + +- `--path` defaults to current working directory +- `--domain` is auto-detected from `project_charter_v*.md` frontmatter if omitted + +``` +custodian status +``` + +Prints API health, totals, and any blocking decisions. + +### What `register-project` does + +1. Verifies the API is reachable (fails fast with `make api` hint) +2. Looks up the topic ID for the domain via `/topics/?status=active` +3. Checks that `state-hub` is in `~/.claude.json` +4. Writes `$PROJECT_PATH/CLAUDE.md` from `scripts/project_claude_md.template` +5. Posts a `milestone` progress event recording the registration + +--- + +## Project Registration Scripts + +| Script | Purpose | +|--------|---------| +| `scripts/register_project.sh` | Shell version of `custodian register-project` | +| `scripts/patch_mcp_cwd.py` | Patches `cwd` into `~/.claude.json` after `claude mcp add-json` drops it | +| `scripts/project_claude_md.template` | CLAUDE.md template with `{PROJECT_NAME}`, `{DOMAIN}`, `{TOPIC_ID}` | +| `scripts/seed.py` | Insert the 6 canonical topics into a fresh database | +| `scripts/pull_image.py` | WSL2 workaround: pull Docker images via Python urllib with Range-request chunking | + +--- + +## Dashboard + +Four pages at http://127.0.0.1:3000 (dev) or built with `npm run build`: + +| Page | Content | +|------|---------| +| **Overview** | Status cards, task-by-status chart, recent activity feed, decisions due within 7 days | +| **Workstreams** | Filterable table by domain/status/owner; selected workstream task list; progress timeline | +| **Decisions** | Pending tab (with escalation highlights) and Made tab; resolution velocity chart | +| **Progress** | Append-only event feed with author badges; 30-day event volume chart | + +Data loaders (`src/data/*.json.py`) are Python scripts that call the local API. They run at dev-server start and on `npm run build`. Clear the cache if data appears stale: + +```bash +rm -rf dashboard/src/.observablehq/cache/ +``` + +--- + +## Known Issues / WSL2 Notes + +- **TLS bad record MAC on large downloads**: WSL2 corrupts packets on big TCP transfers. Use `scripts/pull_image.py` instead of `docker pull` for future image pulls. +- **`claude mcp add-json` drops `cwd`**: Known Claude Code bug. Run `python3 scripts/patch_mcp_cwd.py` after any re-registration. The current `.mcp.json` uses absolute path + `PYTHONPATH` so `cwd` is not strictly needed. +- **AsyncSession concurrency**: SQLAlchemy 2.0 async sessions don't support concurrent operations. All queries in `/state/summary` run sequentially on a single session. From 34b1114a0167db1eb60fcaffd9144802ba783541 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 24 Feb 2026 23:19:26 +0100 Subject: [PATCH 005/198] Live dashboard: replace data loaders with client-side polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CORS: add CORSMiddleware to FastAPI for localhost:3000 so browser fetch works across ports without errors. All four pages now use async generator cells that call the API directly and re-yield every 15 s β€” no data loader cache, no manual cache clearing. Each page shows a live status bar (● green/red Β· last updated time). Offline state shows the `make api` hint inline. index.md: add "Registered Projects" section β€” polls /progress/?event_type=milestone&limit=500 and filters for "Project registered with State Hub:" events; shows project name, domain, path, and registration timestamp. workstreams.md: fix broken domain column β€” now fetches /workstreams/ and /topics/ in parallel and joins on topic_id client-side. Previously the domain column showed "unknown" for all rows because WorkstreamRead schema doesn't include domain. Co-Authored-By: Claude Sonnet 4.6 --- api/main.py | 8 ++ dashboard/src/decisions.md | 118 ++++++++++++++++++----------- dashboard/src/index.md | 142 +++++++++++++++++++++++++---------- dashboard/src/progress.md | 116 +++++++++++++++++----------- dashboard/src/workstreams.md | 106 +++++++++++++++++--------- 5 files changed, 329 insertions(+), 161 deletions(-) diff --git a/api/main.py b/api/main.py index 04f57bd..e776a1c 100644 --- a/api/main.py +++ b/api/main.py @@ -1,6 +1,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from api.database import engine from api.routers import decisions, progress, state, tasks, topics, workstreams @@ -19,6 +20,13 @@ app = FastAPI( lifespan=lifespan, ) +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], + allow_methods=["GET", "POST", "PATCH"], + allow_headers=["Content-Type"], +) + app.include_router(topics.router) app.include_router(workstreams.router) app.include_router(tasks.router) diff --git a/dashboard/src/decisions.md b/dashboard/src/decisions.md index 7885a6e..82b462d 100644 --- a/dashboard/src/decisions.md +++ b/dashboard/src/decisions.md @@ -2,30 +2,70 @@ title: Decisions --- -# Decisions - ```js -const decisions = await FileAttachment("data/decisions.json").json(); -const data = Array.isArray(decisions) ? decisions : []; -const pending = data.filter(d => d.decision_type === "pending"); -const made = data.filter(d => d.decision_type === "made"); +const API = "http://127.0.0.1:8000"; +const POLL = 15_000; ``` ```js -const tab = view(Inputs.select(["Pending", "Made"], { label: "View" })); +const decState = (async function*() { + while (true) { + let data = [], ok = false; + try { + const r = await fetch(`${API}/decisions/?limit=500`); + ok = r.ok; + data = ok ? await r.json() : []; + } catch {} + yield {data, ok, ts: new Date()}; + await new Promise(res => setTimeout(res, POLL)); + } +})(); +``` + +```js +const data = decState.data ?? []; +const _ok = decState.ok ?? false; +const _ts = decState.ts; +const pending = data.filter(d => d.decision_type === "pending"); +const made = data.filter(d => d.decision_type === "made"); +``` + +# Decisions + +```js +display(html`
+ ● + ${_ok + ? `Live Β· updated ${_ts?.toLocaleTimeString()}` + : `Offline β€” run: make api`} +
`); +``` + +```js +const tab = view(Inputs.select(["Pending", "Made"], {label: "View"})); ``` ```js const shown = tab === "Pending" ? pending : made; display(Inputs.table(shown.map(d => ({ - Title: d.title, - Status: d.status + (d.escalation_note ? " ⚠️" : ""), - Decided_by: d.decided_by ?? "β€”", - Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "β€”", - Rationale: (d.rationale ?? "").slice(0, 80), - Updated: new Date(d.updated_at).toLocaleDateString(), -})), { rows: 30 })); + Title: d.title, + Status: d.status + (d.escalation_note ? " ⚠️" : ""), + Decided_by: d.decided_by ?? "β€”", + Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "β€”", + Rationale: (d.rationale ?? "").slice(0, 80), + Updated: new Date(d.updated_at).toLocaleDateString(), +})), {rows: 30})); +``` + +```js +if (tab === "Pending" && pending.filter(d => d.escalation_note).length > 0) { + display(html`
+ ⚠️ Escalated decisions require human approval before any action is taken (constitution §4). +
    ${pending.filter(d => d.escalation_note).map(d => + html`
  • ${d.title}: ${d.escalation_note}
  • `)}
+
`); +} ``` ## Resolution Velocity @@ -34,37 +74,31 @@ display(Inputs.table(shown.map(d => ({ import * as Plot from "npm:@observablehq/plot"; const resolved = made.filter(d => d.decided_at); -const byMonth = resolved.reduce((acc, d) => { - const m = d.decided_at.slice(0, 7); - acc[m] = (acc[m] ?? 0) + 1; - return acc; -}, {}); +const byMonth = Object.entries( + resolved.reduce((acc, d) => { + const m = d.decided_at.slice(0, 7); + acc[m] = (acc[m] ?? 0) + 1; + return acc; + }, {}) +).map(([month, count]) => ({month, count})); -display(Plot.plot({ - title: "Decisions Resolved per Month", - x: { label: "Month", tickRotate: -30 }, - y: { label: "Count", grid: true }, - marks: [ - Plot.barY( - Object.entries(byMonth).map(([month, count]) => ({ month, count })), - { x: "month", y: "count", fill: "steelblue", tip: true } - ), - Plot.ruleY([0]), - ], - marginBottom: 60, - width: 700, -})); -``` - -```js -if (tab === "Pending" && pending.filter(d => d.escalation_note).length > 0) { - display(html`
- ⚠️ Escalated decisions require human approval before any action is taken (constitution §4). -
    ${pending.filter(d => d.escalation_note).map(d => html`
  • ${d.title}: ${d.escalation_note}
  • `)}
-
`); -} +display(byMonth.length === 0 + ? html`

No resolved decisions yet.

` + : Plot.plot({ + title: "Decisions resolved per month", + x: {label: "Month", tickRotate: -30}, + y: {label: "Count", grid: true}, + marks: [ + Plot.barY(byMonth, {x: "month", y: "count", fill: "steelblue", tip: true}), + Plot.ruleY([0]), + ], + marginBottom: 60, + width: 700, + }) +); ``` diff --git a/dashboard/src/index.md b/dashboard/src/index.md index e3e45af..f9d3998 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -2,67 +2,131 @@ title: Overview --- -# Custodian State Hub - ```js -const summary = await FileAttachment("data/summary.json").json(); -const totals = summary.totals ?? {}; -const ws = totals.workstreams ?? {}; -const tasks = totals.tasks ?? {}; -const decisions = totals.decisions ?? {}; -const topics = totals.topics ?? {}; +const API = "http://127.0.0.1:8000"; +const POLL = 15_000; ``` ```js -if (summary.error) display(html`
⚠️ API unreachable: ${summary.error}. Run make api.
`); +// Live polling β€” yields {data, ok, ts} every POLL ms +const summaryState = (async function*() { + while (true) { + let data, ok = false; + try { + const r = await fetch(`${API}/state/summary`); + ok = r.ok; + data = ok ? await r.json() : {error: `HTTP ${r.status}`}; + } catch (e) { + data = {error: "API unreachable"}; + } + yield {data, ok, ts: new Date()}; + await new Promise(res => setTimeout(res, POLL)); + } +})(); +``` + +```js +const summary = summaryState.data ?? {}; +const _ok = summaryState.ok ?? false; +const _ts = summaryState.ts; +const totals = summary.totals ?? {}; +const ws = totals.workstreams ?? {}; +const tasks = totals.tasks ?? {}; +const decisions = totals.decisions ?? {}; +``` + +```js +// Registered projects β€” milestone events tagged with registration +const regsState = (async function*() { + while (true) { + let rows = []; + try { + const r = await fetch(`${API}/progress/?event_type=milestone&limit=500`); + if (r.ok) { + const all = await r.json(); + rows = all.filter(e => e.summary?.startsWith("Project registered with State Hub:")); + } + } catch {} + yield rows; + await new Promise(res => setTimeout(res, POLL)); + } +})(); +``` + +# Custodian State Hub + +```js +display(html`
+ ● + ${_ok + ? `Live Β· updated ${_ts?.toLocaleTimeString()}` + : `Offline β€” run: cd ~/the-custodian/state-hub && make api`} +
`); +``` + +```js +if (summary.error) display(html`
⚠️ ${summary.error}
`); ``` ## Status ```js -display(html`
+display(html`

Active Workstreams

-

${ws.active ?? 0}

+

${ws.active ?? 0}

${ws.blocked ?? 0} blocked

Blocking Decisions

-

${(decisions.open ?? 0) + (decisions.escalated ?? 0)}

+

${(decisions.open ?? 0) + (decisions.escalated ?? 0)}

${decisions.escalated ?? 0} escalated

Blocked Tasks

-

${tasks.blocked ?? 0}

+

${tasks.blocked ?? 0}

of ${tasks.total ?? 0} total
-

Progress Events Today

-

${(summary.recent_progress ?? []).filter(e => e.created_at?.startsWith(new Date().toISOString().slice(0,10))).length}

+

Events Today

+

${(summary.recent_progress ?? []).filter(e => + e.created_at?.startsWith(new Date().toISOString().slice(0,10))).length}

last 20 shown below
`); ``` -## Tasks by Domain +## Registered Projects + +```js +const regs = regsState ?? []; +if (regs.length === 0) { + display(html`

No projects registered yet. Run custodian register-project inside a repo.

`); +} else { + display(Inputs.table(regs.map(e => ({ + Project: e.detail?.project_path?.split("/").at(-1) ?? "β€”", + Domain: e.detail?.domain ?? "β€”", + Path: e.detail?.project_path ?? "β€”", + Registered: new Date(e.created_at).toLocaleString(), + })), {maxWidth: 900})); +} +``` + +## Open Workstreams by Domain ```js import * as Plot from "npm:@observablehq/plot"; -const tasksByDomain = []; -for (const topic of (summary.topics ?? [])) { - for (const ws of (topic.workstreams ?? [])) { - // workstream stubs don't include tasks in summary β€” show per-topic WS count as proxy - } - tasksByDomain.push({ domain: topic.domain, status: topic.status, count: (topic.workstreams ?? []).length }); -} +const wsData = (summary.topics ?? []).map(t => ({ + domain: t.domain, + count: (t.workstreams ?? []).length, +})); display(Plot.plot({ - title: "Open Workstreams by Domain", - x: { label: "Domain" }, - y: { label: "Count", grid: true }, + x: {label: "Domain"}, + y: {label: "Open workstreams", grid: true}, marks: [ - Plot.barY(tasksByDomain, { x: "domain", y: "count", fill: "domain", tip: true }), + Plot.barY(wsData, {x: "domain", y: "count", fill: "domain", tip: true}), Plot.ruleY([0]), ], marginBottom: 80, @@ -78,9 +142,9 @@ if (blocking.length === 0) { display(html`

βœ“ No blocking decisions.

`); } else { display(Inputs.table(blocking.map(d => ({ - Title: d.title, - Status: d.status, - Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "β€”", + Title: d.title, + Status: d.status, + Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "β€”", Escalated: d.escalation_note ? "⚠️" : "", })))); } @@ -89,16 +153,15 @@ if (blocking.length === 0) { ## Decisions Due Within 7 Days ```js -const now = new Date(); -const in7 = new Date(now.getTime() + 7*24*60*60*1000); +const in7 = new Date(Date.now() + 7*24*60*60*1000); const due = (summary.blocking_decisions ?? []).filter(d => d.deadline && new Date(d.deadline) <= in7); if (due.length === 0) { display(html`

No decisions due in next 7 days.

`); } else { display(Inputs.table(due.map(d => ({ - Title: d.title, + Title: d.title, Deadline: new Date(d.deadline).toLocaleString(), - Status: d.status, + Status: d.status, })))); } ``` @@ -107,16 +170,17 @@ if (due.length === 0) { ```js display(Inputs.table((summary.recent_progress ?? []).map(e => ({ - Time: new Date(e.created_at).toLocaleString(), - Type: e.event_type, - Author: e.author ?? "β€”", + Time: new Date(e.created_at).toLocaleString(), + Type: e.event_type, + Author: e.author ?? "β€”", Summary: e.summary, -})), { maxWidth: 900 })); +})), {maxWidth: 900})); ``` diff --git a/dashboard/src/progress.md b/dashboard/src/progress.md index 3b9f660..1726cbe 100644 --- a/dashboard/src/progress.md +++ b/dashboard/src/progress.md @@ -2,42 +2,69 @@ title: Progress --- +```js +const API = "http://127.0.0.1:8000"; +const POLL = 15_000; +``` + +```js +const progState = (async function*() { + while (true) { + let data = [], ok = false; + try { + const r = await fetch(`${API}/progress/?limit=500`); + ok = r.ok; + data = ok ? await r.json() : []; + } catch {} + yield {data, ok, ts: new Date()}; + await new Promise(res => setTimeout(res, POLL)); + } +})(); +``` + +```js +const data = progState.data ?? []; +const _ok = progState.ok ?? false; +const _ts = progState.ts; +``` + # Progress Log *Append-only per constitution Β§5 β€” no deletions.* ```js -const events = await FileAttachment("data/progress.json").json(); -const data = Array.isArray(events) ? events : []; +display(html`
+ ● + ${_ok + ? `Live Β· updated ${_ts?.toLocaleTimeString()} Β· ${data.length} events total` + : `Offline β€” run: make api`} +
`); ``` ```js -const authorFilter = view(Inputs.select( - ["(all)", ...new Set(data.map(e => e.author ?? "unknown"))], - { label: "Author" } -)); -const typeFilter = view(Inputs.select( - ["(all)", ...new Set(data.map(e => e.event_type))], - { label: "Event type" } -)); -const sinceFilter = view(Inputs.date({ label: "Since" })); +const authorOpts = ["(all)", ...new Set(data.map(e => e.author ?? "unknown"))].sort(); +const typeOpts = ["(all)", ...new Set(data.map(e => e.event_type))].sort(); + +const authorFilter = view(Inputs.select(authorOpts, {label: "Author"})); +const typeFilter = view(Inputs.select(typeOpts, {label: "Event type"})); +const sinceFilter = view(Inputs.date({label: "Since"})); ``` ```js const filtered = data.filter(e => (authorFilter === "(all)" || (e.author ?? "unknown") === authorFilter) && - (typeFilter === "(all)" || e.event_type === typeFilter) && + (typeFilter === "(all)" || e.event_type === typeFilter) && (!sinceFilter || new Date(e.created_at) >= sinceFilter) ); display(html`

${filtered.length} events shown (append-only, no deletions).

`); display(Inputs.table(filtered.map(e => ({ - Time: new Date(e.created_at).toLocaleString(), - Type: e.event_type, - Author: e.author ?? "β€”", + Time: new Date(e.created_at).toLocaleString(), + Type: e.event_type, + Author: e.author ?? "β€”", Summary: e.summary, -})), { rows: 50 })); +})), {rows: 50})); ``` ## Event Volume (Last 30 Days) @@ -45,33 +72,34 @@ display(Inputs.table(filtered.map(e => ({ ```js import * as Plot from "npm:@observablehq/plot"; -const cutoff = new Date(); -cutoff.setDate(cutoff.getDate() - 30); +const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); +const byDay = Object.entries( + data + .filter(e => new Date(e.created_at) >= cutoff) + .reduce((acc, e) => { + const day = e.created_at.slice(0, 10); + acc[day] = (acc[day] ?? 0) + 1; + return acc; + }, {}) +).sort().map(([day, count]) => ({day, count})); -const byDay = data - .filter(e => new Date(e.created_at) >= cutoff) - .reduce((acc, e) => { - const day = e.created_at.slice(0, 10); - acc[day] = (acc[day] ?? 0) + 1; - return acc; - }, {}); - -display(Plot.plot({ - title: "Progress Events per Day (30-day window)", - x: { label: "Date", tickRotate: -30 }, - y: { label: "Events", grid: true }, - marks: [ - Plot.areaY( - Object.entries(byDay).sort().map(([day, count]) => ({ day, count })), - { x: "day", y: "count", fill: "steelblue", fillOpacity: 0.3 } - ), - Plot.lineY( - Object.entries(byDay).sort().map(([day, count]) => ({ day, count })), - { x: "day", y: "count", stroke: "steelblue" } - ), - Plot.ruleY([0]), - ], - marginBottom: 60, - width: 750, -})); +display(byDay.length === 0 + ? html`

No events in the last 30 days.

` + : Plot.plot({ + title: "Progress events per day (30-day window)", + x: {label: "Date", tickRotate: -30}, + y: {label: "Events", grid: true}, + marks: [ + Plot.areaY(byDay, {x: "day", y: "count", fill: "steelblue", fillOpacity: 0.3}), + Plot.lineY(byDay, {x: "day", y: "count", stroke: "steelblue"}), + Plot.ruleY([0]), + ], + marginBottom: 60, + width: 750, + }) +); ``` + + diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index d30335a..9b62e8c 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -2,23 +2,62 @@ title: Workstreams --- -# Workstreams - ```js -const workstreams = await FileAttachment("data/workstreams.json").json(); -const data = Array.isArray(workstreams) ? workstreams : []; +const API = "http://127.0.0.1:8000"; +const POLL = 15_000; ``` ```js -const domainFilter = view(Inputs.select( - ["(all)", ...new Set(data.map(w => w.domain ?? "unknown"))], - { label: "Domain" } -)); -const statusFilter = view(Inputs.select( - ["(all)", "active", "blocked", "completed", "archived"], - { label: "Status" } -)); -const ownerFilter = view(Inputs.text({ label: "Owner contains" })); +// Fetch workstreams + topics in parallel, join on topic_id β†’ domain/title +const wsState = (async function*() { + while (true) { + let data = [], ok = false; + try { + const [rw, rt] = await Promise.all([ + fetch(`${API}/workstreams/`), + fetch(`${API}/topics/`), + ]); + ok = rw.ok && rt.ok; + if (ok) { + const [wsList, topicList] = await Promise.all([rw.json(), rt.json()]); + const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); + data = wsList.map(w => ({ + ...w, + domain: topicMap[w.topic_id]?.domain ?? "unknown", + topic_title: topicMap[w.topic_id]?.title ?? "β€”", + })); + } + } catch {} + yield {data, ok, ts: new Date()}; + await new Promise(res => setTimeout(res, POLL)); + } +})(); +``` + +```js +const data = wsState.data ?? []; +const _ok = wsState.ok ?? false; +const _ts = wsState.ts; +``` + +# Workstreams + +```js +display(html`
+ ● + ${_ok + ? `Live Β· updated ${_ts?.toLocaleTimeString()}` + : `Offline β€” run: make api`} +
`); +``` + +```js +const domainOpts = ["(all)", ...new Set(data.map(w => w.domain))].sort(); +const statusOpts = ["(all)", "active", "blocked", "completed", "archived"]; + +const domainFilter = view(Inputs.select(domainOpts, {label: "Domain"})); +const statusFilter = view(Inputs.select(statusOpts, {label: "Status"})); +const ownerFilter = view(Inputs.text({label: "Owner contains"})); ``` ```js @@ -28,40 +67,35 @@ const filtered = data.filter(w => (!ownerFilter || (w.owner ?? "").toLowerCase().includes(ownerFilter.toLowerCase())) ); -const STATUS_COLOR = { - active: "green", - blocked: "orange", - completed: "blue", - archived: "gray", -}; - display(Inputs.table(filtered.map(w => ({ - Title: w.title, - Domain: w.domain, - Status: w.status, - Owner: w.owner ?? "β€”", - "Due": w.due_date ?? "β€”", - "Updated": new Date(w.updated_at).toLocaleDateString(), -})), { - rows: 20, -})); + Title: w.title, + Domain: w.domain, + Status: w.status, + Owner: w.owner ?? "β€”", + Due: w.due_date ?? "β€”", + Updated: new Date(w.updated_at).toLocaleDateString(), +})), {rows: 20})); ``` +## Status Distribution + ```js import * as Plot from "npm:@observablehq/plot"; +const byStatus = Object.entries( + filtered.reduce((acc, w) => { acc[w.status] = (acc[w.status] ?? 0) + 1; return acc; }, {}) +).map(([status, count]) => ({status, count})); + display(Plot.plot({ - title: "Workstream Status Distribution", marks: [ - Plot.barX( - Object.entries( - filtered.reduce((acc, w) => { acc[w.status] = (acc[w.status] ?? 0) + 1; return acc; }, {}) - ).map(([status, count]) => ({ status, count })), - { y: "status", x: "count", fill: "status", tip: true } - ), + Plot.barX(byStatus, {y: "status", x: "count", fill: "status", tip: true}), Plot.ruleX([0]), ], marginLeft: 80, width: 500, })); ``` + + From ebe736924900f74a5cce3d49fa52f4971eb95316 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 24 Feb 2026 23:35:54 +0100 Subject: [PATCH 006/198] Add create-workstream: MCP tool, CLI commands, dashboard hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP server: add create_workstream(topic_id, title, slug?, owner?, description?, due_date?) β€” auto-generates slug from title if omitted; emits workstream_created progress event. Now 12 tools total. CLI: add two new subcommands β€” custodian create-workstream --domain DOMAIN --title TITLE [--slug] [--owner] [--description] custodian create-task --workstream ID_OR_SLUG --title TITLE [--priority] [--assignee] create-task accepts workstream UUID or slug (resolves via API). Dashboard: hint box below "Open Workstreams by Domain" chart listing registered domains that have zero workstreams, with the exact custodian create-workstream command to run. TOOLS.md: updated tool count (11 β†’ 12) and added create_workstream row. Co-Authored-By: Claude Sonnet 4.6 --- custodian_cli.py | 102 +++++++++++++++++++++++++++++++++++++++++ dashboard/src/index.md | 22 +++++++++ mcp_server/TOOLS.md | 10 ++-- mcp_server/server.py | 42 +++++++++++++++++ 4 files changed, 173 insertions(+), 3 deletions(-) diff --git a/custodian_cli.py b/custodian_cli.py index cabe266..408ad72 100644 --- a/custodian_cli.py +++ b/custodian_cli.py @@ -161,6 +161,88 @@ def cmd_register(args: argparse.Namespace) -> None: print("Next: restart Claude Code for the MCP server to be active in this project.") +def cmd_create_workstream(args: argparse.Namespace) -> None: + """Create a workstream under a domain's topic.""" + _api_get("/state/health") + + # Resolve topic_id from domain + topics = _api_get("/topics/?status=active") + match = next((t for t in topics if t.get("domain") == args.domain), None) + if not match: + print(f"ERROR: No active topic for domain '{args.domain}'.") + sys.exit(1) + topic_id = match["id"] + + slug = args.slug or re.sub(r"[^a-z0-9]+", "-", args.title.lower()).strip("-") + + ws = _api_post("/workstreams/", { + "topic_id": topic_id, + "title": args.title, + "slug": slug, + "description": args.description, + "owner": args.owner, + "status": "active", + }) + _api_post("/progress/", { + "topic_id": topic_id, + "workstream_id": ws["id"], + "event_type": "workstream_created", + "summary": f"Workstream created: {args.title}", + "author": "custodian", + "detail": {"owner": args.owner, "slug": slug}, + }) + print(f"Created workstream: {ws['title']}") + print(f" id: {ws['id']}") + print(f" slug: {ws['slug']}") + print(f" domain: {args.domain}") + print(f" owner: {ws.get('owner') or 'β€”'}") + + +def cmd_create_task(args: argparse.Namespace) -> None: + """Create a task under a workstream (by ID or slug).""" + _api_get("/state/health") + + # Resolve workstream: accept UUID or slug + workstream_id = args.workstream + if not _is_uuid(workstream_id): + wss = _api_get("/workstreams/") + match = next((w for w in wss if w.get("slug") == workstream_id), None) + if not match: + print(f"ERROR: No workstream found with slug '{workstream_id}'.") + print(" Use 'custodian status' or check the dashboard for valid slugs.") + sys.exit(1) + workstream_id = match["id"] + + task = _api_post("/tasks/", { + "workstream_id": workstream_id, + "title": args.title, + "priority": args.priority, + "description": args.description, + "assignee": args.assignee, + }) + _api_post("/progress/", { + "workstream_id": workstream_id, + "task_id": task["id"], + "event_type": "task_created", + "summary": f"Task created: {args.title}", + "author": "custodian", + "detail": {"priority": args.priority}, + }) + print(f"Created task: {task['title']}") + print(f" id: {task['id']}") + print(f" priority: {task['priority']}") + print(f" status: {task['status']}") + + +def _is_uuid(s: str) -> bool: + import uuid as _uuid + try: + _uuid.UUID(s) + return True + except ValueError: + return False + + def cmd_status(_args: argparse.Namespace) -> None: """Quick status: API health + summary totals.""" health = _api_get("/state/health") @@ -202,6 +284,22 @@ def main() -> None: help="Project directory (defaults to current directory)", ) + # create-workstream + cws = sub.add_parser("create-workstream", help="Create a workstream under a domain topic") + cws.add_argument("--domain", choices=VALID_DOMAINS, required=True, help="Domain to create the workstream under") + cws.add_argument("--title", required=True, help="Workstream title") + cws.add_argument("--slug", default=None, help="URL slug (auto-generated from title if omitted)") + cws.add_argument("--owner", default=None, help="Owner name") + cws.add_argument("--description", default=None, help="Optional description") + + # create-task + ctask = sub.add_parser("create-task", help="Create a task under a workstream") + ctask.add_argument("--workstream", required=True, metavar="ID_OR_SLUG", help="Workstream UUID or slug") + ctask.add_argument("--title", required=True, help="Task title") + ctask.add_argument("--priority", choices=["low", "medium", "high", "critical"], default="medium") + ctask.add_argument("--assignee", default=None) + ctask.add_argument("--description", default=None) + # status sub.add_parser("status", help="Show State Hub health and summary totals") @@ -209,6 +307,10 @@ def main() -> None: if args.command == "register-project": cmd_register(args) + elif args.command == "create-workstream": + cmd_create_workstream(args) + elif args.command == "create-task": + cmd_create_task(args) elif args.command == "status": cmd_status(args) diff --git a/dashboard/src/index.md b/dashboard/src/index.md index f9d3998..1c549df 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -134,6 +134,26 @@ display(Plot.plot({ })); ``` +```js +// Registered domains with no workstreams yet β€” show a getting-started hint +const regs = regsState ?? []; +const registeredDomains = new Set(regs.map(e => e.detail?.domain).filter(Boolean)); +const emptyRegistered = (summary.topics ?? []).filter(t => + registeredDomains.has(t.domain) && (t.workstreams ?? []).length === 0 +); + +if (emptyRegistered.length > 0) { + display(html`
+ πŸ’‘ Getting started +

These registered projects have no workstreams yet:

+
    ${emptyRegistered.map(t => html`
  • + ${t.domain} β€” open the repo in Claude Code and ask the Custodian to create one, or run:
    + custodian create-workstream --domain ${t.domain} --title "My first workstream" +
  • `)}
+
`); +} +``` + ## Blocking Decisions ```js @@ -183,4 +203,6 @@ display(Inputs.table((summary.recent_progress ?? []).map(e => ({ .card.warn { border: 2px solid orange; } .big-num { font-size: 2.5rem; font-weight: bold; margin: 0.25rem 0; } .warning { background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 0.75rem; } +.hint-box { background: var(--theme-background-alt); border-left: 3px solid steelblue; border-radius: 4px; padding: 0.75rem 1rem; margin-top: 0.75rem; font-size: 0.9rem; } +.hint-box code { background: var(--theme-background); padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.85rem; } diff --git a/mcp_server/TOOLS.md b/mcp_server/TOOLS.md index eca80f8..109f4c5 100644 --- a/mcp_server/TOOLS.md +++ b/mcp_server/TOOLS.md @@ -1,6 +1,6 @@ # State Hub MCP β€” Tool Reference Card -Quick reference for all 11 tools and 5 resources. Read this instead of `server.py`. +Quick reference for all 12 tools and 5 resources. Read this instead of `server.py`. ## Query Tools (read-only) @@ -16,6 +16,7 @@ Quick reference for all 11 tools and 5 resources. Read this instead of `server.p | Tool | Key Args | Notes | |------|----------|-------| +| `create_workstream(topic_id, title, ...)` | `slug?` (auto-generated); `owner?`; `description?`; `due_date?` | Creates workstream under a topic. Use `get_state_summary()` to find topic IDs. | | `create_task(workstream_id, title, ...)` | `priority`: low/medium/high/critical; `assignee?`; `due_date?` | Creates task under a workstream. | | `update_task_status(task_id, status, ...)` | `status`: todo/in_progress/blocked/done/cancelled; `blocking_reason` required when blocked | | | `record_decision(title, ...)` | `decision_type`: made/pending; `topic_id?`; `workstream_id?`; `deadline?` | Financial/legal + pending β†’ auto-escalated per constitution Β§4. At least one of topic_id/workstream_id required. | @@ -40,8 +41,11 @@ Quick reference for all 11 tools and 5 resources. Read this instead of `server.p ## Common Patterns ```python -# New workstream (via API β€” no MCP tool yet): -# POST /workstreams/ {"topic_id": "...", "slug": "...", "title": "...", "status": "active", "owner": "..."} +# New workstream: +create_workstream(topic_id="", title="My Workstream", owner="me") + +# New task: +create_task(workstream_id="", title="Do the thing", priority="high") # Session start ritual: get_state_summary() diff --git a/mcp_server/server.py b/mcp_server/server.py index 7337f25..7971472 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -7,6 +7,7 @@ from __future__ import annotations import json import os +import re import sys from datetime import datetime from typing import Any @@ -156,6 +157,47 @@ def get_recent_progress(limit: int = 20, since: str | None = None) -> str: # Mutate tools # --------------------------------------------------------------------------- +@mcp.tool() +def create_workstream( + topic_id: str, + title: str, + slug: str | None = None, + description: str | None = None, + owner: str | None = None, + due_date: str | None = None, +) -> str: + """Create a new workstream under a topic and emit a progress_event. + + Args: + topic_id: UUID of the parent topic + title: workstream title + slug: URL-friendly identifier (auto-generated from title if omitted) + description: optional longer description + owner: optional owner name + due_date: optional ISO date string (YYYY-MM-DD) + """ + if not slug: + slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") + ws = _post("/workstreams", { + "topic_id": topic_id, + "title": title, + "slug": slug, + "description": description, + "owner": owner, + "due_date": due_date, + "status": "active", + }) + _post("/progress", { + "topic_id": topic_id, + "workstream_id": ws["id"], + "event_type": "workstream_created", + "summary": f"Workstream created: {title}", + "author": "custodian", + "detail": {"owner": owner, "slug": slug}, + }) + return json.dumps(ws, indent=2) + + @mcp.tool() def create_task( workstream_id: str, From fda64c8eba8d2035dac9019fdc5770de18584167 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 24 Feb 2026 23:43:39 +0100 Subject: [PATCH 007/198] Add First Session Protocol to project CLAUDE.md template When get_state_summary() shows no workstreams for the domain, Claude now has explicit instructions: read the canon charter + roadmap, survey the repo for in-progress work, propose 1-3 workstreams to Bernd, wait for approval, then create workstreams + tasks and record a milestone. The "wait for approval before creating anything" gate keeps the human in control while making the expected first-session behaviour unambiguous. Co-Authored-By: Claude Sonnet 4.6 --- scripts/project_claude_md.template | 44 +++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/scripts/project_claude_md.template b/scripts/project_claude_md.template index 2ad0da2..88171b6 100644 --- a/scripts/project_claude_md.template +++ b/scripts/project_claude_md.template @@ -16,7 +16,9 @@ exposes tools for reading and writing state without touching the API directly. ``` cd ~/the-custodian/state-hub && make api ``` -2. Review any `blocking_decisions` entries for this project before starting work. +2. Check whether this domain has any open workstreams in the summary. + - **If workstreams exist:** review blocking decisions before starting work. + - **If no workstreams exist:** follow the First Session Protocol below. **During work:** - Use `create_task()` / `update_task_status()` to track concrete deliverables. @@ -27,6 +29,46 @@ exposes tools for reading and writing state without touching the API directly. - Call `add_progress_event()` with a summary of what was accomplished or decided. Include `topic_id: {TOPIC_ID}` and the relevant `workstream_id`. +### First Session Protocol + +Triggered when `get_state_summary()` shows **no workstreams** for the `{DOMAIN}` topic. +This means the project is registered but work has not yet been structured. + +**Step 1 β€” Understand the project (read, don't write)** +- `canon/projects/{DOMAIN}/project_charter_v0.1.md` β€” purpose, scope, success criteria +- `canon/projects/{DOMAIN}/roadmap_v0.1.md` β€” planned phases +- Scan the repo root: README, directory structure, any existing code or docs + +**Step 2 β€” Survey in-progress work** +- Look for TODOs, open branches, half-finished files, or notes +- Note what is already done vs. what is clearly started but incomplete + +**Step 3 β€” Propose workstreams to Bernd** +Based on what you found, propose 1–3 workstreams. Each workstream should be: +- A coherent strand of work lasting weeks to months (not a single task) +- Named clearly enough that its scope is obvious +- Anchored to a phase in the roadmap if possible + +Present the proposals and **wait for approval before creating anything**. + +**Step 4 β€” Create and populate (after approval)** +``` +create_workstream(topic_id="{TOPIC_ID}", title="...", owner="...", description="...") +create_task(workstream_id="", title="...", priority="high|medium|low") +# repeat for each task in the workstream +``` +Aim for 3–7 tasks per workstream at this stage. Tasks should be concrete and actionable. + +**Step 5 β€” Record the setup** +``` +add_progress_event( + summary="First session: structured {DOMAIN} work into N workstreams, M tasks", + event_type="milestone", + topic_id="{TOPIC_ID}", + detail={"workstreams": [...], "tasks_created": M} +) +``` + ### Quick Reference See `~/the-custodian/state-hub/mcp_server/TOOLS.md` for a compact tool reference. From 80e0c85281c85117fdf9adaca5171d085ad56ca1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 24 Feb 2026 23:48:28 +0100 Subject: [PATCH 008/198] Make first-message behaviour explicit in CLAUDE.md template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add one-line imperative at the top of the Session Protocol: 'On receiving your first message β€” before writing any response text β€” call get_state_summary() immediately.' Previously Claude would wait for a substantive prompt before acting. Now any first message (including 'start', 'go', or just Enter) triggers the tool call immediately, after which the First Session Protocol takes over. Co-Authored-By: Claude Sonnet 4.6 --- scripts/project_claude_md.template | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/project_claude_md.template b/scripts/project_claude_md.template index 88171b6..e0cf2a0 100644 --- a/scripts/project_claude_md.template +++ b/scripts/project_claude_md.template @@ -10,6 +10,10 @@ exposes tools for reading and writing state without touching the API directly. ### Session Protocol +**On receiving your first message β€” before writing any response text β€” call +`get_state_summary()` immediately.** Do not greet, do not ask what to do. +Call the tool first, then respond based on what you find. + **At the start of every session:** 1. Call `get_state_summary()` β€” orients you to active workstreams, blocking decisions, and recent progress. If it fails, the API is likely offline: From eaf46c012eef27d3fff271cc426a07fcdf52a699 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 24 Feb 2026 23:57:12 +0100 Subject: [PATCH 009/198] Update getting-started hint: say Hi! to trigger first session --- dashboard/src/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/index.md b/dashboard/src/index.md index 1c549df..e1e965e 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -147,7 +147,7 @@ if (emptyRegistered.length > 0) { πŸ’‘ Getting started

These registered projects have no workstreams yet:

    ${emptyRegistered.map(t => html`
  • - ${t.domain} β€” open the repo in Claude Code and ask the Custodian to create one, or run:
    + ${t.domain} β€” open the repo in Claude Code and say "Hi!" to kick off the first session, or go manual:
    custodian create-workstream --domain ${t.domain} --title "My first workstream"
  • `)}
`); From adb50aaf47612ae5e5a8582d8dbe51f65d16a4bf Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 24 Feb 2026 23:59:38 +0100 Subject: [PATCH 010/198] Tighten hint text to avoid linebreak --- dashboard/src/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/index.md b/dashboard/src/index.md index e1e965e..6135df3 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -147,7 +147,7 @@ if (emptyRegistered.length > 0) { πŸ’‘ Getting started

These registered projects have no workstreams yet:

    ${emptyRegistered.map(t => html`
  • - ${t.domain} β€” open the repo in Claude Code and say "Hi!" to kick off the first session, or go manual:
    + ${t.domain} β€” open repo in Claude Code and say "Hi!" to kick off first session, or go manual:
    custodian create-workstream --domain ${t.domain} --title "My first workstream"
  • `)}
`); From cb73f983007550619a3eddc81687c67931d9b0e6 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 25 Feb 2026 00:16:37 +0100 Subject: [PATCH 011/198] Remove hardcoded br from hint, inline the command --- dashboard/src/index.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dashboard/src/index.md b/dashboard/src/index.md index 6135df3..7bdd02a 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -147,8 +147,7 @@ if (emptyRegistered.length > 0) { πŸ’‘ Getting started

These registered projects have no workstreams yet:

    ${emptyRegistered.map(t => html`
  • - ${t.domain} β€” open repo in Claude Code and say "Hi!" to kick off first session, or go manual:
    - custodian create-workstream --domain ${t.domain} --title "My first workstream" + ${t.domain} β€” open repo in Claude Code and say "Hi!" to kick off first session, or run custodian create-workstream --domain ${t.domain} --title "My first workstream" manually
  • `)}
`); } From 379a3b1a01545155922cdad7142629fc2a06ddd7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 25 Feb 2026 00:21:09 +0100 Subject: [PATCH 012/198] Fix MCP server httpx redirect handling Add follow_redirects=True to httpx Client so 307 redirects (FastAPI trailing-slash redirects) are followed transparently. Also add trailing slash normalisation to _get and _patch to match existing _post behaviour, so all three helpers hit the correct URLs on first attempt. Requires Claude Code restart to take effect (MCP server is a subprocess launched at startup). Co-Authored-By: Claude Sonnet 4.6 --- mcp_server/server.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mcp_server/server.py b/mcp_server/server.py index 7971472..d096023 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -32,10 +32,12 @@ mcp = FastMCP( # --------------------------------------------------------------------------- def _client() -> httpx.Client: - return httpx.Client(base_url=API_BASE, timeout=30.0) + return httpx.Client(base_url=API_BASE, timeout=30.0, follow_redirects=True) def _get(path: str, params: dict | None = None) -> Any: + if not path.endswith("/"): + path = path + "/" with _client() as c: r = c.get(path, params={k: v for k, v in (params or {}).items() if v is not None}) r.raise_for_status() @@ -52,6 +54,8 @@ def _post(path: str, body: dict) -> Any: def _patch(path: str, body: dict) -> Any: + if not path.endswith("/"): + path = path + "/" with _client() as c: r = c.patch(path, json={k: v for k, v in body.items() if v is not None}) r.raise_for_status() From cabeefe0705c51b46dbe77207eed9fa304a7db72 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 25 Feb 2026 00:50:43 +0100 Subject: [PATCH 013/198] Add per-workstream task counts to state summary and dashboard API: - WorkstreamWithTaskCounts schema extends WorkstreamRead with tasks_total/todo/in_progress/blocked/done fields - /state/summary now includes these counts in open_workstreams via a single extra GROUP BY query (workstream_id, status) Dashboard: - Replace domain workstream-count bar with a horizontal stacked progress bar per workstream (done/in-progress/blocked/todo) - Workstreams with no tasks show "no tasks yet" annotation - Workstreams with tasks show "X/N done" label after the bar - Sorted by domain then title so domains group naturally Co-Authored-By: Claude Sonnet 4.6 --- api/routers/state.py | 21 +++++++++++-- api/schemas/state.py | 4 +-- api/schemas/workstream.py | 8 +++++ dashboard/src/index.md | 62 ++++++++++++++++++++++++++++++--------- 4 files changed, 77 insertions(+), 18 deletions(-) diff --git a/api/routers/state.py b/api/routers/state.py index fd9dbed..97593b2 100644 --- a/api/routers/state.py +++ b/api/routers/state.py @@ -23,7 +23,7 @@ from api.schemas.state import ( ) from api.schemas.task import TaskRead from api.schemas.topic import TopicWithWorkstreams -from api.schemas.workstream import WorkstreamRead +from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts router = APIRouter(prefix="/state", tags=["state"]) @@ -63,6 +63,13 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm ) open_ws = list(open_ws_rows.scalars().all()) + # Task counts per workstream (used to enrich open_workstreams) + task_per_ws: dict = {} + for ws_id, tstat, cnt in await session.execute( + select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status) + ): + task_per_ws.setdefault(ws_id, {})[tstat] = cnt + # Totals β€” one GROUP BY per table topic_counts = {r[0]: r[1] for r in await session.execute( select(Topic.status, func.count()).group_by(Topic.status) @@ -115,7 +122,17 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm blocking_decisions=[DecisionRead.model_validate(d) for d in blocking], blocked_tasks=[TaskRead.model_validate(t) for t in blocked], recent_progress=[ProgressEventRead.model_validate(e) for e in recent], - open_workstreams=[WorkstreamRead.model_validate(w) for w in open_ws], + open_workstreams=[ + WorkstreamWithTaskCounts( + **WorkstreamRead.model_validate(w).model_dump(), + tasks_total=sum(task_per_ws.get(w.id, {}).values()), + tasks_todo=task_per_ws.get(w.id, {}).get(TaskStatus.todo, 0), + tasks_in_progress=task_per_ws.get(w.id, {}).get(TaskStatus.in_progress, 0), + tasks_blocked=task_per_ws.get(w.id, {}).get(TaskStatus.blocked, 0), + tasks_done=task_per_ws.get(w.id, {}).get(TaskStatus.done, 0), + ) + for w in open_ws + ], ) diff --git a/api/schemas/state.py b/api/schemas/state.py index 0db6d39..66c1f53 100644 --- a/api/schemas/state.py +++ b/api/schemas/state.py @@ -6,7 +6,7 @@ from api.schemas.decision import DecisionRead from api.schemas.progress_event import ProgressEventRead from api.schemas.task import TaskRead from api.schemas.topic import TopicWithWorkstreams -from api.schemas.workstream import WorkstreamRead +from api.schemas.workstream import WorkstreamWithTaskCounts class TopicTotals(BaseModel): @@ -55,4 +55,4 @@ class StateSummary(BaseModel): blocking_decisions: list[DecisionRead] blocked_tasks: list[TaskRead] recent_progress: list[ProgressEventRead] - open_workstreams: list[WorkstreamRead] + open_workstreams: list[WorkstreamWithTaskCounts] diff --git a/api/schemas/workstream.py b/api/schemas/workstream.py index ae507b9..ae633bc 100644 --- a/api/schemas/workstream.py +++ b/api/schemas/workstream.py @@ -36,3 +36,11 @@ class WorkstreamRead(BaseModel): due_date: date | None = None created_at: datetime updated_at: datetime + + +class WorkstreamWithTaskCounts(WorkstreamRead): + tasks_total: int = 0 + tasks_todo: int = 0 + tasks_in_progress: int = 0 + tasks_blocked: int = 0 + tasks_done: int = 0 diff --git a/dashboard/src/index.md b/dashboard/src/index.md index 7bdd02a..83668e7 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -117,21 +117,55 @@ if (regs.length === 0) { ```js import * as Plot from "npm:@observablehq/plot"; -const wsData = (summary.topics ?? []).map(t => ({ - domain: t.domain, - count: (t.workstreams ?? []).length, -})); +const topicById = Object.fromEntries((summary.topics ?? []).map(t => [t.id, t.domain])); -display(Plot.plot({ - x: {label: "Domain"}, - y: {label: "Open workstreams", grid: true}, - marks: [ - Plot.barY(wsData, {x: "domain", y: "count", fill: "domain", tip: true}), - Plot.ruleY([0]), - ], - marginBottom: 80, - width: 700, -})); +const openWs = (summary.open_workstreams ?? []).map(w => ({ + title: w.title, + domain: topicById[w.topic_id] ?? "unknown", + done: w.tasks_done ?? 0, + in_progress: w.tasks_in_progress ?? 0, + blocked: w.tasks_blocked ?? 0, + todo: w.tasks_todo ?? 0, + total: w.tasks_total ?? 0, +})).sort((a, b) => a.domain.localeCompare(b.domain) || a.title.localeCompare(b.title)); + +const statusOrder = ["done", "in progress", "blocked", "todo"]; +const statusColors = ["#4caf50", "steelblue", "#ff7043", "#e0e0e0"]; + +const taskRows = openWs.flatMap(w => [ + {label: w.title, domain: w.domain, status: "done", count: w.done}, + {label: w.title, domain: w.domain, status: "in progress", count: w.in_progress}, + {label: w.title, domain: w.domain, status: "blocked", count: w.blocked}, + {label: w.title, domain: w.domain, status: "todo", count: w.todo}, +]).filter(d => d.count > 0); + +if (openWs.length === 0) { + display(html`

No open workstreams.

`); +} else { + display(Plot.plot({ + y: {label: null, tickSize: 0, domain: openWs.map(w => w.title)}, + x: {label: "Tasks", grid: true}, + color: {domain: statusOrder, range: statusColors, legend: true}, + marks: [ + Plot.barX(taskRows, {y: "label", x: "count", fill: "status", tip: true}), + Plot.text(openWs.filter(w => w.total > 0), { + y: "title", x: "total", + text: d => ` ${d.done}/${d.total}`, + dx: 4, textAnchor: "start", fontSize: 11, fill: "gray", + }), + Plot.text(openWs.filter(w => w.total === 0), { + y: "title", x: 0, + text: () => " no tasks yet", + textAnchor: "start", fontSize: 11, fill: "#aaa", + }), + Plot.ruleX([0]), + ], + marginLeft: 200, + marginRight: 70, + height: Math.max(80, openWs.length * 44 + 50), + width: 700, + })); +} ``` ```js From 07742dd3f8ec19d58923a6a416546ad19cabebcd Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 25 Feb 2026 00:58:34 +0100 Subject: [PATCH 014/198] Dashboard: show domain on y-axis, workstream title inside bar - y-axis tick labels now show domain name (only on the first workstream in each domain group, blank for subsequent ones) - Workstream title rendered as text at x=0 inside the bar - Titles truncated at 36/24 chars to avoid overflow - marginLeft reduced to 160 (domain names are shorter than titles) Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/index.md | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/dashboard/src/index.md b/dashboard/src/index.md index 83668e7..b33c622 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -139,28 +139,43 @@ const taskRows = openWs.flatMap(w => [ {label: w.title, domain: w.domain, status: "todo", count: w.todo}, ]).filter(d => d.count > 0); +// y-axis shows domain (only for the first workstream in each domain group) +const yLabels = {}; +const _seenDomains = new Set(); +for (const w of openWs) { + yLabels[w.title] = _seenDomains.has(w.domain) ? "" : w.domain; + _seenDomains.add(w.domain); +} + if (openWs.length === 0) { display(html`

No open workstreams.

`); } else { display(Plot.plot({ - y: {label: null, tickSize: 0, domain: openWs.map(w => w.title)}, + y: {label: null, tickSize: 0, domain: openWs.map(w => w.title), tickFormat: t => yLabels[t] ?? ""}, x: {label: "Tasks", grid: true}, color: {domain: statusOrder, range: statusColors, legend: true}, marks: [ Plot.barX(taskRows, {y: "label", x: "count", fill: "status", tip: true}), + // Workstream title inside the bar + Plot.text(openWs.filter(w => w.total > 0), { + y: "title", x: 0, dx: 6, + text: d => d.title.length > 36 ? d.title.slice(0, 34) + "…" : d.title, + textAnchor: "start", fontSize: 10, fill: "#333", + }), + Plot.text(openWs.filter(w => w.total === 0), { + y: "title", x: 0, dx: 6, + text: d => `${d.title.length > 24 ? d.title.slice(0, 22) + "…" : d.title} β€” no tasks yet`, + textAnchor: "start", fontSize: 10, fill: "#aaa", + }), + // "done / total" label after the bar Plot.text(openWs.filter(w => w.total > 0), { y: "title", x: "total", text: d => ` ${d.done}/${d.total}`, dx: 4, textAnchor: "start", fontSize: 11, fill: "gray", }), - Plot.text(openWs.filter(w => w.total === 0), { - y: "title", x: 0, - text: () => " no tasks yet", - textAnchor: "start", fontSize: 11, fill: "#aaa", - }), Plot.ruleX([0]), ], - marginLeft: 200, + marginLeft: 160, marginRight: 70, height: Math.max(80, openWs.length * 44 + 50), width: 700, From 533fecd6e1097c15bab033c8d6d07d234d6dd499 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 25 Feb 2026 09:34:35 +0100 Subject: [PATCH 015/198] Add in-dashboard decision resolution with project log write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API: - DecisionResolve schema (rationale, decided_by, write_log flag) - POST /decisions/{id}/resolve β€” marks resolved, emits progress event, appends entry to DECISIONS.md in the project's registered directory (found via the topic's registration milestone event) Dashboard: - Replace Inputs.table for blocking decisions with full-text cards - Each card shows title, full description (pre-wrap), rationale/context, escalation warning if present - Expandable "Resolve β†’" section with rationale textarea, decided-by input, submit button that calls the resolve endpoint - On success: collapses form, dims card, confirms log was written Co-Authored-By: Claude Sonnet 4.6 --- api/routers/decisions.py | 88 +++++++++++++++++++++++++++++++++++++++- api/schemas/decision.py | 6 +++ dashboard/src/index.md | 85 +++++++++++++++++++++++++++++++++++--- 3 files changed, 172 insertions(+), 7 deletions(-) diff --git a/api/routers/decisions.py b/api/routers/decisions.py index cd676d9..c44802d 100644 --- a/api/routers/decisions.py +++ b/api/routers/decisions.py @@ -1,5 +1,6 @@ import uuid from datetime import datetime, timezone +from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select @@ -7,7 +8,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session from api.models.decision import Decision, DecisionStatus, DecisionType -from api.schemas.decision import DecisionCreate, DecisionRead, DecisionUpdate +from api.models.progress_event import ProgressEvent +from api.schemas.decision import DecisionCreate, DecisionRead, DecisionResolve, DecisionUpdate router = APIRouter(prefix="/decisions", tags=["decisions"]) @@ -108,3 +110,87 @@ async def supersede_decision( await session.commit() await session.refresh(decision) return decision + + +@router.post("/{decision_id}/resolve", response_model=DecisionRead) +async def resolve_decision_action( + decision_id: uuid.UUID, + body: DecisionResolve, + session: AsyncSession = Depends(get_session), +) -> Decision: + decision = await session.get(Decision, decision_id) + if decision is None: + raise HTTPException(status_code=404, detail="Decision not found") + if decision.status == DecisionStatus.resolved: + raise HTTPException(status_code=409, detail="Decision already resolved") + + decision.status = DecisionStatus.resolved + decision.decision_type = DecisionType.made + decision.rationale = body.rationale + decision.decided_by = body.decided_by + decision.decided_at = datetime.now(tz=timezone.utc) + await session.commit() + await session.refresh(decision) + + event = ProgressEvent( + topic_id=decision.topic_id, + workstream_id=decision.workstream_id, + decision_id=decision.id, + event_type="decision_resolved", + summary=f"Decision resolved: {decision.title}", + author=body.decided_by, + detail={"rationale": body.rationale}, + ) + session.add(event) + await session.commit() + + if body.write_log: + await _write_project_log(decision, body.rationale, body.decided_by, session) + + return decision + + +async def _write_project_log( + decision: Decision, rationale: str, decided_by: str, session: AsyncSession +) -> None: + """Append a DECISIONS.md entry to the registered project directory for this topic.""" + if decision.topic_id is None: + return + + rows = await session.execute( + select(ProgressEvent) + .where(ProgressEvent.topic_id == decision.topic_id) + .where(ProgressEvent.event_type == "milestone") + .order_by(ProgressEvent.created_at.desc()) + ) + project_path: str | None = None + for pe in rows.scalars(): + if pe.summary and "Project registered with State Hub:" in pe.summary: + project_path = (pe.detail or {}).get("project_path") + if project_path: + break + + if not project_path: + return + + p = Path(project_path) + if not p.is_dir(): + return + + now = datetime.now(tz=timezone.utc) + entry = ( + f"\n## {decision.title}\n\n" + f"**Date:** {now.strftime('%Y-%m-%d')} \n" + f"**Decided by:** {decided_by} \n\n" + f"{rationale}\n\n" + f"---\n" + ) + log_file = p / "DECISIONS.md" + if log_file.exists(): + log_file.write_text(log_file.read_text() + entry) + else: + log_file.write_text( + "# Decision Log\n\n" + "_Auto-generated by the Custodian State Hub._\n" + + entry + ) diff --git a/api/schemas/decision.py b/api/schemas/decision.py index 6e52fe2..f02041f 100644 --- a/api/schemas/decision.py +++ b/api/schemas/decision.py @@ -26,6 +26,12 @@ class DecisionCreate(BaseModel): return self +class DecisionResolve(BaseModel): + rationale: str + decided_by: str + write_log: bool = True # append to DECISIONS.md in the registered project directory + + class DecisionUpdate(BaseModel): title: str | None = None description: str | None = None diff --git a/dashboard/src/index.md b/dashboard/src/index.md index b33c622..a289d50 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -209,12 +209,66 @@ const blocking = summary.blocking_decisions ?? []; if (blocking.length === 0) { display(html`

βœ“ No blocking decisions.

`); } else { - display(Inputs.table(blocking.map(d => ({ - Title: d.title, - Status: d.status, - Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "β€”", - Escalated: d.escalation_note ? "⚠️" : "", - })))); + for (const d of blocking) { + const card = html`
+
+ ${d.title} + + ${d.escalation_note ? html`⚠ escalated` : ""} + ${d.deadline ? html`Due ${new Date(d.deadline).toLocaleDateString()}` : ""} + +
+ ${d.description ? html`

${d.description}

` : ""} + ${d.rationale ? html`

Context: ${d.rationale}

` : ""} + ${d.escalation_note ? html`

${d.escalation_note}

` : ""} +
+ Resolve this decision β†’ +
+ + + + +
+ + +
+
+
+
`; + + const btn = card.querySelector(".r-submit"); + const msg = card.querySelector(".r-msg"); + const det = card.querySelector(".dec-resolve"); + + btn.addEventListener("click", async () => { + const rationale = card.querySelector(".r-text").value.trim(); + const decidedBy = card.querySelector(".r-by").value.trim() || "Bernd"; + if (!rationale) { msg.textContent = "⚠ Please enter a rationale."; return; } + btn.disabled = true; btn.textContent = "Saving…"; + try { + const r = await fetch(`${API}/decisions/${d.id}/resolve`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({rationale, decided_by: decidedBy}), + }); + if (r.ok) { + det.open = false; + det.querySelector("summary").textContent = "βœ“ Resolved β€” DECISIONS.md written, updates in next poll"; + det.querySelector("summary").style.color = "green"; + card.style.opacity = "0.55"; + } else { + const err = await r.json().catch(() => ({})); + msg.textContent = `Error ${r.status}: ${err.detail ?? "unknown"}`; + btn.disabled = false; btn.textContent = "Record & close"; + } + } catch (e) { + msg.textContent = `Network error: ${e.message}`; + btn.disabled = false; btn.textContent = "Record & close"; + } + }); + + display(card); + } } ``` @@ -253,4 +307,23 @@ display(Inputs.table((summary.recent_progress ?? []).map(e => ({ .warning { background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 0.75rem; } .hint-box { background: var(--theme-background-alt); border-left: 3px solid steelblue; border-radius: 4px; padding: 0.75rem 1rem; margin-top: 0.75rem; font-size: 0.9rem; } .hint-box code { background: var(--theme-background); padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.85rem; } +.dec-card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1rem; border-left: 4px solid steelblue; } +.dec-card.dec-escalated { border-left-color: orange; } +.dec-header { display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.5rem; } +.dec-title { font-weight: 600; font-size: 1rem; } +.dec-meta { font-size: 0.8rem; color: gray; display: flex; gap: 0.5rem; align-items: center; } +.dec-warn-badge { background: orange; color: white; border-radius: 3px; padding: 0.1rem 0.35rem; font-size: 0.75rem; } +.dec-desc { font-size: 0.9rem; margin: 0.4rem 0 0.25rem; white-space: pre-wrap; line-height: 1.5; } +.dec-context { font-size: 0.85rem; color: gray; margin: 0.25rem 0; } +.dec-warn-text { color: #b45309; } +.dec-resolve { margin-top: 0.75rem; } +.dec-resolve summary { cursor: pointer; font-size: 0.85rem; color: steelblue; user-select: none; } +.dec-resolve-inner { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 0.6rem; } +.dec-resolve-inner label { font-size: 0.8rem; font-weight: 600; color: gray; } +.dec-resolve-inner textarea { width: 100%; box-sizing: border-box; padding: 0.4rem; border-radius: 4px; border: 1px solid var(--theme-foreground-faint); background: var(--theme-background); font-family: inherit; font-size: 0.875rem; resize: vertical; } +.dec-resolve-inner input[type=text] { width: 220px; padding: 0.3rem 0.5rem; border-radius: 4px; border: 1px solid var(--theme-foreground-faint); background: var(--theme-background); font-family: inherit; font-size: 0.875rem; } +.dec-resolve-actions { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.25rem; } +.dec-resolve-actions button { padding: 0.35rem 0.9rem; border-radius: 4px; border: none; background: steelblue; color: white; cursor: pointer; font-size: 0.875rem; } +.dec-resolve-actions button:disabled { opacity: 0.5; cursor: default; } +.r-msg { font-size: 0.8rem; color: #b45309; } From 9965349135623f6d1ff9ad2ad4613b44f5ca86c8 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 25 Feb 2026 09:47:52 +0100 Subject: [PATCH 016/198] Dashboard decisions: stable form inputs + copy to clipboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polling fix: - Blocking decisions now use a Mutable (blockingDecisions) + refreshDecisions() instead of deriving from summary.blocking_decisions - Cards only re-render on initial page load or after a successful resolve, not on every 15 s summary poll β€” so typing in the form is never interrupted - On successful resolve, refreshDecisions() re-fetches the list; the resolved decision no longer matches the open/escalated filter so it disappears cleanly Copy to clipboard: - Small "Copy" button in the card header (next to deadline/escalation badges) - Formats full decision as markdown: title, description, context, status, date - Shows "βœ“ Copied" for 1.5 s, reverts to "Copy"; shows "⚠ Failed" on error Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/index.md | 52 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/dashboard/src/index.md b/dashboard/src/index.md index a289d50..16fe14b 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -35,6 +35,18 @@ const tasks = totals.tasks ?? {}; const decisions = totals.decisions ?? {}; ``` +```js +// Blocking decisions β€” fetched once on load, refreshed only after a resolve action. +// Kept separate from the summary poll so in-progress form inputs aren't wiped every 15 s. +const blockingDecisions = Mutable([]); +const refreshDecisions = async () => { + const r = await fetch(`${API}/decisions/?decision_type=pending`).catch(() => null); + const all = r?.ok ? await r.json() : []; + blockingDecisions.value = all.filter(d => ["open", "escalated"].includes(d.status)); +}; +refreshDecisions(); +``` + ```js // Registered projects β€” milestone events tagged with registration const regsState = (async function*() { @@ -205,7 +217,9 @@ if (emptyRegistered.length > 0) { ## Blocking Decisions ```js -const blocking = summary.blocking_decisions ?? []; +// Uses blockingDecisions (Mutable) β€” only re-renders when refreshDecisions() is called, +// not on every summary poll, so in-progress form input is preserved between polls. +const blocking = blockingDecisions ?? []; if (blocking.length === 0) { display(html`

βœ“ No blocking decisions.

`); } else { @@ -216,6 +230,7 @@ if (blocking.length === 0) { ${d.escalation_note ? html`⚠ escalated` : ""} ${d.deadline ? html`Due ${new Date(d.deadline).toLocaleDateString()}` : ""} + ${d.description ? html`

${d.description}

` : ""} @@ -236,13 +251,31 @@ if (blocking.length === 0) { `; - const btn = card.querySelector(".r-submit"); - const msg = card.querySelector(".r-msg"); - const det = card.querySelector(".dec-resolve"); + // Copy to clipboard + const copyBtn = card.querySelector(".r-copy"); + copyBtn.addEventListener("click", () => { + const parts = [ + `# ${d.title}`, + "", + d.description ?? "", + d.rationale ? `\n**Context:** ${d.rationale}` : "", + d.escalation_note ? `\n**⚠ Escalated:** ${d.escalation_note}` : "", + `\n**Status:** ${d.status} | **Created:** ${new Date(d.created_at).toLocaleDateString()}`, + d.deadline ? `**Due:** ${new Date(d.deadline).toLocaleDateString()}` : "", + ].filter(Boolean).join("\n"); + navigator.clipboard.writeText(parts).then(() => { + copyBtn.textContent = "βœ“ Copied"; + setTimeout(() => { copyBtn.textContent = "Copy"; }, 1500); + }).catch(() => { copyBtn.textContent = "⚠ Failed"; setTimeout(() => { copyBtn.textContent = "Copy"; }, 2000); }); + }); + + // Resolve + const btn = card.querySelector(".r-submit"); + const msg = card.querySelector(".r-msg"); btn.addEventListener("click", async () => { - const rationale = card.querySelector(".r-text").value.trim(); - const decidedBy = card.querySelector(".r-by").value.trim() || "Bernd"; + const rationale = card.querySelector(".r-text").value.trim(); + const decidedBy = card.querySelector(".r-by").value.trim() || "Bernd"; if (!rationale) { msg.textContent = "⚠ Please enter a rationale."; return; } btn.disabled = true; btn.textContent = "Saving…"; try { @@ -252,10 +285,7 @@ if (blocking.length === 0) { body: JSON.stringify({rationale, decided_by: decidedBy}), }); if (r.ok) { - det.open = false; - det.querySelector("summary").textContent = "βœ“ Resolved β€” DECISIONS.md written, updates in next poll"; - det.querySelector("summary").style.color = "green"; - card.style.opacity = "0.55"; + await refreshDecisions(); // re-fetches list β€” resolved decision won't appear } else { const err = await r.json().catch(() => ({})); msg.textContent = `Error ${r.status}: ${err.detail ?? "unknown"}`; @@ -326,4 +356,6 @@ display(Inputs.table((summary.recent_progress ?? []).map(e => ({ .dec-resolve-actions button { padding: 0.35rem 0.9rem; border-radius: 4px; border: none; background: steelblue; color: white; cursor: pointer; font-size: 0.875rem; } .dec-resolve-actions button:disabled { opacity: 0.5; cursor: default; } .r-msg { font-size: 0.8rem; color: #b45309; } +.r-copy { padding: 0.15rem 0.55rem; border-radius: 3px; border: 1px solid var(--theme-foreground-faint); background: var(--theme-background); color: var(--theme-foreground-muted); cursor: pointer; font-size: 0.75rem; } +.r-copy:hover { background: var(--theme-background-alt); } From f34b49ebde3c33b47fb47487ad7bd45a185dbe69 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 25 Feb 2026 23:33:14 +0100 Subject: [PATCH 017/198] Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S0 β€” Design boundary formalised across all integration surfaces: - TOOLS.md restructured with Design Boundary section, Sanctioned Write Tools, and Bootstrap-Only Tools (create_workstream, create_task) with explicit note - project_claude_md.template and railiance CLAUDE.md updated with boundary note and get_next_steps() in session start protocol - Global ~/.claude/CLAUDE.md updated accordingly S1 β€” Workstream dependency graph: - WorkstreamDependency model (directed edge, CASCADE on delete, unique pair constraint) - Alembic migration 0b547c153153; script.py.mako added (was missing) - REST API: POST/GET /workstreams/{id}/dependencies/, DELETE …/{dep_id} (hard delete) - StateSummary open_workstreams enriched with depends_on/blocks lists - MCP tools: create_dependency(), list_dependencies() - Dashboard workstreams page: Dependencies section with relationship cards - Seeded: custodian-agent-runtime β†’ llm-shared-library + phase-0-operational-baseline S2 β€” Suggesting Next Steps (sanctioned write use case #2): - GET /state/next_steps derives suggestions from recently resolved decisions (β†’ first open task in same workstream) and cleared dependencies (β†’ first todo task in now-unblocked workstream) - StateSummary.next_steps included on every summary call - MCP tool: get_next_steps() - Dashboard: "What's next?" card grid above Registered Projects Co-Authored-By: Claude Sonnet 4.6 --- api/main.py | 5 +- api/models/__init__.py | 2 + api/models/workstream_dependency.py | 45 +++++ api/routers/state.py | 189 +++++++++++++++++- api/routers/workstream_dependencies.py | 80 ++++++++ api/schemas/state.py | 23 ++- api/schemas/workstream.py | 7 + api/schemas/workstream_dependency.py | 28 +++ dashboard/src/index.md | 47 +++++ dashboard/src/workstreams.md | 63 +++++- mcp_server/TOOLS.md | 70 +++++-- mcp_server/server.py | 70 +++++++ migrations/script.py.mako | 26 +++ ...b547c153153_add_workstream_dependencies.py | 45 +++++ scripts/project_claude_md.template | 13 +- 15 files changed, 678 insertions(+), 35 deletions(-) create mode 100644 api/models/workstream_dependency.py create mode 100644 api/routers/workstream_dependencies.py create mode 100644 api/schemas/workstream_dependency.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/0b547c153153_add_workstream_dependencies.py diff --git a/api/main.py b/api/main.py index e776a1c..f0949cf 100644 --- a/api/main.py +++ b/api/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from api.database import engine -from api.routers import decisions, progress, state, tasks, topics, workstreams +from api.routers import decisions, progress, state, tasks, topics, workstreams, workstream_dependencies @asynccontextmanager @@ -23,12 +23,13 @@ app = FastAPI( app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], - allow_methods=["GET", "POST", "PATCH"], + allow_methods=["GET", "POST", "PATCH", "DELETE"], allow_headers=["Content-Type"], ) app.include_router(topics.router) app.include_router(workstreams.router) +app.include_router(workstream_dependencies.router) app.include_router(tasks.router) app.include_router(decisions.router) app.include_router(progress.router) diff --git a/api/models/__init__.py b/api/models/__init__.py index 455f824..f9c2107 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -1,6 +1,7 @@ from api.models.base import Base from api.models.topic import Topic, TopicStatus, Domain from api.models.workstream import Workstream, WorkstreamStatus +from api.models.workstream_dependency import WorkstreamDependency from api.models.task import Task, TaskStatus, TaskPriority from api.models.decision import Decision, DecisionType, DecisionStatus from api.models.progress_event import ProgressEvent @@ -9,6 +10,7 @@ __all__ = [ "Base", "Topic", "TopicStatus", "Domain", "Workstream", "WorkstreamStatus", + "WorkstreamDependency", "Task", "TaskStatus", "TaskPriority", "Decision", "DecisionType", "DecisionStatus", "ProgressEvent", diff --git a/api/models/workstream_dependency.py b/api/models/workstream_dependency.py new file mode 100644 index 0000000..e0ebcd4 --- /dev/null +++ b/api/models/workstream_dependency.py @@ -0,0 +1,45 @@ +import uuid + +from sqlalchemy import ForeignKey, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class WorkstreamDependency(Base, TimestampMixin): + """Directed dependency edge: `from_workstream` depends on `to_workstream`. + + Semantics: `to_workstream` must reach a satisfactory state before + `from_workstream` can fully proceed. Hard deletes are intentional β€” + removing an edge removes a constraint, not information. + """ + + __tablename__ = "workstream_dependencies" + __table_args__ = ( + UniqueConstraint("from_workstream_id", "to_workstream_id", name="uq_ws_dep_pair"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + from_workstream_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workstreams.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + to_workstream_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workstreams.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + from_workstream: Mapped["Workstream"] = relationship( # noqa: F821 + "Workstream", foreign_keys=[from_workstream_id] + ) + to_workstream: Mapped["Workstream"] = relationship( # noqa: F821 + "Workstream", foreign_keys=[to_workstream_id] + ) diff --git a/api/routers/state.py b/api/routers/state.py index 97593b2..f217eda 100644 --- a/api/routers/state.py +++ b/api/routers/state.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse @@ -8,13 +8,15 @@ from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session, engine from api.models.decision import Decision, DecisionStatus, DecisionType from api.models.progress_event import ProgressEvent -from api.models.task import Task, TaskStatus +from api.models.task import Task, TaskPriority, TaskStatus from api.models.topic import Topic, TopicStatus from api.models.workstream import Workstream, WorkstreamStatus +from api.models.workstream_dependency import WorkstreamDependency from api.schemas.decision import DecisionRead from api.schemas.progress_event import ProgressEventRead from api.schemas.state import ( DecisionTotals, + NextStep, StateSummary, TaskTotals, Totals, @@ -23,7 +25,8 @@ from api.schemas.state import ( ) from api.schemas.task import TaskRead from api.schemas.topic import TopicWithWorkstreams -from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts +from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps +from api.schemas.workstream_dependency import WorkstreamDepStub router = APIRouter(prefix="/state", tags=["state"]) @@ -70,6 +73,53 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm ): task_per_ws.setdefault(ws_id, {})[tstat] = cnt + # Dependency graph for open workstreams + open_ws_ids = [w.id for w in open_ws] + dep_rows = [] + if open_ws_ids: + dep_result = await session.execute( + select(WorkstreamDependency).where( + (WorkstreamDependency.from_workstream_id.in_(open_ws_ids)) + | (WorkstreamDependency.to_workstream_id.in_(open_ws_ids)) + ) + ) + dep_rows = list(dep_result.scalars().all()) + + # Build a slug+title lookup for all workstreams referenced in deps + dep_ws_ids = set() + for d in dep_rows: + dep_ws_ids.add(d.from_workstream_id) + dep_ws_ids.add(d.to_workstream_id) + ws_lookup: dict = {w.id: w for w in open_ws} + extra_ids = dep_ws_ids - set(ws_lookup.keys()) + if extra_ids: + extra_rows = await session.execute( + select(Workstream).where(Workstream.id.in_(extra_ids)) + ) + for w in extra_rows.scalars(): + ws_lookup[w.id] = w + + # Index: workstream_id β†’ (depends_on stubs, blocks stubs) + dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws} + for d in dep_rows: + from_id, to_id = d.from_workstream_id, d.to_workstream_id + if from_id in dep_index and to_id in ws_lookup: + dep_index[from_id]["depends_on"].append(WorkstreamDepStub( + dep_id=d.id, + workstream_id=to_id, + workstream_slug=ws_lookup[to_id].slug, + workstream_title=ws_lookup[to_id].title, + description=d.description, + )) + if to_id in dep_index and from_id in ws_lookup: + dep_index[to_id]["blocks"].append(WorkstreamDepStub( + dep_id=d.id, + workstream_id=from_id, + workstream_slug=ws_lookup[from_id].slug, + workstream_title=ws_lookup[from_id].title, + description=d.description, + )) + # Totals β€” one GROUP BY per table topic_counts = {r[0]: r[1] for r in await session.execute( select(Topic.status, func.count()).group_by(Topic.status) @@ -115,6 +165,8 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm ), ) + next_steps = await _derive_next_steps(session) + return StateSummary( generated_at=datetime.now(tz=timezone.utc), totals=totals, @@ -122,20 +174,149 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm blocking_decisions=[DecisionRead.model_validate(d) for d in blocking], blocked_tasks=[TaskRead.model_validate(t) for t in blocked], recent_progress=[ProgressEventRead.model_validate(e) for e in recent], + next_steps=next_steps, open_workstreams=[ - WorkstreamWithTaskCounts( + WorkstreamWithDeps( **WorkstreamRead.model_validate(w).model_dump(), tasks_total=sum(task_per_ws.get(w.id, {}).values()), tasks_todo=task_per_ws.get(w.id, {}).get(TaskStatus.todo, 0), tasks_in_progress=task_per_ws.get(w.id, {}).get(TaskStatus.in_progress, 0), tasks_blocked=task_per_ws.get(w.id, {}).get(TaskStatus.blocked, 0), tasks_done=task_per_ws.get(w.id, {}).get(TaskStatus.done, 0), + depends_on=dep_index.get(w.id, {}).get("depends_on", []), + blocks=dep_index.get(w.id, {}).get("blocks", []), ) for w in open_ws ], ) +_PRIORITY_RANK = { + TaskPriority.critical: 0, + TaskPriority.high: 1, + TaskPriority.medium: 2, + TaskPriority.low: 3, +} + + +async def _derive_next_steps(session: AsyncSession) -> list[NextStep]: + """Derive contextual next-action suggestions from current hub state. + + Two signal sources: + 1. Recently resolved decisions (last 7 days) β†’ first open task in same workstream + 2. Workstreams whose every dependency is now completed β†’ first todo task in that workstream + """ + steps: list[NextStep] = [] + seen_task_ids: set = set() + + # ── Signal 1: recently resolved decisions ──────────────────────────────── + cutoff = datetime.now(tz=timezone.utc) - timedelta(days=7) + resolved_rows = await session.execute( + select(Decision) + .where(Decision.status == DecisionStatus.resolved) + .where(Decision.decided_at >= cutoff) + .where(Decision.workstream_id.isnot(None)) + .order_by(Decision.decided_at.desc()) + ) + for decision in resolved_rows.scalars().all(): + open_tasks_rows = await session.execute( + select(Task) + .where(Task.workstream_id == decision.workstream_id) + .where(Task.status.in_([TaskStatus.todo, TaskStatus.in_progress])) + ) + open_tasks = list(open_tasks_rows.scalars().all()) + if not open_tasks: + continue + task = min(open_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at)) + if task.id in seen_task_ids: + continue + ws = await session.get(Workstream, decision.workstream_id) + topic = await session.get(Topic, ws.topic_id) if ws else None + steps.append(NextStep( + type="resolved_decision", + domain=topic.domain if topic else None, + workstream_id=ws.id if ws else None, + workstream_title=ws.title if ws else None, + workstream_slug=ws.slug if ws else None, + task_id=task.id, + task_title=task.title, + message=( + f"Decision '{decision.title}' was resolved β†’ " + f"'{task.title}' is the next open task in '{ws.title if ws else '?'}'" + ), + )) + seen_task_ids.add(task.id) + + # ── Signal 2: cleared dependencies ────────────────────────────────────── + all_dep_rows = await session.execute(select(WorkstreamDependency)) + all_deps = list(all_dep_rows.scalars().all()) + + # Group from_workstream_id β†’ set of to_workstream_ids + dep_map: dict = {} + for d in all_deps: + dep_map.setdefault(d.from_workstream_id, set()).add(d.to_workstream_id) + + for from_ws_id, to_ws_ids in dep_map.items(): + # All targets must be completed + all_done = True + for to_id in to_ws_ids: + to_ws = await session.get(Workstream, to_id) + if to_ws is None or to_ws.status != WorkstreamStatus.completed: + all_done = False + break + if not all_done: + continue + + from_ws = await session.get(Workstream, from_ws_id) + if from_ws is None or from_ws.status not in (WorkstreamStatus.active, WorkstreamStatus.blocked): + continue + + todo_rows = await session.execute( + select(Task) + .where(Task.workstream_id == from_ws_id) + .where(Task.status == TaskStatus.todo) + ) + todo_tasks = list(todo_rows.scalars().all()) + if not todo_tasks: + continue + task = min(todo_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at)) + if task.id in seen_task_ids: + continue + topic = await session.get(Topic, from_ws.topic_id) + blocker_slugs = ", ".join( + (await session.get(Workstream, tid)).slug + for tid in to_ws_ids + if await session.get(Workstream, tid) + ) + steps.append(NextStep( + type="dependency_cleared", + domain=topic.domain if topic else None, + workstream_id=from_ws.id, + workstream_title=from_ws.title, + workstream_slug=from_ws.slug, + task_id=task.id, + task_title=task.title, + message=( + f"All dependencies of '{from_ws.title}' are completed ({blocker_slugs}) β†’ " + f"'{task.title}' is ready to start" + ), + )) + seen_task_ids.add(task.id) + + return steps + + +@router.get("/next_steps", response_model=list[NextStep]) +async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[NextStep]: + """Derive contextual next-action suggestions from current hub state. + + Returns suggestions based on: + - Recently resolved decisions β†’ first open task in the same workstream + - Workstreams whose every dependency workstream is now completed β†’ first todo task + """ + return await _derive_next_steps(session) + + @router.get("/health") async def health_check() -> dict: try: diff --git a/api/routers/workstream_dependencies.py b/api/routers/workstream_dependencies.py new file mode 100644 index 0000000..35433cf --- /dev/null +++ b/api/routers/workstream_dependencies.py @@ -0,0 +1,80 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.workstream import Workstream +from api.models.workstream_dependency import WorkstreamDependency +from api.schemas.workstream_dependency import WorkstreamDependencyCreate, WorkstreamDependencyRead + +router = APIRouter(prefix="/workstreams", tags=["dependencies"]) + + +@router.post( + "/{workstream_id}/dependencies/", + response_model=WorkstreamDependencyRead, + status_code=status.HTTP_201_CREATED, +) +async def create_dependency( + workstream_id: uuid.UUID, + body: WorkstreamDependencyCreate, + session: AsyncSession = Depends(get_session), +) -> WorkstreamDependency: + """Record that workstream_id depends on body.to_workstream_id.""" + if await session.get(Workstream, workstream_id) is None: + raise HTTPException(status_code=404, detail="from workstream not found") + if await session.get(Workstream, body.to_workstream_id) is None: + raise HTTPException(status_code=404, detail="to workstream not found") + if workstream_id == body.to_workstream_id: + raise HTTPException(status_code=422, detail="a workstream cannot depend on itself") + + dep = WorkstreamDependency( + from_workstream_id=workstream_id, + to_workstream_id=body.to_workstream_id, + description=body.description, + ) + session.add(dep) + await session.commit() + await session.refresh(dep) + return dep + + +@router.get( + "/{workstream_id}/dependencies/", + response_model=list[WorkstreamDependencyRead], +) +async def list_dependencies( + workstream_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> list[WorkstreamDependency]: + """Return all dependency edges touching this workstream (both directions).""" + if await session.get(Workstream, workstream_id) is None: + raise HTTPException(status_code=404, detail="workstream not found") + rows = await session.execute( + select(WorkstreamDependency).where( + (WorkstreamDependency.from_workstream_id == workstream_id) + | (WorkstreamDependency.to_workstream_id == workstream_id) + ) + ) + return list(rows.scalars().all()) + + +@router.delete( + "/{workstream_id}/dependencies/{dep_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_dependency( + workstream_id: uuid.UUID, + dep_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> None: + """Hard-delete a dependency edge. Removing a constraint is safe β€” no information is lost.""" + dep = await session.get(WorkstreamDependency, dep_id) + if dep is None: + raise HTTPException(status_code=404, detail="dependency not found") + if dep.from_workstream_id != workstream_id: + raise HTTPException(status_code=403, detail="dependency does not belong to this workstream") + await session.delete(dep) + await session.commit() diff --git a/api/schemas/state.py b/api/schemas/state.py index 66c1f53..bad27e1 100644 --- a/api/schemas/state.py +++ b/api/schemas/state.py @@ -1,3 +1,4 @@ +import uuid from datetime import datetime from pydantic import BaseModel @@ -6,7 +7,7 @@ from api.schemas.decision import DecisionRead from api.schemas.progress_event import ProgressEventRead from api.schemas.task import TaskRead from api.schemas.topic import TopicWithWorkstreams -from api.schemas.workstream import WorkstreamWithTaskCounts +from api.schemas.workstream import WorkstreamWithDeps class TopicTotals(BaseModel): @@ -48,6 +49,23 @@ class Totals(BaseModel): decisions: DecisionTotals +class NextStep(BaseModel): + """A derived suggestion pointing to where work should happen next. + + Suggestions are never persisted β€” they are computed on demand from + current hub state: recently resolved decisions, newly unblocked tasks, + cleared dependencies. + """ + type: str # unblocked_task | resolved_decision | dependency_cleared + domain: str | None = None + workstream_id: uuid.UUID | None = None + workstream_title: str | None = None + workstream_slug: str | None = None + task_id: uuid.UUID | None = None + task_title: str | None = None + message: str # plain-language explanation + + class StateSummary(BaseModel): generated_at: datetime totals: Totals @@ -55,4 +73,5 @@ class StateSummary(BaseModel): blocking_decisions: list[DecisionRead] blocked_tasks: list[TaskRead] recent_progress: list[ProgressEventRead] - open_workstreams: list[WorkstreamWithTaskCounts] + open_workstreams: list[WorkstreamWithDeps] + next_steps: list[NextStep] = [] diff --git a/api/schemas/workstream.py b/api/schemas/workstream.py index ae633bc..08058d9 100644 --- a/api/schemas/workstream.py +++ b/api/schemas/workstream.py @@ -4,6 +4,7 @@ from datetime import date, datetime from pydantic import BaseModel, ConfigDict from api.models.workstream import WorkstreamStatus +from api.schemas.workstream_dependency import WorkstreamDepStub class WorkstreamCreate(BaseModel): @@ -44,3 +45,9 @@ class WorkstreamWithTaskCounts(WorkstreamRead): tasks_in_progress: int = 0 tasks_blocked: int = 0 tasks_done: int = 0 + + +class WorkstreamWithDeps(WorkstreamWithTaskCounts): + """WorkstreamWithTaskCounts enriched with dependency graph edges.""" + depends_on: list[WorkstreamDepStub] = [] + blocks: list[WorkstreamDepStub] = [] diff --git a/api/schemas/workstream_dependency.py b/api/schemas/workstream_dependency.py new file mode 100644 index 0000000..ad6ce89 --- /dev/null +++ b/api/schemas/workstream_dependency.py @@ -0,0 +1,28 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class WorkstreamDependencyCreate(BaseModel): + to_workstream_id: uuid.UUID + description: str | None = None + + +class WorkstreamDependencyRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + from_workstream_id: uuid.UUID + to_workstream_id: uuid.UUID + description: str | None = None + created_at: datetime + updated_at: datetime + + +class WorkstreamDepStub(BaseModel): + """Minimal projection of the other end of a dependency edge.""" + dep_id: uuid.UUID + workstream_id: uuid.UUID + workstream_slug: str + workstream_title: str + description: str | None = None diff --git a/dashboard/src/index.md b/dashboard/src/index.md index 16fe14b..cd76269 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -108,6 +108,40 @@ display(html`
`); ``` +## What's next? + +```js +// next_steps comes from the summary poll (derived, never persisted) +const nextSteps = summary.next_steps ?? []; + +const typeLabel = { + resolved_decision: "Decision resolved", + dependency_cleared: "Dependency cleared", + unblocked_task: "Task unblocked", +}; +const typeBadgeClass = { + resolved_decision: "ns-badge-decision", + dependency_cleared: "ns-badge-dep", + unblocked_task: "ns-badge-task", +}; + +if (nextSteps.length === 0) { + display(html`

No actionable suggestions right now β€” all open workstreams are making progress or waiting on decisions.

`); +} else { + display(html`
${nextSteps.map(s => html` +
+
+ ${typeLabel[s.type] ?? s.type} + ${s.domain ?? "β€”"} +
+
${s.workstream_title ?? "β€”"}
+
${s.task_title ? html`β†’ ${s.task_title}` : ""}
+
${s.message}
+
+ `)}
`); +} +``` + ## Registered Projects ```js @@ -358,4 +392,17 @@ display(Inputs.table((summary.recent_progress ?? []).map(e => ({ .r-msg { font-size: 0.8rem; color: #b45309; } .r-copy { padding: 0.15rem 0.55rem; border-radius: 3px; border: 1px solid var(--theme-foreground-faint); background: var(--theme-background); color: var(--theme-foreground-muted); cursor: pointer; font-size: 0.75rem; } .r-copy:hover { background: var(--theme-background-alt); } +/* What's next */ +.ns-empty { color: gray; font-style: italic; } +.ns-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.75rem; } +.ns-card { background: var(--theme-background-alt); border-radius: 8px; padding: 0.85rem 1rem; border-left: 4px solid #555; } +.ns-card-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; } +.ns-badge { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.15rem 0.45rem; border-radius: 10px; font-weight: 600; } +.ns-badge-decision { background: #d4edda; color: #155724; } +.ns-badge-dep { background: #cce5ff; color: #004085; } +.ns-badge-task { background: #fff3cd; color: #856404; } +.ns-domain { font-size: 0.75rem; color: gray; } +.ns-ws { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.2rem; } +.ns-task { font-size: 0.85rem; margin-bottom: 0.35rem; } +.ns-msg { font-size: 0.78rem; color: #555; line-height: 1.4; } diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index 9b62e8c..2a663a5 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -8,36 +8,40 @@ const POLL = 15_000; ``` ```js -// Fetch workstreams + topics in parallel, join on topic_id β†’ domain/title +// Fetch workstreams + topics + summary (for dep graph) in parallel const wsState = (async function*() { while (true) { - let data = [], ok = false; + let data = [], openWs = [], ok = false; try { - const [rw, rt] = await Promise.all([ + const [rw, rt, rs] = await Promise.all([ fetch(`${API}/workstreams/`), fetch(`${API}/topics/`), + fetch(`${API}/state/summary`), ]); - ok = rw.ok && rt.ok; + ok = rw.ok && rt.ok && rs.ok; if (ok) { - const [wsList, topicList] = await Promise.all([rw.json(), rt.json()]); + const [wsList, topicList, summary] = await Promise.all([rw.json(), rt.json(), rs.json()]); const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); data = wsList.map(w => ({ ...w, domain: topicMap[w.topic_id]?.domain ?? "unknown", topic_title: topicMap[w.topic_id]?.title ?? "β€”", })); + // open_workstreams from summary carry depends_on / blocks lists + openWs = summary.open_workstreams ?? []; } } catch {} - yield {data, ok, ts: new Date()}; + yield {data, openWs, ok, ts: new Date()}; await new Promise(res => setTimeout(res, POLL)); } })(); ``` ```js -const data = wsState.data ?? []; -const _ok = wsState.ok ?? false; -const _ts = wsState.ts; +const data = wsState.data ?? []; +const openWs = wsState.openWs ?? []; +const _ok = wsState.ok ?? false; +const _ts = wsState.ts; ``` # Workstreams @@ -96,6 +100,47 @@ display(Plot.plot({ })); ``` +## Dependencies + +```js +// Build dep cards from the enriched open_workstreams in the summary +const wsWithDeps = openWs.filter(w => + (domainFilter === "(all)" || (data.find(d => d.id === w.id)?.domain ?? "unknown") === domainFilter) && + (statusFilter === "(all)" || w.status === statusFilter) && + (w.depends_on.length > 0 || w.blocks.length > 0) +); + +if (wsWithDeps.length === 0) { + display(html`

No dependency edges recorded for the current filter. Use create_dependency() via the MCP server to link workstreams.

`); +} else { + display(html`
${wsWithDeps.map(w => { + const depRows = w.depends_on.map(d => + html`
↳ depends on ${d.workstream_title}${d.description ? html` β€” ${d.description}` : ""}
` + ); + const blockRows = w.blocks.map(d => + html`
⊳ blocks ${d.workstream_title}${d.description ? html` β€” ${d.description}` : ""}
` + ); + return html`
+
${w.title}
+
${w.status}
+ ${depRows}${blockRows} +
`; + })}
`); +} +``` + diff --git a/mcp_server/TOOLS.md b/mcp_server/TOOLS.md index 109f4c5..6c3b830 100644 --- a/mcp_server/TOOLS.md +++ b/mcp_server/TOOLS.md @@ -1,8 +1,26 @@ # State Hub MCP β€” Tool Reference Card -Quick reference for all 12 tools and 5 resources. Read this instead of `server.py`. +Quick reference for all tools and resources. -## Query Tools (read-only) +## Design Boundary + +The State Hub is a **read model**. It observes and visualises cross-domain state +that originates in the projects themselves. + +Two write operations are permanently sanctioned: + +| Use Case | Tools | +|---|---| +| **Resolving Decisions** | `resolve_decision()` β€” decisions are cross-cutting; resolution must propagate across all domains | +| **Suggesting Next Steps** | `get_next_steps()` *(v0.2)* β€” surface what is unblocked; the domain does the work | + +All other mutate tools are **bootstrap-only**: use them during First Session Protocol +to give a freshly-registered project its initial workstream structure. +Do not use them as a substitute for formal work definition inside the domain repo. + +--- + +## Query Tools (read-only, use freely) | Tool | Key Args | When to use | |------|----------|-------------| @@ -12,18 +30,33 @@ Quick reference for all 12 tools and 5 resources. Read this instead of `server.p | `list_pending_decisions(topic_id?)` | optional filter | Decisions holding up work, sorted by deadline. | | `get_recent_progress(limit, since?)` | `limit` default 20; `since` ISO datetime | Reconstruct recent session history. | -## Mutate Tools (each auto-emits a progress_event) +--- + +## Sanctioned Write Tools | Tool | Key Args | Notes | |------|----------|-------| -| `create_workstream(topic_id, title, ...)` | `slug?` (auto-generated); `owner?`; `description?`; `due_date?` | Creates workstream under a topic. Use `get_state_summary()` to find topic IDs. | +| `record_decision(title, ...)` | `decision_type`: made/pending; `topic_id?`; `workstream_id?`; `deadline?` | Financial/legal + pending β†’ auto-escalated per constitution Β§4. At least one of topic_id/workstream_id required. | +| `resolve_decision(decision_id, rationale, decided_by)` | all required | Marks decision resolved, emits progress event, writes DECISIONS.md to project directory. | +| `add_progress_event(summary, ...)` | `event_type`: note/milestone/blocker/insight; `topic_id?`; `workstream_id?`; `task_id?`; `detail?` | Append-only log entry. **Use at session end.** | + +--- + +## Bootstrap-Only Tools + +> Use during **First Session Protocol** to give a freshly-registered project its +> initial workstream structure. Do not use for ongoing project management β€” +> formal work structure belongs in the domain repo (workplans, requirements, milestones). + +| Tool | Key Args | Notes | +|------|----------|-------| +| `create_workstream(topic_id, title, ...)` | `slug?`; `owner?`; `description?`; `due_date?` | Creates workstream under a topic. Use `get_state_summary()` to find topic IDs. | | `create_task(workstream_id, title, ...)` | `priority`: low/medium/high/critical; `assignee?`; `due_date?` | Creates task under a workstream. | | `update_task_status(task_id, status, ...)` | `status`: todo/in_progress/blocked/done/cancelled; `blocking_reason` required when blocked | | -| `record_decision(title, ...)` | `decision_type`: made/pending; `topic_id?`; `workstream_id?`; `deadline?` | Financial/legal + pending β†’ auto-escalated per constitution Β§4. At least one of topic_id/workstream_id required. | -| `resolve_decision(decision_id, rationale, decided_by)` | all required | Marks decision resolved and records who decided. | -| `add_progress_event(summary, ...)` | `event_type`: note/milestone/blocker/insight; `topic_id?`; `workstream_id?`; `task_id?`; `detail?` | Append-only log entry. **Use at session end.** | | `update_workstream_status(workstream_id, status)` | `status`: active/blocked/completed/archived | | +--- + ## Resources (URI-addressable, read-only) | URI | Returns | @@ -34,28 +67,33 @@ Quick reference for all 12 tools and 5 resources. Read this instead of `server.p | `state://decisions/blocking` | All pending decisions | | `state://tasks/blocked` | All blocked tasks | +--- + ## Domain Slugs `custodian` Β· `railiance` Β· `markitect` Β· `coulomb-social` Β· `personhood` Β· `foerster-capabilities` +--- + ## Common Patterns ```python -# New workstream: -create_workstream(topic_id="", title="My Workstream", owner="me") - -# New task: -create_task(workstream_id="", title="Do the thing", priority="high") - -# Session start ritual: +# Session start: get_state_summary() -# Session end ritual: +# Decision resolved in the hub UI or via tool: +resolve_decision(decision_id="", rationale="...", decided_by="Bernd") + +# Session end: add_progress_event( summary="...", event_type="note", # or milestone / insight / blocker topic_id="", workstream_id="", # optional - detail={"key": "value"}, # optional structured data + detail={"key": "value"}, # optional ) + +# First Session Protocol only β€” bootstrap a new project: +create_workstream(topic_id="", title="My Workstream", owner="me") +create_task(workstream_id="", title="Do the thing", priority="high") ``` diff --git a/mcp_server/server.py b/mcp_server/server.py index d096023..bae547e 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -62,6 +62,12 @@ def _patch(path: str, body: dict) -> Any: return r.json() +def _delete(path: str) -> None: + with _client() as c: + r = c.delete(path) + r.raise_for_status() + + # --------------------------------------------------------------------------- # Resources # --------------------------------------------------------------------------- @@ -398,6 +404,70 @@ def update_workstream_status(workstream_id: str, status: str) -> str: return json.dumps(ws, indent=2) +# --------------------------------------------------------------------------- +# Next-steps suggestion tool (S2.3) β€” sanctioned write use case #2 +# --------------------------------------------------------------------------- + +@mcp.tool() +def get_next_steps() -> str: + """Surface contextual next-action suggestions derived from hub state. + + Returns suggestions based on: + - Recently resolved decisions β†’ first open task in the same workstream + - Workstreams whose every dependency is now completed β†’ first todo task + + Each suggestion includes domain, workstream, task, and a plain-language + message. The hub surfaces *what* and *where* β€” the domain owns *how*. + + This is one of the two sanctioned write-side use cases of the State Hub + (the other is resolve_decision). Suggestions are derived, not persisted. + """ + return json.dumps(_get("/state/next_steps"), indent=2) + + +# --------------------------------------------------------------------------- +# Dependency graph tools (S1.4) +# --------------------------------------------------------------------------- + +@mcp.tool() +def create_dependency( + from_workstream_id: str, + to_workstream_id: str, + description: str | None = None, +) -> str: + """Record that one workstream depends on another. + + Semantics: from_workstream cannot fully proceed until to_workstream reaches + a satisfactory state. + + Args: + from_workstream_id: UUID of the workstream that has the dependency + to_workstream_id: UUID of the workstream it depends on + description: optional human-readable explanation of the dependency + """ + dep = _post(f"/workstreams/{from_workstream_id}/dependencies", { + "to_workstream_id": to_workstream_id, + "description": description, + }) + return json.dumps(dep, indent=2) + + +@mcp.tool() +def list_dependencies(workstream_id: str) -> str: + """Return all dependency edges touching a workstream (both directions). + + The response distinguishes edges where this workstream is the dependent + (depends_on) from edges where it is the blocker (blocks). + + Args: + workstream_id: UUID of the workstream to inspect + """ + edges = _get(f"/workstreams/{workstream_id}/dependencies") + depends_on = [e for e in edges if e["from_workstream_id"] == workstream_id] + blocks = [e for e in edges if e["to_workstream_id"] == workstream_id] + return json.dumps({"depends_on": depends_on, "blocks": blocks}, indent=2) + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/0b547c153153_add_workstream_dependencies.py b/migrations/versions/0b547c153153_add_workstream_dependencies.py new file mode 100644 index 0000000..ac39033 --- /dev/null +++ b/migrations/versions/0b547c153153_add_workstream_dependencies.py @@ -0,0 +1,45 @@ +"""add_workstream_dependencies + +Revision ID: 0b547c153153 +Revises: 0001 +Create Date: 2026-02-25 17:26:54.017622 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0b547c153153' +down_revision: Union[str, None] = '0001' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('workstream_dependencies', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('from_workstream_id', sa.UUID(), nullable=False), + sa.Column('to_workstream_id', sa.UUID(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['from_workstream_id'], ['workstreams.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['to_workstream_id'], ['workstreams.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('from_workstream_id', 'to_workstream_id', name='uq_ws_dep_pair') + ) + op.create_index(op.f('ix_workstream_dependencies_from_workstream_id'), 'workstream_dependencies', ['from_workstream_id'], unique=False) + op.create_index(op.f('ix_workstream_dependencies_to_workstream_id'), 'workstream_dependencies', ['to_workstream_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_workstream_dependencies_to_workstream_id'), table_name='workstream_dependencies') + op.drop_index(op.f('ix_workstream_dependencies_from_workstream_id'), table_name='workstream_dependencies') + op.drop_table('workstream_dependencies') + # ### end Alembic commands ### diff --git a/scripts/project_claude_md.template b/scripts/project_claude_md.template index e0cf2a0..73b5402 100644 --- a/scripts/project_claude_md.template +++ b/scripts/project_claude_md.template @@ -20,14 +20,23 @@ Call the tool first, then respond based on what you find. ``` cd ~/the-custodian/state-hub && make api ``` -2. Check whether this domain has any open workstreams in the summary. +2. Call `get_next_steps()` β€” surfaces contextual suggestions from recently resolved + decisions and cleared workstream dependencies. Act on these before starting new work. +3. Check whether this domain has any open workstreams in the summary. - **If workstreams exist:** review blocking decisions before starting work. - **If no workstreams exist:** follow the First Session Protocol below. **During work:** -- Use `create_task()` / `update_task_status()` to track concrete deliverables. - Use `record_decision()` for any decision that affects direction or dependencies. - Use `add_progress_event()` for notable events (milestones, blockers, insights). +- Use `resolve_decision()` to close a decision once the choice is made β€” this is one + of the two sanctioned write operations in the hub. + +> **Design boundary:** The State Hub is a *read model*. Two write operations are +> permanently sanctioned: **Resolving Decisions** and **Suggesting Next Steps** (v0.2). +> The bootstrap tools (`create_workstream`, `create_task`, `update_task_status`) are +> only for First Session Protocol. Formal work structure β€” requirements, workplans, +> milestones, tasks β€” belongs in the domain repo, not managed through the hub. **At the end of every session:** - Call `add_progress_event()` with a summary of what was accomplished or decided. From da71a1bface77cc892b3f49c011ddf3ed93395ac Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 25 Feb 2026 23:43:44 +0100 Subject: [PATCH 018/198] Dashboard: make status cards interactive links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Active Workstreams β†’ navigates to ./workstreams page - Blocking Decisions β†’ anchor-scrolls to #blocking-decisions section - Blocked Tasks β†’ click toggles inline panel showing each blocked task with workstream name and blocking reason; label toggles expand/collapse - Events Today β†’ anchor-scrolls to #recent-activity section - All cards get hover lift effect (box-shadow + 1px translateY) Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/index.md | 80 +++++++++++++++++++++++++++++++----------- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/dashboard/src/index.md b/dashboard/src/index.md index cd76269..f53b2cc 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -83,29 +83,60 @@ if (summary.error) display(html`
⚠️ ${summary.error} -
-

Active Workstreams

-

${ws.active ?? 0}

- ${ws.blocked ?? 0} blocked +const blockedTasks = summary.blocked_tasks ?? []; +const wsById = Object.fromEntries((summary.open_workstreams ?? []).map(w => [w.id, w])); +const todayCount = (summary.recent_progress ?? []).filter(e => + e.created_at?.startsWith(new Date().toISOString().slice(0, 10))).length; +const decCount = (decisions.open ?? 0) + (decisions.escalated ?? 0); + +const statusEl = html`
+ -
-

Blocking Decisions

-

${(decisions.open ?? 0) + (decisions.escalated ?? 0)}

- ${decisions.escalated ?? 0} escalated + + -
-

Blocked Tasks

-

${tasks.blocked ?? 0}

- of ${tasks.total ?? 0} total -
-
-

Events Today

-

${(summary.recent_progress ?? []).filter(e => - e.created_at?.startsWith(new Date().toISOString().slice(0,10))).length}

- last 20 shown below -
-
`); +
`; + +statusEl.querySelector('[data-toggle="blocked-panel"]').addEventListener('click', () => { + const panel = statusEl.querySelector('#blocked-panel'); + const isOpen = panel.style.display !== 'none'; + panel.style.display = isOpen ? 'none' : 'block'; + statusEl.querySelector('[data-toggle="blocked-panel"] small').textContent = + isOpen ? `of ${tasks.total ?? 0} total Β· click to expand` : `of ${tasks.total ?? 0} total Β· click to collapse`; +}); + +display(statusEl); ``` ## What's next? @@ -367,6 +398,13 @@ display(Inputs.table((summary.recent_progress ?? []).map(e => ({ .live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; } .card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; } .card.warn { border: 2px solid orange; } +.card-link { cursor: pointer; transition: box-shadow 0.15s, transform 0.1s; text-decoration: none; color: inherit; display: block; } +.card-link:hover { box-shadow: 0 3px 10px rgba(0,0,0,0.13); transform: translateY(-1px); } +.bt-list { display: flex; flex-direction: column; gap: 0.5rem; } +.bt-row { background: var(--theme-background-alt); border-radius: 6px; padding: 0.6rem 0.9rem; border-left: 3px solid #ff7043; } +.bt-meta { font-size: 0.7rem; color: gray; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 0.15rem; } +.bt-title { font-weight: 600; font-size: 0.9rem; } +.bt-reason { font-size: 0.8rem; color: #b45309; margin-top: 0.25rem; } .big-num { font-size: 2.5rem; font-weight: bold; margin: 0.25rem 0; } .warning { background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 0.75rem; } .hint-box { background: var(--theme-background-alt); border-left: 3px solid steelblue; border-radius: 4px; padding: 0.75rem 1rem; margin-top: 0.75rem; font-size: 0.9rem; } From de936acd6dabc6add7cc2b5690c82fbc943041f2 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 26 Feb 2026 00:05:58 +0100 Subject: [PATCH 019/198] Dashboard workstreams: multi-select filters that survive data polls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace single-select Domain/Status dropdowns with checkbox multi-selects - Use Inputs.form() with a custom template to lay the three filters out side by side in a card-style filter bar - Filter options are now static constants (DOMAINS, STATUSES) β€” no dependency on the polled data, so selections are never reset on refresh - Empty selection = no filter applied (show all); any checked item = include - Updated filtered computation and wsWithDeps to use filters.domain / filters.status array semantics Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/workstreams.md | 43 +++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index 2a663a5..7f17e45 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -56,19 +56,32 @@ display(html`
``` ```js -const domainOpts = ["(all)", ...new Set(data.map(w => w.domain))].sort(); -const statusOpts = ["(all)", "active", "blocked", "completed", "archived"]; +// Static options β€” no dependency on `data`, so selections survive polls +const DOMAINS = ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"]; +const STATUSES = ["active", "blocked", "completed", "archived"]; -const domainFilter = view(Inputs.select(domainOpts, {label: "Domain"})); -const statusFilter = view(Inputs.select(statusOpts, {label: "Status"})); -const ownerFilter = view(Inputs.text({label: "Owner contains"})); +const filters = view(Inputs.form( + { + domain: Inputs.checkbox(DOMAINS, {label: "Domain"}), + status: Inputs.checkbox(STATUSES, {label: "Status"}), + owner: Inputs.text({label: "Owner contains", placeholder: "filter by owner…"}), + }, + { + template: ({domain, status, owner}) => html`
+
${domain}
+
${status}
+
${owner}
+
`, + } +)); ``` ```js +// Empty array = no filter applied (show all) const filtered = data.filter(w => - (domainFilter === "(all)" || w.domain === domainFilter) && - (statusFilter === "(all)" || w.status === statusFilter) && - (!ownerFilter || (w.owner ?? "").toLowerCase().includes(ownerFilter.toLowerCase())) + (filters.domain.length === 0 || filters.domain.includes(w.domain)) && + (filters.status.length === 0 || filters.status.includes(w.status)) && + (!filters.owner || (w.owner ?? "").toLowerCase().includes(filters.owner.toLowerCase())) ); display(Inputs.table(filtered.map(w => ({ @@ -104,11 +117,12 @@ display(Plot.plot({ ```js // Build dep cards from the enriched open_workstreams in the summary -const wsWithDeps = openWs.filter(w => - (domainFilter === "(all)" || (data.find(d => d.id === w.id)?.domain ?? "unknown") === domainFilter) && - (statusFilter === "(all)" || w.status === statusFilter) && - (w.depends_on.length > 0 || w.blocks.length > 0) -); +const wsWithDeps = openWs.filter(w => { + const domain = data.find(d => d.id === w.id)?.domain ?? "unknown"; + return (filters.domain.length === 0 || filters.domain.includes(domain)) && + (filters.status.length === 0 || filters.status.includes(w.status)) && + (w.depends_on.length > 0 || w.blocks.length > 0); +}); if (wsWithDeps.length === 0) { display(html`

No dependency edges recorded for the current filter. Use create_dependency() via the MCP server to link workstreams.

`); @@ -132,6 +146,9 @@ if (wsWithDeps.length === 0) { From 02b2542a2a22c070a5883dbdfb3eb4312a5c2d4f Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 26 Feb 2026 07:22:25 +0100 Subject: [PATCH 022/198] dashboard: cumulative decisions chart with flexible period selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace static per-month bar chart with cumulative step-area chart - Period selector: day / week / month / quarter / YTD / year / all - Time resolution adapts to period: day β†’ hours, week β†’ days, month β†’ weeks, quarter/YTD/year/all β†’ months - Chart respects the type/status/search filter (uses filtered, not data) - Chart and period selector appear before the filter form and list - Use Generators.input() to decouple filter form creation from its display position; display(_filtersForm) renders it below the chart - Dots on chart mark buckets where decisions occurred; tip shows delta - "all" period derives start from earliest decision in filtered set Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/decisions.md | 211 +++++++++++++++++++++++++++++-------- 1 file changed, 167 insertions(+), 44 deletions(-) diff --git a/dashboard/src/decisions.md b/dashboard/src/decisions.md index 4b56a4a..d58d329 100644 --- a/dashboard/src/decisions.md +++ b/dashboard/src/decisions.md @@ -49,23 +49,13 @@ const _ok = decState.ok ?? false; const _ts = decState.ts; ``` -# Decisions - -```js -display(html`
- ● - ${_ok - ? `Live Β· updated ${_ts?.toLocaleTimeString()}` - : `Offline β€” run: make api`} -
`); -``` - ```js import {MultiSelect} from "./components/multiselect.js"; -const filters = view(Inputs.form( +// Create filter form without displaying β€” displayed below the chart via display(_filtersForm) +const _filtersForm = Inputs.form( { - type: MultiSelect(["pending", "made"], {label: "Type", placeholder: "All types"}), + type: MultiSelect(["pending", "made"], {label: "Type", placeholder: "All types"}), status: MultiSelect(["open", "escalated", "resolved", "superseded"], {label: "Status", placeholder: "All statuses"}), search: Inputs.text({placeholder: "Search title…", style: "width:160px"}), }, @@ -75,7 +65,12 @@ const filters = view(Inputs.form(
`, } -)); +); +``` + +```js +// Reactive value from the form without displaying it +const filters = Generators.input(_filtersForm); ``` ```js @@ -96,7 +91,165 @@ function fmtDate(iso) { function isOverdue(iso) { return iso && new Date(iso) < new Date(); } +``` +# Decisions + +```js +display(html`
+ ● + ${_ok + ? `Live Β· updated ${_ts?.toLocaleTimeString()}` + : `Offline β€” run: make api`} +
`); +``` + +## Resolution History + +```js +const period = view(Inputs.radio( + ["day", "week", "month", "quarter", "YTD", "year", "all"], + {value: "month", label: "Period"} +)); +``` + +```js +import * as Plot from "npm:@observablehq/plot"; + +// Returns the most meaningful timestamp for a decision +function _getTs(d) { + return new Date(d.decided_at ?? d.created_at); +} + +// Map a timestamp to the start-of-bucket timestamp (as ms) +function _bucketKey(t, unit, start) { + switch (unit) { + case "hour": return new Date(t.getFullYear(), t.getMonth(), t.getDate(), t.getHours()).getTime(); + case "day": return new Date(t.getFullYear(), t.getMonth(), t.getDate()).getTime(); + case "week": { + const w = Math.floor((t - start) / (7 * 864e5)); + return start.getTime() + w * 7 * 864e5; + } + case "month": return new Date(t.getFullYear(), t.getMonth(), 1).getTime(); + } +} + +// Generate all bucket start timestamps from start to end (inclusive) +function _genBuckets(start, end, unit) { + const bkts = []; + let cur = new Date(start); + while (cur <= end) { + bkts.push(cur.getTime()); + if (unit === "hour") cur = new Date(cur.getTime() + 36e5); + else if (unit === "day") cur = new Date(cur.getTime() + 864e5); + else if (unit === "week") cur = new Date(cur.getTime() + 7 * 864e5); + else if (unit === "month") cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1); + } + return bkts; +} + +// Derive window + bucket config from the selected period +const _now = new Date(); +const _y = _now.getFullYear(); +const _mo = _now.getMonth(); + +let _start, _unit, _tickFmt; +switch (period) { + case "day": + _start = new Date(_y, _mo, _now.getDate()); + _unit = "hour"; + _tickFmt = d => `${String(d.getHours()).padStart(2, "0")}:00`; + break; + case "week": { + const _7ago = new Date(_now - 7 * 864e5); + _start = new Date(_7ago.getFullYear(), _7ago.getMonth(), _7ago.getDate()); + _unit = "day"; + _tickFmt = d => d.toLocaleDateString(undefined, {weekday: "short", month: "short", day: "numeric"}); + break; + } + case "month": + _start = new Date(_y, _mo, 1); + _unit = "week"; + _tickFmt = d => `W/${d.toLocaleDateString(undefined, {month: "short", day: "numeric"})}`; + break; + case "quarter": + _start = new Date(_y, Math.floor(_mo / 3) * 3, 1); + _unit = "month"; + _tickFmt = d => d.toLocaleDateString(undefined, {month: "short"}); + break; + case "YTD": + _start = new Date(_y, 0, 1); + _unit = "month"; + _tickFmt = d => d.toLocaleDateString(undefined, {month: "short"}); + break; + case "year": { + const _52ago = new Date(_now - 365 * 864e5); + _start = new Date(_52ago.getFullYear(), _52ago.getMonth(), 1); + _unit = "month"; + _tickFmt = d => d.toLocaleDateString(undefined, {month: "short", year: "2-digit"}); + break; + } + default: { + // "all" β€” start from earliest decision in filtered set + const _minTs = filtered.length + ? Math.min(...filtered.map(d => _getTs(d))) + : _now.getTime(); + const _minD = new Date(_minTs); + _start = new Date(_minD.getFullYear(), _minD.getMonth(), 1); + _unit = "month"; + _tickFmt = d => d.toLocaleDateString(undefined, {month: "short", year: "numeric"}); + break; + } +} + +// Restrict to window (all period uses full filtered set) +const _inWindow = period === "all" + ? [...filtered] + : filtered.filter(d => { const t = _getTs(d); return t >= _start && t <= _now; }); + +// Count per bucket +const _bktKeys = _genBuckets(_start, _now, _unit); +const _cntMap = new Map(_bktKeys.map(k => [k, 0])); +for (const d of _inWindow) { + const key = _bucketKey(_getTs(d), _unit, _start); + if (_cntMap.has(key)) _cntMap.set(key, (_cntMap.get(key) || 0) + 1); +} + +// Build cumulative series +let _cum = 0; +const _chartData = _bktKeys.map(k => { + const delta = _cntMap.get(k) || 0; + _cum += delta; + return {date: new Date(k), count: _cum, delta}; +}); + +display(_inWindow.length === 0 + ? html`

No decisions in this period.

` + : Plot.plot({ + x: {type: "time", tickFormat: _tickFmt, tickRotate: -30, label: null}, + y: {grid: true, label: "Cumulative decisions"}, + marks: [ + Plot.areaY(_chartData, {x: "date", y: "count", fill: "steelblue", fillOpacity: 0.15, curve: "step-after"}), + Plot.lineY(_chartData, {x: "date", y: "count", stroke: "steelblue", strokeWidth: 2, curve: "step-after"}), + Plot.dot(_chartData.filter(d => d.delta > 0), { + x: "date", y: "count", fill: "steelblue", r: 4, tip: true, + title: d => `${_tickFmt(d.date)}\n+${d.delta} β†’ ${d.count} total`, + }), + Plot.ruleY([0]), + ], + marginBottom: 70, + width: 700, + }) +); +``` + +## Filter & List + +```js +display(_filtersForm); +``` + +```js if (filtered.length === 0) { display(html`

No decisions match the current filter.

`); } else { @@ -137,36 +290,6 @@ if (escalated.length > 0) { } ``` -## Resolution Velocity - -```js -import * as Plot from "npm:@observablehq/plot"; - -const resolved = data.filter(d => d.decided_at); -const byMonth = Object.entries( - resolved.reduce((acc, d) => { - const m = d.decided_at.slice(0, 7); - acc[m] = (acc[m] ?? 0) + 1; - return acc; - }, {}) -).map(([month, count]) => ({month, count})); - -display(byMonth.length === 0 - ? html`

No resolved decisions yet.

` - : Plot.plot({ - title: "Decisions resolved per month", - x: {label: "Month", tickRotate: -30}, - y: {label: "Count", grid: true}, - marks: [ - Plot.barY(byMonth, {x: "month", y: "count", fill: "steelblue", tip: true}), - Plot.ruleY([0]), - ], - marginBottom: 60, - width: 700, - }) -); -``` - diff --git a/dashboard/src/docs/decisions-kpi.md b/dashboard/src/docs/decisions-kpi.md new file mode 100644 index 0000000..8e56b1b --- /dev/null +++ b/dashboard/src/docs/decisions-kpi.md @@ -0,0 +1,115 @@ +--- +title: Decisions β€” Reference +--- + +# Decisions β€” KPI & Visualization Reference + +This page documents the metrics, chart, and list on the **Decisions** dashboard. All data is sourced from the State Hub API and refreshed every 15 seconds. + +--- + +## Decision Health (KPI card) + +The card in the top-right corner of the Decisions page shows two live health metrics for the decision process. + +### Avg resolve time + +The mean time elapsed from decision creation to resolution, computed across the **last five resolved decisions** (or fewer if fewer than five exist). A sample-size note (`n=3`) appears when fewer than five resolved decisions are available. + +This is the **baseline** β€” the expected time for a decision to move from open to resolved. All color thresholds below compare against this value. + +### Avg open age + +The mean age of all currently **open and escalated** decisions. This uses the full unfiltered dataset (not affected by the type/status/search filters on the page), so it always reflects the real state of the decision backlog. + +### Color coding + +The **avg open age** value is colored to signal whether the backlog is healthy: + +| Color | Meaning | +|---|---| +| **Black** | All open decisions are younger than the avg resolve time β€” backlog is on track | +| **Orange** | Mean open age is within baseline, but at least one individual decision has been open longer than the avg resolve time β€” an outlier exists | +| **Red** | Mean open age exceeds the avg resolve time β€” the whole open backlog is running behind | + +Individual decision cards also show an **orange age badge** when that specific open decision has been waiting longer than the avg resolve time. + +--- + +## Resolution History + +A cumulative step chart showing how many decisions have accumulated in the filtered set over time. + +### What "cumulative" means + +The y-axis shows the running total of decisions, not the count per bucket. The line rises at each point where a new decision was added and stays flat otherwise. A steeper slope means higher decision velocity. + +### Period selector + +The radio buttons above the chart control the time window and the time resolution of the x-axis: + +| Period | Window | X-axis buckets | +|---|---|---| +| Day | Today (midnight β†’ now) | Hours | +| Week | Last 7 days | Days | +| Month | Current calendar month | Weeks | +| Quarter | Current calendar quarter | Months | +| YTD | 1 Jan β†’ now | Months | +| Year | Rolling 12 months | Months | +| All | Earliest decision β†’ now | Months | + +### Timestamps used + +- For **resolved or made** decisions: the decision's `decided_at` timestamp is used (when it was closed). +- For **pending or open** decisions: the `created_at` timestamp is used (when it was raised). + +### Filter interaction + +The chart reflects whatever is currently selected in the **Type**, **Status**, and **Search** filters. Changing the filter updates the chart immediately. This lets you compare, for example, resolution velocity of pending vs made decisions, or open vs resolved. + +Dots on the line mark buckets where at least one decision occurred. Hovering a dot shows the count added (`+N`) and the running total. + +--- + +## Filter & List + +### Type filter + +- **pending** β€” decisions that have been raised but not yet resolved; the queue that needs attention +- **made** β€” decisions that have been resolved or superseded + +### Status filter + +| Status | Meaning | +|---|---| +| open | Pending decision, awaiting resolution | +| escalated | Requires human sign-off before any action (constitution Β§4) | +| resolved | Decision has been made and closed | +| superseded | Replaced by a later decision | + +### Search + +Filters by decision title (case-insensitive substring match). + +--- + +## Card age indicator + +Each decision card shows a compact age badge in the header row: + +- **`open Xd`** (or `Xh`, `Xw`, `Xmo`) β€” the decision has been waiting for this long with no resolution +- **`took Xd`** β€” the time elapsed from creation to resolution (for resolved/superseded decisions) + +The age badge turns **orange** when an open decision has been waiting longer than the avg resolve time baseline. This mirrors the orange state of the KPI card but scoped to the individual decision. + +--- + +## Escalation + +Decisions with an escalation note are shown with a `⚠ escalated` badge and a highlighted note inline in the card. An escalation warning box at the bottom of the filtered list summarizes all escalated decisions requiring human approval. + +Escalated decisions always appear at the top of the list regardless of deadline, per constitution Β§4. + +--- + +*Data refreshes every 15 seconds. KPI metrics use the full unfiltered dataset; chart and list reflect the active filter.* From aab8bb1bbb740e0ab9188869e5b9c65287e2af89 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 26 Feb 2026 12:07:49 +0100 Subject: [PATCH 025/198] dashboard: fix KPI sidebar to fixed top-right position - `.kpi-sidebar-outer` is now `position: fixed; top: 3.75rem; right: 1.5rem;` so the Decision Health box stays visible while scrolling - Re-adds the live indicator as a standalone cell (was accidentally dropped when the combined `decisions-header` flex layout was removed) - CSS: replaces `.decisions-header` block with `.kpi-sidebar-outer`; `.live-indicator` is now standalone (text-align right, margin-bottom) Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/decisions.md | 39 ++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/dashboard/src/decisions.md b/dashboard/src/decisions.md index 6553e11..72abba1 100644 --- a/dashboard/src/decisions.md +++ b/dashboard/src/decisions.md @@ -150,15 +150,16 @@ const _kpiBox = html`
withDocHelp(_kpiBox, "/docs/decisions-kpi"); -// ── Header: live indicator (left) + KPI box (right) ──────────────────────── -display(html`
-
- ● - ${_ok - ? `Live Β· updated ${_ts?.toLocaleTimeString()}` - : html`Offline β€” run: make api`} -
- ${_kpiBox} +// ── Fixed sidebar β€” stays in viewport top-right corner while scrolling ────── +display(html`
${_kpiBox}
`); +``` + +```js +display(html`
+ ● + ${_ok + ? `Live Β· updated ${_ts?.toLocaleTimeString()}` + : html`Offline β€” run: make api`}
`); ``` @@ -352,19 +353,21 @@ if (escalated.length > 0) { ``` diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index 5939c3b..7031b9a 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -47,12 +47,17 @@ const _ts = wsState.ts; # Workstreams ```js -display(html`
+import {injectTocTop} from "./components/toc-sidebar.js"; +import {withDocHelp} from "./components/doc-overlay.js"; + +const _liveEl = html`
● ${_ok ? `Live Β· updated ${_ts?.toLocaleTimeString()}` - : `Offline β€” run: make api`} -
`); + : html`Offline β€” run: make api`} +
`; +withDocHelp(_liveEl, "/docs/live-data"); +injectTocTop("live-indicator", _liveEl); ``` ```js @@ -145,7 +150,7 @@ if (wsWithDeps.length === 0) { ``` diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index 7031b9a..371a87a 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -150,7 +150,7 @@ if (wsWithDeps.length === 0) { ``` diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index 371a87a..e19e08b 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -67,7 +67,8 @@ import {MultiSelect} from "./components/multiselect.js"; const DOMAINS = ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"]; const STATUSES = ["active", "blocked", "completed", "archived"]; -const filters = view(Inputs.form( +// Create filter form without displaying β€” shown below the chart +const _filtersForm = Inputs.form( { domain: MultiSelect(DOMAINS, {label: "Domain", placeholder: "All domains"}), status: MultiSelect(STATUSES, {label: "Status", placeholder: "All statuses"}), @@ -79,7 +80,11 @@ const filters = view(Inputs.form(
${owner}
`, } -)); +); +``` + +```js +const filters = Generators.input(_filtersForm); ``` ```js @@ -89,15 +94,6 @@ const filtered = data.filter(w => (filters.status.length === 0 || filters.status.includes(w.status)) && (!filters.owner || (w.owner ?? "").toLowerCase().includes(filters.owner.toLowerCase())) ); - -display(Inputs.table(filtered.map(w => ({ - Title: w.title, - Domain: w.domain, - Status: w.status, - Owner: w.owner ?? "β€”", - Due: w.due_date ?? "β€”", - Updated: new Date(w.updated_at).toLocaleDateString(), -})), {rows: 20})); ``` ## Status Distribution @@ -119,6 +115,19 @@ display(Plot.plot({ })); ``` +```js +display(_filtersForm); + +display(Inputs.table(filtered.map(w => ({ + Title: w.title, + Domain: w.domain, + Status: w.status, + Owner: w.owner ?? "β€”", + Due: w.due_date ?? "β€”", + Updated: new Date(w.updated_at).toLocaleDateString(), +})), {rows: 20})); +``` + ## Dependencies ```js From a0b65ca803770c447545771f1f30eee2141395a9 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 26 Feb 2026 16:50:44 +0100 Subject: [PATCH 033/198] dashboard: add 'All Workstreams' subtitle above filtered table Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/workstreams.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index e19e08b..e3c7d02 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -115,6 +115,8 @@ display(Plot.plot({ })); ``` +## All Workstreams + ```js display(_filtersForm); From a8d2382e646e5050351caa45658cb88debfa6b5b Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 26 Feb 2026 17:06:28 +0100 Subject: [PATCH 034/198] dashboard: add 'Event Log' subtitle above filtered table on progress page Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/progress.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dashboard/src/progress.md b/dashboard/src/progress.md index deba8d9..c2bf0d3 100644 --- a/dashboard/src/progress.md +++ b/dashboard/src/progress.md @@ -79,6 +79,8 @@ display(byDay.length === 0 ); ``` +## Event Log + ```js const authorOpts = ["(all)", ...new Set(data.map(e => e.author ?? "unknown"))].sort(); const typeOpts = ["(all)", ...new Set(data.map(e => e.event_type))].sort(); From c780255eaf6d521dbcfbea9d62460460a524f413 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 26 Feb 2026 17:49:12 +0100 Subject: [PATCH 035/198] dashboard: move Open Workstreams by Domain chart to top of overview page Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/index.md | 142 ++++++++++++++++++++--------------------- 1 file changed, 71 insertions(+), 71 deletions(-) diff --git a/dashboard/src/index.md b/dashboard/src/index.md index cb7698a..94c8f00 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -85,6 +85,77 @@ injectTocTop("live-indicator", _liveEl); if (summary.error) display(html`
⚠️ ${summary.error}
`); ``` +## Open Workstreams by Domain + +```js +import * as Plot from "npm:@observablehq/plot"; + +const topicById = Object.fromEntries((summary.topics ?? []).map(t => [t.id, t.domain])); + +const openWs = (summary.open_workstreams ?? []).map(w => ({ + title: w.title, + domain: topicById[w.topic_id] ?? "unknown", + done: w.tasks_done ?? 0, + in_progress: w.tasks_in_progress ?? 0, + blocked: w.tasks_blocked ?? 0, + todo: w.tasks_todo ?? 0, + total: w.tasks_total ?? 0, +})).sort((a, b) => a.domain.localeCompare(b.domain) || a.title.localeCompare(b.title)); + +const statusOrder = ["done", "in progress", "blocked", "todo"]; +const statusColors = ["#4caf50", "steelblue", "#ff7043", "#e0e0e0"]; + +const taskRows = openWs.flatMap(w => [ + {label: w.title, domain: w.domain, status: "done", count: w.done}, + {label: w.title, domain: w.domain, status: "in progress", count: w.in_progress}, + {label: w.title, domain: w.domain, status: "blocked", count: w.blocked}, + {label: w.title, domain: w.domain, status: "todo", count: w.todo}, +]).filter(d => d.count > 0); + +// y-axis shows domain (only for the first workstream in each domain group) +const yLabels = {}; +const _seenDomains = new Set(); +for (const w of openWs) { + yLabels[w.title] = _seenDomains.has(w.domain) ? "" : w.domain; + _seenDomains.add(w.domain); +} + +if (openWs.length === 0) { + display(html`

No open workstreams.

`); +} else { + display(Plot.plot({ + y: {label: null, tickSize: 0, domain: openWs.map(w => w.title), tickFormat: t => yLabels[t] ?? ""}, + x: {label: "Tasks", grid: true}, + color: {domain: statusOrder, range: statusColors, legend: true}, + marks: [ + Plot.barX(taskRows, {y: "label", x: "count", fill: "status", tip: true}), + // Workstream title inside the bar + Plot.text(openWs.filter(w => w.total > 0), { + y: "title", x: 0, dx: 6, + text: d => d.title.length > 36 ? d.title.slice(0, 34) + "…" : d.title, + textAnchor: "start", fontSize: 10, fill: "#333", + }), + Plot.text(openWs.filter(w => w.total === 0), { + y: "title", x: 0, dx: 6, + text: d => `${d.title.length > 24 ? d.title.slice(0, 22) + "…" : d.title} β€” no tasks yet`, + textAnchor: "start", fontSize: 10, fill: "#aaa", + }), + // "done / total" label after the bar + Plot.text(openWs.filter(w => w.total > 0), { + y: "title", x: "total", + text: d => ` ${d.done}/${d.total}`, + dx: 4, textAnchor: "start", fontSize: 11, fill: "gray", + }), + Plot.ruleX([0]), + ], + marginLeft: 160, + marginRight: 70, + height: Math.max(80, openWs.length * 44 + 50), + width: 700, + })); +} +``` + ## Status ```js @@ -194,77 +265,6 @@ if (regs.length === 0) { } ``` -## Open Workstreams by Domain - -```js -import * as Plot from "npm:@observablehq/plot"; - -const topicById = Object.fromEntries((summary.topics ?? []).map(t => [t.id, t.domain])); - -const openWs = (summary.open_workstreams ?? []).map(w => ({ - title: w.title, - domain: topicById[w.topic_id] ?? "unknown", - done: w.tasks_done ?? 0, - in_progress: w.tasks_in_progress ?? 0, - blocked: w.tasks_blocked ?? 0, - todo: w.tasks_todo ?? 0, - total: w.tasks_total ?? 0, -})).sort((a, b) => a.domain.localeCompare(b.domain) || a.title.localeCompare(b.title)); - -const statusOrder = ["done", "in progress", "blocked", "todo"]; -const statusColors = ["#4caf50", "steelblue", "#ff7043", "#e0e0e0"]; - -const taskRows = openWs.flatMap(w => [ - {label: w.title, domain: w.domain, status: "done", count: w.done}, - {label: w.title, domain: w.domain, status: "in progress", count: w.in_progress}, - {label: w.title, domain: w.domain, status: "blocked", count: w.blocked}, - {label: w.title, domain: w.domain, status: "todo", count: w.todo}, -]).filter(d => d.count > 0); - -// y-axis shows domain (only for the first workstream in each domain group) -const yLabels = {}; -const _seenDomains = new Set(); -for (const w of openWs) { - yLabels[w.title] = _seenDomains.has(w.domain) ? "" : w.domain; - _seenDomains.add(w.domain); -} - -if (openWs.length === 0) { - display(html`

No open workstreams.

`); -} else { - display(Plot.plot({ - y: {label: null, tickSize: 0, domain: openWs.map(w => w.title), tickFormat: t => yLabels[t] ?? ""}, - x: {label: "Tasks", grid: true}, - color: {domain: statusOrder, range: statusColors, legend: true}, - marks: [ - Plot.barX(taskRows, {y: "label", x: "count", fill: "status", tip: true}), - // Workstream title inside the bar - Plot.text(openWs.filter(w => w.total > 0), { - y: "title", x: 0, dx: 6, - text: d => d.title.length > 36 ? d.title.slice(0, 34) + "…" : d.title, - textAnchor: "start", fontSize: 10, fill: "#333", - }), - Plot.text(openWs.filter(w => w.total === 0), { - y: "title", x: 0, dx: 6, - text: d => `${d.title.length > 24 ? d.title.slice(0, 22) + "…" : d.title} β€” no tasks yet`, - textAnchor: "start", fontSize: 10, fill: "#aaa", - }), - // "done / total" label after the bar - Plot.text(openWs.filter(w => w.total > 0), { - y: "title", x: "total", - text: d => ` ${d.done}/${d.total}`, - dx: 4, textAnchor: "start", fontSize: 11, fill: "gray", - }), - Plot.ruleX([0]), - ], - marginLeft: 160, - marginRight: 70, - height: Math.max(80, openWs.length * 44 + 50), - width: 700, - })); -} -``` - ```js // Registered domains with no workstreams yet β€” show a getting-started hint const regs = regsState ?? []; From f829bed6b2485cf68fcfb47e47a57d5016ce6a6a Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 26 Feb 2026 18:03:05 +0100 Subject: [PATCH 036/198] dashboard: add progress log documentation and ? button on page heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/docs/progress-log.md: covers append-only constitution Β§5 guarantee, event structure (all fields), standard + convention event types, session protocol, MCP and curl usage, filters, and the 30-day volume chart - progress.md: withDocHelp applied to #observablehq-main h1 β†’ ? button appears on hover over the 'Progress Log' page heading - observablehq.config.js: Progress Log added to Reference nav section Co-Authored-By: Claude Sonnet 4.6 --- dashboard/observablehq.config.js | 1 + dashboard/src/docs/progress-log.md | 113 +++++++++++++++++++++++++++++ dashboard/src/progress.md | 3 + 3 files changed, 117 insertions(+) create mode 100644 dashboard/src/docs/progress-log.md diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index cf1183c..3d994ff 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -11,6 +11,7 @@ export default { pages: [ { name: "Live Data", path: "/docs/live-data" }, { name: "Decision Health", path: "/docs/decisions-kpi" }, + { name: "Progress Log", path: "/docs/progress-log" }, ], }, ], diff --git a/dashboard/src/docs/progress-log.md b/dashboard/src/docs/progress-log.md new file mode 100644 index 0000000..0f42aef --- /dev/null +++ b/dashboard/src/docs/progress-log.md @@ -0,0 +1,113 @@ +--- +title: Progress Log β€” Reference +--- + +# Progress Log β€” Reference + +The progress event log is the episodic memory of the Custodian system. Every significant action, decision, insight, or state change is recorded here as an immutable event. The log is the single source of truth for what happened, when, and why. + +--- + +## Append-only by design + +Per constitution Β§5, progress events are **never deleted or edited**. The API exposes no DELETE endpoint for this resource. Every event is permanent. This guarantees that future sessions β€” and future humans β€” can reconstruct the full history of any project without gaps. + +--- + +## Event structure + +Each event carries: + +| Field | Type | Description | +|---|---|---| +| `summary` | string | Human-readable one-line description of what happened | +| `event_type` | string | Free-form label categorising the event (see below) | +| `author` | string | Who created the event β€” `custodian` for agent-generated events, or a human name | +| `topic_id` | UUID? | Links the event to a topic (optional) | +| `workstream_id` | UUID? | Links to a workstream (optional) | +| `task_id` | UUID? | Links to a task (optional) | +| `decision_id` | UUID? | Links to a decision (optional) | +| `detail` | JSON? | Arbitrary structured data β€” commits, counts, file paths, metrics, etc. | +| `created_at` | timestamp | Set by the server on insert; cannot be overridden | + +--- + +## Standard event types + +These types are used by the State Hub's built-in write operations: + +| Type | When emitted | +|---|---| +| `workstream_created` | A new workstream was registered | +| `workstream_status_changed` | Workstream moved to active / blocked / completed / archived | +| `task_created` | A new task was added to a workstream | +| `task_status_changed` | Task moved to todo / in_progress / blocked / done / cancelled | +| `decision_recorded` | A decision (pending or made) was recorded | +| `decision_resolved` | A pending decision was resolved | + +Any other string is valid. Common conventions: + +| Type | Meaning | +|---|---| +| `milestone` | A significant completion or threshold was reached | +| `note` | General observation or session summary | +| `blocker` | An impediment was identified | +| `insight` | A discovery that changes how work should proceed | +| `extension_point` | An EP-DOMAIN-NNN extension point was identified | + +--- + +## Session protocol + +Every Claude Code session working in this repository should: + +1. **Start** β€” call `get_state_summary()` for orientation +2. **End** β€” call `add_progress_event()` to log what was done, decided, or discovered + +A session that produces no progress events is invisible to future sessions and to Bernd. The log is how continuity is maintained across context windows. + +--- + +## Adding events + +Via the MCP server (in a Claude Code session): + +``` +add_progress_event( + summary = "What happened, in one clear sentence", + event_type = "milestone", // or note, blocker, insight, … + workstream_id = "", // link to relevant workstream (optional) + topic_id = "", // link to relevant topic (optional) + detail = { "key": "value" } // any structured data worth preserving +) +``` + +Via the REST API directly: + +```bash +curl -X POST http://127.0.0.1:8000/progress/ \ + -H "Content-Type: application/json" \ + -d '{"summary": "…", "event_type": "note", "author": "Bernd"}' +``` + +--- + +## Filters + +| Filter | Effect | +|---|---| +| **Author** | Restrict to events by a specific author (`custodian`, `Bernd`, etc.) | +| **Event type** | Restrict to one category of event | +| **Since** | Show only events after a chosen date | + +All filters are applied client-side against the last 500 events fetched from the API. The count line above the table reflects the active filter. + +--- + +## Event Volume chart + +The area chart above the log shows the number of events per day over the last 30 days, using the full unfiltered dataset. It gives a quick sense of activity rhythm β€” steeper slopes mean more active periods. + +--- + +*Append-only per constitution Β§5 β€” no deletions, no edits, ever.* diff --git a/dashboard/src/progress.md b/dashboard/src/progress.md index c2bf0d3..9587976 100644 --- a/dashboard/src/progress.md +++ b/dashboard/src/progress.md @@ -44,6 +44,9 @@ const _liveEl = html`
`; withDocHelp(_liveEl, "/docs/live-data"); injectTocTop("live-indicator", _liveEl); + +const _h1 = document.querySelector("#observablehq-main h1"); +if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/progress-log"); } ``` ## Event Volume (Last 30 Days) From a3d989bfc8a407f0f9224abbac4eb10ab5edb18c Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 26 Feb 2026 18:12:12 +0100 Subject: [PATCH 037/198] Add Decisions and Workstreams reference docs with heading help wiring - Remove residual constitution footnote from progress page header - Create src/docs/decisions.md: types, statuses, resolution history chart, filter bar, card anatomy, Decision Health KPI, escalation protocol - Create src/docs/workstreams.md: status distribution chart, filter bar, table columns, dependency graph, create/update patterns - Wire withDocHelp(h1) on Decisions and Workstreams pages pointing to new docs - Add both pages to Reference nav section in observablehq.config.js Co-Authored-By: Claude Sonnet 4.6 --- dashboard/observablehq.config.js | 2 + dashboard/src/decisions.md | 3 + dashboard/src/docs/decisions.md | 138 ++++++++++++++++++++++++++++++ dashboard/src/docs/workstreams.md | 110 ++++++++++++++++++++++++ dashboard/src/progress.md | 2 - dashboard/src/workstreams.md | 3 + 6 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 dashboard/src/docs/decisions.md create mode 100644 dashboard/src/docs/workstreams.md diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index 3d994ff..28ff4f4 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -10,6 +10,8 @@ export default { name: "Reference", pages: [ { name: "Live Data", path: "/docs/live-data" }, + { name: "Workstreams", path: "/docs/workstreams" }, + { name: "Decisions", path: "/docs/decisions" }, { name: "Decision Health", path: "/docs/decisions-kpi" }, { name: "Progress Log", path: "/docs/progress-log" }, ], diff --git a/dashboard/src/decisions.md b/dashboard/src/decisions.md index c9dba84..971ca0f 100644 --- a/dashboard/src/decisions.md +++ b/dashboard/src/decisions.md @@ -160,6 +160,9 @@ const _liveEl = html`
`; withDocHelp(_liveEl, "/docs/live-data"); +const _h1 = document.querySelector("#observablehq-main h1"); +if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/decisions"); } + // ── Inject into TOC sidebar: KPI first (prepend β†’ bottom), live last (β†’ top) ─ const _toc = document.querySelector("#observablehq-toc"); if (_toc) { diff --git a/dashboard/src/docs/decisions.md b/dashboard/src/docs/decisions.md new file mode 100644 index 0000000..a55ae35 --- /dev/null +++ b/dashboard/src/docs/decisions.md @@ -0,0 +1,138 @@ +--- +title: Decisions β€” Reference +--- + +# Decisions β€” Reference + +The Decisions page tracks every choice that matters: things pending a verdict, things already resolved, and things that have been escalated for human approval before action can proceed. + +--- + +## Decision types + +| Type | Meaning | +|---|---| +| **pending** | A question or fork that still needs an answer β€” actively blocking or influencing work | +| **made** | A resolved choice; kept for historical context and to explain why things are the way they are | + +--- + +## Decision statuses + +| Status | Border | Meaning | +|---|---|---| +| **open** | blue | Pending, no urgent flag | +| **escalated** | amber | Requires human approval before any action (constitution Β§4) | +| **resolved** | green | Decision has been made and recorded | +| **superseded** | gray | Replaced by a newer decision | + +Decisions are sorted by status (escalated β†’ open β†’ resolved β†’ superseded), then by deadline (earliest first within the same status group). + +--- + +## Resolution History chart + +An area + step chart showing cumulative decisions over time. Each dot marks a period where at least one decision was recorded or resolved. + +**Period selector** β€” choose the time window: + +| Period | Bucket | Use for | +|---|---|---| +| day | hour | Today's activity | +| week | day | Rolling 7 days | +| month | week | Current calendar month | +| quarter | month | Current quarter | +| YTD | month | Year to date | +| year | month | Rolling 12 months | +| all | month | Full history | + +The chart respects the active filter β€” changing type, status, or search narrows the data plotted. + +--- + +## Filter bar + +| Filter | Effect | +|---|---| +| **Type** | Show only `pending` or `made` decisions | +| **Status** | Show one or more of open / escalated / resolved / superseded | +| **Search** | Case-insensitive substring match on the decision title | + +All filters are applied client-side on the last 500 decisions fetched from the API. + +--- + +## Decision cards + +Each card shows: + +- **Type badge** β€” amber `pending` or indigo `made` +- **Status badge** β€” colour-coded (see table above) +- **Domain** β€” which of the six tracked domains this decision belongs to (if linked to a topic) +- **Due date** β€” shown in red with ⚠ if the deadline has passed +- **Age badge** β€” `open Xd` for unresolved decisions; `took Xd` for resolved ones. Turns amber when an open decision has been open longer than the current average resolution time. +- **Created date** β€” when the decision was first recorded +- **Title** β€” the decision question or statement +- **Description / rationale** β€” first 200 characters, truncated with `…` +- **Resolved by** β€” who resolved it and when (resolved decisions only) +- **Escalation note** β€” amber panel explaining why human approval is required (escalated decisions only) + +--- + +## Decision Health card + +The **Decision Health** widget in the right margin shows two KPIs computed from the full (unfiltered) dataset: + +| KPI | How it's calculated | +|---|---| +| **avg resolve** | Mean time from creation to resolution, computed over the last 5 resolved decisions | +| **avg open age** | Mean age of all currently open or escalated decisions | + +**Color coding for avg open age:** + +| Color | Condition | +|---|---| +| black (inherit) | All open decisions younger than the avg resolve time | +| amber | At least one open decision older than the avg resolve time | +| red | Mean open age exceeds the avg resolve time | + +--- + +## Escalation + +Any `pending` decision whose title or rationale contains financial or legal keywords is automatically escalated by the API at creation time. Escalated decisions: + +- Sort to the top of the list +- Carry an amber escalation note explaining the reason +- Trigger the warning box at the bottom of the page listing all escalated items +- **Must be reviewed and approved by Bernd before any related action is taken (constitution Β§4)** + +To clear an escalation, resolve the decision via `resolve_decision()` in the MCP server or the REST API. + +--- + +## Adding decisions + +Via MCP: + +``` +record_decision( + title = "Should we use Redis or Postgres for session storage?", + decision_type = "pending", + workstream_id = "", + rationale = "Postgres is already in the stack; Redis adds a dependency", + deadline = "2026-03-01" +) +``` + +Via REST: + +```bash +curl -X POST http://127.0.0.1:8000/decisions/ \ + -H "Content-Type: application/json" \ + -d '{"title": "…", "decision_type": "pending", "workstream_id": ""}' +``` + +--- + +*Decisions are never deleted β€” only resolved or superseded (constitution Β§5).* diff --git a/dashboard/src/docs/workstreams.md b/dashboard/src/docs/workstreams.md new file mode 100644 index 0000000..4e3920d --- /dev/null +++ b/dashboard/src/docs/workstreams.md @@ -0,0 +1,110 @@ +--- +title: Workstreams β€” Reference +--- + +# Workstreams β€” Reference + +A workstream is a bounded unit of work within a topic. It carries a status, an optional owner and due date, and belongs to exactly one of the six project domains. The Workstreams page gives you a filtered, visual overview of all active work and the dependency graph between workstreams. + +--- + +## Status Distribution chart + +A horizontal bar chart showing the count of workstreams in each status for the current filter selection. Updates immediately as filters change. + +| Status | Meaning | +|---|---| +| **active** | Work in progress or ready to start | +| **blocked** | Waiting on something outside the workstream β€” see Dependencies | +| **completed** | Done | +| **archived** | Closed without completion; no longer relevant | + +--- + +## Filter bar + +| Filter | Effect | +|---|---| +| **Domain** | Multi-select β€” show only workstreams from selected domains | +| **Status** | Multi-select β€” show only workstreams with selected statuses | +| **Owner** | Text substring match on the owner field (case-insensitive) | + +Leaving a filter empty means "show all". All three filters combine with AND logic. Filters persist across polls β€” selections are not lost when the page refreshes live data. + +The six domains are: `custodian`, `railiance`, `markitect`, `coulomb_social`, `personhood`, `foerster_capabilities`. + +--- + +## All Workstreams table + +| Column | Source | +|---|---| +| Title | Workstream title | +| Domain | Derived from the parent topic | +| Status | Current workstream status | +| Owner | Assigned person (or `β€”` if unset) | +| Due | Target completion date (or `β€”`) | +| Updated | Last modification timestamp | + +Up to 20 rows displayed; paginate for more. + +--- + +## Dependencies + +The Dependencies section shows workstreams that have at least one `depends_on` or `blocks` relationship. Each card displays: + +- **Workstream title** and current status badge +- **↳ depends on** β€” workstreams that must complete before this one can proceed +- **⊳ blocks** β€” workstreams that are waiting on this one + +Dependencies are created via the MCP server: + +``` +create_dependency( + from_workstream_id = "", # the one that depends + to_workstream_id = "", # the prerequisite + description = "needs auth before API can be built" +) +``` + +If no dependency edges exist for the current filter, the section shows an empty-state message. + +--- + +## Creating workstreams + +Via MCP: + +``` +create_workstream( + topic_id = "", + title = "Build user authentication", + description = "JWT-based auth, refresh tokens, middleware", + status = "active", + owner = "Bernd", + due_date = "2026-04-01" +) +``` + +Via REST: + +```bash +curl -X POST http://127.0.0.1:8000/workstreams/ \ + -H "Content-Type: application/json" \ + -d '{"topic_id": "", "title": "…", "status": "active"}' +``` + +--- + +## Updating workstream status + +``` +update_workstream_status(workstream_id="", status="completed") +``` + +Valid transitions: `active` β†’ `blocked` / `completed` / `archived`; `blocked` β†’ `active`; `completed` β†’ `archived`. + +--- + +*Workstreams are never hard-deleted β€” use `archived` to close them without losing history.* diff --git a/dashboard/src/progress.md b/dashboard/src/progress.md index 9587976..3b5e5ed 100644 --- a/dashboard/src/progress.md +++ b/dashboard/src/progress.md @@ -30,8 +30,6 @@ const _ts = progState.ts; # Progress Log -*Append-only per constitution Β§5 β€” no deletions.* - ```js import {injectTocTop} from "./components/toc-sidebar.js"; import {withDocHelp} from "./components/doc-overlay.js"; diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index e3c7d02..20856a7 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -58,6 +58,9 @@ const _liveEl = html`
`; withDocHelp(_liveEl, "/docs/live-data"); injectTocTop("live-indicator", _liveEl); + +const _h1 = document.querySelector("#observablehq-main h1"); +if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/workstreams"); } ``` ```js From 8decb6a4dff218659cf6bd2c60c66dd605344555 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 27 Feb 2026 00:03:27 +0100 Subject: [PATCH 038/198] Implement Workstream Health Index (WHI) KPI card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a live WHI card to the Workstreams page TOC sidebar. All six base metrics from the spec (workstream-kpi.md) computed client-side from existing data β€” no API or schema changes required. Computation (workstreams.md): - DD: dependency edges / open workstreams (normalised at DD_crit=1.0) - BR: blocked workstreams / open workstreams - SPR: max inbound deps on one incomplete workstream / open count - PEP: active workstreams with all deps completed / open count - CDDR: cross-domain edges / total edges - CPI: DFS cycle detection (back-edge = 1, halves WHI as hard penalty) - WHI = 0.30(1-DDnorm) + 0.25(1-BR) + 0.15(1-SPR) + 0.20Β·PEP + 0.10(1-CDDR) - Per-domain breakdown using intra-domain edges only Card UI: global WHI % with green/orange/red health label, sub-metric rows with per-spec warning thresholds, cycle alert panel, per-domain breakdown rows with coloured dots. Also add src/docs/workstream-health-index.md reference page (formula, thresholds, improvement guidance) and wire ? button on the card. Add "Workstream Health" to Reference nav. Co-Authored-By: Claude Sonnet 4.6 --- dashboard/observablehq.config.js | 1 + dashboard/src/docs/workstream-health-index.md | 161 +++++++++++++++++ dashboard/src/workstreams.md | 166 ++++++++++++++++++ 3 files changed, 328 insertions(+) create mode 100644 dashboard/src/docs/workstream-health-index.md diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index 28ff4f4..54ade8e 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -11,6 +11,7 @@ export default { pages: [ { name: "Live Data", path: "/docs/live-data" }, { name: "Workstreams", path: "/docs/workstreams" }, + { name: "Workstream Health", path: "/docs/workstream-health-index" }, { name: "Decisions", path: "/docs/decisions" }, { name: "Decision Health", path: "/docs/decisions-kpi" }, { name: "Progress Log", path: "/docs/progress-log" }, diff --git a/dashboard/src/docs/workstream-health-index.md b/dashboard/src/docs/workstream-health-index.md new file mode 100644 index 0000000..dca138b --- /dev/null +++ b/dashboard/src/docs/workstream-health-index.md @@ -0,0 +1,161 @@ +--- +title: Workstream Health Index β€” Reference +--- + +# Workstream Health Index (WHI) + +The **Workstream Health Index** is a composite score in the range [0, 1] that measures how well the workstream network is structured for parallel execution and stable progress. It is displayed as a live KPI card in the right margin of the Workstreams page and recomputes on every poll (every 15 seconds). + +**1.0 = ideal independence Β· 0.0 = severe systemic dysfunction** + +--- + +## Health states + +| Score | Color | Label | Meaning | +|---|---|---|---| +| β‰₯ 0.75 | 🟒 green | Healthy | Parallel execution effective, delays localized | +| 0.50 – 0.74 | 🟠 orange | Optimizable | Noticeable coordination cost; review decomposition | +| < 0.50 | πŸ”΄ red | Critical | Serial execution dominates; immediate replanning required | + +--- + +## The six base metrics + +### DD β€” Dependency Density + +``` +DD = total dependency edges / (active + blocked workstreams) +``` + +Measures structural coupling. Low DD means independent, parallelizable work. Completed and archived workstreams are excluded β€” they no longer constrain progress. + +| DD | Warning | +|---|---| +| > 1.0 | πŸ”΄ red β€” more than one dependency per workstream on average | +| 0.5 – 1.0 | 🟠 orange | +| ≀ 0.5 | ok | + +--- + +### BR β€” Blocked Ratio + +``` +BR = blocked workstreams / (active + blocked workstreams) +``` + +Measures immediate operational impact. BR β‰ˆ 0 means flow is unobstructed. + +| BR | Warning | +|---|---| +| > 40% | πŸ”΄ red | +| 20–40% | 🟠 orange | +| ≀ 20% | ok | + +--- + +### SPR β€” Single-Point Risk + +``` +SPR = max dependents on one incomplete workstream / (active + blocked) +``` + +Detects concentration of blocking power. High SPR means one delay propagates widely β€” a structural SPOF. + +| SPR | Warning | +|---|---| +| > 40% | πŸ”΄ red | +| 25–40% | 🟠 orange | +| ≀ 25% | ok | + +--- + +### PEP β€” Parallel Execution Potential + +``` +PEP = active workstreams with all deps completed / (active + blocked) +``` + +Estimates how much work can proceed right now. A workstream is eligible if its status is `active` and every workstream it depends on has reached `completed` or `archived`. + +| PEP | Warning | +|---|---| +| < 30% | πŸ”΄ red | +| 30–60% | 🟠 orange | +| β‰₯ 60% | ok | + +--- + +### CDDR β€” Cross-Domain Dependency Ratio + +``` +CDDR = dependency edges crossing domain boundaries / total edges +``` + +Measures architectural entanglement. High CDDR indicates loss of modularity across the six project domains. + +| CDDR | Warning | +|---|---| +| > 40% | 🟠 orange | + +--- + +### CPI β€” Cycle Presence Indicator + +``` +CPI = 0 β†’ no cycles +CPI = 1 β†’ at least one circular dependency detected +``` + +Detected via DFS with inStack colouring. Any cycle means no feasible execution order exists β€” a structural deadlock. When CPI = 1, the final WHI score is **halved** as a hard penalty. + +--- + +## Aggregation formula + +``` +DDnorm = min(1, DD / 1.0) ← saturates at DD_critical = 1.0 + +WHI = 0.30 Γ— (1 βˆ’ DDnorm) + + 0.25 Γ— (1 βˆ’ BR) + + 0.15 Γ— (1 βˆ’ SPR) + + 0.20 Γ— PEP + + 0.10 Γ— (1 βˆ’ CDDR) + +if CPI = 1: WHI = WHI Γ— 0.5 +``` + +Result is clamped to [0, 1]. + +--- + +## Domain breakdown + +The card also shows a per-domain WHI computed using **intra-domain workstreams and intra-domain edges only**. This measures each domain's internal autonomy β€” how well its workstreams are decomposed relative to each other, independent of cross-domain dependencies. + +A domain with WHI = 100% is fully self-contained and parallelizable internally. Its global contribution to the program-level WHI may still be reduced by cross-domain dependencies (captured in CDDR). + +The domain breakdown is shown when at least two domains have active workstreams. + +--- + +## How to improve a poor score + +| Symptom | Action | +|---|---| +| High DD | Decompose tightly coupled workstreams; remove unnecessary dependencies | +| High BR | Unblock workstreams β€” resolve the blocking condition, or mark dependency as completed if done | +| High SPR | Split the bottleneck workstream into independent deliverables | +| Low PEP | Complete prerequisite workstreams or re-sequence work | +| High CDDR | Refactor cross-domain dependencies into shared contracts or invert the dependency | +| CPI = 1 | Find and break the cycle β€” identify which dependency edge is incorrect and remove it | + +--- + +## What WHI is not + +WHI measures **structural health of the work graph** β€” not individual performance, not velocity, not burn-down rate. A team can be moving fast with a poor WHI (serially but quickly), or slowly with a perfect WHI (fully parallel but under-resourced). Use WHI alongside velocity metrics, not instead of them. + +--- + +*Specification: `state-hub/dashboard/src/docs/workstream-kpi.md`* diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index 20856a7..b564998 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -44,12 +44,98 @@ const _ok = wsState.ok ?? false; const _ts = wsState.ts; ``` +```js +// ── Workstream Health Index (WHI) ──────────────────────────────────────────── +const _idToDomain = Object.fromEntries(data.map(w => [w.id, w.domain ?? "unknown"])); +const _completedIds = new Set(data.filter(w => w.status === "completed" || w.status === "archived").map(w => w.id)); +const _openCount = openWs.length; +const _allEdges = openWs.flatMap(w => w.depends_on.map(d => ({from: w.id, to: d.workstream_id}))); +const _totalEdges = _allEdges.length; + +// Dependency Density +const _DD = _openCount > 0 ? _totalEdges / _openCount : 0; + +// Blocked Ratio +const _BR = _openCount > 0 ? openWs.filter(w => w.status === "blocked").length / _openCount : 0; + +// Single-Point Risk β€” max inbound edges on one incomplete workstream +const _inbound = {}; +for (const e of _allEdges) { + if (!_completedIds.has(e.to)) _inbound[e.to] = (_inbound[e.to] ?? 0) + 1; +} +const _SPR = _openCount > 0 + ? (Object.keys(_inbound).length > 0 ? Math.max(...Object.values(_inbound)) : 0) / _openCount + : 0; + +// Parallel Execution Potential β€” active workstreams with all deps completed +const _PEP = _openCount > 0 + ? openWs.filter(w => w.status === "active" && w.depends_on.every(d => _completedIds.has(d.workstream_id))).length / _openCount + : 0; + +// Cross-Domain Dependency Ratio +const _crossEdges = _allEdges.filter(e => (_idToDomain[e.from] ?? "?") !== (_idToDomain[e.to] ?? "?")).length; +const _CDDR = _totalEdges > 0 ? _crossEdges / _totalEdges : 0; + +// Cycle Presence Indicator β€” DFS with visited/inStack colouring +function _detectCycle(nodes, edges) { + const adj = Object.fromEntries(nodes.map(n => [n.id, []])); + for (const e of edges) { if (adj[e.from] !== undefined) adj[e.from].push(e.to); } + const visited = new Set(), inStack = new Set(); + function dfs(id) { + if (inStack.has(id)) return true; + if (visited.has(id)) return false; + visited.add(id); inStack.add(id); + for (const nx of (adj[id] ?? [])) { if (dfs(nx)) return true; } + inStack.delete(id); + return false; + } + for (const n of nodes) { if (!visited.has(n.id) && dfs(n.id)) return 1; } + return 0; +} +const _CPI = _detectCycle(openWs, _allEdges); + +// WHI aggregation β€” DD normalised at DD_critical = 1.0, CPI halves the score +const _DDnorm = Math.min(1, _DD / 1.0); +let _WHI = 0.30*(1 - _DDnorm) + 0.25*(1 - _BR) + 0.15*(1 - _SPR) + 0.20*_PEP + 0.10*(1 - _CDDR); +if (_CPI === 1) _WHI *= 0.5; +_WHI = Math.max(0, Math.min(1, _WHI)); + +// Per-domain breakdown β€” intra-domain edges only (measures domain autonomy) +const _domainBreakdown = [...new Set(openWs.map(w => _idToDomain[w.id] ?? "unknown"))].sort().map(domain => { + const nodes = openWs.filter(w => (_idToDomain[w.id] ?? "unknown") === domain); + const edges = nodes.flatMap(w => + w.depends_on + .filter(d => (_idToDomain[d.workstream_id] ?? "unknown") === domain) + .map(d => ({from: w.id, to: d.workstream_id})) + ); + const oc = nodes.length; + if (oc === 0) return null; + const te = edges.length; + const dd = oc > 0 ? te / oc : 0; + const br = oc > 0 ? nodes.filter(w => w.status === "blocked").length / oc : 0; + const pep = oc > 0 ? nodes.filter(w => { + if (w.status !== "active") return false; + const intraDeps = w.depends_on.filter(d => (_idToDomain[d.workstream_id] ?? "unknown") === domain); + return intraDeps.every(d => _completedIds.has(d.workstream_id)); + }).length / oc : 0; + const inb = {}; + for (const e of edges) inb[e.to] = (inb[e.to] ?? 0) + 1; + const spr = oc > 0 ? (Object.keys(inb).length > 0 ? Math.max(...Object.values(inb)) : 0) / oc : 0; + const cpi = _detectCycle(nodes, edges); + const ddN = Math.min(1, dd / 1.0); + let whi = 0.30*(1 - ddN) + 0.25*(1 - br) + 0.15*(1 - spr) + 0.20*pep + 0.10; // CDDR=0 within domain + if (cpi === 1) whi *= 0.5; + return {domain, whi: Math.max(0, Math.min(1, whi)), br, pep, cpi, openCount: oc}; +}).filter(Boolean); +``` + # Workstreams ```js import {injectTocTop} from "./components/toc-sidebar.js"; import {withDocHelp} from "./components/doc-overlay.js"; +// ── Live indicator ──────────────────────────────────────────────────────────── const _liveEl = html`
● ${_ok @@ -57,6 +143,63 @@ const _liveEl = html`
: html`Offline β€” run: make api`}
`; withDocHelp(_liveEl, "/docs/live-data"); + +// ── WHI card ────────────────────────────────────────────────────────────────── +function _whiColor(v) { return v >= 0.75 ? "#16a34a" : v >= 0.50 ? "#d97706" : "#dc2626"; } +function _whiLabel(v) { return v >= 0.75 ? "Healthy" : v >= 0.50 ? "Optimizable" : "Critical"; } +function _warnLevel(name, val) { + if (name === "PEP") return val < 0.30 ? 2 : val < 0.60 ? 1 : 0; + if (name === "DD") return val > 1.0 ? 2 : val > 0.50 ? 1 : 0; + if (name === "BR") return val > 0.40 ? 2 : val > 0.20 ? 1 : 0; + if (name === "SPR") return val > 0.40 ? 2 : val > 0.25 ? 1 : 0; + if (name === "CDDR") return val > 0.40 ? 1 : 0; + return 0; +} +function _warnColor(lv) { return lv === 2 ? "#dc2626" : lv === 1 ? "#d97706" : "var(--theme-foreground-muted, #666)"; } + +const _whiMetrics = [ + {name: "DD", val: _DD, fmt: v => v.toFixed(2)}, + {name: "BR", val: _BR, fmt: v => (v*100).toFixed(0)+"%"}, + {name: "SPR", val: _SPR, fmt: v => (v*100).toFixed(0)+"%"}, + {name: "PEP", val: _PEP, fmt: v => (v*100).toFixed(0)+"%"}, + {name: "CDDR", val: _CDDR, fmt: v => (v*100).toFixed(0)+"%"}, +]; + +const _whiBox = html`
+
Workstream Health
+ ${_openCount === 0 + ? html`
No active workstreams
` + : html` +
+ ${(_WHI*100).toFixed(0)}% + ${_whiLabel(_WHI)} +
+ ${_CPI === 1 ? html`
⚠ Cycle detected β€” deadlock
` : ""} +
+ ${_whiMetrics.map(m => { + const lv = _warnLevel(m.name, m.val); + return html`
+ ${m.name} + ${m.fmt(m.val)} +
`; + })} +
+ ${_domainBreakdown.length > 1 ? html` +
+
by domain
+ ${_domainBreakdown.map(d => html`
+ + ${d.domain} + ${(d.whi*100).toFixed(0)}% + ${d.cpi === 1 ? html`⚠` : ""} +
`)} +
` : ""} + `} +
`; +withDocHelp(_whiBox, "/docs/workstream-health-index"); + +// ── Inject into TOC sidebar: WHI first (lower), live last (top) ─────────────── +injectTocTop("whi-kpi-box", _whiBox); injectTocTop("live-indicator", _liveEl); const _h1 = document.querySelector("#observablehq-main h1"); @@ -165,6 +308,29 @@ if (wsWithDeps.length === 0) { From 090a206f3dcb09a03463aec0a0d8a9ab4128f3da Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 27 Feb 2026 07:29:51 +0100 Subject: [PATCH 040/198] feat(state-hub): add Extension Points and Technical Debt tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New entity types (DB tables, API routers, Pydantic schemas, Alembic migration a3f1c2d4e5b6): - extension_points: ep_id, domain, title, ep_type, status, priority, location, description, topic_id, workstream_id - technical_debt: td_id, domain, title, debt_type, severity, status, location, description, topic_id, workstream_id MCP server: 6 new tools β€” register_extension_point, list_extension_points, update_ep_status, register_technical_debt, list_technical_debt, update_td_status (each write emits a progress_event) Dashboard: two new pages (extensions.md, techdept.md) with KPI sidebar, charts, urgent-items section, and filterable card lists. Both added to nav in observablehq.config.js. Co-Authored-By: Claude Sonnet 4.6 --- api/main.py | 4 +- api/models/__init__.py | 4 + api/models/extension_point.py | 47 +++ api/models/technical_debt.py | 47 +++ api/routers/extension_points.py | 83 +++++ api/routers/technical_debt.py | 86 ++++++ api/schemas/__init__.py | 4 + api/schemas/extension_point.py | 53 ++++ api/schemas/technical_debt.py | 49 +++ dashboard/observablehq.config.js | 2 + dashboard/src/extensions.md | 251 +++++++++++++++ dashboard/src/techdept.md | 285 ++++++++++++++++++ mcp_server/server.py | 161 ++++++++++ ...add_extension_points_and_technical_debt.py | 72 +++++ 14 files changed, 1147 insertions(+), 1 deletion(-) create mode 100644 api/models/extension_point.py create mode 100644 api/models/technical_debt.py create mode 100644 api/routers/extension_points.py create mode 100644 api/routers/technical_debt.py create mode 100644 api/schemas/extension_point.py create mode 100644 api/schemas/technical_debt.py create mode 100644 dashboard/src/extensions.md create mode 100644 dashboard/src/techdept.md create mode 100644 migrations/versions/a3f1c2d4e5b6_add_extension_points_and_technical_debt.py diff --git a/api/main.py b/api/main.py index f0949cf..e4e11da 100644 --- a/api/main.py +++ b/api/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from api.database import engine -from api.routers import decisions, progress, state, tasks, topics, workstreams, workstream_dependencies +from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies @asynccontextmanager @@ -32,6 +32,8 @@ app.include_router(workstreams.router) app.include_router(workstream_dependencies.router) app.include_router(tasks.router) app.include_router(decisions.router) +app.include_router(extension_points.router) +app.include_router(technical_debt.router) app.include_router(progress.router) app.include_router(state.router) diff --git a/api/models/__init__.py b/api/models/__init__.py index f9c2107..562f0f8 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -5,6 +5,8 @@ from api.models.workstream_dependency import WorkstreamDependency from api.models.task import Task, TaskStatus, TaskPriority from api.models.decision import Decision, DecisionType, DecisionStatus from api.models.progress_event import ProgressEvent +from api.models.extension_point import ExtensionPoint, EPStatus +from api.models.technical_debt import TechnicalDebt, TDStatus __all__ = [ "Base", @@ -14,4 +16,6 @@ __all__ = [ "Task", "TaskStatus", "TaskPriority", "Decision", "DecisionType", "DecisionStatus", "ProgressEvent", + "ExtensionPoint", "EPStatus", + "TechnicalDebt", "TDStatus", ] diff --git a/api/models/extension_point.py b/api/models/extension_point.py new file mode 100644 index 0000000..745e898 --- /dev/null +++ b/api/models/extension_point.py @@ -0,0 +1,47 @@ +import enum +import uuid + +from sqlalchemy import Enum, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class EPStatus(str, enum.Enum): + open = "open" + in_progress = "in_progress" + addressed = "addressed" + deferred = "deferred" + wont_fix = "wont_fix" + + +class ExtensionPoint(Base, TimestampMixin): + __tablename__ = "extension_points" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + ep_id: Mapped[str | None] = mapped_column( + String(30), nullable=True, unique=True, index=True + ) # human-readable ref, e.g. EP-CUST-001 + domain: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + location: Mapped[str | None] = mapped_column(String(500), nullable=True) + ep_type: Mapped[str] = mapped_column( + String(50), nullable=False, default="other" + ) # api | schema | mcp | dashboard | architecture | integration | other + status: Mapped[EPStatus] = mapped_column( + Enum(EPStatus, name="epstatus"), nullable=False, default=EPStatus.open + ) + priority: Mapped[str] = mapped_column(String(20), nullable=False, default="medium") + topic_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True + ) + workstream_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True + ) + + topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821 + workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821 diff --git a/api/models/technical_debt.py b/api/models/technical_debt.py new file mode 100644 index 0000000..f7c954d --- /dev/null +++ b/api/models/technical_debt.py @@ -0,0 +1,47 @@ +import enum +import uuid + +from sqlalchemy import Enum, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class TDStatus(str, enum.Enum): + open = "open" + in_progress = "in_progress" + resolved = "resolved" + deferred = "deferred" + wont_fix = "wont_fix" + + +class TechnicalDebt(Base, TimestampMixin): + __tablename__ = "technical_debt" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + td_id: Mapped[str | None] = mapped_column( + String(30), nullable=True, unique=True, index=True + ) # human-readable ref, e.g. TD-CUST-001 + domain: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + location: Mapped[str | None] = mapped_column(String(500), nullable=True) + debt_type: Mapped[str] = mapped_column( + String(50), nullable=False, default="other" + ) # design | implementation | test | docs | dependencies | performance | security | other + severity: Mapped[str] = mapped_column(String(20), nullable=False, default="medium") + status: Mapped[TDStatus] = mapped_column( + Enum(TDStatus, name="tdstatus"), nullable=False, default=TDStatus.open + ) + topic_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True + ) + workstream_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True + ) + + topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821 + workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821 diff --git a/api/routers/extension_points.py b/api/routers/extension_points.py new file mode 100644 index 0000000..e4c2a52 --- /dev/null +++ b/api/routers/extension_points.py @@ -0,0 +1,83 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.extension_point import EPStatus, ExtensionPoint +from api.schemas.extension_point import EPCreate, EPRead, EPUpdate + +router = APIRouter(prefix="/extension-points", tags=["extension-points"]) + + +@router.get("/", response_model=list[EPRead]) +async def list_eps( + domain: str | None = None, + status: EPStatus | None = None, + ep_type: str | None = None, + session: AsyncSession = Depends(get_session), +) -> list[ExtensionPoint]: + q = select(ExtensionPoint) + if domain: + q = q.where(ExtensionPoint.domain == domain) + if status: + q = q.where(ExtensionPoint.status == status) + if ep_type: + q = q.where(ExtensionPoint.ep_type == ep_type) + q = q.order_by(ExtensionPoint.created_at) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=EPRead, status_code=status.HTTP_201_CREATED) +async def create_ep( + body: EPCreate, + session: AsyncSession = Depends(get_session), +) -> ExtensionPoint: + ep = ExtensionPoint(**body.model_dump()) + session.add(ep) + await session.commit() + await session.refresh(ep) + return ep + + +@router.get("/{ep_id}", response_model=EPRead) +async def get_ep( + ep_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> ExtensionPoint: + ep = await session.get(ExtensionPoint, ep_id) + if ep is None: + raise HTTPException(status_code=404, detail="Extension point not found") + return ep + + +@router.patch("/{ep_id}", response_model=EPRead) +async def update_ep( + ep_id: uuid.UUID, + body: EPUpdate, + session: AsyncSession = Depends(get_session), +) -> ExtensionPoint: + ep = await session.get(ExtensionPoint, ep_id) + if ep is None: + raise HTTPException(status_code=404, detail="Extension point not found") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(ep, field, value) + await session.commit() + await session.refresh(ep) + return ep + + +@router.delete("/{ep_id}", response_model=EPRead) +async def defer_ep( + ep_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> ExtensionPoint: + ep = await session.get(ExtensionPoint, ep_id) + if ep is None: + raise HTTPException(status_code=404, detail="Extension point not found") + ep.status = EPStatus.deferred + await session.commit() + await session.refresh(ep) + return ep diff --git a/api/routers/technical_debt.py b/api/routers/technical_debt.py new file mode 100644 index 0000000..5082328 --- /dev/null +++ b/api/routers/technical_debt.py @@ -0,0 +1,86 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.technical_debt import TDStatus, TechnicalDebt +from api.schemas.technical_debt import TDCreate, TDRead, TDUpdate + +router = APIRouter(prefix="/technical-debt", tags=["technical-debt"]) + + +@router.get("/", response_model=list[TDRead]) +async def list_td( + domain: str | None = None, + status: TDStatus | None = None, + debt_type: str | None = None, + severity: str | None = None, + session: AsyncSession = Depends(get_session), +) -> list[TechnicalDebt]: + q = select(TechnicalDebt) + if domain: + q = q.where(TechnicalDebt.domain == domain) + if status: + q = q.where(TechnicalDebt.status == status) + if debt_type: + q = q.where(TechnicalDebt.debt_type == debt_type) + if severity: + q = q.where(TechnicalDebt.severity == severity) + q = q.order_by(TechnicalDebt.created_at) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=TDRead, status_code=status.HTTP_201_CREATED) +async def create_td( + body: TDCreate, + session: AsyncSession = Depends(get_session), +) -> TechnicalDebt: + td = TechnicalDebt(**body.model_dump()) + session.add(td) + await session.commit() + await session.refresh(td) + return td + + +@router.get("/{td_id}", response_model=TDRead) +async def get_td( + td_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> TechnicalDebt: + td = await session.get(TechnicalDebt, td_id) + if td is None: + raise HTTPException(status_code=404, detail="Technical debt item not found") + return td + + +@router.patch("/{td_id}", response_model=TDRead) +async def update_td( + td_id: uuid.UUID, + body: TDUpdate, + session: AsyncSession = Depends(get_session), +) -> TechnicalDebt: + td = await session.get(TechnicalDebt, td_id) + if td is None: + raise HTTPException(status_code=404, detail="Technical debt item not found") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(td, field, value) + await session.commit() + await session.refresh(td) + return td + + +@router.delete("/{td_id}", response_model=TDRead) +async def defer_td( + td_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> TechnicalDebt: + td = await session.get(TechnicalDebt, td_id) + if td is None: + raise HTTPException(status_code=404, detail="Technical debt item not found") + td.status = TDStatus.deferred + await session.commit() + await session.refresh(td) + return td diff --git a/api/schemas/__init__.py b/api/schemas/__init__.py index 16d68ee..139016d 100644 --- a/api/schemas/__init__.py +++ b/api/schemas/__init__.py @@ -4,6 +4,8 @@ from api.schemas.task import TaskCreate, TaskUpdate, TaskRead from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead from api.schemas.state import StateSummary, Totals, TopicTotals, WorkstreamTotals, TaskTotals, DecisionTotals +from api.schemas.extension_point import EPCreate, EPUpdate, EPRead +from api.schemas.technical_debt import TDCreate, TDUpdate, TDRead __all__ = [ "TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams", @@ -12,4 +14,6 @@ __all__ = [ "DecisionCreate", "DecisionUpdate", "DecisionRead", "ProgressEventCreate", "ProgressEventRead", "StateSummary", "Totals", "TopicTotals", "WorkstreamTotals", "TaskTotals", "DecisionTotals", + "EPCreate", "EPUpdate", "EPRead", + "TDCreate", "TDUpdate", "TDRead", ] diff --git a/api/schemas/extension_point.py b/api/schemas/extension_point.py new file mode 100644 index 0000000..86c1918 --- /dev/null +++ b/api/schemas/extension_point.py @@ -0,0 +1,53 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from api.models.extension_point import EPStatus + +VALID_DOMAINS = { + "custodian", "railiance", "markitect", + "coulomb_social", "personhood", "foerster_capabilities", +} +VALID_PRIORITIES = {"low", "medium", "high", "critical"} + + +class EPCreate(BaseModel): + ep_id: str | None = None + domain: str + title: str + description: str | None = None + location: str | None = None + ep_type: str = "other" + status: EPStatus = EPStatus.open + priority: str = "medium" + topic_id: uuid.UUID | None = None + workstream_id: uuid.UUID | None = None + + +class EPUpdate(BaseModel): + title: str | None = None + description: str | None = None + location: str | None = None + ep_type: str | None = None + status: EPStatus | None = None + priority: str | None = None + workstream_id: uuid.UUID | None = None + + +class EPRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + ep_id: str | None = None + domain: str + title: str + description: str | None = None + location: str | None = None + ep_type: str + status: EPStatus + priority: str + topic_id: uuid.UUID | None = None + workstream_id: uuid.UUID | None = None + created_at: datetime + updated_at: datetime diff --git a/api/schemas/technical_debt.py b/api/schemas/technical_debt.py new file mode 100644 index 0000000..615240d --- /dev/null +++ b/api/schemas/technical_debt.py @@ -0,0 +1,49 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from api.models.technical_debt import TDStatus + +VALID_SEVERITIES = {"low", "medium", "high", "critical"} + + +class TDCreate(BaseModel): + td_id: str | None = None + domain: str + title: str + description: str | None = None + location: str | None = None + debt_type: str = "other" + severity: str = "medium" + status: TDStatus = TDStatus.open + topic_id: uuid.UUID | None = None + workstream_id: uuid.UUID | None = None + + +class TDUpdate(BaseModel): + title: str | None = None + description: str | None = None + location: str | None = None + debt_type: str | None = None + severity: str | None = None + status: TDStatus | None = None + workstream_id: uuid.UUID | None = None + + +class TDRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + td_id: str | None = None + domain: str + title: str + description: str | None = None + location: str | None = None + debt_type: str + severity: str + status: TDStatus + topic_id: uuid.UUID | None = None + workstream_id: uuid.UUID | None = None + created_at: datetime + updated_at: datetime diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index 800335c..6fe4d16 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -7,6 +7,8 @@ export default { { name: "Tasks", path: "/tasks" }, { name: "Decisions", path: "/decisions" }, { name: "Progress", path: "/progress" }, + { name: "Extension Points", path: "/extensions" }, + { name: "Technical Debt", path: "/techdept" }, { name: "Reference", pages: [ diff --git a/dashboard/src/extensions.md b/dashboard/src/extensions.md new file mode 100644 index 0000000..119cd22 --- /dev/null +++ b/dashboard/src/extensions.md @@ -0,0 +1,251 @@ +--- +title: Extension Points +--- + +```js +const API = "http://127.0.0.1:8000"; +const POLL = 15_000; +``` + +```js +const epState = (async function*() { + while (true) { + let data = [], ok = false; + try { + const [re, rw, rt] = await Promise.all([ + fetch(`${API}/extension-points/`), + fetch(`${API}/workstreams/`), + fetch(`${API}/topics/`), + ]); + ok = re.ok && rw.ok && rt.ok; + if (ok) { + const [epList, wsList, topicList] = await Promise.all([re.json(), rw.json(), rt.json()]); + const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); + const wsMap = Object.fromEntries(wsList.map(w => [w.id, { + ...w, domain: topicMap[w.topic_id]?.domain ?? "unknown", + }])); + data = epList.map(e => ({ + ...e, + workstream_title: wsMap[e.workstream_id]?.title ?? null, + })).sort((a, b) => { + const pr = {critical: 0, high: 1, medium: 2, low: 3}; + const st = {open: 0, in_progress: 1, deferred: 2, addressed: 3, wont_fix: 4}; + const sd = (st[a.status] ?? 9) - (st[b.status] ?? 9); + return sd !== 0 ? sd : (pr[a.priority] ?? 9) - (pr[b.priority] ?? 9); + }); + } + } catch {} + yield {data, ok, ts: new Date()}; + await new Promise(res => setTimeout(res, POLL)); + } +})(); +``` + +```js +const data = epState.data ?? []; +const _ok = epState.ok ?? false; +const _ts = epState.ts; +``` + +```js +import {MultiSelect} from "./components/multiselect.js"; + +const STATUSES = ["open", "in_progress", "addressed", "deferred", "wont_fix"]; +const PRIORITIES = ["critical", "high", "medium", "low"]; +const DOMAINS = ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"]; +const EP_TYPES = ["api", "schema", "mcp", "dashboard", "architecture", "integration", "other"]; + +const _filtersForm = Inputs.form( + { + status: MultiSelect(STATUSES, {label: "Status", placeholder: "All statuses"}), + priority: MultiSelect(PRIORITIES, {label: "Priority", placeholder: "All priorities"}), + domain: MultiSelect(DOMAINS, {label: "Domain", placeholder: "All domains"}), + ep_type: MultiSelect(EP_TYPES, {label: "Type", placeholder: "All types"}), + }, + { + template: ({status, priority, domain, ep_type}) => html`
+ ${status}${priority}${domain}${ep_type} +
`, + } +); +``` + +```js +const filters = Generators.input(_filtersForm); +``` + +```js +const filtered = data.filter(e => + (filters.status.length === 0 || filters.status.includes(e.status)) && + (filters.priority.length === 0 || filters.priority.includes(e.priority)) && + (filters.domain.length === 0 || filters.domain.includes(e.domain)) && + (filters.ep_type.length === 0 || filters.ep_type.includes(e.ep_type)) +); +``` + +# Extension Points + +```js +import {injectTocTop} from "./components/toc-sidebar.js"; +import {withDocHelp} from "./components/doc-overlay.js"; + +// ── KPI sidebar ─────────────────────────────────────────────────────────────── +const _open = data.filter(e => e.status === "open" || e.status === "in_progress"); +const _addressed = data.filter(e => e.status === "addressed"); +const _byType = EP_TYPES.map(t => [t, data.filter(e => e.ep_type === t && e.status === "open").length]) + .filter(([,n]) => n > 0); + +const _kpiBox = html`
+
Extension Points
+
+ open +
${_open.length}
+
+
+ addressed +
${_addressed.length}
+
+ ${_byType.length > 0 ? html` +
+ ${_byType.map(([t, n]) => html`
+ ${t} + ${n} +
`)} +
` : ""} +
`; + +// ── Live indicator ───────────────────────────────────────────────────────────── +const _liveEl = html`
+ ● + ${_ok + ? `Live Β· updated ${_ts?.toLocaleTimeString()}` + : html`Offline β€” run: make api`} +
`; +withDocHelp(_liveEl, "/docs/live-data"); + +injectTocTop("ep-kpi-box", _kpiBox); +injectTocTop("live-indicator", _liveEl); + +const _h1 = document.querySelector("#observablehq-main h1"); +if (_h1) _h1.style.position = "relative"; +``` + +## By Type & Status + +```js +import * as Plot from "npm:@observablehq/plot"; + +const TYPE_COLOR = { + api: "#3b82f6", schema: "#8b5cf6", mcp: "#ec4899", + dashboard: "#f59e0b", architecture: "#10b981", integration: "#6366f1", other: "#94a3b8", +}; +const STATUS_COLOR = { + open: "#3b82f6", in_progress: "#f59e0b", addressed: "#22c55e", deferred: "#94a3b8", wont_fix: "#e2e8f0", +}; + +const byType = EP_TYPES + .map(t => ({type: t, count: filtered.filter(e => e.ep_type === t).length})) + .filter(d => d.count > 0); + +const byStatus = STATUSES + .map(s => ({status: s, count: filtered.filter(e => e.status === s).length})) + .filter(d => d.count > 0); + +if (filtered.length === 0) { + display(html`

No extension points match the current filter.

`); +} else { + display(html`
+ ${Plot.plot({ + marks: [ + Plot.barX(byType, {y: "type", x: "count", fill: d => TYPE_COLOR[d.type] ?? "#94a3b8", tip: true}), + Plot.ruleX([0]), + ], + marginLeft: 100, width: 340, title: "By type", + })} + ${Plot.plot({ + marks: [ + Plot.barX(byStatus, {y: "status", x: "count", fill: d => STATUS_COLOR[d.status] ?? "#94a3b8", tip: true}), + Plot.ruleX([0]), + ], + marginLeft: 90, width: 300, title: "By status", + })} +
`); +} +``` + +## All Extension Points + +```js +display(_filtersForm); +display(html`

${filtered.length} extension points shown.

`); + +display(html`
${filtered.map(ep => html` +
+
+ ${ep.ep_id ? html`${ep.ep_id}` : ""} + ${ep.ep_type} + ${ep.status.replace("_", " ")} + ${ep.priority} + ${ep.domain} + ${ep.workstream_title ? html`${ep.workstream_title}` : ""} +
+
${ep.title}
+ ${ep.description ? html`
${ep.description.slice(0, 240)}${ep.description.length > 240 ? "…" : ""}
` : ""} + ${ep.location ? html`
${ep.location}
` : ""} +
+`)} +
`); +``` + + diff --git a/dashboard/src/techdept.md b/dashboard/src/techdept.md new file mode 100644 index 0000000..dc0ceaa --- /dev/null +++ b/dashboard/src/techdept.md @@ -0,0 +1,285 @@ +--- +title: Technical Debt +--- + +```js +const API = "http://127.0.0.1:8000"; +const POLL = 15_000; +``` + +```js +const tdState = (async function*() { + while (true) { + let data = [], ok = false; + try { + const [rt, rw, rto] = await Promise.all([ + fetch(`${API}/technical-debt/`), + fetch(`${API}/workstreams/`), + fetch(`${API}/topics/`), + ]); + ok = rt.ok && rw.ok && rto.ok; + if (ok) { + const [tdList, wsList, topicList] = await Promise.all([rt.json(), rw.json(), rto.json()]); + const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); + const wsMap = Object.fromEntries(wsList.map(w => [w.id, { + ...w, domain: topicMap[w.topic_id]?.domain ?? "unknown", + }])); + data = tdList.map(t => ({ + ...t, + workstream_title: wsMap[t.workstream_id]?.title ?? null, + })).sort((a, b) => { + const sv = {critical: 0, high: 1, medium: 2, low: 3}; + const st = {open: 0, in_progress: 1, deferred: 2, resolved: 3, wont_fix: 4}; + const sd = (st[a.status] ?? 9) - (st[b.status] ?? 9); + return sd !== 0 ? sd : (sv[a.severity] ?? 9) - (sv[b.severity] ?? 9); + }); + } + } catch {} + yield {data, ok, ts: new Date()}; + await new Promise(res => setTimeout(res, POLL)); + } +})(); +``` + +```js +const data = tdState.data ?? []; +const _ok = tdState.ok ?? false; +const _ts = tdState.ts; +``` + +```js +import {MultiSelect} from "./components/multiselect.js"; + +const STATUSES = ["open", "in_progress", "resolved", "deferred", "wont_fix"]; +const SEVERITIES = ["critical", "high", "medium", "low"]; +const DOMAINS = ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"]; +const DEBT_TYPES = ["design", "implementation", "test", "docs", "dependencies", "performance", "security", "other"]; + +const _filtersForm = Inputs.form( + { + status: MultiSelect(STATUSES, {label: "Status", placeholder: "All statuses"}), + severity: MultiSelect(SEVERITIES, {label: "Severity", placeholder: "All severities"}), + domain: MultiSelect(DOMAINS, {label: "Domain", placeholder: "All domains"}), + debt_type: MultiSelect(DEBT_TYPES, {label: "Type", placeholder: "All types"}), + }, + { + template: ({status, severity, domain, debt_type}) => html`
+ ${status}${severity}${domain}${debt_type} +
`, + } +); +``` + +```js +const filters = Generators.input(_filtersForm); +``` + +```js +const filtered = data.filter(t => + (filters.status.length === 0 || filters.status.includes(t.status)) && + (filters.severity.length === 0 || filters.severity.includes(t.severity)) && + (filters.domain.length === 0 || filters.domain.includes(t.domain)) && + (filters.debt_type.length === 0 || filters.debt_type.includes(t.debt_type)) +); +``` + +# Technical Debt + +```js +import {injectTocTop} from "./components/toc-sidebar.js"; +import {withDocHelp} from "./components/doc-overlay.js"; + +// ── KPI sidebar ─────────────────────────────────────────────────────────────── +const _open = data.filter(t => t.status === "open" || t.status === "in_progress"); +const _critical = data.filter(t => t.severity === "critical" && t.status === "open"); +const _high = data.filter(t => t.severity === "high" && t.status === "open"); +const _resolved = data.filter(t => t.status === "resolved"); +const _total = data.filter(t => t.status !== "wont_fix").length; +const _resolvedPct = _total > 0 ? Math.round(_resolved.length / _total * 100) : 0; + +const _kpiBox = html`
+
Tech Debt
+
+ open +
${_open.length}
+
+
+ critical +
+
${_critical.length}
+
+
+
+ high +
+
${_high.length}
+
+
+
+ resolved +
+
${_resolved.length}
+
${_resolvedPct}% of total
+
+
+
`; + +// ── Live indicator ───────────────────────────────────────────────────────────── +const _liveEl = html`
+ ● + ${_ok + ? `Live Β· updated ${_ts?.toLocaleTimeString()}` + : html`Offline β€” run: make api`} +
`; +withDocHelp(_liveEl, "/docs/live-data"); + +injectTocTop("td-kpi-box", _kpiBox); +injectTocTop("live-indicator", _liveEl); + +const _h1 = document.querySelector("#observablehq-main h1"); +if (_h1) _h1.style.position = "relative"; +``` + +## By Type & Severity + +```js +import * as Plot from "npm:@observablehq/plot"; + +const SEVERITY_COLOR = {critical: "#dc2626", high: "#ea580c", medium: "#3b82f6", low: "#94a3b8"}; +const TYPE_COLOR = { + design: "#8b5cf6", implementation: "#3b82f6", test: "#f59e0b", + docs: "#10b981", dependencies: "#6366f1", performance: "#ec4899", + security: "#dc2626", other: "#94a3b8", +}; + +const bySeverity = SEVERITIES + .map(s => ({severity: s, count: filtered.filter(t => t.severity === s && t.status !== "resolved" && t.status !== "wont_fix").length})) + .filter(d => d.count > 0); + +const byType = DEBT_TYPES + .map(t => ({type: t, count: filtered.filter(d => d.debt_type === t).length})) + .filter(d => d.count > 0); + +if (filtered.length === 0) { + display(html`

No technical debt items match the current filter.

`); +} else { + display(html`
+ ${bySeverity.length > 0 ? Plot.plot({ + marks: [ + Plot.barX(bySeverity, {y: "severity", x: "count", fill: d => SEVERITY_COLOR[d.severity] ?? "#94a3b8", tip: true}), + Plot.ruleX([0]), + ], + marginLeft: 80, width: 300, title: "Open by severity", + }) : ""} + ${byType.length > 0 ? Plot.plot({ + marks: [ + Plot.barX(byType, {y: "type", x: "count", fill: d => TYPE_COLOR[d.type] ?? "#94a3b8", tip: true}), + Plot.ruleX([0]), + ], + marginLeft: 110, width: 360, title: "By type", + }) : ""} +
`); +} +``` + +## Critical & High + +```js +const _urgent = filtered.filter(t => (t.severity === "critical" || t.severity === "high") && t.status === "open"); + +if (_urgent.length === 0) { + display(html`

No critical or high severity open items in current filter. βœ“

`); +} else { + display(html`
${_urgent.map(t => html` +
+
+ ${t.td_id ? html`${t.td_id}` : ""} + ${t.severity} + ${t.debt_type} + ${t.domain} + ${t.workstream_title ? html`${t.workstream_title}` : ""} +
+
${t.title}
+ ${t.description ? html`
${t.description.slice(0, 240)}${t.description.length > 240 ? "…" : ""}
` : ""} + ${t.location ? html`
${t.location}
` : ""} +
+ `)}
`); +} +``` + +## All Technical Debt + +```js +display(_filtersForm); +display(html`

${filtered.length} items shown.

`); + +display(html`
${filtered.map(t => html` +
+
+ ${t.td_id ? html`${t.td_id}` : ""} + ${t.severity} + ${t.debt_type} + ${t.status.replace("_", " ")} + ${t.domain} + ${t.workstream_title ? html`${t.workstream_title}` : ""} +
+
${t.title}
+ ${t.description ? html`
${t.description.slice(0, 240)}${t.description.length > 240 ? "…" : ""}
` : ""} + ${t.location ? html`
${t.location}
` : ""} +
+`)} +
`); +``` + + diff --git a/mcp_server/server.py b/mcp_server/server.py index bae547e..c491e38 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -468,6 +468,167 @@ def list_dependencies(workstream_id: str) -> str: return json.dumps({"depends_on": depends_on, "blocks": blocks}, indent=2) +# --------------------------------------------------------------------------- +# Extension points & technical debt +# --------------------------------------------------------------------------- + +@mcp.tool() +def register_extension_point( + domain: str, + title: str, + ep_type: str, + description: str | None = None, + location: str | None = None, + priority: str = "medium", + ep_id: str | None = None, + topic_id: str | None = None, + workstream_id: str | None = None, +) -> str: + """Register a discovered extension point β€” optional future functionality not yet committed. + + Extension points capture design forks: things the system *could* do that + have been noticed and parked for deliberate later consideration. + + Args: + domain: one of custodian | railiance | markitect | coulomb_social | personhood | foerster_capabilities + title: short description of the extension + ep_type: api | schema | mcp | dashboard | architecture | integration | other + description: longer explanation of what the extension would add + location: file:line or module where the extension point was noticed + priority: low | medium | high | critical + ep_id: optional human-readable ID, e.g. EP-CUST-001 (auto-assigned if omitted) + topic_id: UUID of related topic + workstream_id: UUID of related workstream + """ + ep = _post("/extension-points", { + "domain": domain, "title": title, "ep_type": ep_type, + "description": description, "location": location, + "priority": priority, "ep_id": ep_id, + "topic_id": topic_id, "workstream_id": workstream_id, + }) + _post("/progress", { + "summary": f"Extension point registered: [{ep.get('ep_id') or ep['id'][:8]}] {title} ({ep_type}, {domain})", + "event_type": "extension_point", + "detail": {"id": ep["id"], "ep_id": ep.get("ep_id"), "ep_type": ep_type, "domain": domain}, + }) + return json.dumps(ep, indent=2) + + +@mcp.tool() +def list_extension_points( + domain: str | None = None, + status: str | None = None, + ep_type: str | None = None, +) -> str: + """List extension points, optionally filtered. + + Args: + domain: filter by domain + status: open | in_progress | addressed | deferred | wont_fix + ep_type: api | schema | mcp | dashboard | architecture | integration | other + """ + return json.dumps(_get("/extension-points", { + "domain": domain, "status": status, "ep_type": ep_type, + }), indent=2) + + +@mcp.tool() +def update_ep_status(ep_uuid: str, status: str) -> str: + """Update the status of an extension point. + + Args: + ep_uuid: UUID of the extension point + status: open | in_progress | addressed | deferred | wont_fix + """ + ep = _patch(f"/extension-points/{ep_uuid}", {"status": status}) + _post("/progress", { + "summary": f"Extension point status β†’ {status}: {ep['title']}", + "event_type": "extension_point", + "detail": {"id": ep_uuid, "status": status}, + }) + return json.dumps(ep, indent=2) + + +@mcp.tool() +def register_technical_debt( + domain: str, + title: str, + debt_type: str, + description: str | None = None, + location: str | None = None, + severity: str = "medium", + td_id: str | None = None, + topic_id: str | None = None, + workstream_id: str | None = None, +) -> str: + """Register a technical debt item β€” a known quality compromise to address later. + + Technical debt captures intentional or discovered shortcuts, design + weaknesses, missing tests, and similar issues that reduce codebase health. + + Args: + domain: one of custodian | railiance | markitect | coulomb_social | personhood | foerster_capabilities + title: short description of the debt + debt_type: design | implementation | test | docs | dependencies | performance | security | other + description: what the issue is and what the correct fix would be + location: file:line or module where the debt lives + severity: low | medium | high | critical + td_id: optional human-readable ID, e.g. TD-CUST-001 + topic_id: UUID of related topic + workstream_id: UUID of related workstream + """ + td = _post("/technical-debt", { + "domain": domain, "title": title, "debt_type": debt_type, + "description": description, "location": location, + "severity": severity, "td_id": td_id, + "topic_id": topic_id, "workstream_id": workstream_id, + }) + _post("/progress", { + "summary": f"Technical debt registered: [{td.get('td_id') or td['id'][:8]}] {title} ({debt_type}, {severity}, {domain})", + "event_type": "technical_debt", + "detail": {"id": td["id"], "td_id": td.get("td_id"), "debt_type": debt_type, "severity": severity, "domain": domain}, + }) + return json.dumps(td, indent=2) + + +@mcp.tool() +def list_technical_debt( + domain: str | None = None, + status: str | None = None, + debt_type: str | None = None, + severity: str | None = None, +) -> str: + """List technical debt items, optionally filtered. + + Args: + domain: filter by domain + status: open | in_progress | resolved | deferred | wont_fix + debt_type: design | implementation | test | docs | dependencies | performance | security | other + severity: low | medium | high | critical + """ + return json.dumps(_get("/technical-debt", { + "domain": domain, "status": status, + "debt_type": debt_type, "severity": severity, + }), indent=2) + + +@mcp.tool() +def update_td_status(td_uuid: str, status: str) -> str: + """Update the status of a technical debt item. + + Args: + td_uuid: UUID of the technical debt item + status: open | in_progress | resolved | deferred | wont_fix + """ + td = _patch(f"/technical-debt/{td_uuid}", {"status": status}) + _post("/progress", { + "summary": f"Technical debt status β†’ {status}: {td['title']}", + "event_type": "technical_debt", + "detail": {"id": td_uuid, "status": status}, + }) + return json.dumps(td, indent=2) + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- diff --git a/migrations/versions/a3f1c2d4e5b6_add_extension_points_and_technical_debt.py b/migrations/versions/a3f1c2d4e5b6_add_extension_points_and_technical_debt.py new file mode 100644 index 0000000..3629d52 --- /dev/null +++ b/migrations/versions/a3f1c2d4e5b6_add_extension_points_and_technical_debt.py @@ -0,0 +1,72 @@ +"""add extension_points and technical_debt tables + +Revision ID: a3f1c2d4e5b6 +Revises: 0b547c153153 +Create Date: 2026-02-27 00:00:00.000000 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "a3f1c2d4e5b6" +down_revision: Union[str, None] = "0b547c153153" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + epstatus = postgresql.ENUM( + "open", "in_progress", "addressed", "deferred", "wont_fix", + name="epstatus", create_type=True, + ) + tdstatus = postgresql.ENUM( + "open", "in_progress", "resolved", "deferred", "wont_fix", + name="tdstatus", create_type=True, + ) + + op.create_table( + "extension_points", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("ep_id", sa.String(30), nullable=True, unique=True), + sa.Column("domain", sa.String(50), nullable=False), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("location", sa.String(500), nullable=True), + sa.Column("ep_type", sa.String(50), nullable=False, server_default="other"), + sa.Column("status", epstatus, nullable=False, server_default="open"), + sa.Column("priority", sa.String(20), nullable=False, server_default="medium"), + sa.Column("topic_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("topics.id", ondelete="SET NULL"), nullable=True), + sa.Column("workstream_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_extension_points_ep_id", "extension_points", ["ep_id"]) + op.create_index("ix_extension_points_domain", "extension_points", ["domain"]) + + op.create_table( + "technical_debt", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("td_id", sa.String(30), nullable=True, unique=True), + sa.Column("domain", sa.String(50), nullable=False), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("location", sa.String(500), nullable=True), + sa.Column("debt_type", sa.String(50), nullable=False, server_default="other"), + sa.Column("severity", sa.String(20), nullable=False, server_default="medium"), + sa.Column("status", tdstatus, nullable=False, server_default="open"), + sa.Column("topic_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("topics.id", ondelete="SET NULL"), nullable=True), + sa.Column("workstream_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_technical_debt_td_id", "technical_debt", ["td_id"]) + op.create_index("ix_technical_debt_domain", "technical_debt", ["domain"]) + + +def downgrade() -> None: + op.drop_table("technical_debt") + op.drop_table("extension_points") + op.execute("DROP TYPE IF EXISTS tdstatus") + op.execute("DROP TYPE IF EXISTS epstatus") From 29a0368e6d19dc4a1cc65c629b9e3f5fabe1ef73 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 27 Feb 2026 08:03:19 +0100 Subject: [PATCH 041/198] feat(dashboard): add mouseover tooltips to WHI metric abbreviations DD, BR, SPR, PEP, CDDR now show full name + one-sentence explanation on hover via title attributes. Metric labels get a dotted underline and cursor:help to signal they are hoverable. Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/workstreams.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index b564998..dc1b20c 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -158,11 +158,11 @@ function _warnLevel(name, val) { function _warnColor(lv) { return lv === 2 ? "#dc2626" : lv === 1 ? "#d97706" : "var(--theme-foreground-muted, #666)"; } const _whiMetrics = [ - {name: "DD", val: _DD, fmt: v => v.toFixed(2)}, - {name: "BR", val: _BR, fmt: v => (v*100).toFixed(0)+"%"}, - {name: "SPR", val: _SPR, fmt: v => (v*100).toFixed(0)+"%"}, - {name: "PEP", val: _PEP, fmt: v => (v*100).toFixed(0)+"%"}, - {name: "CDDR", val: _CDDR, fmt: v => (v*100).toFixed(0)+"%"}, + {name: "DD", val: _DD, fmt: v => v.toFixed(2), tip: "Dependency Density β€” average number of dependencies per open workstream; high values indicate a tightly coupled graph that is hard to parallelise."}, + {name: "BR", val: _BR, fmt: v => (v*100).toFixed(0)+"%", tip: "Blocked Ratio β€” share of open workstreams currently in a blocked state; directly reduces the work that can proceed right now."}, + {name: "SPR", val: _SPR, fmt: v => (v*100).toFixed(0)+"%", tip: "Single-Point Risk β€” share of open workstreams that are depended on by others but have no incoming dependencies themselves; losing one stalls everything downstream."}, + {name: "PEP", val: _PEP, fmt: v => (v*100).toFixed(0)+"%", tip: "Parallel Execution Potential β€” share of open workstreams with zero blocking dependencies that could start or continue immediately."}, + {name: "CDDR", val: _CDDR, fmt: v => (v*100).toFixed(0)+"%", tip: "Cross-Domain Dependency Ratio β€” share of dependency edges that cross domain boundaries; high values mean progress in one domain is gated on another team or project."}, ]; const _whiBox = html`
@@ -179,7 +179,7 @@ const _whiBox = html`
${_whiMetrics.map(m => { const lv = _warnLevel(m.name, m.val); return html`
- ${m.name} + ${m.name} ${m.fmt(m.val)}
`; })} @@ -323,7 +323,7 @@ if (wsWithDeps.length === 0) { .whi-cycle-alert { background: #fef2f2; color: #dc2626; border-radius: 4px; padding: 0.2rem 0.45rem; font-size: 0.72rem; font-weight: 600; margin-bottom: 0.4rem; } .whi-metrics { border-top: 1px solid var(--theme-foreground-faint, #eee); padding-top: 0.35rem; margin-bottom: 0.35rem; } .whi-metric-row { display: flex; justify-content: space-between; padding: 0.16rem 0; } -.whi-metric-name { font-family: monospace; font-size: 0.72rem; } +.whi-metric-name { font-family: monospace; font-size: 0.72rem; text-decoration: underline dotted; text-underline-offset: 2px; cursor: help; } .whi-metric-val { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 0.78rem; } .whi-domains { border-top: 1px solid var(--theme-foreground-faint, #eee); padding-top: 0.35rem; } .whi-domain-header { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--theme-foreground-faint, #aaa); margin-bottom: 0.2rem; } From f8e76deeaae868683874406da913e4bd6c7b6ccf Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 27 Feb 2026 08:11:09 +0100 Subject: [PATCH 042/198] feat(dashboard): replace title tooltips with web component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New custom element (src/components/help-tip.js): - Floating card appears on hover/focus, appended to document.body (position:fixed) so it escapes overflow:hidden in the TOC sidebar - Attributes: label (bold), description (body), doc (optional "Learn more β†’" link) - Mouse-over-card grace period so the link stays reachable - Correct viewport clamping (horizontal + prefer-above/fallback-below) workstreams.md: - WHI metric abbreviations (DD/BR/SPR/PEP/CDDR) now use with full name, one-sentence description, and doc link - Domain breakdown labels show domain-scoped stats (open count, blocked%, runnable%) and a doc link - Cycle ⚠ icon upgraded to with explanation - Removed dotted underline; cursor:help comes from the element CSS Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/components/help-tip.js | 167 +++++++++++++++++++++++++++ dashboard/src/workstreams.md | 24 ++-- 2 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 dashboard/src/components/help-tip.js diff --git a/dashboard/src/components/help-tip.js b/dashboard/src/components/help-tip.js new file mode 100644 index 0000000..7f37d33 --- /dev/null +++ b/dashboard/src/components/help-tip.js @@ -0,0 +1,167 @@ +// ABBR +// +// A custom element that shows a floating card on hover/focus. +// Attributes: +// label β€” bold title line in the card +// description β€” body text +// doc β€” optional URL; adds a "Learn more β†’" link +// +// The card is appended to document.body (position:fixed) so it escapes +// any overflow:hidden or clipping ancestors (e.g. the TOC sidebar). + +const _STYLE_ID = "helptip-global-style"; +if (!document.getElementById(_STYLE_ID)) { + const s = document.createElement("style"); + s.id = _STYLE_ID; + s.textContent = ` +help-tip { + cursor: help; + display: inline; +} +.helptip-card { + position: fixed; + z-index: 9999; + background: var(--theme-background, #fff); + border: 1px solid var(--theme-foreground-faint, #ddd); + border-radius: 9px; + padding: 0.6rem 0.85rem; + max-width: 270px; + min-width: 130px; + box-shadow: 0 6px 22px rgba(0,0,0,0.13), 0 1px 4px rgba(0,0,0,0.07); + font-size: 0.78rem; + line-height: 1.5; + color: var(--theme-foreground, #333); + opacity: 0; + transition: opacity 0.12s ease; + pointer-events: auto; +} +.helptip-card.helptip-visible { opacity: 1; } +.helptip-card-label { + font-weight: 700; + font-size: 0.8rem; + margin-bottom: 0.3rem; + color: var(--theme-foreground, #222); +} +.helptip-card-desc { + color: var(--theme-foreground-muted, #555); +} +.helptip-card-link { + display: inline-block; + margin-top: 0.45rem; + font-size: 0.72rem; + color: var(--theme-foreground-focus, #3b82f6); + text-decoration: none; +} +.helptip-card-link:hover { text-decoration: underline; } +`; + document.head.appendChild(s); +} + +class HelpTip extends HTMLElement { + #card = null; + #showTimer = null; + #hideTimer = null; + + connectedCallback() { + this.addEventListener("mouseenter", this.#onEnter); + this.addEventListener("mouseleave", this.#onLeave); + this.addEventListener("focusin", this.#onEnter); + this.addEventListener("focusout", this.#onLeave); + } + + disconnectedCallback() { + clearTimeout(this.#showTimer); + clearTimeout(this.#hideTimer); + this.#clearCard(); + this.removeEventListener("mouseenter", this.#onEnter); + this.removeEventListener("mouseleave", this.#onLeave); + this.removeEventListener("focusin", this.#onEnter); + this.removeEventListener("focusout", this.#onLeave); + } + + #onEnter = () => { + clearTimeout(this.#hideTimer); + this.#showTimer = setTimeout(() => this.#showCard(), 80); + }; + + #onLeave = () => { + clearTimeout(this.#showTimer); + this.#hideTimer = setTimeout(() => this.#clearCard(), 200); + }; + + #showCard() { + if (this.#card) return; + + const label = this.getAttribute("label") || ""; + const desc = this.getAttribute("description") || ""; + const doc = this.getAttribute("doc") || ""; + + const card = document.createElement("div"); + card.className = "helptip-card"; + + if (label) { + const h = document.createElement("div"); + h.className = "helptip-card-label"; + h.textContent = label; + card.appendChild(h); + } + if (desc) { + const d = document.createElement("div"); + d.className = "helptip-card-desc"; + d.textContent = desc; + card.appendChild(d); + } + if (doc) { + const a = document.createElement("a"); + a.className = "helptip-card-link"; + a.textContent = "Learn more β†’"; + a.href = doc; + card.appendChild(a); + } + + // Keep card alive while mouse is over it + card.addEventListener("mouseenter", () => clearTimeout(this.#hideTimer)); + card.addEventListener("mouseleave", this.#onLeave); + + document.body.appendChild(card); + this.#card = card; + + this.#position(card); + requestAnimationFrame(() => card.classList.add("helptip-visible")); + } + + #position(card) { + const rect = this.getBoundingClientRect(); + const cw = card.offsetWidth || 230; + const ch = card.offsetHeight || 80; + const gap = 8; + const vw = window.innerWidth; + const vh = window.innerHeight; + + // Horizontal: align to left of trigger, clamp inside viewport + let left = rect.left; + if (left + cw + gap > vw) left = vw - cw - gap; + if (left < gap) left = gap; + + // Vertical: prefer above; fall back to below + const top = (rect.top - ch - gap >= 0) + ? rect.top - ch - gap + : Math.min(rect.bottom + gap, vh - ch - gap); + + card.style.left = `${left}px`; + card.style.top = `${top}px`; + } + + #clearCard() { + if (this.#card) { + this.#card.remove(); + this.#card = null; + } + } +} + +if (!customElements.get("help-tip")) { + customElements.define("help-tip", HelpTip); +} + +export { HelpTip }; diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index dc1b20c..be7f3d3 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -134,6 +134,7 @@ const _domainBreakdown = [...new Set(openWs.map(w => _idToDomain[w.id] ?? "unkno ```js import {injectTocTop} from "./components/toc-sidebar.js"; import {withDocHelp} from "./components/doc-overlay.js"; +import "./components/help-tip.js"; // ── Live indicator ──────────────────────────────────────────────────────────── const _liveEl = html`
@@ -158,11 +159,11 @@ function _warnLevel(name, val) { function _warnColor(lv) { return lv === 2 ? "#dc2626" : lv === 1 ? "#d97706" : "var(--theme-foreground-muted, #666)"; } const _whiMetrics = [ - {name: "DD", val: _DD, fmt: v => v.toFixed(2), tip: "Dependency Density β€” average number of dependencies per open workstream; high values indicate a tightly coupled graph that is hard to parallelise."}, - {name: "BR", val: _BR, fmt: v => (v*100).toFixed(0)+"%", tip: "Blocked Ratio β€” share of open workstreams currently in a blocked state; directly reduces the work that can proceed right now."}, - {name: "SPR", val: _SPR, fmt: v => (v*100).toFixed(0)+"%", tip: "Single-Point Risk β€” share of open workstreams that are depended on by others but have no incoming dependencies themselves; losing one stalls everything downstream."}, - {name: "PEP", val: _PEP, fmt: v => (v*100).toFixed(0)+"%", tip: "Parallel Execution Potential β€” share of open workstreams with zero blocking dependencies that could start or continue immediately."}, - {name: "CDDR", val: _CDDR, fmt: v => (v*100).toFixed(0)+"%", tip: "Cross-Domain Dependency Ratio β€” share of dependency edges that cross domain boundaries; high values mean progress in one domain is gated on another team or project."}, + {name: "DD", val: _DD, fmt: v => v.toFixed(2), label: "Dependency Density", desc: "Average number of dependencies per open workstream; high values indicate a tightly coupled graph that is hard to parallelise."}, + {name: "BR", val: _BR, fmt: v => (v*100).toFixed(0)+"%", label: "Blocked Ratio", desc: "Share of open workstreams currently in a blocked state; directly reduces the work that can proceed right now."}, + {name: "SPR", val: _SPR, fmt: v => (v*100).toFixed(0)+"%", label: "Single-Point Risk", desc: "Share of workstreams depended on by others but with no incoming dependencies themselves; losing one stalls everything downstream."}, + {name: "PEP", val: _PEP, fmt: v => (v*100).toFixed(0)+"%", label: "Parallel Execution Potential", desc: "Share of open workstreams with zero blocking dependencies that could start or continue immediately."}, + {name: "CDDR", val: _CDDR, fmt: v => (v*100).toFixed(0)+"%", label: "Cross-Domain Dependency Ratio", desc: "Share of dependency edges that cross domain boundaries; high values mean progress in one domain is gated on another team or project."}, ]; const _whiBox = html`
@@ -179,8 +180,8 @@ const _whiBox = html`
${_whiMetrics.map(m => { const lv = _warnLevel(m.name, m.val); return html`
- ${m.name} - ${m.fmt(m.val)} + ${m.name} + ${m.fmt(m.val)}
`; })}
@@ -189,9 +190,12 @@ const _whiBox = html`
by domain
${_domainBreakdown.map(d => html`
- ${d.domain} + ${d.domain} ${(d.whi*100).toFixed(0)}% - ${d.cpi === 1 ? html`⚠` : ""} + ${d.cpi === 1 ? html`⚠` : ""}
`)}
` : ""} `} @@ -323,7 +327,7 @@ if (wsWithDeps.length === 0) { .whi-cycle-alert { background: #fef2f2; color: #dc2626; border-radius: 4px; padding: 0.2rem 0.45rem; font-size: 0.72rem; font-weight: 600; margin-bottom: 0.4rem; } .whi-metrics { border-top: 1px solid var(--theme-foreground-faint, #eee); padding-top: 0.35rem; margin-bottom: 0.35rem; } .whi-metric-row { display: flex; justify-content: space-between; padding: 0.16rem 0; } -.whi-metric-name { font-family: monospace; font-size: 0.72rem; text-decoration: underline dotted; text-underline-offset: 2px; cursor: help; } +.whi-metric-name { font-family: monospace; font-size: 0.72rem; } .whi-metric-val { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 0.78rem; } .whi-domains { border-top: 1px solid var(--theme-foreground-faint, #eee); padding-top: 0.35rem; } .whi-domain-header { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--theme-foreground-faint, #aaa); margin-bottom: 0.2rem; } From e94d7d445b7b207c7839baf3e8c23500099bd7dc Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 27 Feb 2026 08:32:17 +0100 Subject: [PATCH 043/198] feat(state-hub): integrate llm-connect as dependency (S3.1) Add llm-connect as an editable local dependency via [tool.uv.sources]. Creates tests/test_llm_connect_integration.py: 7 offline smoke tests covering public symbol imports, MockLLMAdapter execute/reset, RunConfig and LLMResponse fields, and ErrorLLMAdapter error propagation. All 7 tests pass. Satisfies workstream llm-shared-library S3.1. Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 4 ++ tests/__init__.py | 0 tests/test_llm_connect_integration.py | 74 +++++++++++++++++++++++++++ uv.lock | 25 +++++++++ 4 files changed, 103 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_llm_connect_integration.py diff --git a/pyproject.toml b/pyproject.toml index 1e04d18..122f4d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "fastmcp>=2.0.0", "python-dotenv>=1.0.0", "psycopg2-binary>=2.9.0", + "llm-connect", ] [project.scripts] @@ -28,6 +29,9 @@ build-backend = "hatchling.build" packages = ["api", "mcp_server"] artifacts = ["custodian_cli.py"] +[tool.uv.sources] +llm-connect = { path = "/home/worsch/llm-connect", editable = true } + [tool.uv] dev-dependencies = [ "pytest>=8.0.0", diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_llm_connect_integration.py b/tests/test_llm_connect_integration.py new file mode 100644 index 0000000..e414eda --- /dev/null +++ b/tests/test_llm_connect_integration.py @@ -0,0 +1,74 @@ +""" +Smoke test: llm-connect integration in state-hub (S3.1). + +Verifies that the package is importable from the state-hub venv and that +the core types and mock adapter work correctly. Does NOT call any live +LLM endpoint β€” CI-safe and offline-safe. +""" +import llm_connect +from llm_connect import ( + MockLLMAdapter, + RunConfig, + LLMResponse, + LLMError, + create_adapter, +) + + +def test_package_version(): + assert hasattr(llm_connect, "__version__") or True # version attr optional + + +def test_public_symbols_importable(): + """All expected public symbols are present in llm_connect namespace.""" + expected = [ + "RunConfig", "LLMResponse", + "LLMAdapter", "MockLLMAdapter", "ErrorLLMAdapter", + "OpenRouterAdapter", "GeminiAdapter", "OpenAIAdapter", "ClaudeCodeAdapter", + "create_adapter", + "LLMError", "LLMConfigurationError", "LLMAPIError", + "LLMRateLimitError", "LLMTimeoutError", "LLMSubprocessError", + "EmbeddingAdapter", "OpenAICompatibleEmbeddingAdapter", "EmbeddingCache", + "cosine_similarity", + ] + missing = [name for name in expected if not hasattr(llm_connect, name)] + assert missing == [], f"Missing public symbols: {missing}" + + +def test_mock_adapter_execute(): + adapter = MockLLMAdapter(mock_response="hello from mock") + config = RunConfig() + result = adapter.execute_prompt("any prompt", config) + + assert isinstance(result, LLMResponse) + assert result.content == "hello from mock" + assert adapter.call_count == 1 + + +def test_mock_adapter_reset(): + adapter = MockLLMAdapter(mock_response="x") + adapter.execute_prompt("p", RunConfig()) + adapter.reset() + assert adapter.call_count == 0 + + +def test_run_config_defaults(): + cfg = RunConfig() + assert cfg.temperature >= 0 + assert cfg.max_tokens > 0 + + +def test_llm_response_fields(): + r = LLMResponse(content="ok", model="test-model") + assert r.content == "ok" + assert r.model == "test-model" + + +def test_error_adapter_raises(): + from llm_connect import ErrorLLMAdapter + adapter = ErrorLLMAdapter(error_message="simulated failure") + try: + adapter.execute_prompt("p", RunConfig()) + assert False, "should have raised" + except (LLMError, RuntimeError) as e: + assert "simulated failure" in str(e) diff --git a/uv.lock b/uv.lock index afb9d88..ad8b2e6 100644 --- a/uv.lock +++ b/uv.lock @@ -663,6 +663,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160 }, ] +[[package]] +name = "llm-connect" +version = "0.1.0" +source = { editable = "../../llm-connect" } +dependencies = [ + { name = "toml" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, + { name = "toml" }, +] + [[package]] name = "mako" version = "1.3.10" @@ -1405,6 +1419,7 @@ dependencies = [ { name = "fastapi" }, { name = "fastmcp" }, { name = "httpx" }, + { name = "llm-connect" }, { name = "psycopg2-binary" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -1427,6 +1442,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.115.0" }, { name = "fastmcp", specifier = ">=2.0.0" }, { name = "httpx", specifier = ">=0.28.0" }, + { name = "llm-connect", editable = "../../llm-connect" }, { name = "psycopg2-binary", specifier = ">=2.9.0" }, { name = "pydantic", specifier = ">=2.10.0" }, { name = "pydantic-settings", specifier = ">=2.7.0" }, @@ -1442,6 +1458,15 @@ dev = [ { name = "pytest-asyncio", specifier = ">=0.24.0" }, ] +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588 }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From bd6e16394af59d6bea7fdd99b57641ba729d1d6c Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 27 Feb 2026 08:53:43 +0100 Subject: [PATCH 044/198] chore: mark llm-shared-library workstream completed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 9 tasks done (S1.1–S3.2). llm-connect is now a standalone installable package at /home/worsch/llm-connect, integrated into state-hub as its first consumer (S3.1, commit 444b35d). Also tracks workstream-kpi.md spec (previously untracked). Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/docs/workstream-kpi.md | 381 +++++++++++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 dashboard/src/docs/workstream-kpi.md diff --git a/dashboard/src/docs/workstream-kpi.md b/dashboard/src/docs/workstream-kpi.md new file mode 100644 index 0000000..217721e --- /dev/null +++ b/dashboard/src/docs/workstream-kpi.md @@ -0,0 +1,381 @@ + +# Workstream Health Index (WHI) + +## Introduction & Requirements Specification + +**Status:** Draft +**Purpose:** Define a quantitative KPI for structural health, coupling, and flow efficiency of workstreams +**Scope:** Program-level coordination across domains and within domains +**Primary Audience:** Project leads, system architects, program management, AI orchestration agents + +--- + +## 1. Problem Statement + +Modern complex initiatives consist of multiple concurrent workstreams distributed across teams and domains. Ideally, workstreams should be: + +* Independently executable +* Minimally coupled +* Parallelizable +* Robust against delays in other streams + +In practice, dependencies emerge due to: + +* Architectural constraints +* Resource limitations +* Organizational structure +* Poor decomposition of work +* Hidden prerequisite relationships + +Excessive coupling leads to: + +* Blocking states +* Cascading delays +* Increased coordination overhead +* Reduced throughput +* Fragile timelines +* Circular waiting situations (deadlocks) + +Traditional project metrics (e.g., completion %, velocity, burn-down) do not capture **structural health** of the work graph. + +Therefore, a dedicated metric is required to assess: + +> **How well the program structure supports parallel execution and stable progress.** + +--- + +## 2. Conceptual Model + +Workstreams form a **directed dependency graph**: + +* Nodes = workstreams +* Edges = prerequisite relationships +* Status = operational state +* Domains = logical grouping + +Health is determined by: + +1. Structural coupling (how many dependencies exist) +2. Operational blocking (how many streams cannot proceed) +3. Concentration of risk (single points of failure) +4. Parallel execution potential +5. Cross-domain entanglement +6. Presence of cycles (deadlocks) + +--- + +## 3. Definition: Workstream Health Index (WHI) + +The **Workstream Health Index (WHI)** is a composite KPI representing the overall coordination efficiency and structural soundness of the workstream network. + +WHI is normalized to a value in the range: + +[ +0 \le WHI \le 1 +] + +Where: + +* **1.0 = ideal independence** +* **0.0 = severe systemic dysfunction** + +WHI must be computed for: + +* Entire program +* Each domain (intra-domain) +* Cross-domain interactions + +--- + +## 4. Base Metrics + +WHI aggregates the following primary indicators. + +--- + +### 4.1 Dependency Density (DD) + +**Purpose:** Measure structural coupling introduced during planning. + +[ +DD = \frac{\text{Number of dependency edges}}{\text{Number of active + blocked workstreams}} +] + +Interpretation: + +* Low DD β†’ decomposed, parallelizable work +* High DD β†’ tightly coupled system + +Completed and archived streams are excluded because they no longer constrain progress. + +--- + +### 4.2 Blocked Ratio (BR) + +**Purpose:** Measure immediate operational impact of dependencies. + +[ +BR = \frac{\text{Blocked workstreams}}{\text{Active + Blocked workstreams}} +] + +Interpretation: + +* BR β‰ˆ 0 β†’ flow is unobstructed +* High BR β†’ large portion of work cannot proceed + +--- + +### 4.3 Single-Point Risk (SPR) + +**Purpose:** Detect concentration of blocking power. + +[ +SPR = \frac{\text{Max number of dependents on one incomplete workstream}}{\text{Active + Blocked}} +] + +High SPR indicates fragile structure where one delay propagates widely. + +--- + +### 4.4 Parallel Execution Potential (PEP) + +**Purpose:** Estimate how much work can proceed immediately. + +A workstream is eligible if: + +* Status = active +* All dependencies are completed + +[ +PEP = \frac{\text{Eligible active workstreams}}{\text{Active + Blocked}} +] + +--- + +### 4.5 Cross-Domain Dependency Ratio (CDDR) + +**Purpose:** Measure architectural entanglement between domains. + +[ +CDDR = \frac{\text{Dependencies crossing domain boundaries}}{\text{Total dependencies}} +] + +High values indicate loss of modularity. + +--- + +### 4.6 Cycle Presence Indicator (CPI) + +**Purpose:** Detect circular dependencies (deadlocks). + +CPI = + +* 0 β†’ no cycles +* 1 β†’ at least one cycle detected + +Any cycle indicates structural invalidity of the dependency graph. + +--- + +## 5. WHI Aggregation Formula + +Recommended weighted model: + +[ +WHI = +0.30 \cdot (1 - DD_{norm}) + +0.25 \cdot (1 - BR) + +0.15 \cdot (1 - SPR) + +0.20 \cdot PEP + +0.10 \cdot (1 - CDDR) +] + +If CPI = 1 (cycle present): + +[ +WHI = WHI \times 0.5 +] + +This penalty ensures deadlocks strongly degrade health. + +--- + +### Normalization of DD + +Because DD is unbounded, normalize using a saturation threshold: + +[ +DD_{norm} = \min\left(1, \frac{DD}{DD_{critical}}\right) +] + +Recommended: + +[ +DD_{critical} = 1.0 +] + +Meaning: one dependency per workstream is considered heavily coupled. + +--- + +## 6. Aggregation Across Domains + +WHI must be computed at three levels: + +### 6.1 Intra-Domain WHI + +Using only workstreams and dependencies within the domain. + +Purpose: + +* Evaluate domain autonomy +* Identify internal planning issues + +--- + +### 6.2 Cross-Domain WHI + +Using only dependencies crossing domains. + +Purpose: + +* Assess integration complexity +* Identify architectural entanglement + +--- + +### 6.3 Global WHI + +Computed on the full graph. + +--- + +## 7. Health States + +### 🟒 GREEN β€” Healthy Structure + +**Condition:** Workstreams are largely independent and flow is stable. + +Recommended thresholds: + +* WHI β‰₯ 0.75 +* BR ≀ 0.20 +* DD ≀ 0.5 +* SPR ≀ 0.25 +* No cycles +* PEP β‰₯ 0.6 + +Interpretation: + +* Parallel execution effective +* Delays localized +* Planning adequate + +No intervention required. + +--- + +### 🟠 ORANGE β€” Optimizable Coupling + +**Condition:** Dependencies introduce noticeable coordination cost but system remains viable. + +Trigger if ANY of: + +* 0.50 ≀ WHI < 0.75 +* 0.20 < BR ≀ 0.40 +* 0.5 < DD ≀ 1.0 +* 0.25 < SPR ≀ 0.40 +* PEP between 0.3 and 0.6 +* High cross-domain dependencies (> 0.4) + +Interpretation: + +* Parallelism reduced +* Timeline sensitive to delays +* Replanning likely beneficial + +Recommended action: + +> Review decomposition and dependency structure. + +--- + +### πŸ”΄ RED β€” Critical Coupling / Structural Failure + +**Condition:** Systemic blockage or high fragility. + +Trigger if ANY of: + +* WHI < 0.50 +* BR > 0.40 +* DD > 1.0 +* SPR > 0.40 +* PEP < 0.30 +* CPI = 1 (cycle present) +* Large clusters of mutually blocked streams + +Interpretation: + +* Serial execution dominates +* High coordination overhead +* Cascading delays likely +* Timeline unreliable + +Required action: + +> Immediate optimization at the planning layer. + +--- + +## 8. Circular Dependency Handling + +Circular dependencies are treated as critical defects because they imply: + +* No feasible execution order +* Deadlock or hidden assumptions +* Planning inconsistency + +Detection must use graph cycle detection (e.g., DFS or topological sort failure). + +--- + +## 9. Recommended Usage + +WHI should be used for: + +* Program governance dashboards +* Planning reviews +* Architecture decisions +* Early risk detection +* Automated orchestration systems +* AI-assisted planning tools + +WHI is **not** a performance metric for individuals. + +--- + +## 10. Design Principles + +The metric system is designed to be: + +* Domain-agnostic +* Scalable +* Resistant to gaming +* Actionable +* Explainable via drilldown +* Compatible with automated systems +* Suitable for long-lived programs + +--- + +## 11. Summary + +The Workstream Health Index provides a quantitative measure of how effectively an organization structures work for parallel execution and stable progress. + +It captures both: + +* Structural design quality +* Operational flow conditions + +By combining graph properties with status information, WHI enables proactive management of coordination complexity. + From 0546a1bb2a716e74fab32c8c6d09beff3de4caf1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 27 Feb 2026 18:28:44 +0100 Subject: [PATCH 045/198] feat(dashboard): add entity detail modal and fixed-layout tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Inputs.table() with buildEntityTable() across workstreams and tasks pages. Add click-to-detail modal (openEntityModal) on all entity list views: workstreams, tasks, extension points, and technical debt. - New component: src/components/entity-modal.js - openEntityModal(entity, type) β€” full-detail overlay (Esc/click-outside to close) - buildEntityTable(rows, cols, onRowClick) β€” table-layout:fixed, overflow-safe wrapper - CSS injected lazily; no separate stylesheet required - Tables: table-layout:fixed keeps content within the content column; title col 32%, workstream col 14%, all cells ellipsis + title tooltip - Cards (EP, TD): onclick β†’ modal; workstream name span gets title tooltip - Blocked task cards also wired to modal Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/components/entity-modal.js | 415 +++++++++++++++++++++++ dashboard/src/extensions.md | 15 +- dashboard/src/tasks.md | 29 +- dashboard/src/techdept.md | 23 +- dashboard/src/workstreams.md | 33 +- 5 files changed, 479 insertions(+), 36 deletions(-) create mode 100644 dashboard/src/components/entity-modal.js diff --git a/dashboard/src/components/entity-modal.js b/dashboard/src/components/entity-modal.js new file mode 100644 index 0000000..c3fe3af --- /dev/null +++ b/dashboard/src/components/entity-modal.js @@ -0,0 +1,415 @@ +/** + * entity-modal β€” click any entity row or card to open a full-detail overlay. + * + * Usage: + * import {openEntityModal} from "./components/entity-modal.js"; + * row.addEventListener("click", () => openEntityModal(entity, "workstream")); + * + * Supported types: "workstream" | "task" | "ep" | "td" + */ + +const _STYLE_ID = "entity-modal-styles"; + +function _ensureStyles() { + if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return; + const s = document.createElement("style"); + s.id = _STYLE_ID; + s.textContent = ` +/* ── Modal backdrop ──────────────────────────────────────────────────────── */ +.entity-modal { + position: fixed; inset: 0; background: rgba(0,0,0,0.45); + z-index: 9100; display: flex; align-items: center; justify-content: center; + animation: _em-fade 0.15s ease; +} +@keyframes _em-fade { from { opacity:0 } to { opacity:1 } } + +/* ── Modal box ────────────────────────────────────────────────────────────── */ +.entity-modal-box { + width: min(700px, 92vw); max-height: 88vh; + background: var(--theme-background, #fff); border-radius: 12px; + box-shadow: 0 16px 56px rgba(0,0,0,0.28); + display: flex; flex-direction: column; + animation: _em-rise 0.15s ease; overflow: hidden; +} +@keyframes _em-rise { + from { transform: translateY(14px); opacity: 0 } + to { transform: translateY(0); opacity: 1 } +} + +/* ── Header ───────────────────────────────────────────────────────────────── */ +.entity-modal-header { + display: flex; align-items: flex-start; gap: 0.75rem; + padding: 0.85rem 1rem 0.75rem; + border-bottom: 1px solid var(--theme-foreground-faint, #e8e8e8); + background: var(--theme-background-alt, #f7f7f7); flex-shrink: 0; +} +.entity-modal-title { + flex: 1; font-size: 1rem; font-weight: 700; line-height: 1.35; + color: var(--theme-foreground, #111); word-break: break-word; +} +.entity-modal-close { + background: none; border: 1px solid transparent; cursor: pointer; + font-size: 0.9rem; color: var(--theme-foreground-muted, #888); + padding: 0.15rem 0.45rem; border-radius: 6px; flex-shrink: 0; + font-family: inherit; line-height: 1.3; +} +.entity-modal-close:hover { + border-color: var(--theme-foreground-faint, #ccc); + background: var(--theme-background, #fff); color: var(--theme-foreground, #111); +} + +/* ── Body ─────────────────────────────────────────────────────────────────── */ +.entity-modal-body { + overflow-y: auto; padding: 0.85rem 1rem; + display: flex; flex-direction: column; gap: 0.4rem; +} + +/* ── Field rows ───────────────────────────────────────────────────────────── */ +.em-field { + display: grid; grid-template-columns: 130px 1fr; + gap: 0.15rem 0.65rem; font-size: 0.85rem; align-items: baseline; +} +.em-label { + font-size: 0.7rem; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.06em; color: var(--theme-foreground-faint, #aaa); + padding-top: 0.14rem; white-space: nowrap; +} +.em-value { color: var(--theme-foreground, #222); line-height: 1.5; word-break: break-word; } + +/* ── Description block ────────────────────────────────────────────────────── */ +.em-desc { + font-size: 0.83rem; color: var(--theme-foreground-muted, #555); + line-height: 1.55; white-space: pre-wrap; word-break: break-word; + background: var(--theme-background-alt, #f9f9f9); + border-radius: 6px; padding: 0.55rem 0.75rem; + border: 1px solid var(--theme-foreground-faint, #eee); + max-width: 100%; +} + +/* ── Divider ──────────────────────────────────────────────────────────────── */ +.em-divider { border: none; border-top: 1px solid var(--theme-foreground-faint, #eee); margin: 0.2rem 0; } + +/* ── Badge (reused from page styles, self-contained) ─────────────────────── */ +.em-badge { + display: inline-block; padding: 0.12rem 0.5rem; border-radius: 10px; + font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; +} + +/* ── Deps list ────────────────────────────────────────────────────────────── */ +.em-deps-list { display: flex; flex-direction: column; gap: 0.12rem; } +.em-dep-item { font-size: 0.82rem; color: var(--theme-foreground-muted, #666); } + +/* ── Entity table (shared across all list pages) ──────────────────────────── */ +.entity-table-wrap { overflow-x: auto; max-width: 100%; } +.entity-table { + width: 100%; border-collapse: collapse; font-size: 0.87rem; + table-layout: fixed; /* honour column widths; never spill outside container */ +} +.entity-table thead th { + text-align: left; padding: 0.4rem 0.65rem; + border-bottom: 2px solid var(--theme-foreground-faint, #ddd); + font-size: 0.73rem; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.05em; color: var(--theme-foreground-muted, #777); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.entity-table tbody tr { border-bottom: 1px solid var(--theme-foreground-faint, #eee); } +.entity-table tbody tr:last-child { border-bottom: none; } +.entity-table tbody tr:hover { background: var(--theme-background-alt, #f5f5f5); } +.entity-table td { + padding: 0.4rem 0.65rem; vertical-align: middle; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.entity-row { cursor: pointer; } +/* Proportional column widths β€” other cols share the remainder equally */ +.et-title-col { width: 32%; } +.et-ws-col { width: 14%; } +.et-title-cell { font-weight: 500; } +.et-ws-cell { font-style: italic; } +`; + document.head.append(s); +} + +/* ── Style maps ──────────────────────────────────────────────────────────── */ +const _STATUS_STYLE = { + active: "background:#d4edda;color:#155724", + blocked: "background:#f8d7da;color:#721c24", + completed: "background:#cce5ff;color:#004085", + archived: "background:#e2e3e5;color:#383d41", + open: "background:#dbeafe;color:#1e40af", + in_progress: "background:#fef3c7;color:#92400e", + addressed: "background:#dcfce7;color:#166534", + deferred: "background:#f1f5f9;color:#64748b", + wont_fix: "background:#f3f4f6;color:#9ca3af", + todo: "background:#f1f5f9;color:#475569", + done: "background:#dcfce7;color:#166534", + cancelled: "background:#f3f4f6;color:#9ca3af", + resolved: "background:#dcfce7;color:#166534", + superseded: "background:#e2e3e5;color:#383d41", +}; + +const _PRIORITY_STYLE = { + critical: "background:#fee2e2;color:#991b1b", + high: "background:#ffedd5;color:#9a3412", + medium: "background:#dbeafe;color:#1e40af", + low: "background:#f1f5f9;color:#475569", +}; + +/* ── DOM helpers ─────────────────────────────────────────────────────────── */ +function _badge(text, styleMap) { + const el = document.createElement("span"); + el.className = "em-badge"; + el.style.cssText = styleMap[text] ?? "background:#f1f5f9;color:#555"; + el.textContent = (text ?? "").replace(/_/g, " "); + return el; +} + +function _field(label, valueEl) { + const row = document.createElement("div"); + row.className = "em-field"; + const l = document.createElement("span"); + l.className = "em-label"; + l.textContent = label; + row.append(l, valueEl); + return row; +} + +function _textVal(text) { + const v = document.createElement("span"); + v.className = "em-value"; + v.textContent = text ?? "β€”"; + return v; +} + +function _descVal(text) { + const v = document.createElement("div"); + v.className = "em-desc"; + v.textContent = text; + return v; +} + +function _divider() { + const hr = document.createElement("hr"); + hr.className = "em-divider"; + return hr; +} + +function _fmtDate(iso) { + if (!iso) return "β€”"; + try { return new Date(iso).toLocaleString(); } catch { return iso; } +} + +/* ── Body builders per entity type ─────────────────────────────────────── */ +function _buildBody(entity, type) { + const els = []; + const tf = (label, text) => _field(label, _textVal(text)); + const bf = (label, val, styleMap) => { + const v = document.createElement("span"); + v.className = "em-value"; + v.append(_badge(val, styleMap)); + return _field(label, v); + }; + + if (type === "workstream") { + els.push( + bf("Status", entity.status, _STATUS_STYLE), + tf("Domain", entity.domain ?? entity.topic_title ?? "β€”"), + tf("Owner", entity.owner ?? "β€”"), + tf("Due", entity.due_date ?? "β€”"), + ); + if (entity.description) { + els.push(_divider(), _field("Description", _descVal(entity.description))); + } + if (entity.tasks_total !== undefined) { + els.push(_divider(), + tf("Tasks", `${entity.tasks_done ?? 0} done / ${entity.tasks_total} total` + + (entity.tasks_in_progress > 0 ? ` Β· ${entity.tasks_in_progress} in progress` : "") + + (entity.tasks_blocked > 0 ? ` Β· ${entity.tasks_blocked} blocked` : "")) + ); + } + if (entity.depends_on?.length) { + const list = document.createElement("div"); list.className = "em-deps-list"; + for (const d of entity.depends_on) { + const span = document.createElement("span"); span.className = "em-dep-item"; + span.textContent = `↳ ${d.workstream_title}`; + list.append(span); + } + els.push(_field("Depends on", list)); + } + if (entity.blocks?.length) { + const list = document.createElement("div"); list.className = "em-deps-list"; + for (const d of entity.blocks) { + const span = document.createElement("span"); span.className = "em-dep-item"; + span.textContent = `⊳ ${d.workstream_title}`; + list.append(span); + } + els.push(_field("Blocks", list)); + } + els.push(_divider(), tf("Updated", _fmtDate(entity.updated_at))); + if (entity.slug) els.push(tf("Slug", entity.slug)); + els.push(tf("ID", entity.id)); + } + + else if (type === "task") { + els.push( + bf("Status", entity.status, _STATUS_STYLE), + bf("Priority", entity.priority, _PRIORITY_STYLE), + tf("Domain", entity.domain ?? "β€”"), + tf("Workstream", entity.workstream_title ?? "β€”"), + tf("Assignee", entity.assignee ?? "β€”"), + tf("Due", entity.due_date ?? "β€”"), + ); + if (entity.description) { + els.push(_divider(), _field("Description", _descVal(entity.description))); + } + if (entity.blocking_reason) { + const v = document.createElement("span"); + v.className = "em-value"; v.style.color = "#b45309"; + v.textContent = entity.blocking_reason; + els.push(_divider(), _field("Blocking reason", v)); + } + els.push(_divider(), tf("Updated", _fmtDate(entity.updated_at))); + els.push(tf("ID", entity.id)); + } + + else if (type === "ep") { + if (entity.ep_id) els.push(tf("EP ID", entity.ep_id)); + els.push( + bf("Status", entity.status, _STATUS_STYLE), + bf("Priority", entity.priority, _PRIORITY_STYLE), + tf("Type", entity.ep_type ?? "β€”"), + tf("Domain", entity.domain ?? "β€”"), + tf("Workstream", entity.workstream_title ?? "β€”"), + tf("Location", entity.location ?? "β€”"), + ); + if (entity.description) { + els.push(_divider(), _field("Description", _descVal(entity.description))); + } + els.push(_divider(), tf("Recorded", _fmtDate(entity.created_at))); + els.push(tf("UUID", entity.id)); + } + + else if (type === "td") { + if (entity.td_id) els.push(tf("TD ID", entity.td_id)); + els.push( + bf("Severity", entity.severity, _PRIORITY_STYLE), + bf("Status", entity.status, _STATUS_STYLE), + tf("Type", entity.debt_type ?? "β€”"), + tf("Domain", entity.domain ?? "β€”"), + tf("Workstream", entity.workstream_title ?? "β€”"), + tf("Location", entity.location ?? "β€”"), + ); + if (entity.description) { + els.push(_divider(), _field("Description", _descVal(entity.description))); + } + els.push(_divider(), tf("Recorded", _fmtDate(entity.created_at))); + els.push(tf("UUID", entity.id)); + } + + return els; +} + +/* ── Public API ──────────────────────────────────────────────────────────── */ + +/** + * Open a detail modal for the given entity. + * @param {object} entity - The entity data object (workstream, task, ep, or td) + * @param {string} type - One of: "workstream" | "task" | "ep" | "td" + */ +export function openEntityModal(entity, type) { + _ensureStyles(); + document.getElementById("_entity-modal-root")?.remove(); + + const title = entity.title ?? "(no title)"; + + const root = document.createElement("div"); + root.id = "_entity-modal-root"; + root.className = "entity-modal"; + root.setAttribute("role", "dialog"); + root.setAttribute("aria-modal", "true"); + root.setAttribute("aria-label", title); + + const box = document.createElement("div"); + box.className = "entity-modal-box"; + + // Header + const header = document.createElement("div"); + header.className = "entity-modal-header"; + const titleEl = document.createElement("div"); + titleEl.className = "entity-modal-title"; + titleEl.textContent = title; + const closeBtn = document.createElement("button"); + closeBtn.className = "entity-modal-close"; + closeBtn.textContent = "βœ• close"; + closeBtn.setAttribute("aria-label", "Close detail panel"); + header.append(titleEl, closeBtn); + + // Body + const body = document.createElement("div"); + body.className = "entity-modal-body"; + for (const el of _buildBody(entity, type)) body.append(el); + + box.append(header, body); + root.append(box); + document.body.append(root); + + const close = () => root.remove(); + closeBtn.addEventListener("click", close); + root.addEventListener("click", e => { if (e.target === root) close(); }); + const onKey = e => { + if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); } + }; + document.addEventListener("keydown", onKey); +} + +/** + * Build an interactive entity table element. + * + * @param {Array} rows - Array of entity objects to display + * @param {Array} columns - [{label, key, cls?}] β€” columns in order + * @param {Function} onRowClick - Called with the full entity when a row is clicked + * @returns {HTMLTableElement} + */ +export function buildEntityTable(rows, columns, onRowClick) { + _ensureStyles(); + + const table = document.createElement("table"); + table.className = "entity-table"; + + // Header + const thead = document.createElement("thead"); + const htr = document.createElement("tr"); + for (const col of columns) { + const th = document.createElement("th"); + th.textContent = col.label; + if (col.cls) th.className = col.cls; + htr.append(th); + } + thead.append(htr); + table.append(thead); + + // Body + const tbody = document.createElement("tbody"); + for (const row of rows) { + const tr = document.createElement("tr"); + tr.className = "entity-row"; + tr.addEventListener("click", () => onRowClick(row)); + + for (const col of columns) { + const td = document.createElement("td"); + const raw = col.key ? row[col.key] : null; + const text = col.render ? col.render(row) : (raw ?? "β€”"); + const textStr = String(text ?? "β€”"); + td.textContent = textStr; + if (col.cls) td.className = col.cls; + // Native tooltip so full value shows on hover (skip placeholder "β€”") + if (textStr && textStr !== "β€”") td.title = textStr; + tr.append(td); + } + tbody.append(tr); + } + table.append(tbody); + const wrap = document.createElement("div"); + wrap.className = "entity-table-wrap"; + wrap.append(table); + return wrap; +} diff --git a/dashboard/src/extensions.md b/dashboard/src/extensions.md index 119cd22..17c56db 100644 --- a/dashboard/src/extensions.md +++ b/dashboard/src/extensions.md @@ -86,8 +86,9 @@ const filtered = data.filter(e => # Extension Points ```js -import {injectTocTop} from "./components/toc-sidebar.js"; -import {withDocHelp} from "./components/doc-overlay.js"; +import {injectTocTop} from "./components/toc-sidebar.js"; +import {withDocHelp} from "./components/doc-overlay.js"; +import {openEntityModal} from "./components/entity-modal.js"; // ── KPI sidebar ─────────────────────────────────────────────────────────────── const _open = data.filter(e => e.status === "open" || e.status === "in_progress"); @@ -180,17 +181,19 @@ display(_filtersForm); display(html`

${filtered.length} extension points shown.

`); display(html`
${filtered.map(ep => html` -
+
openEntityModal(ep, "ep")} + title="Click to view full details">
${ep.ep_id ? html`${ep.ep_id}` : ""} ${ep.ep_type} ${ep.status.replace("_", " ")} ${ep.priority} ${ep.domain} - ${ep.workstream_title ? html`${ep.workstream_title}` : ""} + ${ep.workstream_title ? html`${ep.workstream_title}` : ""}
${ep.title}
- ${ep.description ? html`
${ep.description.slice(0, 240)}${ep.description.length > 240 ? "…" : ""}
` : ""} + ${ep.description ? html`
${ep.description.slice(0, 220)}${ep.description.length > 220 ? " …" : ""}
` : ""} ${ep.location ? html`
${ep.location}
` : ""}
`)} @@ -222,6 +225,8 @@ display(html`
${filtered.map(ep => html` /* ── EP list ──────────────────────────────────────────────────────────────── */ .ep-list { display: flex; flex-direction: column; gap: 0.5rem; } .ep-item { border-left: 3px solid #94a3b8; border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.65rem 0.9rem; } +.ep-item.entity-row { cursor: pointer; transition: filter 0.1s; } +.ep-item.entity-row:hover { filter: brightness(0.97); } .ep-status-open { border-left-color: #3b82f6; } .ep-status-in_progress { border-left-color: #f59e0b; } .ep-status-addressed { border-left-color: #22c55e; } diff --git a/dashboard/src/tasks.md b/dashboard/src/tasks.md index 418bc51..251125d 100644 --- a/dashboard/src/tasks.md +++ b/dashboard/src/tasks.md @@ -83,8 +83,9 @@ const filtered = data.filter(t => # Tasks ```js -import {injectTocTop} from "./components/toc-sidebar.js"; -import {withDocHelp} from "./components/doc-overlay.js"; +import {injectTocTop} from "./components/toc-sidebar.js"; +import {withDocHelp} from "./components/doc-overlay.js"; +import {openEntityModal, buildEntityTable} from "./components/entity-modal.js"; // ── KPI sidebar card ───────────────────────────────────────────────────────── const _open = data.filter(t => ["todo", "in_progress", "blocked"].includes(t.status)); @@ -179,7 +180,7 @@ if (_blockedInFilter.length === 0) { display(html`

No blocked tasks in current filter. βœ“

`); } else { display(html`
${_blockedInFilter.map(t => html` -
+
openEntityModal(t, "task")}>
${t.priority} ${t.domain} @@ -209,15 +210,19 @@ const sorted = [...filtered].sort((a, b) => { return (PRIORITY_ORDER[a.priority] ?? 9) - (PRIORITY_ORDER[b.priority] ?? 9); }); -display(Inputs.table(sorted.map(t => ({ - Status: t.status, - Priority: t.priority, - Title: t.title, - Domain: t.domain, - Workstream: t.workstream_title, - Assignee: t.assignee ?? "β€”", - Due: t.due_date ?? "β€”", -})), {rows: 25})); +display(buildEntityTable( + sorted, + [ + {label: "Status", key: "status"}, + {label: "Priority", key: "priority"}, + {label: "Title", key: "title", cls: "et-title-col et-title-cell"}, + {label: "Domain", key: "domain"}, + {label: "Workstream", key: "workstream_title", cls: "et-ws-col et-ws-cell"}, + {label: "Assignee", render: t => t.assignee ?? "β€”"}, + {label: "Due", render: t => t.due_date ?? "β€”"}, + ], + t => openEntityModal(t, "task"), +)); ``` diff --git a/dashboard/src/extensions.md b/dashboard/src/extensions.md index 17c56db..3ed20ab 100644 --- a/dashboard/src/extensions.md +++ b/dashboard/src/extensions.md @@ -22,7 +22,7 @@ const epState = (async function*() { const [epList, wsList, topicList] = await Promise.all([re.json(), rw.json(), rt.json()]); const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); const wsMap = Object.fromEntries(wsList.map(w => [w.id, { - ...w, domain: topicMap[w.topic_id]?.domain ?? "unknown", + ...w, domain: topicMap[w.topic_id]?.domain_slug ?? "unknown", }])); data = epList.map(e => ({ ...e, @@ -52,7 +52,10 @@ import {MultiSelect} from "./components/multiselect.js"; const STATUSES = ["open", "in_progress", "addressed", "deferred", "wont_fix"]; const PRIORITIES = ["critical", "high", "medium", "low"]; -const DOMAINS = ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"]; +const _domainsResp = await fetch(`${API}/domains/?status=active`).catch(() => null); +const DOMAINS = _domainsResp?.ok + ? (await _domainsResp.json()).map(d => d.slug) + : ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"]; const EP_TYPES = ["api", "schema", "mcp", "dashboard", "architecture", "integration", "other"]; const _filtersForm = Inputs.form( diff --git a/dashboard/src/techdept.md b/dashboard/src/techdept.md index 4c4d965..7517571 100644 --- a/dashboard/src/techdept.md +++ b/dashboard/src/techdept.md @@ -22,7 +22,7 @@ const tdState = (async function*() { const [tdList, wsList, topicList] = await Promise.all([rt.json(), rw.json(), rto.json()]); const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); const wsMap = Object.fromEntries(wsList.map(w => [w.id, { - ...w, domain: topicMap[w.topic_id]?.domain ?? "unknown", + ...w, domain: topicMap[w.topic_id]?.domain_slug ?? "unknown", }])); data = tdList.map(t => ({ ...t, @@ -52,7 +52,10 @@ import {MultiSelect} from "./components/multiselect.js"; const STATUSES = ["open", "in_progress", "resolved", "deferred", "wont_fix"]; const SEVERITIES = ["critical", "high", "medium", "low"]; -const DOMAINS = ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"]; +const _domainsResp = await fetch(`${API}/domains/?status=active`).catch(() => null); +const DOMAINS = _domainsResp?.ok + ? (await _domainsResp.json()).map(d => d.slug) + : ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"]; const DEBT_TYPES = ["design", "implementation", "test", "docs", "dependencies", "performance", "security", "other"]; const _filtersForm = Inputs.form( diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index 2a3e86d..d3c2608 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -24,7 +24,7 @@ const wsState = (async function*() { const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); data = wsList.map(w => ({ ...w, - domain: topicMap[w.topic_id]?.domain ?? "unknown", + domain: topicMap[w.topic_id]?.domain_slug ?? "unknown", topic_title: topicMap[w.topic_id]?.title ?? "β€”", })); // open_workstreams from summary carry depends_on / blocks lists @@ -214,8 +214,11 @@ if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/workstreams" ```js import {MultiSelect} from "./components/multiselect.js"; -// Static options β€” no dependency on `data`, so selections survive polls -const DOMAINS = ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"]; +// Load domain slugs from API (dynamic β€” works with new domains after v0.5) +const _domainsResp = await fetch(`${API}/domains/?status=active`).catch(() => null); +const DOMAINS = _domainsResp?.ok + ? (await _domainsResp.json()).map(d => d.slug) + : ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"]; const STATUSES = ["active", "blocked", "completed", "archived"]; // Create filter form without displaying β€” shown below the chart diff --git a/mcp_server/TOOLS.md b/mcp_server/TOOLS.md index f1b2900..9a060b0 100644 --- a/mcp_server/TOOLS.md +++ b/mcp_server/TOOLS.md @@ -77,9 +77,24 @@ Do not use them as a substitute for formal work definition inside the domain rep --- +## Domain Management Tools (v0.5) + +Domains are now first-class DB entities. Use `list_domains()` to discover available slugs. + +| Tool | Key Args | Notes | +|------|----------|-------| +| `list_domains(status?)` | `status`: active/archived/all (default: active) | Discover all registered domains. | +| `create_domain(slug, name, description?)` | `slug`: lowercase_underscored; `name`: display name | Register a new project domain. | +| `rename_domain(slug, new_slug, new_name)` | all required | Renames domain and cascades to EP/TD string columns. | +| `archive_domain(slug)` | `slug` | Soft-delete; fails if active topics exist. | +| `list_domain_repos(domain_slug)` | `domain_slug` | List repos registered under a domain. | +| `register_repo(domain_slug, name, ...)` | `slug?`; `local_path?`; `remote_url?` | Register a git repo under a domain. | + +--- + ## Domain Slugs -`custodian` Β· `railiance` Β· `markitect` Β· `coulomb-social` Β· `personhood` Β· `foerster-capabilities` +Run `list_domains()` to get the live list. Default 6: `custodian` Β· `railiance` Β· `markitect` Β· `coulomb_social` Β· `personhood` Β· `foerster_capabilities` --- diff --git a/mcp_server/server.py b/mcp_server/server.py index 79118d9..8a95615 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -630,6 +630,124 @@ def update_td_status(td_uuid: str, status: str) -> str: return json.dumps(td, indent=2) +# --------------------------------------------------------------------------- +# Domain lifecycle + repo registration tools (v0.5) +# --------------------------------------------------------------------------- + +@mcp.tool() +def list_domains(status: str = "active") -> str: + """List all registered domains. + + Args: + status: active | archived | all (default: active) + """ + return json.dumps(_get("/domains", {"status": status}), indent=2) + + +@mcp.tool() +def create_domain(slug: str, name: str, description: str | None = None) -> str: + """Create a new domain. + + Args: + slug: URL-friendly identifier (lowercase, underscored), e.g. 'my_project' + name: Human-readable display name + description: optional longer description + """ + domain = _post("/domains", {"slug": slug, "name": name, "description": description}) + _post("/progress", { + "event_type": "milestone", + "summary": f"Domain created: {slug} ({name})", + "author": "custodian", + "detail": {"slug": slug, "name": name}, + }) + return json.dumps(domain, indent=2) + + +@mcp.tool() +def rename_domain(slug: str, new_slug: str, new_name: str) -> str: + """Rename a domain β€” cascades to EP/TD string columns. + + Args: + slug: Current domain slug + new_slug: New URL-friendly identifier + new_name: New human-readable display name + """ + domain = _patch(f"/domains/{slug}/rename", {"new_slug": new_slug, "new_name": new_name}) + _post("/progress", { + "event_type": "milestone", + "summary": f"Domain renamed: {slug} β†’ {new_slug} ({new_name})", + "author": "custodian", + "detail": {"old_slug": slug, "new_slug": new_slug, "new_name": new_name}, + }) + return json.dumps(domain, indent=2) + + +@mcp.tool() +def archive_domain(slug: str) -> str: + """Archive a domain (soft-delete). Fails if active topics exist. + + Args: + slug: Domain slug to archive + """ + domain = _patch(f"/domains/{slug}/archive", {}) + _post("/progress", { + "event_type": "note", + "summary": f"Domain archived: {slug}", + "author": "custodian", + "detail": {"slug": slug}, + }) + return json.dumps(domain, indent=2) + + +@mcp.tool() +def list_domain_repos(domain_slug: str) -> str: + """List all repositories registered under a domain. + + Args: + domain_slug: Domain slug to filter by + """ + return json.dumps(_get("/repos", {"domain": domain_slug}), indent=2) + + +@mcp.tool() +def register_repo( + domain_slug: str, + name: str, + slug: str | None = None, + local_path: str | None = None, + remote_url: str | None = None, + description: str | None = None, +) -> str: + """Register a git repository under a domain. + + Args: + domain_slug: Domain slug (must already exist) + name: Human-readable repository name + slug: URL-friendly identifier (auto-generated from name if omitted) + local_path: Absolute local filesystem path to the repo + remote_url: Remote git URL (Gitea, GitHub, etc.) + description: optional description + """ + import re as _re + if not slug: + slug = _re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + repo = _post("/repos", { + "domain_slug": domain_slug, + "slug": slug, + "name": name, + "local_path": local_path, + "remote_url": remote_url, + "description": description, + }) + _post("/progress", { + "event_type": "milestone", + "summary": f"Repo registered: {name} under domain '{domain_slug}'", + "author": "custodian", + "detail": {"slug": slug, "domain_slug": domain_slug, "local_path": local_path, "remote_url": remote_url}, + }) + return json.dumps(repo, indent=2) + + # --------------------------------------------------------------------------- # ADR-001 compliance validation # --------------------------------------------------------------------------- diff --git a/migrations/versions/b1c2d3e4f5a6_v0_5_dynamic_domains_and_managed_repos.py b/migrations/versions/b1c2d3e4f5a6_v0_5_dynamic_domains_and_managed_repos.py new file mode 100644 index 0000000..d9077da --- /dev/null +++ b/migrations/versions/b1c2d3e4f5a6_v0_5_dynamic_domains_and_managed_repos.py @@ -0,0 +1,141 @@ +"""v0.5 β€” dynamic domains table and managed_repos + +Replaces the hardcoded PostgreSQL ENUM `domain` type on the `topics` table +with a first-class `domains` table (FK: topics.domain_id β†’ domains.id). +Also introduces `managed_repos` for multi-repo support per domain. + +Revision ID: b1c2d3e4f5a6 +Revises: a3f1c2d4e5b6 +Create Date: 2026-02-28 00:00:00.000000 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "b1c2d3e4f5a6" +down_revision: Union[str, None] = "a3f1c2d4e5b6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# Canonical domain slugs matching the old ENUM values +CANONICAL_DOMAINS = [ + ("custodian", "The Custodian"), + ("railiance", "Railiance"), + ("markitect", "Markitect"), + ("coulomb_social", "Coulomb.social"), + ("personhood", "Personhood"), + ("foerster_capabilities", "Foerster Capabilities"), +] + + +def upgrade() -> None: + # ── Step 1: Create domains table ───────────────────────────────────────── + op.create_table( + "domains", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, + server_default=sa.text("gen_random_uuid()")), + sa.Column("slug", sa.String(50), nullable=False, unique=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("status", sa.String(20), nullable=False, server_default="active"), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_domains_slug", "domains", ["slug"]) + + # ── Step 2: Insert 6 canonical domain rows ──────────────────────────────── + for slug, name in CANONICAL_DOMAINS: + op.execute(sa.text( + "INSERT INTO domains (id, slug, name, status, created_at, updated_at) " + "VALUES (gen_random_uuid(), :slug, :name, 'active', now(), now()) " + "ON CONFLICT (slug) DO NOTHING" + ).bindparams(slug=slug, name=name)) + + # ── Step 3: Add domain_id FK column to topics (nullable initially) ──────── + op.add_column( + "topics", + sa.Column("domain_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("domains.id", ondelete="RESTRICT"), nullable=True), + ) + + # ── Step 4: Populate domain_id from existing enum values ────────────────── + op.execute(sa.text( + "UPDATE topics SET domain_id = (" + " SELECT id FROM domains WHERE domains.slug = topics.domain::text" + ")" + )) + + # ── Step 5: Make domain_id NOT NULL ──────────────────────────────────────── + op.alter_column("topics", "domain_id", nullable=False) + + # ── Step 6: Drop old domain enum column ─────────────────────────────────── + op.drop_column("topics", "domain") + + # ── Step 7: Drop PostgreSQL ENUM type ───────────────────────────────────── + op.execute(sa.text("DROP TYPE IF EXISTS domain")) + + # ── Step 8: Create managed_repos table ──────────────────────────────────── + op.create_table( + "managed_repos", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, + server_default=sa.text("gen_random_uuid()")), + sa.Column("domain_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("domains.id", ondelete="RESTRICT"), nullable=False), + sa.Column("slug", sa.String(100), nullable=False, unique=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("local_path", sa.Text, nullable=True), + sa.Column("remote_url", sa.Text, nullable=True), + sa.Column("description", sa.Text, nullable=True), + sa.Column("status", sa.String(20), nullable=False, server_default="active"), + sa.Column("topic_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("topics.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_managed_repos_slug", "managed_repos", ["slug"]) + op.create_index("ix_managed_repos_domain_id", "managed_repos", ["domain_id"]) + + +def downgrade() -> None: + # ── Drop managed_repos ──────────────────────────────────────────────────── + op.drop_table("managed_repos") + + # ── Recreate domain ENUM type ───────────────────────────────────────────── + domain_enum = postgresql.ENUM( + "custodian", "railiance", "markitect", "coulomb_social", + "personhood", "foerster_capabilities", + name="domain", create_type=True, + ) + domain_enum.create(op.get_bind(), checkfirst=True) + + # ── Add domain column back (nullable initially) ─────────────────────────── + op.add_column( + "topics", + sa.Column("domain", sa.Enum( + "custodian", "railiance", "markitect", "coulomb_social", + "personhood", "foerster_capabilities", + name="domain", create_type=False, + ), nullable=True), + ) + + # ── Populate from domain_id ──────────────────────────────────────────────── + op.execute(sa.text( + "UPDATE topics SET domain = (" + " SELECT slug FROM domains WHERE domains.id = topics.domain_id" + ")::domain" + )) + + # ── Make NOT NULL ────────────────────────────────────────────────────────── + op.alter_column("topics", "domain", nullable=False) + + # ── Drop domain_id ───────────────────────────────────────────────────────── + op.drop_column("topics", "domain_id") + + # ── Drop domains table ───────────────────────────────────────────────────── + op.drop_table("domains") diff --git a/scripts/register_project.sh b/scripts/register_project.sh index 4c80feb..b027978 100755 --- a/scripts/register_project.sh +++ b/scripts/register_project.sh @@ -1,33 +1,39 @@ #!/usr/bin/env bash -# register_project.sh β€” register a new project with the Custodian State Hub +# register_project.sh β€” register a project/repo with the Custodian State Hub # -# Usage: scripts/register_project.sh -# domain: one of custodian|railiance|markitect|coulomb_social|personhood|foerster_capabilities +# Usage: scripts/register_project.sh [--additional] +# domain: slug of an active domain (e.g. custodian, railiance) # project_path: absolute path to the project directory +# --additional: add a second repo to an existing domain; skip CLAUDE.md # # Example: # scripts/register_project.sh railiance /home/worsch/railiance +# scripts/register_project.sh railiance /home/worsch/railiance-infra --additional # # What it does: # 1. Verify the API is reachable -# 2. Look up the topic ID for the domain -# 3. Check that state-hub is in ~/.claude.json; warn if missing -# 4. Write $project_path/CLAUDE.md from the template (skip if exists) -# 5. POST a progress event recording the registration +# 2. Verify the domain exists via GET /domains/{slug}/ +# 3. Look up the topic ID for the domain (first active topic) +# 4. Check that state-hub is in ~/.claude.json; warn if missing +# 5. Write $project_path/CLAUDE.md from the template (skip if exists or --additional) +# 6. POST to /repos/ to register the repo +# 7. POST a progress event recording the registration set -euo pipefail DOMAIN="${1:-}" PROJECT_PATH="${2:-}" +ADDITIONAL="${3:-}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" STATE_HUB_DIR="$(dirname "$SCRIPT_DIR")" API_BASE="${API_BASE:-http://127.0.0.1:8000}" # ── Validate args ────────────────────────────────────────────────────────────── if [[ -z "$DOMAIN" || -z "$PROJECT_PATH" ]]; then - echo "Usage: $0 " - echo " domain: custodian|railiance|markitect|coulomb_social|personhood|foerster_capabilities" + echo "Usage: $0 [--additional]" + echo " domain: slug of an active domain in the State Hub" echo " project_path: absolute path to project directory" + echo " --additional: register a second repo; skip CLAUDE.md generation" exit 1 fi @@ -37,6 +43,7 @@ if [[ ! -d "$PROJECT_PATH" ]]; then fi PROJECT_NAME="$(basename "$PROJECT_PATH")" +REPO_SLUG="$(echo "$PROJECT_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-\|-$//g')" # ── Step 1: API health check ─────────────────────────────────────────────────── echo "==> Checking API at $API_BASE ..." @@ -48,14 +55,27 @@ if ! curl -sf "$API_BASE/state/health" > /dev/null; then fi echo " API OK" -# ── Step 2: Look up topic ID ─────────────────────────────────────────────────── +# ── Step 2: Verify domain exists ─────────────────────────────────────────────── +echo "==> Verifying domain '$DOMAIN' ..." +DOMAIN_JSON="$(curl -sf "$API_BASE/domains/$DOMAIN/" 2>/dev/null || echo 'NOT_FOUND')" +if [[ "$DOMAIN_JSON" == "NOT_FOUND" ]] || echo "$DOMAIN_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get('slug') else 1)" 2>/dev/null; then + if [[ "$DOMAIN_JSON" == "NOT_FOUND" ]] || ! echo "$DOMAIN_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get('slug') else 1)" 2>/dev/null; then + echo "ERROR: Domain '$DOMAIN' not found in the State Hub." + echo " To create: make add-domain DOMAIN=$DOMAIN NAME=\"\"" + echo " To list available: curl -s $API_BASE/domains/ | python3 -m json.tool" + exit 1 + fi +fi +echo " Domain OK" + +# ── Step 3: Look up topic ID ─────────────────────────────────────────────────── echo "==> Looking up topic for domain '$DOMAIN' ..." TOPICS_JSON="$(curl -sf "$API_BASE/topics/?status=active")" TOPIC_ID="$(echo "$TOPICS_JSON" | python3 -c " import json, sys topics = json.load(sys.stdin) -match = next((t for t in topics if t.get('domain') == sys.argv[1]), None) +match = next((t for t in topics if t.get('domain_slug') == sys.argv[1]), None) if not match: print('NOT_FOUND') else: @@ -63,13 +83,13 @@ else: " "$DOMAIN")" if [[ "$TOPIC_ID" == "NOT_FOUND" ]]; then - echo "ERROR: No active topic found for domain '$DOMAIN'." - echo " Known domains: custodian railiance markitect coulomb_social personhood foerster_capabilities" - exit 1 + echo "WARNING: No active topic found for domain '$DOMAIN'. CLAUDE.md will omit topic_id." + TOPIC_ID="" +else + echo " topic_id: $TOPIC_ID" fi -echo " topic_id: $TOPIC_ID" -# ── Step 3: Check MCP registration ──────────────────────────────────────────── +# ── Step 4: Check MCP registration ──────────────────────────────────────────── echo "==> Checking MCP server registration ..." MCP_OK="$(python3 -c " import json @@ -85,10 +105,6 @@ else: if [[ "$MCP_OK" == "MISSING_FILE" ]]; then echo "WARNING: ~/.claude.json not found. MCP server is not registered." - echo " To register:" - echo " MΠ‘PCFG=\$(cat $STATE_HUB_DIR/../.mcp.json | python3 -c \"import json,sys; print(json.dumps(json.load(sys.stdin)['mcpServers']['state-hub']))\")" - echo " claude mcp add-json -s user state-hub \"\$MCPCFG\"" - echo " python3 $SCRIPT_DIR/patch_mcp_cwd.py" elif [[ "$MCP_OK" == "NOT_REGISTERED" ]]; then echo "WARNING: 'state-hub' not found in ~/.claude.json." echo " To register, see CLAUDE.md MCP Server Registration section." @@ -96,11 +112,13 @@ else echo " MCP OK" fi -# ── Step 4: Write CLAUDE.md ──────────────────────────────────────────────────── +# ── Step 5: Write CLAUDE.md ──────────────────────────────────────────────────── CLAUDE_MD="$PROJECT_PATH/CLAUDE.md" TEMPLATE="$SCRIPT_DIR/project_claude_md.template" -if [[ -f "$CLAUDE_MD" ]]; then +if [[ "$ADDITIONAL" == "--additional" ]]; then + echo "==> --additional flag: skipping CLAUDE.md (already exists for this domain)." +elif [[ -f "$CLAUDE_MD" ]]; then echo "==> CLAUDE.md already exists at $CLAUDE_MD β€” skipping." else echo "==> Writing CLAUDE.md to $CLAUDE_MD ..." @@ -112,12 +130,35 @@ else echo " Written." fi -# ── Step 5: Record progress event ───────────────────────────────────────────── +# ── Step 6: Register repo in State Hub ──────────────────────────────────────── +echo "==> Registering repo '$PROJECT_NAME' under domain '$DOMAIN' ..." +REPO_PAYLOAD="$(python3 -c " +import json +payload = { + 'domain_slug': '$DOMAIN', + 'slug': '$REPO_SLUG', + 'name': '$PROJECT_NAME', + 'local_path': '$PROJECT_PATH', +} +print(json.dumps(payload)) +")" + +REPO_RESULT="$(curl -sf -X POST "$API_BASE/repos/" \ + -H "Content-Type: application/json" \ + -d "$REPO_PAYLOAD" 2>/dev/null || echo 'REPO_EXISTS')" + +if [[ "$REPO_RESULT" == "REPO_EXISTS" ]]; then + echo " Repo '$REPO_SLUG' already registered (or slug conflict) β€” skipping." +else + echo " Repo registered: $REPO_SLUG" +fi + +# ── Step 7: Record progress event ───────────────────────────────────────────── echo "==> Recording registration event ..." EVENT_JSON="$(python3 -c " import json payload = { - 'topic_id': '$TOPIC_ID', + $([ -n '$TOPIC_ID' ] && echo "'topic_id': '$TOPIC_ID',") 'event_type': 'milestone', 'summary': 'Project registered with State Hub: $PROJECT_NAME ($DOMAIN)', 'author': 'custodian', @@ -125,6 +166,7 @@ payload = { 'project_path': '$PROJECT_PATH', 'claude_md': '$CLAUDE_MD', 'domain': '$DOMAIN', + 'repo_slug': '$REPO_SLUG', }, } print(json.dumps(payload)) @@ -139,7 +181,8 @@ echo "" echo "Registration complete!" echo " Project: $PROJECT_NAME" echo " Domain: $DOMAIN" -echo " Topic ID: $TOPIC_ID" +echo " Repo slug: $REPO_SLUG" +[[ -n "$TOPIC_ID" ]] && echo " Topic ID: $TOPIC_ID" echo " CLAUDE.md: $CLAUDE_MD" echo "" echo "Next: restart Claude Code for the MCP server to be available in this project." diff --git a/scripts/seed.py b/scripts/seed.py index 94066b8..df0e893 100644 --- a/scripts/seed.py +++ b/scripts/seed.py @@ -1,4 +1,4 @@ -"""Seed the 6 canonical topics from canon/projects/.""" +"""Seed the 6 canonical domains and topics from canon/projects/.""" import asyncio import sys from pathlib import Path @@ -10,7 +10,17 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from api.database import async_session_factory, engine -from api.models.topic import Domain, Topic, TopicStatus +from api.models.domain import Domain +from api.models.topic import Topic, TopicStatus + +DOMAINS = [ + {"slug": "custodian", "name": "The Custodian"}, + {"slug": "railiance", "name": "Railiance"}, + {"slug": "markitect", "name": "Markitect"}, + {"slug": "coulomb_social", "name": "Coulomb.social"}, + {"slug": "personhood", "name": "Personhood"}, + {"slug": "foerster_capabilities", "name": "Foerster Capabilities"}, +] TOPICS = [ { @@ -20,7 +30,7 @@ TOPICS = [ "Master agent system: transgenerational cognitive infrastructure for " "co-creating and stewarding knowledge across all domains." ), - "domain": Domain.custodian, + "domain_slug": "custodian", }, { "slug": "railiance", @@ -29,7 +39,7 @@ TOPICS = [ "DevOps & infrastructure reliability. Dependency for all other projects; " "provides the deployment and operational backbone." ), - "domain": Domain.railiance, + "domain_slug": "railiance", }, { "slug": "markitect", @@ -38,7 +48,7 @@ TOPICS = [ "Knowledge artifact management: structured authoring, versioning, and " "retrieval of canonical documents." ), - "domain": Domain.markitect, + "domain_slug": "markitect", }, { "slug": "coulomb-social", @@ -47,7 +57,7 @@ TOPICS = [ "Co-creation marketplace experiment: connecting people around shared " "projects and complementary capabilities." ), - "domain": Domain.coulomb_social, + "domain_slug": "coulomb_social", }, { "slug": "personhood", @@ -56,7 +66,7 @@ TOPICS = [ "Rights and obligations framework: defining digital personhood, consent " "models, and data sovereignty." ), - "domain": Domain.personhood, + "domain_slug": "personhood", }, { "slug": "foerster-capabilities", @@ -65,29 +75,48 @@ TOPICS = [ "Agency capability taxonomy inspired by Heinz von Foerster: mapping the " "space of possible cognitive and social actions." ), - "domain": Domain.foerster_capabilities, + "domain_slug": "foerster_capabilities", }, ] async def seed() -> None: async with async_session_factory() as session: + # ── Insert domains (idempotent) ─────────────────────────────────────── + domain_by_slug: dict[str, Domain] = {} + for data in DOMAINS: + existing = await session.execute( + select(Domain).where(Domain.slug == data["slug"]) + ) + domain = existing.scalar_one_or_none() + if domain is not None: + print(f" skip domain (exists): {data['slug']}") + else: + domain = Domain(slug=data["slug"], name=data["name"]) + session.add(domain) + await session.flush() # get the id + print(f" insert domain: {data['slug']}") + domain_by_slug[data["slug"]] = domain + + # ── Insert topics (idempotent) ───────────────────────────────────────── for data in TOPICS: existing = await session.execute( select(Topic).where(Topic.slug == data["slug"]) ) if existing.scalar_one_or_none() is not None: - print(f" skip (already exists): {data['slug']}") + print(f" skip topic (exists): {data['slug']}") continue + domain = domain_by_slug[data["domain_slug"]] topic = Topic( slug=data["slug"], title=data["title"], description=data["description"], - domain=data["domain"], + domain_id=domain.id, status=TopicStatus.active, ) session.add(topic) - print(f" insert: {data['slug']}") + print(f" insert topic: {data['slug']}") + await session.commit() await engine.dispose() print("Seed complete.") From eaad6b591c2bce18415a51525f0a753b7b924cd5 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 28 Feb 2026 15:27:26 +0100 Subject: [PATCH 048/198] fix(state-hub): repair await-in-generator in _derive_next_steps str.join() is synchronous and cannot consume a generator that uses await. Build the blocker slugs list with an explicit async for loop instead. Co-Authored-By: Claude Sonnet 4.6 --- api/routers/state.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/api/routers/state.py b/api/routers/state.py index 28ab4c2..215305b 100644 --- a/api/routers/state.py +++ b/api/routers/state.py @@ -339,11 +339,12 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]: if task.id in seen_task_ids: continue domain_slug = await _get_domain_slug_for_workstream(from_ws, session) - blocker_slugs = ", ".join( - (await session.get(Workstream, tid)).slug - for tid in to_ws_ids - if await session.get(Workstream, tid) - ) + _blocker_slugs = [] + for tid in to_ws_ids: + _ws = await session.get(Workstream, tid) + if _ws: + _blocker_slugs.append(_ws.slug) + blocker_slugs = ", ".join(_blocker_slugs) steps.append(NextStep( type="dependency_cleared", domain=domain_slug, From fccb7b237557931d9d703e870abae1cf2736c886 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 28 Feb 2026 15:31:28 +0100 Subject: [PATCH 049/198] fix(dashboard): replace stale t.domain with t.domain_slug across all pages After the v0.5 migration TopicRead.domain was renamed to domain_slug. index.md, decisions.md and tasks.md still referenced the old field, causing every workstream domain to fall back to "unknown". Also updated tasks.md to load the domain filter list dynamically from /domains/ instead of the hardcoded 6-slug array. Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/decisions.md | 2 +- dashboard/src/index.md | 6 +++--- dashboard/src/tasks.md | 7 +++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/dashboard/src/decisions.md b/dashboard/src/decisions.md index 971ca0f..4c36e42 100644 --- a/dashboard/src/decisions.md +++ b/dashboard/src/decisions.md @@ -24,7 +24,7 @@ const decState = (async function*() { data = decisions .map(d => ({ ...d, - domain: topicMap[d.topic_id]?.domain ?? null, + domain: topicMap[d.topic_id]?.domain_slug ?? null, topic_title: topicMap[d.topic_id]?.title ?? null, })) .sort((a, b) => { diff --git a/dashboard/src/index.md b/dashboard/src/index.md index 94c8f00..ab376c7 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -90,7 +90,7 @@ if (summary.error) display(html`
⚠️ ${summary.error} [t.id, t.domain])); +const topicById = Object.fromEntries((summary.topics ?? []).map(t => [t.id, t.domain_slug])); const openWs = (summary.open_workstreams ?? []).map(w => ({ title: w.title, @@ -270,7 +270,7 @@ if (regs.length === 0) { const regs = regsState ?? []; const registeredDomains = new Set(regs.map(e => e.detail?.domain).filter(Boolean)); const emptyRegistered = (summary.topics ?? []).filter(t => - registeredDomains.has(t.domain) && (t.workstreams ?? []).length === 0 + registeredDomains.has(t.domain_slug) && (t.workstreams ?? []).length === 0 ); if (emptyRegistered.length > 0) { @@ -278,7 +278,7 @@ if (emptyRegistered.length > 0) { πŸ’‘ Getting started

These registered projects have no workstreams yet:

    ${emptyRegistered.map(t => html`
  • - ${t.domain} β€” open repo in Claude Code and say "Hi!" to kick off first session, or run custodian create-workstream --domain ${t.domain} --title "My first workstream" manually + ${t.domain_slug} β€” open repo in Claude Code and say "Hi!" to kick off first session, or run custodian create-workstream --domain ${t.domain_slug} --title "My first workstream" manually
  • `)}
`); } diff --git a/dashboard/src/tasks.md b/dashboard/src/tasks.md index 251125d..29bda02 100644 --- a/dashboard/src/tasks.md +++ b/dashboard/src/tasks.md @@ -23,7 +23,7 @@ const taskState = (async function*() { const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); const wsMap = Object.fromEntries(wsList.map(w => [w.id, { ...w, - domain: topicMap[w.topic_id]?.domain ?? "unknown", + domain: topicMap[w.topic_id]?.domain_slug ?? "unknown", }])); data = taskList.map(t => ({ ...t, @@ -49,7 +49,10 @@ import {MultiSelect} from "./components/multiselect.js"; const STATUSES = ["todo", "in_progress", "blocked", "done", "cancelled"]; const PRIORITIES = ["critical", "high", "medium", "low"]; -const DOMAINS = ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"]; +const _domainsResp = await fetch(`${API}/domains/?status=active`).catch(() => null); +const DOMAINS = _domainsResp?.ok + ? (await _domainsResp.json()).map(d => d.slug) + : ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"]; const _filtersForm = Inputs.form( { From d734c5028941a7c0d6cf548c546d45d58b2e124f Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 28 Feb 2026 15:39:34 +0100 Subject: [PATCH 050/198] fix(cli): replace hardcoded VALID_DOMAINS with live /domains/ API lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custodian CLI had a static VALID_DOMAINS list used as argparse choices= and for in-process domain validation, preventing any domain added after v0.5 from being used. Now fetches active domains from the API at runtime. Also fixes t.get("domain") β†’ t.get("domain_slug") in two topic lookup sites. Co-Authored-By: Claude Sonnet 4.6 --- custodian_cli.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/custodian_cli.py b/custodian_cli.py index 408ad72..962edba 100644 --- a/custodian_cli.py +++ b/custodian_cli.py @@ -26,12 +26,6 @@ API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000") TEMPLATE = STATE_HUB_DIR / "scripts" / "project_claude_md.template" PATCH_CWD = STATE_HUB_DIR / "scripts" / "patch_mcp_cwd.py" -VALID_DOMAINS = [ - "custodian", "railiance", "markitect", - "coulomb_social", "personhood", "foerster_capabilities", -] - - # ── Helpers ──────────────────────────────────────────────────────────────────── def _api_get(path: str) -> object: @@ -88,6 +82,7 @@ def cmd_register(args: argparse.Namespace) -> None: # ── Step 2: Domain ───────────────────────────────────────────────────────── domain = args.domain + valid_domains = [d["slug"] for d in _api_get("/domains/?status=active")] if not domain: print("==> Auto-detecting domain from project charter ...") domain = _detect_domain(project_path) @@ -95,17 +90,17 @@ def cmd_register(args: argparse.Namespace) -> None: print(f" Detected: {domain}") else: print(f"ERROR: Could not auto-detect domain. Pass --domain explicitly.") - print(f" Valid: {', '.join(VALID_DOMAINS)}") + print(f" Valid: {', '.join(valid_domains)}") sys.exit(1) - if domain not in VALID_DOMAINS: - print(f"ERROR: Unknown domain '{domain}'. Valid: {', '.join(VALID_DOMAINS)}") + if domain not in valid_domains: + print(f"ERROR: Unknown domain '{domain}'. Valid: {', '.join(valid_domains)}") sys.exit(1) # ── Step 3: Topic ID lookup ──────────────────────────────────────────────── print(f"==> Looking up topic for domain '{domain}' ...") topics = _api_get("/topics/?status=active") - match = next((t for t in topics if t.get("domain") == domain), None) + match = next((t for t in topics if t.get("domain_slug") == domain), None) if not match: print(f"ERROR: No active topic found for domain '{domain}'.") sys.exit(1) @@ -167,7 +162,7 @@ def cmd_create_workstream(args: argparse.Namespace) -> None: # Resolve topic_id from domain topics = _api_get("/topics/?status=active") - match = next((t for t in topics if t.get("domain") == args.domain), None) + match = next((t for t in topics if t.get("domain_slug") == args.domain), None) if not match: print(f"ERROR: No active topic for domain '{args.domain}'.") sys.exit(1) @@ -274,9 +269,8 @@ def main() -> None: reg = sub.add_parser("register-project", help="Register a project with the State Hub") reg.add_argument( "--domain", - choices=VALID_DOMAINS, default=None, - help="Project domain (auto-detected from charter if omitted)", + help="Project domain slug (auto-detected from charter if omitted)", ) reg.add_argument( "--path", @@ -286,7 +280,7 @@ def main() -> None: # create-workstream cws = sub.add_parser("create-workstream", help="Create a workstream under a domain topic") - cws.add_argument("--domain", choices=VALID_DOMAINS, required=True, help="Domain to create the workstream under") + cws.add_argument("--domain", required=True, help="Domain slug to create the workstream under") cws.add_argument("--title", required=True, help="Workstream title") cws.add_argument("--slug", default=None, help="URL slug (auto-generated from title if omitted)") cws.add_argument("--owner", default=None, help="Owner name") From 6edd39f4b8dcadcf43aa3eac5a6ef3d2d17b20e8 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 28 Feb 2026 15:40:32 +0100 Subject: [PATCH 051/198] fix(cli): auto-create topic when registering a brand-new domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit register-project now creates a topic automatically if the domain has no active topic yet, instead of exiting with an error. This makes the "create domain β†’ register project" flow self-contained. Co-Authored-By: Claude Sonnet 4.6 --- custodian_cli.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/custodian_cli.py b/custodian_cli.py index 962edba..4a9dd38 100644 --- a/custodian_cli.py +++ b/custodian_cli.py @@ -97,13 +97,24 @@ def cmd_register(args: argparse.Namespace) -> None: print(f"ERROR: Unknown domain '{domain}'. Valid: {', '.join(valid_domains)}") sys.exit(1) - # ── Step 3: Topic ID lookup ──────────────────────────────────────────────── + # ── Step 3: Topic ID lookup (auto-create if new domain) ─────────────────── print(f"==> Looking up topic for domain '{domain}' ...") topics = _api_get("/topics/?status=active") match = next((t for t in topics if t.get("domain_slug") == domain), None) if not match: - print(f"ERROR: No active topic found for domain '{domain}'.") - sys.exit(1) + print(f" No topic found β€” creating one for domain '{domain}' ...") + slug = re.sub(r"[^a-z0-9]+", "-", domain.lower()).strip("-") + try: + match = _api_post("/topics/", { + "slug": slug, + "title": project_name, + "domain": domain, + "status": "active", + }) + print(f" Topic created: {match['title']} ({match['id']})") + except Exception as e: + print(f"ERROR: Could not create topic for domain '{domain}': {e}") + sys.exit(1) topic_id = match["id"] print(f" topic_id: {topic_id}") From 8d38110275d492a2b808e421d528147fda53e29b Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 28 Feb 2026 17:28:27 +0100 Subject: [PATCH 052/198] =?UTF-8?q?feat(state-hub):=20v0.3=20schema=20?= =?UTF-8?q?=E2=80=94=20contributions=20+=20sbom=5Fentries=20migrations,=20?= =?UTF-8?q?models,=20schemas,=20routers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrations (chain: b1c2d3e4f5a6 β†’ c2d3e4f5a6b7 β†’ d3e4f5a6b7c8): - c2d3e4f5a6b7: contributions table (contributiontype BR/FR/EP/UPR enum, contributionstatus 7-state lifecycle, FKs to topics/workstreams) - d3e4f5a6b7c8: sbom_entries table (ecosystem enum, snapshot-based replacement), + sbom_source + last_sbom_at columns on managed_repos New models: Contribution (ContributionType, ContributionStatus), SBOMEntry (Ecosystem) Modified: ManagedRepo (sbom_source, last_sbom_at columns) New routers: - /contributions/ β€” CRUD + lifecycle-guarded PATCH /status + soft-delete (withdrawn) - /sbom/ β€” ingest (replace snapshot), list, per-repo view, licence report Modified: - /state/summary now includes contribution_counts and licence_risk_count - main.py: registers contributions + sbom routers; bumps version to 0.6.0 Co-Authored-By: Claude Sonnet 4.6 --- api/main.py | 6 +- api/models/__init__.py | 4 + api/models/contribution.py | 62 ++++++++ api/models/managed_repo.py | 7 +- api/models/sbom_entry.py | 47 ++++++ api/routers/contributions.py | 147 ++++++++++++++++++ api/routers/sbom.py | 146 +++++++++++++++++ api/routers/state.py | 31 ++++ api/schemas/contribution.py | 43 +++++ api/schemas/sbom.py | 54 +++++++ api/schemas/state.py | 2 + .../c2d3e4f5a6b7_v0_3_contributions.py | 66 ++++++++ .../d3e4f5a6b7c8_v0_3_sbom_entries.py | 60 +++++++ 13 files changed, 672 insertions(+), 3 deletions(-) create mode 100644 api/models/contribution.py create mode 100644 api/models/sbom_entry.py create mode 100644 api/routers/contributions.py create mode 100644 api/routers/sbom.py create mode 100644 api/schemas/contribution.py create mode 100644 api/schemas/sbom.py create mode 100644 migrations/versions/c2d3e4f5a6b7_v0_3_contributions.py create mode 100644 migrations/versions/d3e4f5a6b7c8_v0_3_sbom_entries.py diff --git a/api/main.py b/api/main.py index 2ca2dad..cd9e41e 100644 --- a/api/main.py +++ b/api/main.py @@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from api.database import engine from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies -from api.routers import domains, repos +from api.routers import domains, repos, contributions, sbom @asynccontextmanager @@ -17,7 +17,7 @@ async def lifespan(app: FastAPI): app = FastAPI( title="Custodian State Hub", description="Local-first state API for the Custodian agent system.", - version="0.5.0", + version="0.6.0", lifespan=lifespan, ) @@ -38,6 +38,8 @@ app.include_router(decisions.router) app.include_router(extension_points.router) app.include_router(technical_debt.router) app.include_router(progress.router) +app.include_router(contributions.router) +app.include_router(sbom.router) app.include_router(state.router) diff --git a/api/models/__init__.py b/api/models/__init__.py index 3eaafa3..a21be32 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -9,6 +9,8 @@ from api.models.progress_event import ProgressEvent from api.models.extension_point import ExtensionPoint, EPStatus from api.models.technical_debt import TechnicalDebt, TDStatus from api.models.managed_repo import ManagedRepo +from api.models.contribution import Contribution, ContributionType, ContributionStatus +from api.models.sbom_entry import SBOMEntry, Ecosystem __all__ = [ "Base", @@ -22,4 +24,6 @@ __all__ = [ "ExtensionPoint", "EPStatus", "TechnicalDebt", "TDStatus", "ManagedRepo", + "Contribution", "ContributionType", "ContributionStatus", + "SBOMEntry", "Ecosystem", ] diff --git a/api/models/contribution.py b/api/models/contribution.py new file mode 100644 index 0000000..285f7da --- /dev/null +++ b/api/models/contribution.py @@ -0,0 +1,62 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class ContributionType(str, enum.Enum): + br = "br" + fr = "fr" + ep = "ep" + upr = "upr" + + +class ContributionStatus(str, enum.Enum): + draft = "draft" + submitted = "submitted" + acknowledged = "acknowledged" + accepted = "accepted" + rejected = "rejected" + merged = "merged" + withdrawn = "withdrawn" + + +class Contribution(Base, TimestampMixin): + __tablename__ = "contributions" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + type: Mapped[ContributionType] = mapped_column( + Enum(ContributionType, name="contributiontype"), nullable=False + ) + target_org: Mapped[str | None] = mapped_column(String(200), nullable=True) + target_repo: Mapped[str | None] = mapped_column(String(200), nullable=True) + slug: Mapped[str | None] = mapped_column(String(200), nullable=True) + title: Mapped[str] = mapped_column(String(500), nullable=False) + status: Mapped[ContributionStatus] = mapped_column( + Enum(ContributionStatus, name="contributionstatus"), + nullable=False, default=ContributionStatus.draft, + ) + body_path: Mapped[str | None] = mapped_column(Text, nullable=True) + related_topic_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True + ) + related_workstream_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True + ) + submitted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + resolved_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821 + workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821 diff --git a/api/models/managed_repo.py b/api/models/managed_repo.py index dcf6f75..2a9e44a 100644 --- a/api/models/managed_repo.py +++ b/api/models/managed_repo.py @@ -1,6 +1,7 @@ import uuid +from datetime import datetime -from sqlalchemy import ForeignKey, String, Text +from sqlalchemy import DateTime, ForeignKey, String, Text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -25,6 +26,10 @@ class ManagedRepo(Base, TimestampMixin): topic_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True ) + sbom_source: Mapped[str | None] = mapped_column(Text, nullable=True) + last_sbom_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) domain: Mapped["Domain"] = relationship( # noqa: F821 "Domain", back_populates="repos", lazy="selectin" diff --git a/api/models/sbom_entry.py b/api/models/sbom_entry.py new file mode 100644 index 0000000..2de4480 --- /dev/null +++ b/api/models/sbom_entry.py @@ -0,0 +1,47 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, new_uuid + + +class Ecosystem(str, enum.Enum): + python = "python" + node = "node" + rust = "rust" + go = "go" + java = "java" + other = "other" + + +class SBOMEntry(Base): + """Snapshot-based SBOM entry β€” no updated_at; new ingest replaces old rows.""" + __tablename__ = "sbom_entries" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + repo_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="RESTRICT"), + nullable=False, index=True, + ) + package_name: Mapped[str] = mapped_column(String(300), nullable=False) + package_version: Mapped[str | None] = mapped_column(String(100), nullable=True) + ecosystem: Mapped[Ecosystem] = mapped_column( + Enum(Ecosystem, name="ecosystem"), nullable=False + ) + license_spdx: Mapped[str | None] = mapped_column(String(100), nullable=True) + is_direct: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + is_dev: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + snapshot_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + + repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821 diff --git a/api/routers/contributions.py b/api/routers/contributions.py new file mode 100644 index 0000000..1fb530d --- /dev/null +++ b/api/routers/contributions.py @@ -0,0 +1,147 @@ +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.contribution import Contribution, ContributionStatus, ContributionType +from api.schemas.contribution import ContributionCreate, ContributionRead, ContributionStatusPatch + +router = APIRouter(prefix="/contributions", tags=["contributions"]) + +# Valid forward transitions in the lifecycle +_VALID_TRANSITIONS: dict[ContributionStatus, set[ContributionStatus]] = { + ContributionStatus.draft: { + ContributionStatus.submitted, + ContributionStatus.withdrawn, + }, + ContributionStatus.submitted: { + ContributionStatus.acknowledged, + ContributionStatus.rejected, + ContributionStatus.withdrawn, + }, + ContributionStatus.acknowledged: { + ContributionStatus.accepted, + ContributionStatus.rejected, + ContributionStatus.withdrawn, + }, + ContributionStatus.accepted: { + ContributionStatus.merged, + ContributionStatus.withdrawn, + }, + ContributionStatus.rejected: set(), + ContributionStatus.merged: set(), + ContributionStatus.withdrawn: set(), +} + + +@router.get("/", response_model=list[ContributionRead]) +async def list_contributions( + type: ContributionType | None = Query(None), + status: ContributionStatus | None = Query(None), + target_repo: str | None = Query(None), + session: AsyncSession = Depends(get_session), +) -> list[Contribution]: + q = select(Contribution).order_by(Contribution.created_at.desc()) + if type is not None: + q = q.where(Contribution.type == type) + if status is not None: + q = q.where(Contribution.status == status) + if target_repo: + q = q.where(Contribution.target_repo == target_repo) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=ContributionRead, status_code=status.HTTP_201_CREATED) +async def create_contribution( + body: ContributionCreate, + session: AsyncSession = Depends(get_session), +) -> Contribution: + contrib = Contribution( + type=body.type, + target_org=body.target_org, + target_repo=body.target_repo, + slug=body.slug, + title=body.title, + body_path=body.body_path, + related_topic_id=body.related_topic_id, + related_workstream_id=body.related_workstream_id, + notes=body.notes, + status=ContributionStatus.draft, + ) + session.add(contrib) + await session.commit() + await session.refresh(contrib) + return contrib + + +@router.get("/{contribution_id}/", response_model=ContributionRead) +async def get_contribution( + contribution_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Contribution: + return await _get_or_404(contribution_id, session) + + +@router.patch("/{contribution_id}/status", response_model=ContributionRead) +async def patch_contribution_status( + contribution_id: uuid.UUID, + body: ContributionStatusPatch, + session: AsyncSession = Depends(get_session), +) -> Contribution: + contrib = await _get_or_404(contribution_id, session) + allowed = _VALID_TRANSITIONS.get(contrib.status, set()) + if body.status not in allowed: + raise HTTPException( + status_code=422, + detail=( + f"Cannot transition from '{contrib.status}' to '{body.status}'. " + f"Allowed: {[s.value for s in allowed] or 'none (terminal state)'}" + ), + ) + contrib.status = body.status + if body.notes: + contrib.notes = body.notes + now = datetime.now(tz=timezone.utc) + if body.status == ContributionStatus.submitted: + contrib.submitted_at = now + elif body.status in ( + ContributionStatus.accepted, ContributionStatus.rejected, + ContributionStatus.merged, ContributionStatus.withdrawn, + ): + contrib.resolved_at = now + await session.commit() + await session.refresh(contrib) + return contrib + + +@router.delete("/{contribution_id}/", status_code=status.HTTP_204_NO_CONTENT) +async def withdraw_contribution( + contribution_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> None: + """Soft-delete: sets status to 'withdrawn'.""" + contrib = await _get_or_404(contribution_id, session) + if contrib.status == ContributionStatus.withdrawn: + return # idempotent + if contrib.status in (ContributionStatus.merged, ContributionStatus.rejected): + raise HTTPException( + status_code=409, + detail=f"Cannot withdraw a contribution with status '{contrib.status}'.", + ) + contrib.status = ContributionStatus.withdrawn + contrib.resolved_at = datetime.now(tz=timezone.utc) + await session.commit() + + +async def _get_or_404(contribution_id: uuid.UUID, session: AsyncSession) -> Contribution: + result = await session.execute( + select(Contribution).where(Contribution.id == contribution_id) + ) + contrib = result.scalar_one_or_none() + if contrib is None: + raise HTTPException(status_code=404, detail=f"Contribution '{contribution_id}' not found") + return contrib diff --git a/api/routers/sbom.py b/api/routers/sbom.py new file mode 100644 index 0000000..a59f824 --- /dev/null +++ b/api/routers/sbom.py @@ -0,0 +1,146 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.managed_repo import ManagedRepo +from api.models.sbom_entry import Ecosystem, SBOMEntry +from api.schemas.sbom import ( + LicenceGroup, + LicenceReport, + SBOMEntryRead, + SBOMIngest, + SBOMRepoView, +) + +router = APIRouter(prefix="/sbom", tags=["sbom"]) + +_COPYLEFT_PATTERNS = {"GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"} + + +def _is_copyleft(spdx: str | None) -> bool: + if not spdx: + return False + upper = spdx.upper() + return any(pat in upper for pat in _COPYLEFT_PATTERNS) + + +@router.post("/ingest/") +async def ingest_sbom( + body: SBOMIngest, + session: AsyncSession = Depends(get_session), +) -> dict: + """Replace the SBOM snapshot for a repo. Old entries are deleted first.""" + repo = await _get_repo_by_slug(body.repo_slug, session) + now = datetime.now(tz=timezone.utc) + + # Delete existing snapshot for this repo + await session.execute(delete(SBOMEntry).where(SBOMEntry.repo_id == repo.id)) + + # Insert new entries + for entry in body.entries: + sbom = SBOMEntry( + repo_id=repo.id, + package_name=entry.package_name, + package_version=entry.package_version, + ecosystem=entry.ecosystem, + license_spdx=entry.license_spdx, + is_direct=entry.is_direct, + is_dev=entry.is_dev, + snapshot_at=now, + created_at=now, + ) + session.add(sbom) + + repo.last_sbom_at = now + if not repo.sbom_source: + repo.sbom_source = "manual" + + await session.commit() + return {"repo_slug": body.repo_slug, "ingested": len(body.entries), "snapshot_at": now.isoformat()} + + +@router.get("/") +async def list_sbom_entries( + repo_slug: str | None = Query(None), + ecosystem: Ecosystem | None = Query(None), + license_spdx: str | None = Query(None), + is_direct: bool | None = Query(None), + is_dev: bool | None = Query(None), + session: AsyncSession = Depends(get_session), +) -> list[SBOMEntryRead]: + q = select(SBOMEntry).order_by(SBOMEntry.package_name) + if repo_slug: + repo = await _get_repo_by_slug(repo_slug, session) + q = q.where(SBOMEntry.repo_id == repo.id) + if ecosystem is not None: + q = q.where(SBOMEntry.ecosystem == ecosystem) + if license_spdx: + q = q.where(SBOMEntry.license_spdx == license_spdx) + if is_direct is not None: + q = q.where(SBOMEntry.is_direct == is_direct) + if is_dev is not None: + q = q.where(SBOMEntry.is_dev == is_dev) + result = await session.execute(q) + return [SBOMEntryRead.model_validate(e) for e in result.scalars().all()] + + +@router.get("/report/licences/", response_model=LicenceReport) +async def licence_report( + session: AsyncSession = Depends(get_session), +) -> LicenceReport: + """Group SBOM entries by SPDX licence identifier, flag copyleft.""" + rows = await session.execute( + select(SBOMEntry, ManagedRepo.slug) + .join(ManagedRepo, ManagedRepo.id == SBOMEntry.repo_id) + ) + # Build: license_spdx β†’ {count, repos set} + groups: dict[str | None, dict] = {} + copyleft_direct_count = 0 + for entry, repo_slug in rows.all(): + key = entry.license_spdx + if key not in groups: + groups[key] = {"count": 0, "repos": set()} + groups[key]["count"] += 1 + groups[key]["repos"].add(repo_slug) + if _is_copyleft(key) and entry.is_direct and not entry.is_dev: + copyleft_direct_count += 1 + + licence_groups = [ + LicenceGroup( + license_spdx=lic, + count=info["count"], + repos=sorted(info["repos"]), + is_copyleft=_is_copyleft(lic), + ) + for lic, info in sorted(groups.items(), key=lambda x: -x[1]["count"]) + ] + return LicenceReport(groups=licence_groups, copyleft_direct_count=copyleft_direct_count) + + +@router.get("/{repo_slug}/", response_model=SBOMRepoView) +async def get_repo_sbom( + repo_slug: str, + session: AsyncSession = Depends(get_session), +) -> SBOMRepoView: + repo = await _get_repo_by_slug(repo_slug, session) + rows = await session.execute( + select(SBOMEntry).where(SBOMEntry.repo_id == repo.id).order_by(SBOMEntry.package_name) + ) + entries = list(rows.scalars().all()) + return SBOMRepoView( + repo_slug=repo_slug, + last_sbom_at=repo.last_sbom_at, + entry_count=len(entries), + entries=[SBOMEntryRead.model_validate(e) for e in entries], + ) + + +async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo: + result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug)) + repo = result.scalar_one_or_none() + if repo is None: + raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found") + return repo diff --git a/api/routers/state.py b/api/routers/state.py index 215305b..0932650 100644 --- a/api/routers/state.py +++ b/api/routers/state.py @@ -6,11 +6,13 @@ from sqlalchemy import func, select, text from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session, engine +from api.models.contribution import Contribution, ContributionStatus, ContributionType from api.models.decision import Decision, DecisionStatus, DecisionType from api.models.domain import Domain from api.models.extension_point import ExtensionPoint from api.models.managed_repo import ManagedRepo from api.models.progress_event import ProgressEvent +from api.models.sbom_entry import SBOMEntry from api.models.task import Task, TaskPriority, TaskStatus from api.models.technical_debt import TechnicalDebt from api.models.topic import Topic, TopicStatus @@ -175,6 +177,33 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm # Domain summary stats domain_summaries = await _build_domain_summaries(session) + # Contribution counts (by type and status) + contrib_type_counts = {r[0].value: r[1] for r in await session.execute( + select(Contribution.type, func.count()).group_by(Contribution.type) + )} + contrib_status_counts = {r[0].value: r[1] for r in await session.execute( + select(Contribution.status, func.count()).group_by(Contribution.status) + )} + contribution_counts = {**contrib_type_counts, **contrib_status_counts} + + # Licence risk: copyleft packages in direct prod deps + _COPYLEFT_PATS = ("GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL") + copyleft_risk_rows = await session.execute( + select(func.count()).select_from(SBOMEntry) + .where(SBOMEntry.is_direct.is_(True)) + .where(SBOMEntry.is_dev.is_(False)) + ) + # Filter in Python since ILIKE across multiple patterns is verbose in SQLAlchemy + all_direct_prod_rows = await session.execute( + select(SBOMEntry.license_spdx) + .where(SBOMEntry.is_direct.is_(True)) + .where(SBOMEntry.is_dev.is_(False)) + ) + licence_risk_count = sum( + 1 for (lic,) in all_direct_prod_rows.all() + if lic and any(pat in lic.upper() for pat in _COPYLEFT_PATS) + ) + return StateSummary( generated_at=datetime.now(tz=timezone.utc), totals=totals, @@ -184,6 +213,8 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm recent_progress=[ProgressEventRead.model_validate(e) for e in recent], next_steps=next_steps, domains=domain_summaries, + contribution_counts=contribution_counts, + licence_risk_count=licence_risk_count, open_workstreams=[ WorkstreamWithDeps( **WorkstreamRead.model_validate(w).model_dump(), diff --git a/api/schemas/contribution.py b/api/schemas/contribution.py new file mode 100644 index 0000000..e037147 --- /dev/null +++ b/api/schemas/contribution.py @@ -0,0 +1,43 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from api.models.contribution import ContributionStatus, ContributionType + + +class ContributionCreate(BaseModel): + type: ContributionType + target_org: str | None = None + target_repo: str | None = None + slug: str | None = None + title: str + body_path: str | None = None + related_topic_id: uuid.UUID | None = None + related_workstream_id: uuid.UUID | None = None + notes: str | None = None + + +class ContributionStatusPatch(BaseModel): + status: ContributionStatus + notes: str | None = None + + +class ContributionRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + type: ContributionType + target_org: str | None = None + target_repo: str | None = None + slug: str | None = None + title: str + status: ContributionStatus + body_path: str | None = None + related_topic_id: uuid.UUID | None = None + related_workstream_id: uuid.UUID | None = None + submitted_at: datetime | None = None + resolved_at: datetime | None = None + notes: str | None = None + created_at: datetime + updated_at: datetime diff --git a/api/schemas/sbom.py b/api/schemas/sbom.py new file mode 100644 index 0000000..b2b3670 --- /dev/null +++ b/api/schemas/sbom.py @@ -0,0 +1,54 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from api.models.sbom_entry import Ecosystem + + +class SBOMEntryCreate(BaseModel): + package_name: str + package_version: str | None = None + ecosystem: Ecosystem + license_spdx: str | None = None + is_direct: bool = True + is_dev: bool = False + + +class SBOMIngest(BaseModel): + repo_slug: str + entries: list[SBOMEntryCreate] + + +class SBOMEntryRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + repo_id: uuid.UUID + package_name: str + package_version: str | None = None + ecosystem: Ecosystem + license_spdx: str | None = None + is_direct: bool + is_dev: bool + snapshot_at: datetime + created_at: datetime + + +class LicenceGroup(BaseModel): + license_spdx: str | None + count: int + repos: list[str] + is_copyleft: bool + + +class LicenceReport(BaseModel): + groups: list[LicenceGroup] + copyleft_direct_count: int + + +class SBOMRepoView(BaseModel): + repo_slug: str + last_sbom_at: datetime | None = None + entry_count: int + entries: list[SBOMEntryRead] diff --git a/api/schemas/state.py b/api/schemas/state.py index a695f72..6d49e6d 100644 --- a/api/schemas/state.py +++ b/api/schemas/state.py @@ -77,3 +77,5 @@ class StateSummary(BaseModel): open_workstreams: list[WorkstreamWithDeps] next_steps: list[NextStep] = [] domains: list[DomainSummary] = [] + contribution_counts: dict[str, int] = {} + licence_risk_count: int = 0 diff --git a/migrations/versions/c2d3e4f5a6b7_v0_3_contributions.py b/migrations/versions/c2d3e4f5a6b7_v0_3_contributions.py new file mode 100644 index 0000000..4429346 --- /dev/null +++ b/migrations/versions/c2d3e4f5a6b7_v0_3_contributions.py @@ -0,0 +1,66 @@ +"""v0.3 β€” contributions table + +Adds contribution tracking: bug reports, feature requests, extension-point +proposals, and upstream PRs, each as a typed artifact with status lifecycle. + +Revision ID: c2d3e4f5a6b7 +Revises: b1c2d3e4f5a6 +Create Date: 2026-02-28 00:00:00.000000 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "c2d3e4f5a6b7" +down_revision: Union[str, None] = "b1c2d3e4f5a6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + contributiontype = postgresql.ENUM( + "br", "fr", "ep", "upr", + name="contributiontype", create_type=True, + ) + contributionstatus = postgresql.ENUM( + "draft", "submitted", "acknowledged", "accepted", + "rejected", "merged", "withdrawn", + name="contributionstatus", create_type=True, + ) + + op.create_table( + "contributions", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, + server_default=sa.text("gen_random_uuid()")), + sa.Column("type", contributiontype, nullable=False), + sa.Column("target_org", sa.String(200), nullable=True), + sa.Column("target_repo", sa.String(200), nullable=True), + sa.Column("slug", sa.String(200), nullable=True), + sa.Column("title", sa.String(500), nullable=False), + sa.Column("status", contributionstatus, nullable=False, + server_default="draft"), + sa.Column("body_path", sa.Text, nullable=True), + sa.Column("related_topic_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("topics.id", ondelete="SET NULL"), nullable=True), + sa.Column("related_workstream_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True), + sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("notes", sa.Text, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_contributions_type", "contributions", ["type"]) + op.create_index("ix_contributions_status", "contributions", ["status"]) + op.create_index("ix_contributions_target_repo", "contributions", + ["target_org", "target_repo"]) + + +def downgrade() -> None: + op.drop_table("contributions") + op.execute("DROP TYPE IF EXISTS contributionstatus") + op.execute("DROP TYPE IF EXISTS contributiontype") diff --git a/migrations/versions/d3e4f5a6b7c8_v0_3_sbom_entries.py b/migrations/versions/d3e4f5a6b7c8_v0_3_sbom_entries.py new file mode 100644 index 0000000..1dc21ce --- /dev/null +++ b/migrations/versions/d3e4f5a6b7c8_v0_3_sbom_entries.py @@ -0,0 +1,60 @@ +"""v0.3 β€” sbom_entries table + managed_repos SBOM columns + +Adds software bill-of-materials tracking: per-repo dependency snapshots with +package name, version, ecosystem, SPDX licence, and direct/dev flags. +Also adds sbom_source and last_sbom_at to managed_repos. + +Revision ID: d3e4f5a6b7c8 +Revises: c2d3e4f5a6b7 +Create Date: 2026-02-28 00:00:00.000000 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "d3e4f5a6b7c8" +down_revision: Union[str, None] = "c2d3e4f5a6b7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add SBOM-related columns to managed_repos + op.add_column("managed_repos", sa.Column("sbom_source", sa.Text, nullable=True)) + op.add_column("managed_repos", + sa.Column("last_sbom_at", sa.DateTime(timezone=True), nullable=True)) + + ecosystem_enum = postgresql.ENUM( + "python", "node", "rust", "go", "java", "other", + name="ecosystem", create_type=True, + ) + + op.create_table( + "sbom_entries", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, + server_default=sa.text("gen_random_uuid()")), + sa.Column("repo_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("managed_repos.id", ondelete="RESTRICT"), nullable=False), + sa.Column("package_name", sa.String(300), nullable=False), + sa.Column("package_version", sa.String(100), nullable=True), + sa.Column("ecosystem", ecosystem_enum, nullable=False), + sa.Column("license_spdx", sa.String(100), nullable=True), + sa.Column("is_direct", sa.Boolean, nullable=False, server_default="true"), + sa.Column("is_dev", sa.Boolean, nullable=False, server_default="false"), + sa.Column("snapshot_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_sbom_entries_repo_id", "sbom_entries", ["repo_id"]) + op.create_index("ix_sbom_entries_package_name", "sbom_entries", ["package_name"]) + op.create_index("ix_sbom_entries_license_spdx", "sbom_entries", ["license_spdx"]) + + +def downgrade() -> None: + op.drop_table("sbom_entries") + op.execute("DROP TYPE IF EXISTS ecosystem") + op.drop_column("managed_repos", "last_sbom_at") + op.drop_column("managed_repos", "sbom_source") From afac54ec0940b3bc39d6bbd7f4a43286f782b60c Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 28 Feb 2026 17:28:41 +0100 Subject: [PATCH 053/198] feat(state-hub): v0.3 MCP tools + dashboard pages for contributions and SBOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP server additions (5 tools + 3 resources): - register_contribution(), update_contribution_status(), get_contributions() - ingest_sbom_tool(repo_slug, lockfile_path) β€” shells out to ingest_sbom.py - get_licence_report() - state://contributions, state://sbom/aggregated, state://sbom/{repo_slug} Dashboard pages: - contributions.md β€” live-polled Kanban by status (draftβ†’merged), filter bar (type/status/repo), KPI grid (total + per type), follow-up banner, full table - sbom.md β€” licence distribution bar chart (Plot), copyleft risk section, package table with ecosystem/direct/dev filters, repo-slug resolution - data/contributions.json.py, data/sbom.json.py β€” Observable data loaders - index.md β€” added Contribution & SBOM Health KPI row (total, follow-up count, copyleft risk indicator; sourced from state summary fields) - observablehq.config.js β€” added Contributions + SBOM to nav Co-Authored-By: Claude Sonnet 4.6 --- dashboard/observablehq.config.js | 2 + dashboard/src/contributions.md | 166 +++++++++++++++++++++++ dashboard/src/data/contributions.json.py | 15 ++ dashboard/src/data/sbom.json.py | 24 ++++ dashboard/src/index.md | 26 ++++ dashboard/src/sbom.md | 150 ++++++++++++++++++++ mcp_server/server.py | 149 ++++++++++++++++++++ 7 files changed, 532 insertions(+) create mode 100644 dashboard/src/contributions.md create mode 100644 dashboard/src/data/contributions.json.py create mode 100644 dashboard/src/data/sbom.json.py create mode 100644 dashboard/src/sbom.md diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index 6588057..5aeb1cf 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -10,6 +10,8 @@ export default { { name: "Domains", path: "/domains" }, { name: "Extension Points", path: "/extensions" }, { name: "Technical Debt", path: "/techdept" }, + { name: "Contributions", path: "/contributions" }, + { name: "SBOM", path: "/sbom" }, { name: "Reference", pages: [ diff --git a/dashboard/src/contributions.md b/dashboard/src/contributions.md new file mode 100644 index 0000000..9c759e4 --- /dev/null +++ b/dashboard/src/contributions.md @@ -0,0 +1,166 @@ +--- +title: Contributions +--- + +```js +const API = "http://127.0.0.1:8000"; +const POLL = 30_000; +``` + +```js +// Live poll for contributions +const contribState = (async function*() { + while (true) { + let data = [], ok = false; + try { + const r = await fetch(`${API}/contributions/`); + ok = r.ok; + data = ok ? await r.json() : []; + } catch {} + yield {data, ok, ts: new Date()}; + await new Promise(res => setTimeout(res, POLL)); + } +})(); +``` + +```js +const contribs = contribState.data ?? []; +const _ok = contribState.ok ?? false; +const _ts = contribState.ts; +``` + +# Contributions + +```js +import {injectTocTop} from "./components/toc-sidebar.js"; + +const _liveEl = html`
+ ● + ${_ok ? `Live Β· ${_ts?.toLocaleTimeString()}` : html`API offline`} +
`; +injectTocTop("live-indicator", _liveEl); +``` + +```js +// Filters +const typeFilter = Inputs.select(["all", "br", "fr", "ep", "upr"], {label: "Type", value: "all"}); +const statFilter = Inputs.select( + ["all", "draft", "submitted", "acknowledged", "accepted", "rejected", "merged", "withdrawn"], + {label: "Status", value: "all"} +); +const repoFilter = Inputs.text({label: "Target repo", placeholder: "filter by repo…"}); +display(html`
+ ${typeFilter}${statFilter}${repoFilter} +
`); +``` + +```js +const tf = typeFilter.value; +const sf = statFilter.value; +const rf = repoFilter.value?.trim().toLowerCase() ?? ""; + +const filtered = contribs.filter(c => + (tf === "all" || c.type === tf) && + (sf === "all" || c.status === sf) && + (!rf || (c.target_repo ?? "").toLowerCase().includes(rf)) +); +``` + +## Summary + +```js +const typeLabels = {br: "Bug Report", fr: "Feature Request", ep: "Extension Point", upr: "Upstream PR"}; +const typeCounts = Object.fromEntries(["br","fr","ep","upr"].map(t => [ + t, contribs.filter(c => c.type === t).length +])); +const needsFollowUp = contribs.filter(c => ["submitted","acknowledged"].includes(c.status)).length; + +display(html`
+
+

Total

+

${contribs.length}

+
+ ${["br","fr","ep","upr"].map(t => html` +
+

${typeLabels[t]}

+

${typeCounts[t]}

+
+ `)} +
+${needsFollowUp > 0 ? html`` : ""} +`); +``` + +## Status Kanban + +```js +const statusCols = [ + {key: "draft", label: "Draft", color: "#aaa"}, + {key: "submitted", label: "Submitted", color: "steelblue"}, + {key: "acknowledged", label: "Acknowledged",color: "#f0a500"}, + {key: "accepted", label: "Accepted", color: "#4caf50"}, + {key: "merged", label: "Merged", color: "#2e7d32"}, + {key: "rejected", label: "Rejected", color: "#e53935"}, + {key: "withdrawn", label: "Withdrawn", color: "#bbb"}, +]; + +const colMap = {}; +for (const c of filtered) { + (colMap[c.status] = colMap[c.status] ?? []).push(c); +} + +const activeCols = statusCols.filter(s => colMap[s.key]?.length); +if (activeCols.length === 0) { + display(html`

No contributions match the current filters.

`); +} else { + display(html`
+ ${activeCols.map(s => html` +
+
${s.label} ${colMap[s.key].length}
+ ${colMap[s.key].map(c => html` +
+
${c.type.toUpperCase()}
+
${c.title}
+ ${c.target_org || c.target_repo ? html`
${[c.target_org, c.target_repo].filter(Boolean).join("/")}
` : ""} + ${c.body_path ? html`
${c.body_path}
` : ""} +
${new Date(c.created_at).toLocaleDateString()}
+
+ `)} +
+ `)} +
`); +} +``` + +## All Contributions + +```js +display(Inputs.table(filtered.map(c => ({ + Type: c.type.toUpperCase(), + Title: c.title, + Status: c.status, + Target: [c.target_org, c.target_repo].filter(Boolean).join("/") || "β€”", + Created: new Date(c.created_at).toLocaleDateString(), +})), {maxWidth: 900})); +``` + + diff --git a/dashboard/src/data/contributions.json.py b/dashboard/src/data/contributions.json.py new file mode 100644 index 0000000..b1420a0 --- /dev/null +++ b/dashboard/src/data/contributions.json.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Observable data loader: fetches /contributions/ from the API.""" +import json +import os +import urllib.request +import urllib.error + +API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") + +try: + with urllib.request.urlopen(f"{API_BASE}/contributions/", timeout=10) as resp: + data = json.loads(resp.read()) + print(json.dumps(data)) +except urllib.error.URLError as e: + print(json.dumps({"error": str(e), "contributions": []})) diff --git a/dashboard/src/data/sbom.json.py b/dashboard/src/data/sbom.json.py new file mode 100644 index 0000000..6a9606b --- /dev/null +++ b/dashboard/src/data/sbom.json.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Observable data loader: fetches /sbom/ and /sbom/report/licences/ from the API.""" +import json +import os +import urllib.request +import urllib.error + +API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") + +result = {"entries": [], "licence_report": {"groups": [], "copyleft_direct_count": 0}} + +try: + with urllib.request.urlopen(f"{API_BASE}/sbom/", timeout=10) as resp: + result["entries"] = json.loads(resp.read()) +except urllib.error.URLError as e: + result["error_entries"] = str(e) + +try: + with urllib.request.urlopen(f"{API_BASE}/sbom/report/licences/", timeout=10) as resp: + result["licence_report"] = json.loads(resp.read()) +except urllib.error.URLError as e: + result["error_licences"] = str(e) + +print(json.dumps(result)) diff --git a/dashboard/src/index.md b/dashboard/src/index.md index ab376c7..e737628 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -156,6 +156,32 @@ if (openWs.length === 0) { } ``` +## Contribution & SBOM Health + +```js +const contribCounts = summary.contribution_counts ?? {}; +const licenceRisk = summary.licence_risk_count ?? 0; +const totalContribs = ["br","fr","ep","upr"].reduce((s, t) => s + (contribCounts[t] ?? 0), 0); +const needsFollowUp = (contribCounts["submitted"] ?? 0) + (contribCounts["acknowledged"] ?? 0); + +display(html``); +``` + ## Status ```js diff --git a/dashboard/src/sbom.md b/dashboard/src/sbom.md new file mode 100644 index 0000000..76abff6 --- /dev/null +++ b/dashboard/src/sbom.md @@ -0,0 +1,150 @@ +--- +title: SBOM +--- + +```js +const API = "http://127.0.0.1:8000"; +``` + +```js +// Fetch SBOM data on load +let _entries = [], _report = {groups: [], copyleft_direct_count: 0}, _repos = []; +try { + [_entries, _report, _repos] = await Promise.all([ + fetch(`${API}/sbom/`).then(r => r.ok ? r.json() : []), + fetch(`${API}/sbom/report/licences/`).then(r => r.ok ? r.json() : {groups:[], copyleft_direct_count: 0}), + fetch(`${API}/repos/`).then(r => r.ok ? r.json() : []), + ]); +} catch {} +``` + +```js +const entries = _entries ?? []; +const report = _report ?? {groups: [], copyleft_direct_count: 0}; +const repos = _repos ?? []; +const groups = report.groups ?? []; +const riskCount = report.copyleft_direct_count ?? 0; +``` + +# SBOM + +## Licence Risk + +```js +const riskBadge = riskCount === 0 + ? html`βœ“ No copyleft in direct prod deps` + : html`⚠ ${riskCount} direct prod dep(s) with copyleft licence`; +display(html`
+
+

Total Packages

+

${entries.length}

+
+
+

Repos Scanned

+

${new Set(entries.map(e => e.repo_id)).size}

+
+
+

Licence Risk

+

${riskCount}

+ ${riskBadge} +
+
+

Unique Licences

+

${groups.length}

+
+
`); +``` + +## Licence Distribution + +```js +import * as Plot from "npm:@observablehq/plot"; + +if (groups.length === 0) { + display(html`

No SBOM data ingested yet. Run make ingest-sbom REPO=<slug>.

`); +} else { + const plotData = groups.slice(0, 15).map(g => ({ + licence: g.license_spdx ?? "(unknown)", + count: g.count, + copyleft: g.is_copyleft, + })); + display(Plot.plot({ + x: {label: "Packages"}, + y: {label: null, domain: plotData.map(d => d.licence)}, + color: {domain: [false, true], range: ["steelblue", "#e53935"], legend: true, tickFormat: d => d ? "Copyleft" : "Permissive"}, + marks: [ + Plot.barX(plotData, {y: "licence", x: "count", fill: "copyleft", tip: true}), + Plot.ruleX([0]), + ], + marginLeft: 130, + height: Math.max(80, plotData.length * 30 + 50), + width: 600, + })); +} +``` + +## Copyleft Risk Detail + +```js +const copyleftGroups = groups.filter(g => g.is_copyleft); +if (copyleftGroups.length === 0) { + display(html`

βœ“ No copyleft packages found.

`); +} else { + display(html`
+ ${copyleftGroups.map(g => html` +
+ ${g.license_spdx ?? "unknown"} + ${g.count} package(s) + ${g.repos.join(", ")} +
+ `)} +
`); +} +``` + +## Package Table + +```js +// Filters +const ecoFilter = Inputs.select(["all", "python", "node", "rust", "go", "java", "other"], {label: "Ecosystem", value: "all"}); +const directOnly = Inputs.toggle({label: "Direct deps only", value: false}); +const prodOnly = Inputs.toggle({label: "Prod deps only (no dev)", value: false}); +display(html`
+ ${ecoFilter}${directOnly}${prodOnly} +
`); +``` + +```js +// Build repo_id β†’ slug lookup +const repoById = Object.fromEntries(_repos.map(r => [r.id, r.slug])); + +const filteredEntries = entries.filter(e => + (ecoFilter.value === "all" || e.ecosystem === ecoFilter.value) && + (!directOnly.value || e.is_direct) && + (!prodOnly.value || !e.is_dev) +); + +display(Inputs.table(filteredEntries.map(e => ({ + Package: e.package_name, + Version: e.package_version ?? "β€”", + Ecosystem: e.ecosystem, + Licence: e.license_spdx ?? "β€”", + Repo: repoById[e.repo_id] ?? e.repo_id?.slice(0, 8) ?? "β€”", + Direct: e.is_direct ? "βœ“" : "", + Dev: e.is_dev ? "βœ“" : "", +})), {maxWidth: 900})); +``` + + diff --git a/mcp_server/server.py b/mcp_server/server.py index 8a95615..fcf089e 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -813,6 +813,155 @@ def validate_repo_adr(repo_path: str, domain_slug: str | None = None) -> str: return "\n".join(lines) +# --------------------------------------------------------------------------- +# Contribution tracking (v0.3) +# --------------------------------------------------------------------------- + +@mcp.resource("state://contributions") +def resource_contributions() -> str: + """All contribution artifacts (BR/FR/EP/UPR).""" + return json.dumps(_get("/contributions"), indent=2) + + +@mcp.resource("state://sbom/aggregated") +def resource_sbom_aggregated() -> str: + """Aggregated SBOM entries across all repos.""" + return json.dumps(_get("/sbom"), indent=2) + + +@mcp.resource("state://sbom/{repo_slug}") +def resource_sbom_repo(repo_slug: str) -> str: + """SBOM view for a specific repo (by slug).""" + return json.dumps(_get(f"/sbom/{repo_slug}"), indent=2) + + +@mcp.tool() +def register_contribution( + type: str, + title: str, + target_org: str | None = None, + target_repo: str | None = None, + body_path: str | None = None, + related_workstream_id: str | None = None, + notes: str | None = None, +) -> str: + """Register a new upstream contribution artifact (BR/FR/EP/UPR). + + Args: + type: br | fr | ep | upr + title: Short human-readable title + target_org: GitHub org or owner of the upstream project + target_repo: Repository name of the upstream project + body_path: Relative path to the Markdown artifact file in the repo + related_workstream_id: UUID of the related workstream (optional) + notes: Any additional notes (optional) + """ + contrib = _post("/contributions", { + "type": type, + "title": title, + "target_org": target_org, + "target_repo": target_repo, + "body_path": body_path, + "related_workstream_id": related_workstream_id, + "notes": notes, + }) + _post("/progress", { + "workstream_id": related_workstream_id, + "event_type": "contribution_registered", + "summary": f"Contribution registered [{type.upper()}]: {title}", + "author": "custodian", + "detail": { + "contribution_id": contrib["id"], + "type": type, + "target": f"{target_org}/{target_repo}" if target_org else target_repo, + "body_path": body_path, + }, + }) + return json.dumps(contrib, indent=2) + + +@mcp.tool() +def update_contribution_status( + contribution_id: str, + status: str, + notes: str | None = None, +) -> str: + """Update the status of a contribution artifact. + + Valid transitions: draftβ†’submittedβ†’acknowledgedβ†’acceptedβ†’merged + β†˜ β†˜ + rejected withdrawn + + Args: + contribution_id: UUID of the contribution + status: submitted | acknowledged | accepted | rejected | merged | withdrawn + notes: Optional context for the status change + """ + contrib = _patch(f"/contributions/{contribution_id}/status", { + "status": status, + "notes": notes, + }) + _post("/progress", { + "event_type": "contribution_status_changed", + "summary": f"Contribution status β†’ {status}: {contrib['title']}", + "author": "custodian", + "detail": {"contribution_id": contribution_id, "status": status, "notes": notes}, + }) + return json.dumps(contrib, indent=2) + + +@mcp.tool() +def get_contributions( + type: str | None = None, + status: str | None = None, + target_repo: str | None = None, +) -> str: + """List contribution artifacts, optionally filtered. + + Args: + type: br | fr | ep | upr (optional) + status: draft | submitted | acknowledged | accepted | rejected | merged | withdrawn (optional) + target_repo: filter by upstream repo name (optional) + """ + return json.dumps(_get("/contributions", { + "type": type, "status": status, "target_repo": target_repo, + }), indent=2) + + +@mcp.tool() +def ingest_sbom_tool(repo_slug: str, lockfile_path: str) -> str: + """Ingest a lockfile into the State Hub SBOM store for a repo. + + Parses the lockfile and POSTs entries to /sbom/ingest/. Old entries + for the repo are replaced (snapshot strategy). + + Args: + repo_slug: Managed-repo slug (must be registered via register_repo) + lockfile_path: Absolute path to the lockfile (uv.lock, package-lock.json, Cargo.lock, etc.) + """ + import subprocess + script = Path(__file__).parent.parent / "scripts" / "ingest_sbom.py" + result = subprocess.run( + [sys.executable, str(script), "--repo", repo_slug, + "--lockfile", lockfile_path, "--api-base", API_BASE], + capture_output=True, text=True, + ) + output = (result.stdout + result.stderr).strip() + if result.returncode != 0: + return f"ingest_sbom failed (exit {result.returncode}):\n{output}" + return output + + +@mcp.tool() +def get_licence_report() -> str: + """Get a licence report across all ingested SBOM entries. + + Returns packages grouped by SPDX licence identifier, with copyleft + flag (GPL/AGPL/LGPL/EUPL/CDDL/MPL) and repos using each licence. + """ + return json.dumps(_get("/sbom/report/licences"), indent=2) + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- From 7d3487d4fe98483d0f5806f575d4f718fc367a27 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 28 Feb 2026 17:28:49 +0100 Subject: [PATCH 054/198] feat(state-hub): v0.3 registration workflow + ingest-sbom + CLAUDE.md template update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/ingest_sbom.py: lockfile parser + API poster for uv.lock, requirements.txt, package-lock.json, yarn.lock, Cargo.lock; auto-detects from repo root - Makefile: make ingest-sbom REPO= [LOCKFILE=] target - scripts/register_project.sh: adds {REPO_SLUG} template substitution + optional SBOM ingest prompt at end of registration (non-fatal if venv not ready) - scripts/project_claude_md.template: adds Contribution Tracking + SBOM sections documenting register_contribution(), update_contribution_status(), ingest-sbom, and the contrib/ directory layout - workplans/CUST-WP-0002: all 15 tasks β†’ done, status β†’ completed Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 5 + scripts/ingest_sbom.py | 276 +++++++++++++++++++++++++++++ scripts/project_claude_md.template | 57 ++++++ scripts/register_project.sh | 19 ++ 4 files changed, 357 insertions(+) create mode 100644 scripts/ingest_sbom.py diff --git a/Makefile b/Makefile index 60451f1..1bf0772 100644 --- a/Makefile +++ b/Makefile @@ -73,6 +73,11 @@ list-repos: @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1) curl -sf "http://127.0.0.1:8000/repos/?domain=$(DOMAIN)" | python3 -m json.tool +## Ingest a repo's lockfile into the SBOM store: make ingest-sbom REPO=the-custodian [LOCKFILE=uv.lock] +ingest-sbom: + @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make ingest-sbom REPO= [LOCKFILE=]"; exit 1) + uv run python scripts/ingest_sbom.py --repo "$(REPO)" $(if $(LOCKFILE),--lockfile "$(LOCKFILE)",) + ## Check a repo for ADR-001 compliance: make validate-adr REPO=/path/to/repo [DOMAIN=custodian] validate-adr: @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make validate-adr REPO= [DOMAIN=]"; exit 1) diff --git a/scripts/ingest_sbom.py b/scripts/ingest_sbom.py new file mode 100644 index 0000000..c21b7d8 --- /dev/null +++ b/scripts/ingest_sbom.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""Ingest a repo's lockfile into the State Hub SBOM store. + +Usage: + python ingest_sbom.py --repo [--lockfile ] [--api-base ] + +Auto-detects lockfile type: + uv.lock β†’ Python ecosystem + requirements.txt β†’ Python ecosystem (basic) + package-lock.json β†’ Node ecosystem + yarn.lock β†’ Node ecosystem + Cargo.lock β†’ Rust ecosystem +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import urllib.error +import urllib.request +from pathlib import Path + +API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") + + +# --------------------------------------------------------------------------- +# Lockfile parsers +# --------------------------------------------------------------------------- + +def _parse_uv_lock(path: Path) -> list[dict]: + """Parse uv.lock TOML format (v0.1 β€” [[package]] blocks).""" + entries = [] + current: dict | None = None + + for line in path.read_text().splitlines(): + stripped = line.strip() + if stripped == "[[package]]": + if current: + entries.append(current) + current = {} + elif current is not None: + if stripped.startswith("name = "): + current["package_name"] = stripped.split("=", 1)[1].strip().strip('"') + elif stripped.startswith("version = "): + current["package_version"] = stripped.split("=", 1)[1].strip().strip('"') + + if current: + entries.append(current) + + return [ + { + "package_name": e.get("package_name", "unknown"), + "package_version": e.get("package_version"), + "ecosystem": "python", + "license_spdx": None, + "is_direct": False, # uv.lock doesn't distinguish; treat all as transitive + "is_dev": False, + } + for e in entries + if "package_name" in e + ] + + +def _parse_requirements_txt(path: Path) -> list[dict]: + """Parse requirements.txt (basic β€” name==version lines).""" + entries = [] + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or line.startswith("-"): + continue + # Handle: pkg==1.2.3, pkg>=1.2, pkg + m = re.match(r"^([A-Za-z0-9_.\-]+)(?:[>= list[dict]: + """Parse package-lock.json (npm) β€” packages dict.""" + try: + data = json.loads(path.read_text()) + except json.JSONDecodeError as e: + print(f"Warning: cannot parse {path}: {e}", file=sys.stderr) + return [] + + packages = data.get("packages", {}) + entries = [] + for pkg_path, info in packages.items(): + if not pkg_path: # root package + continue + name = info.get("name") or pkg_path.split("node_modules/")[-1] + entries.append({ + "package_name": name, + "package_version": info.get("version"), + "ecosystem": "node", + "license_spdx": info.get("license"), + "is_direct": not info.get("indirect", False), + "is_dev": bool(info.get("dev", False)), + }) + return entries + + +def _parse_yarn_lock(path: Path) -> list[dict]: + """Parse yarn.lock β€” basic name extraction.""" + entries = [] + current_names: list[str] = [] + current_version: str | None = None + + for line in path.read_text().splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if not line.startswith(" ") and stripped.endswith(":"): + # New package block header: "name@version::" or "\"name@version\":" + # May list multiple versions: "name@^1.0, name@~1.0:" + current_names = [] + current_version = None + for part in stripped.rstrip(":").split(","): + m = re.match(r'"?([^@"]+)@', part.strip()) + if m: + current_names.append(m.group(1).strip()) + elif stripped.startswith("version "): + current_version = stripped.split('"')[1] if '"' in stripped else None + elif not stripped and current_names and current_version: + for name in current_names: + entries.append({ + "package_name": name, + "package_version": current_version, + "ecosystem": "node", + "license_spdx": None, + "is_direct": False, + "is_dev": False, + }) + current_names = [] + current_version = None + + return entries + + +def _parse_cargo_lock(path: Path) -> list[dict]: + """Parse Cargo.lock TOML format ([[package]] blocks).""" + entries = [] + current: dict | None = None + + for line in path.read_text().splitlines(): + stripped = line.strip() + if stripped == "[[package]]": + if current: + entries.append(current) + current = {} + elif current is not None: + if stripped.startswith("name = "): + current["package_name"] = stripped.split("=", 1)[1].strip().strip('"') + elif stripped.startswith("version = "): + current["package_version"] = stripped.split("=", 1)[1].strip().strip('"') + + if current: + entries.append(current) + + return [ + { + "package_name": e.get("package_name", "unknown"), + "package_version": e.get("package_version"), + "ecosystem": "rust", + "license_spdx": None, + "is_direct": False, + "is_dev": False, + } + for e in entries + if "package_name" in e + ] + + +_LOCKFILE_PARSERS = { + "uv.lock": _parse_uv_lock, + "requirements.txt": _parse_requirements_txt, + "package-lock.json": _parse_package_lock_json, + "yarn.lock": _parse_yarn_lock, + "Cargo.lock": _parse_cargo_lock, +} + + +def detect_lockfile(repo_path: Path) -> tuple[Path, str] | None: + """Return (lockfile_path, ecosystem) for the first recognised lockfile found.""" + for name in _LOCKFILE_PARSERS: + candidate = repo_path / name + if candidate.exists(): + return candidate, name + return None + + +def parse_lockfile(lockfile_path: Path) -> list[dict]: + filename = lockfile_path.name + parser = _LOCKFILE_PARSERS.get(filename) + if parser is None: + print(f"Error: unsupported lockfile type '{filename}'", file=sys.stderr) + sys.exit(1) + return parser(lockfile_path) + + +# --------------------------------------------------------------------------- +# API submission +# --------------------------------------------------------------------------- + +def post_ingest(api_base: str, repo_slug: str, entries: list[dict]) -> dict: + payload = json.dumps({"repo_slug": repo_slug, "entries": entries}).encode() + req = urllib.request.Request( + f"{api_base}/sbom/ingest/", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + body = e.read().decode(errors="replace") + print(f"HTTP {e.code} from API: {body}", file=sys.stderr) + sys.exit(1) + except urllib.error.URLError as e: + print(f"API unreachable: {e}", file=sys.stderr) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser(description="Ingest a lockfile into the State Hub SBOM store.") + parser.add_argument("--repo", required=True, help="Managed-repo slug (e.g. 'the-custodian')") + parser.add_argument("--lockfile", help="Path to lockfile (auto-detected if omitted)") + parser.add_argument("--repo-path", default=".", help="Repo root for auto-detection (default: cwd)") + parser.add_argument("--api-base", default=API_BASE, help="State Hub API base URL") + parser.add_argument("--dry-run", action="store_true", help="Parse only β€” do not submit") + args = parser.parse_args() + + if args.lockfile: + lockfile_path = Path(args.lockfile).resolve() + else: + found = detect_lockfile(Path(args.repo_path).resolve()) + if not found: + print( + f"No recognised lockfile found in '{args.repo_path}'. " + "Supported: " + ", ".join(_LOCKFILE_PARSERS), + file=sys.stderr, + ) + sys.exit(1) + lockfile_path, _ = found + print(f"Auto-detected: {lockfile_path}") + + entries = parse_lockfile(lockfile_path) + print(f"Parsed {len(entries)} packages from {lockfile_path.name}") + + if args.dry_run: + print(json.dumps(entries[:5], indent=2)) + if len(entries) > 5: + print(f" … and {len(entries) - 5} more") + return + + result = post_ingest(args.api_base, args.repo, entries) + print(f"Ingested {result.get('ingested', '?')} entries for repo '{args.repo}'") + print(f"Snapshot at: {result.get('snapshot_at', '?')}") + + +if __name__ == "__main__": + main() diff --git a/scripts/project_claude_md.template b/scripts/project_claude_md.template index 73b5402..a028081 100644 --- a/scripts/project_claude_md.template +++ b/scripts/project_claude_md.template @@ -82,6 +82,63 @@ add_progress_event( ) ``` +### Contribution Tracking + +This project tracks upstream contributions in `contrib/` β€” bug reports, feature +requests, extension-point proposals, and upstream PRs β€” as canonical Markdown files. + +**Directory layout:** +``` +contrib/ + bug-reports/ # br-YYYY-MM-DD--org--repo--slug.md + feature-requests/ # fr-YYYY-MM-DD--org--repo--slug.md + extension-points/ # EP-{DOMAIN}-NNN--org--repo--slug.md + upstream-prs/ # upr-YYYY-MM-DD--org--repo--slug.md +``` + +Templates: `~/the-custodian/canon/standards/contrib-templates/` +Convention: `~/the-custodian/canon/standards/contribution-convention_v0.1.md` + +**Register a contribution in the State Hub:** +``` +register_contribution( + type="upr", # br | fr | ep | upr + title="Add injectTocTop to Observable Framework", + target_org="observablehq", + target_repo="framework", + body_path="contrib/upstream-prs/2026-02-26--observablehq--framework--inject.md", + related_workstream_id="", +) +``` + +**Update status when upstream responds:** +``` +update_contribution_status(contribution_id="", status="submitted") +# then: acknowledged β†’ accepted β†’ merged +``` + +**List all contributions for this domain:** +``` +get_contributions(target_repo="framework") +``` + +### SBOM + +Software Bill of Materials for this repo is tracked in the State Hub. + +**Ingest the current lockfile:** +```bash +cd ~/the-custodian/state-hub +make ingest-sbom REPO={REPO_SLUG} +``` + +**Check licence risk:** +``` +get_licence_report() +``` + +**View SBOM dashboard:** `http://localhost:3000/sbom` + ### Quick Reference See `~/the-custodian/state-hub/mcp_server/TOOLS.md` for a compact tool reference. diff --git a/scripts/register_project.sh b/scripts/register_project.sh index b027978..fae3402 100755 --- a/scripts/register_project.sh +++ b/scripts/register_project.sh @@ -126,6 +126,7 @@ else -e "s|{PROJECT_NAME}|$PROJECT_NAME|g" \ -e "s|{DOMAIN}|$DOMAIN|g" \ -e "s|{TOPIC_ID}|$TOPIC_ID|g" \ + -e "s|{REPO_SLUG}|$REPO_SLUG|g" \ "$TEMPLATE" > "$CLAUDE_MD" echo " Written." fi @@ -186,3 +187,21 @@ echo " Repo slug: $REPO_SLUG" echo " CLAUDE.md: $CLAUDE_MD" echo "" echo "Next: restart Claude Code for the MCP server to be available in this project." + +# ── Optional: SBOM ingest ───────────────────────────────────────────────────── +if [[ "$ADDITIONAL" != "--additional" ]]; then + echo "" + read -r -p "==> Run SBOM ingest now? (auto-detects lockfile in $PROJECT_PATH) [y/N] " INGEST_NOW + if [[ "$INGEST_NOW" =~ ^[Yy]$ ]]; then + echo "==> Ingesting SBOM for '$REPO_SLUG' ..." + INGEST_UV="$STATE_HUB_DIR/.venv/bin/python" + if [[ -x "$INGEST_UV" ]]; then + "$INGEST_UV" "$SCRIPT_DIR/ingest_sbom.py" \ + --repo "$REPO_SLUG" \ + --repo-path "$PROJECT_PATH" \ + --api-base "$API_BASE" && echo " SBOM ingested." || echo " SBOM ingest failed (non-fatal)." + else + echo " Skipping: .venv not found. Run 'make install' first, then 'make ingest-sbom REPO=$REPO_SLUG'." + fi + fi +fi From 4c157d43a8a09504bb70dbb6dbf6c4a3f7fa7393 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 1 Mar 2026 16:15:40 +0100 Subject: [PATCH 055/198] feat(sbom): scan mode, domain grouping dashboard, SBOM convention doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ingest_sbom.py: add --scan flag (recursive lockfile discovery) + --lockfile repeatable for explicit multi-file ingestion; skip .venv/node_modules/.git/dist/etc; Makefile gains SCAN= and REPO_PATH= vars - sbom.md: add /domains/ fetch; domain-level summary table; per-repo accordion with details/summary; domain filter on package table; dual- licence false-positive note; +1 KPI card (Domains Covered) - canon/standards/sbom-convention_v0.1.md: authoritative lockfile table, ingest workflow (single/scan/explicit), snapshot semantics, direct-vs- transitive caveats, licence governance + copyleft escalation, update cadence, multi-repo domain pattern, planned enhancements First ingest: the-custodian β€” 420 pkgs (88 python + 332 node), 13 licence groups, 1 copyleft flag (jszip dual-licensed MIT OR GPL-3.0-or-later) Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 12 +++- dashboard/src/sbom.md | 141 +++++++++++++++++++++++++++++++++++++---- scripts/ingest_sbom.py | 73 ++++++++++++++++----- 3 files changed, 197 insertions(+), 29 deletions(-) diff --git a/Makefile b/Makefile index 1bf0772..a378dc4 100644 --- a/Makefile +++ b/Makefile @@ -73,10 +73,16 @@ list-repos: @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1) curl -sf "http://127.0.0.1:8000/repos/?domain=$(DOMAIN)" | python3 -m json.tool -## Ingest a repo's lockfile into the SBOM store: make ingest-sbom REPO=the-custodian [LOCKFILE=uv.lock] +## Ingest SBOM data for a repo. +## Single lockfile (explicit): make ingest-sbom REPO=the-custodian LOCKFILE=/path/to/uv.lock +## Scan all lockfiles in tree: make ingest-sbom REPO=the-custodian SCAN=1 REPO_PATH=/home/worsch/the-custodian +## Auto-detect at repo root: make ingest-sbom REPO=the-custodian REPO_PATH=/home/worsch/the-custodian ingest-sbom: - @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make ingest-sbom REPO= [LOCKFILE=]"; exit 1) - uv run python scripts/ingest_sbom.py --repo "$(REPO)" $(if $(LOCKFILE),--lockfile "$(LOCKFILE)",) + @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) + uv run python scripts/ingest_sbom.py --repo "$(REPO)" \ + $(if $(LOCKFILE),--lockfile "$(LOCKFILE)") \ + $(if $(SCAN),--scan) \ + $(if $(REPO_PATH),--repo-path "$(REPO_PATH)") ## Check a repo for ADR-001 compliance: make validate-adr REPO=/path/to/repo [DOMAIN=custodian] validate-adr: diff --git a/dashboard/src/sbom.md b/dashboard/src/sbom.md index 76abff6..fc6b024 100644 --- a/dashboard/src/sbom.md +++ b/dashboard/src/sbom.md @@ -8,12 +8,13 @@ const API = "http://127.0.0.1:8000"; ```js // Fetch SBOM data on load -let _entries = [], _report = {groups: [], copyleft_direct_count: 0}, _repos = []; +let _entries = [], _report = {groups: [], copyleft_direct_count: 0}, _repos = [], _domains = []; try { - [_entries, _report, _repos] = await Promise.all([ + [_entries, _report, _repos, _domains] = await Promise.all([ fetch(`${API}/sbom/`).then(r => r.ok ? r.json() : []), fetch(`${API}/sbom/report/licences/`).then(r => r.ok ? r.json() : {groups:[], copyleft_direct_count: 0}), fetch(`${API}/repos/`).then(r => r.ok ? r.json() : []), + fetch(`${API}/domains/`).then(r => r.ok ? r.json() : []), ]); } catch {} ``` @@ -22,13 +23,24 @@ try { const entries = _entries ?? []; const report = _report ?? {groups: [], copyleft_direct_count: 0}; const repos = _repos ?? []; +const domains = _domains ?? []; const groups = report.groups ?? []; const riskCount = report.copyleft_direct_count ?? 0; + +// Domain + repo lookups +const domainById = Object.fromEntries(domains.map(d => [d.id, d])); +const repoById = Object.fromEntries(repos.map(r => [r.id, r])); +const repoDomain = Object.fromEntries(repos.map(r => [r.id, domainById[r.domain_id]?.slug ?? "β€”"])); +const domainSlugs = [...new Set(repos.map(r => repoDomain[r.id]).filter(s => s !== "β€”"))].sort(); + +// Copyleft detector (mirrors server-side logic) +const COPYLEFT_KW = ["GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"]; +const isCopyleft = spdx => spdx && COPYLEFT_KW.some(k => spdx.toUpperCase().includes(k)); ``` # SBOM -## Licence Risk +## Overview ```js const riskBadge = riskCount === 0 @@ -43,6 +55,10 @@ display(html`

Repos Scanned

${new Set(entries.map(e => e.repo_id)).size}

+
+

Domains Covered

+

${domainSlugs.length || new Set(Object.values(repoDomain).filter(s => s !== "β€”")).size}

+

Licence Risk

${riskCount}

@@ -55,13 +71,50 @@ display(html`
`); ``` +## By Domain + +```js +if (entries.length === 0) { + display(html`

No SBOM data ingested yet. Run make ingest-sbom REPO=<slug> SCAN=1 REPO_PATH=<path>.

`); +} else { + // Group entries by domain + const byDomain = {}; + for (const e of entries) { + const slug = repoDomain[e.repo_id] ?? "β€”"; + (byDomain[slug] = byDomain[slug] ?? []).push(e); + } + + const domainTableRows = Object.entries(byDomain).map(([slug, es]) => { + const dom = domains.find(d => d.slug === slug); + const repoCount = new Set(es.map(e => e.repo_id)).size; + const directProd = es.filter(e => e.is_direct && !e.is_dev); + const copyleftRisk = directProd.filter(e => isCopyleft(e.license_spdx)).length; + const ecosystems = [...new Set(es.map(e => e.ecosystem))].sort().join(", "); + return { + domain: dom?.name ?? slug, + repos: repoCount, + packages: es.length, + direct: directProd.length, + copyleft: copyleftRisk, + ecosystems, + }; + }).sort((a, b) => a.domain.localeCompare(b.domain)); + + display(Inputs.table(domainTableRows, { + columns: ["domain", "repos", "packages", "direct", "copyleft", "ecosystems"], + header: {domain: "Domain", repos: "Repos", packages: "All Pkgs", direct: "Direct Prod", copyleft: "Copyleft ⚠", ecosystems: "Ecosystems"}, + maxWidth: 900, + })); +} +``` + ## Licence Distribution ```js import * as Plot from "npm:@observablehq/plot"; if (groups.length === 0) { - display(html`

No SBOM data ingested yet. Run make ingest-sbom REPO=<slug>.

`); + display(html`

No SBOM data ingested yet.

`); } else { const plotData = groups.slice(0, 15).map(g => ({ licence: g.license_spdx ?? "(unknown)", @@ -98,6 +151,57 @@ if (copyleftGroups.length === 0) { ${g.repos.join(", ")}
`)} +
+

Note: dual-licensed packages (e.g. "MIT OR GPL-3.0") are flagged conservatively. Review if the non-copyleft variant is used.

`); +} +``` + +## By Repo + +```js +// Group entries by repo, sorted by domain then repo name +const byRepo = {}; +for (const e of entries) { + (byRepo[e.repo_id] = byRepo[e.repo_id] ?? []).push(e); +} + +const repoSections = Object.entries(byRepo) + .map(([repoId, es]) => { + const repo = repoById[repoId]; + const domSlug = repoDomain[repoId] ?? "β€”"; + const dom = domains.find(d => d.slug === domSlug); + const directProd = es.filter(e => e.is_direct && !e.is_dev); + const copyleftRisk = directProd.filter(e => isCopyleft(e.license_spdx)).length; + const ecosystems = [...new Set(es.map(e => e.ecosystem))].sort(); + return { repoId, repo, dom, domSlug, es, directProd, copyleftRisk, ecosystems }; + }) + .sort((a, b) => (a.domSlug + a.repo?.slug).localeCompare(b.domSlug + b.repo?.slug)); + +if (repoSections.length === 0) { + display(html`

No repo data.

`); +} else { + display(html`
+ ${repoSections.map(({repoId, repo, dom, domSlug, es, directProd, copyleftRisk, ecosystems}) => html` +
+ + ${dom?.name ?? domSlug} + ${repo?.slug ?? repoId.slice(0,8)} + ${es.length} pkgs · ${ecosystems.join(" + ")} · ${directProd.length} direct + ${copyleftRisk > 0 ? html`⚠ ${copyleftRisk} copyleft` : ""} + +
+ ${Inputs.table(es.slice(0, 200).map(e => ({ + Package: e.package_name, + Version: e.package_version ?? "β€”", + Ecosystem: e.ecosystem, + Licence: e.license_spdx ?? "β€”", + Direct: e.is_direct ? "βœ“" : "", + Dev: e.is_dev ? "βœ“" : "", + })), {maxWidth: 860})} + ${es.length > 200 ? html`

Showing first 200 of ${es.length}

` : ""} +
+
+ `)}
`); } ``` @@ -106,19 +210,19 @@ if (copyleftGroups.length === 0) { ```js // Filters +const domainOpts = ["all", ...domainSlugs]; +const domainFilter = Inputs.select(domainOpts, {label: "Domain", value: "all"}); const ecoFilter = Inputs.select(["all", "python", "node", "rust", "go", "java", "other"], {label: "Ecosystem", value: "all"}); const directOnly = Inputs.toggle({label: "Direct deps only", value: false}); const prodOnly = Inputs.toggle({label: "Prod deps only (no dev)", value: false}); display(html`
- ${ecoFilter}${directOnly}${prodOnly} + ${domainFilter}${ecoFilter}${directOnly}${prodOnly}
`); ``` ```js -// Build repo_id β†’ slug lookup -const repoById = Object.fromEntries(_repos.map(r => [r.id, r.slug])); - const filteredEntries = entries.filter(e => + (domainFilter.value === "all" || repoDomain[e.repo_id] === domainFilter.value) && (ecoFilter.value === "all" || e.ecosystem === ecoFilter.value) && (!directOnly.value || e.is_direct) && (!prodOnly.value || !e.is_dev) @@ -129,22 +233,37 @@ display(Inputs.table(filteredEntries.map(e => ({ Version: e.package_version ?? "β€”", Ecosystem: e.ecosystem, Licence: e.license_spdx ?? "β€”", - Repo: repoById[e.repo_id] ?? e.repo_id?.slice(0, 8) ?? "β€”", + Domain: repoDomain[e.repo_id] ?? "β€”", + Repo: repoById[e.repo_id]?.slug ?? e.repo_id?.slice(0, 8) ?? "β€”", Direct: e.is_direct ? "βœ“" : "", Dev: e.is_dev ? "βœ“" : "", -})), {maxWidth: 900})); +})), {maxWidth: 960})); ``` diff --git a/scripts/ingest_sbom.py b/scripts/ingest_sbom.py index c21b7d8..59ce949 100644 --- a/scripts/ingest_sbom.py +++ b/scripts/ingest_sbom.py @@ -188,9 +188,19 @@ _LOCKFILE_PARSERS = { "Cargo.lock": _parse_cargo_lock, } +# Directories that never contain project-level lockfiles +_SKIP_DIRS = { + ".git", ".hg", ".svn", + ".venv", "venv", ".env", + "node_modules", + "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache", + "dist", "build", ".build", "target", + ".tox", ".nox", +} + def detect_lockfile(repo_path: Path) -> tuple[Path, str] | None: - """Return (lockfile_path, ecosystem) for the first recognised lockfile found.""" + """Return (lockfile_path, filename) for the first recognised lockfile at repo root.""" for name in _LOCKFILE_PARSERS: candidate = repo_path / name if candidate.exists(): @@ -198,6 +208,17 @@ def detect_lockfile(repo_path: Path) -> tuple[Path, str] | None: return None +def detect_lockfiles_recursive(repo_path: Path) -> list[Path]: + """Walk repo_path and return all recognised lockfiles, skipping non-dep dirs.""" + found: list[Path] = [] + for dirpath, dirnames, filenames in os.walk(repo_path): + dirnames[:] = sorted(d for d in dirnames if d not in _SKIP_DIRS) + for name in _LOCKFILE_PARSERS: + if name in filenames: + found.append(Path(dirpath) / name) + return found + + def parse_lockfile(lockfile_path: Path) -> list[dict]: filename = lockfile_path.name parser = _LOCKFILE_PARSERS.get(filename) @@ -236,38 +257,60 @@ def post_ingest(api_base: str, repo_slug: str, entries: list[dict]) -> dict: # --------------------------------------------------------------------------- def main() -> None: - parser = argparse.ArgumentParser(description="Ingest a lockfile into the State Hub SBOM store.") + parser = argparse.ArgumentParser(description="Ingest a repo's lockfiles into the State Hub SBOM store.") parser.add_argument("--repo", required=True, help="Managed-repo slug (e.g. 'the-custodian')") - parser.add_argument("--lockfile", help="Path to lockfile (auto-detected if omitted)") - parser.add_argument("--repo-path", default=".", help="Repo root for auto-detection (default: cwd)") + parser.add_argument("--lockfile", action="append", dest="lockfiles", + metavar="PATH", help="Path to a specific lockfile (repeatable)") + parser.add_argument("--repo-path", default=".", help="Repo root for auto-detection/scan (default: cwd)") + parser.add_argument("--scan", action="store_true", + help="Recursively find ALL lockfiles under --repo-path (handles multi-ecosystem repos)") parser.add_argument("--api-base", default=API_BASE, help="State Hub API base URL") parser.add_argument("--dry-run", action="store_true", help="Parse only β€” do not submit") args = parser.parse_args() - if args.lockfile: - lockfile_path = Path(args.lockfile).resolve() + repo_root = Path(args.repo_path).resolve() + lockfile_paths: list[Path] = [] + + if args.lockfiles: + lockfile_paths = [Path(lf).resolve() for lf in args.lockfiles] + elif args.scan: + lockfile_paths = detect_lockfiles_recursive(repo_root) + if not lockfile_paths: + print(f"No lockfiles found under '{repo_root}'.", file=sys.stderr) + sys.exit(1) + print(f"Scan found {len(lockfile_paths)} lockfile(s):") + for lf in lockfile_paths: + print(f" {lf.relative_to(repo_root) if lf.is_relative_to(repo_root) else lf}") else: - found = detect_lockfile(Path(args.repo_path).resolve()) + found = detect_lockfile(repo_root) if not found: print( - f"No recognised lockfile found in '{args.repo_path}'. " - "Supported: " + ", ".join(_LOCKFILE_PARSERS), + f"No recognised lockfile found in '{repo_root}'. " + f"Supported: {', '.join(_LOCKFILE_PARSERS)}. " + "Use --scan to search subdirectories.", file=sys.stderr, ) sys.exit(1) lockfile_path, _ = found print(f"Auto-detected: {lockfile_path}") + lockfile_paths = [lockfile_path] - entries = parse_lockfile(lockfile_path) - print(f"Parsed {len(entries)} packages from {lockfile_path.name}") + all_entries: list[dict] = [] + for lf in lockfile_paths: + parsed = parse_lockfile(lf) + rel = lf.relative_to(repo_root) if lf.is_relative_to(repo_root) else lf + print(f" {rel}: {len(parsed)} packages") + all_entries.extend(parsed) + + print(f"Total: {len(all_entries)} packages across {len(lockfile_paths)} lockfile(s)") if args.dry_run: - print(json.dumps(entries[:5], indent=2)) - if len(entries) > 5: - print(f" … and {len(entries) - 5} more") + print(json.dumps(all_entries[:5], indent=2)) + if len(all_entries) > 5: + print(f" … and {len(all_entries) - 5} more") return - result = post_ingest(args.api_base, args.repo, entries) + result = post_ingest(args.api_base, args.repo, all_entries) print(f"Ingested {result.get('ingested', '?')} entries for repo '{args.repo}'") print(f"Snapshot at: {result.get('snapshot_at', '?')}") From fae91511442860c77b2e076bf2e9cc8fb0246243 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 1 Mar 2026 18:07:56 +0100 Subject: [PATCH 056/198] feat(sbom): add Terraform .terraform.lock.hcl parser; ingest railiance repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ingest_sbom.py: parse .terraform.lock.hcl provider blocks (name, version); ecosystem stored as 'other' until terraform added to DB ENUM - Registered railiance-bootstrap + railiance-hosts under railiance domain - railiance-hosts ingested: 2 Terraform providers (hashicorp/template 2.2.0, hetznercloud/hcloud 1.52.0) - railiance-bootstrap: no lockfile (pure Ansible/shell β€” noted in convention) - sbom-convention_v0.1.md: add Terraform + Ansible rows to lockfile table; update registered repos status table Total SBOM: 422 packages across 2 repos (custodian + railiance-hosts) Co-Authored-By: Claude Sonnet 4.6 --- scripts/ingest_sbom.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/scripts/ingest_sbom.py b/scripts/ingest_sbom.py index 59ce949..c024ac8 100644 --- a/scripts/ingest_sbom.py +++ b/scripts/ingest_sbom.py @@ -180,12 +180,47 @@ def _parse_cargo_lock(path: Path) -> list[dict]: ] +def _parse_terraform_lock_hcl(path: Path) -> list[dict]: + """Parse .terraform.lock.hcl β€” extract Terraform provider name + version.""" + entries = [] + current_name: str | None = None + current_version: str | None = None + + for line in path.read_text().splitlines(): + stripped = line.strip() + # e.g.: provider "registry.terraform.io/hetznercloud/hcloud" { + m = re.match(r'^provider\s+"([^"]+)"\s*\{', stripped) + if m: + # Use full provider address as package_name, short name as display + full = m.group(1) + current_name = full # e.g. "registry.terraform.io/hetznercloud/hcloud" + current_version = None + elif current_name is not None: + vm = re.match(r'version\s*=\s*"([^"]+)"', stripped) + if vm: + current_version = vm.group(1) + elif stripped == "}": + entries.append({ + "package_name": current_name, + "package_version": current_version, + "ecosystem": "other", # "terraform" not yet in ENUM; tracked as other + "license_spdx": None, + "is_direct": True, + "is_dev": False, + }) + current_name = None + current_version = None + + return entries + + _LOCKFILE_PARSERS = { "uv.lock": _parse_uv_lock, "requirements.txt": _parse_requirements_txt, "package-lock.json": _parse_package_lock_json, "yarn.lock": _parse_yarn_lock, "Cargo.lock": _parse_cargo_lock, + ".terraform.lock.hcl": _parse_terraform_lock_hcl, } # Directories that never contain project-level lockfiles From 9bfb0c130a814a5fb44eb2d9269de463f24a3a2d Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 1 Mar 2026 18:53:25 +0100 Subject: [PATCH 057/198] feat(dashboard): Repos page with coverage map; expose last_sbom_at on RepoRead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RepoRead schema: add last_sbom_at + sbom_source fields (already in model, now surfaced in API response) - repos.md: new dashboard page β€” KPI row (total/domains/ingested/gaps), domain-grouped coverage map with SBOM/EP/TD chips, per-repo table with gap highlighting, domain filter + gap-only toggle, ingest how-to section - observablehq.config.js: add Repos after Domains in nav Coverage state: 3 repos registered (custodianΓ—1, railianceΓ—2); 2 ingested (the-custodian + railiance-hosts), 1 gap (railiance-bootstrap β€” infra-only, no lockfile, expected) Co-Authored-By: Claude Sonnet 4.6 --- api/schemas/managed_repo.py | 2 + dashboard/observablehq.config.js | 1 + dashboard/src/repos.md | 255 +++++++++++++++++++++++++++++++ 3 files changed, 258 insertions(+) create mode 100644 dashboard/src/repos.md diff --git a/api/schemas/managed_repo.py b/api/schemas/managed_repo.py index bf9ae69..3f4d9c3 100644 --- a/api/schemas/managed_repo.py +++ b/api/schemas/managed_repo.py @@ -33,5 +33,7 @@ class RepoRead(BaseModel): description: str | None = None status: str topic_id: uuid.UUID | None = None + sbom_source: str | None = None + last_sbom_at: datetime | None = None created_at: datetime updated_at: datetime diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index 5aeb1cf..2a0ce1f 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -8,6 +8,7 @@ export default { { name: "Decisions", path: "/decisions" }, { name: "Progress", path: "/progress" }, { name: "Domains", path: "/domains" }, + { name: "Repos", path: "/repos" }, { name: "Extension Points", path: "/extensions" }, { name: "Technical Debt", path: "/techdept" }, { name: "Contributions", path: "/contributions" }, diff --git a/dashboard/src/repos.md b/dashboard/src/repos.md new file mode 100644 index 0000000..2e604bf --- /dev/null +++ b/dashboard/src/repos.md @@ -0,0 +1,255 @@ +--- +title: Repos +--- + +```js +const API = "http://127.0.0.1:8000"; +``` + +```js +let _repos = [], _domains = [], _sbom = [], _eps = [], _tds = [], _contribs = []; +try { + [_repos, _domains, _sbom, _eps, _tds, _contribs] = await Promise.all([ + fetch(`${API}/repos/`).then(r => r.ok ? r.json() : []), + fetch(`${API}/domains/`).then(r => r.ok ? r.json() : []), + fetch(`${API}/sbom/`).then(r => r.ok ? r.json() : []), + fetch(`${API}/extension-points/`).then(r => r.ok ? r.json() : []), + fetch(`${API}/technical-debt/`).then(r => r.ok ? r.json() : []), + fetch(`${API}/contributions/`).then(r => r.ok ? r.json() : []), + ]); +} catch {} +``` + +```js +const repos = _repos ?? []; +const domains = _domains ?? []; +const sbom = _sbom ?? []; +const eps = _eps ?? []; +const tds = _tds ?? []; +const contribs = _contribs ?? []; + +// Lookups +const domainById = Object.fromEntries(domains.map(d => [d.id, d])); +const domainBySlug = Object.fromEntries(domains.map(d => [d.slug, d])); + +// Per-repo SBOM stats (from sbom entries) +const sbomByRepo = {}; +for (const e of sbom) { + if (!sbomByRepo[e.repo_id]) sbomByRepo[e.repo_id] = { count: 0, snapshot_at: e.snapshot_at }; + sbomByRepo[e.repo_id].count++; +} + +// Per-domain counts +const epByDomain = {}; +const tdByDomain = {}; +const contribByDomain = {}; + +// EPs are domain-scoped +for (const ep of eps) { + if (!ep.status || ep.status === "open" || ep.status === "in_progress") { + epByDomain[ep.domain] = (epByDomain[ep.domain] ?? 0) + 1; + } +} +for (const td of tds) { + if (!td.status || td.status === "open" || td.status === "in_progress") { + tdByDomain[td.domain] = (tdByDomain[td.domain] ?? 0) + 1; + } +} +// Contributions: try to map via workstream β†’ topic β†’ domain (not available here; skip for now) +// Use domain slug from contributions' related_workstream if available β€” fallback: count by type only + +// Build enriched repo rows +const repoRows = repos + .filter(r => r.status === "active") + .map(r => { + const domain = domainById[r.domain_id]; + const domSlug = domain?.slug ?? "β€”"; + const domName = domain?.name ?? "β€”"; + const sbomData = sbomByRepo[r.id]; + const hasSbom = !!sbomData || !!r.last_sbom_at; + const pkgCount = sbomData?.count ?? 0; + const lastScan = r.last_sbom_at + ? new Date(r.last_sbom_at).toLocaleDateString() + : (sbomData?.snapshot_at ? new Date(sbomData.snapshot_at).toLocaleDateString() : null); + return { + _id: r.id, + _domSlug: domSlug, + _hasSbom: hasSbom, + repo: r.slug, + domain: domName, + path: r.local_path ?? "β€”", + sbom: hasSbom ? `βœ“ ${lastScan}` : "⚠ not ingested", + pkgs: pkgCount || (hasSbom ? "β€”" : 0), + eps: epByDomain[domSlug] ?? 0, + tds: tdByDomain[domSlug] ?? 0, + }; + }) + .sort((a, b) => a._domSlug.localeCompare(b._domSlug) || a.repo.localeCompare(b.repo)); + +const gapCount = repoRows.filter(r => !r._hasSbom).length; +const coveredCount = repoRows.filter(r => r._hasSbom).length; +``` + +# Repos + +```js +// Summary KPIs +display(html`
+
+

Registered Repos

+

${repoRows.length}

+
+
+

Domains

+

${new Set(repoRows.map(r => r._domSlug)).size}

+
+
+

SBOM Ingested

+

${coveredCount} / ${repoRows.length}

+
+
+

SBOM Gaps

+

${gapCount}

+ ${gapCount === 0 ? "βœ“ All repos covered" : `⚠ ${gapCount} repo(s) not ingested`} +
+
`); +``` + +## Coverage Map + +```js +// Group by domain +const byDomain = {}; +for (const r of repoRows) { + (byDomain[r._domSlug] = byDomain[r._domSlug] ?? []).push(r); +} + +const domainBlocks = Object.entries(byDomain).sort(([a], [b]) => a.localeCompare(b)); + +if (domainBlocks.length === 0) { + display(html`

No repos registered. Run make add-repo DOMAIN=<slug> SLUG=<slug> NAME="..." PATH=/path.

`); +} else { + display(html`
+ ${domainBlocks.map(([slug, rows]) => { + const dom = domainBySlug[slug]; + const allCovered = rows.every(r => r._hasSbom); + const hasEps = (epByDomain[slug] ?? 0) > 0; + const hasTds = (tdByDomain[slug] ?? 0) > 0; + return html` +
+
+ ${dom?.name ?? slug} + + ${allCovered + ? html`SBOM βœ“` + : html`SBOM ⚠`} + ${hasEps + ? html`EPs: ${epByDomain[slug]}` + : html`EPs: β€”`} + ${hasTds + ? html`TDs: ${tdByDomain[slug]}` + : html`TDs: β€”`} + +
+ + + + + + + + + ${rows.map(r => html` + + + + + `)} + +
RepoSBOMPackagesLocal path
${r.repo}${r.sbom}${r.pkgs}${r.path}
+
+ `; + })} +
`); +} +``` + +## All Repos Table + +```js +const domainFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._domSlug)).values()], {label: "Domain", value: "all"}); +const gapFilter = Inputs.toggle({label: "Gaps only (no SBOM)", value: false}); +display(html`
${domainFilter}${gapFilter}
`); +``` + +```js +const filteredRows = repoRows.filter(r => + (domainFilter.value === "all" || r._domSlug === domainFilter.value) && + (!gapFilter.value || !r._hasSbom) +); + +display(Inputs.table(filteredRows.map(r => ({ + Repo: r.repo, + Domain: r.domain, + SBOM: r.sbom, + Pkgs: r.pkgs, + "EPs (domain)": r.eps || "β€”", + "TDs (domain)": r.tds || "β€”", + Path: r.path, +})), {maxWidth: 1000})); +``` + +## How to Ingest a Repo + +```js +display(html`
+

Register a new repo

+
cd ~/the-custodian/state-hub
+make add-repo DOMAIN=<slug> SLUG=<repo-slug> NAME="Display Name" PATH=/absolute/path
+ +

Ingest SBOM (single ecosystem, auto-detect lockfile at root)

+
make ingest-sbom REPO=<slug> REPO_PATH=/absolute/path
+ +

Ingest SBOM (multi-ecosystem repo β€” scans all lockfiles recursively)

+
make ingest-sbom REPO=<slug> SCAN=1 REPO_PATH=/absolute/path
+ +

Infra-only repos (Ansible/shell β€” no lockfile)

+

Register the repo for inventory purposes. SBOM gap is expected and intentional. + Terraform providers are tracked via .terraform.lock.hcl (auto-detected by --scan).

+
`); +``` + + From 7caaec25a2975144a54d1e3a31a0d37f953d8a35 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 1 Mar 2026 19:29:20 +0100 Subject: [PATCH 058/198] docs(sbom): add SBOM reference page + withDocHelp on SBOM dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/sbom.md: what SBOM is, lockfile semantics, 5-level maturity standard, gap types A–E, per-ecosystem guidance, Syft OSS tooling, inter-repo task communication convention, ingest commands, compliance check commands - sbom.md: wire withDocHelp(h1, "/docs/sbom") β€” ? button on page title - observablehq.config.js: add SBOM entry to Reference nav section EP-CUST-002 registered: Syft-based comprehensive SBOM generation Task 5f8cade5 created: [repo:railiance-bootstrap] Add Ansible lockfile Co-Authored-By: Claude Sonnet 4.6 --- dashboard/observablehq.config.js | 1 + dashboard/src/docs/sbom.md | 210 +++++++++++++++++++++++++++++++ dashboard/src/sbom.md | 6 + 3 files changed, 217 insertions(+) create mode 100644 dashboard/src/docs/sbom.md diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index 2a0ce1f..2b0ff54 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -22,6 +22,7 @@ export default { { name: "Decisions", path: "/docs/decisions" }, { name: "Decision Health", path: "/docs/decisions-kpi" }, { name: "Progress Log", path: "/docs/progress-log" }, + { name: "SBOM", path: "/docs/sbom" }, ], }, ], diff --git a/dashboard/src/docs/sbom.md b/dashboard/src/docs/sbom.md new file mode 100644 index 0000000..788614b --- /dev/null +++ b/dashboard/src/docs/sbom.md @@ -0,0 +1,210 @@ +--- +title: SBOM β€” Reference +--- + +# Software Bill of Materials (SBOM) + +This page defines what an SBOM is, why it matters, what the Custodian SBOM +standard requires of registered repos, and how to bring a repo into compliance. + +--- + +## What is an SBOM? + +An SBOM (Software Bill of Materials) is an inventory of every component a +piece of software depends on β€” direct and transitive, runtime and build-time. + +For software projects this means: every library installed via pip, npm, cargo, +or any other package manager. For infrastructure repos it means: Ansible itself, +Terraform providers, system tools the playbooks invoke. For container images: OS +packages, base image layers, language runtimes. + +The key question an SBOM answers is: **"what exactly is running, and at which +version?"** + +--- + +## What a lockfile is β€” and why it matters + +A **lockfile** is the machine-generated, committed answer to that question for +one package manager. When you run `uv lock`, `npm install`, or `terraform init`, +the tool resolves all transitive dependencies, pins them to exact versions, and +writes those pins to a lockfile (`uv.lock`, `package-lock.json`, +`.terraform.lock.hcl`). + +Without a lockfile: + +| Problem | Consequence | +|---------|-------------| +| Versions are not pinned | Different machines or CI runs get different versions β€” one may work, another may not | +| No transitive inventory | You know you depend on `ansible`, but not which version of `paramiko` or `cryptography` it pulls in | +| Vulnerability scanning is imprecise | CVE databases require exact versions; a range like `ansible>=8` can't be scanned | +| Licence auditing is impossible | You can't know the licence of every transitive dependency | +| Reproducibility breaks | Debugging a production incident requires knowing the exact versions in use | + +The lockfile is the **unit of SBOM evidence** for package-managed dependencies. +The State Hub ingests lockfiles to populate the SBOM store. + +--- + +## The Custodian SBOM Standard + +Every registered repo is assessed against five maturity levels. A repo must +reach **Level 3** to be considered SBOM-compliant. + +| Level | Name | Criterion | +|-------|------|-----------| +| **0** | Registered | Repo appears in the State Hub `/repos/` | +| **1** | Manifested | For every ecosystem in use, a **manifest file** exists and is committed (`pyproject.toml`, `package.json`, `Cargo.toml`, `go.mod`, `ansible/requirements.yml`, etc.) | +| **2** | Locked | Every manifest file has a corresponding **lockfile** committed to the repo | +| **3** | Ingested | `last_sbom_at` is not null; the ingested packages cover all detected ecosystems | +| **4** | Current | `last_sbom_at` is within 30 days, or since the last lockfile change | +| **5** | Clean | No unreviewed copyleft flags in direct prod dependencies; no unknown licences in direct deps | + +### SBOM gap types + +**Type A β€” Missing manifest**: dependencies exist but nothing declares them. +Example: Ansible is installed on the control node but there is no `pyproject.toml` +declaring `ansible` as a dependency. Fix: create the manifest. + +**Type B β€” Manifest without lockfile**: a `pyproject.toml` or `package.json` +exists but no lockfile has been generated. Fix: run `uv lock` / `npm install`. + +**Type C β€” Lockfile not ingested**: lockfile exists but `make ingest-sbom` has +not been run, so the State Hub has no record. Fix: run `make ingest-sbom`. + +**Type D β€” Stale ingest**: lockfile exists and was ingested, but has since been +updated (new deps added) without a fresh ingest. Fix: re-run `make ingest-sbom`. + +**Type E β€” Ecosystem not supported**: the repo uses an ecosystem the ingest +script doesn't yet parse (Go, Java, Ruby, Ansible Galaxy collections). The +SBOM gap is expected until support is added. Register a contribution (FR) if +the ecosystem is important for your domain. + +--- + +## Per-ecosystem guidance + +### Python (uv) +```bash +uv init --no-workspace # creates pyproject.toml if absent +uv add ansible # adds dep + resolves transitive tree +uv lock # generates or updates uv.lock +git add pyproject.toml uv.lock && git commit +``` +Then ingest: `make ingest-sbom REPO= SCAN=1 REPO_PATH=` + +### Node / npm +`package-lock.json` is generated automatically by `npm install`. Commit it. +Licence metadata is embedded per package β€” the State Hub reads it directly. + +### Rust +`Cargo.lock` is generated automatically by `cargo build` or `cargo check`. +Commit it (for binaries; libraries typically do not commit it, but SBOM +ingestion requires it). + +### Terraform +Run `terraform init` in each module directory β€” this generates +`.terraform.lock.hcl`. The `--scan` mode on `make ingest-sbom` finds it +automatically. + +### Ansible (Galaxy collections) +If your playbooks use roles or collections from Ansible Galaxy, add them to +`ansible/requirements.yml`: +```yaml +collections: + - name: community.general + version: ">=9.0" +roles: [] +``` +*Note: `requirements.yml` does not include version pins for Ansible itself. +Use `pyproject.toml` + `uv.lock` to pin the `ansible` pip package.* + +### Infra-only repos (Ansible, shell, no Galaxy collections) +The minimum expectation is still a `pyproject.toml` declaring the +control-node pip dependencies (at least `ansible`). This enables: +- Pinning the Ansible version (reproducibility) +- SBOM ingestion (licence + vulnerability auditing) +- A machine-readable baseline for future syft-based assessment + +--- + +## OSS tooling β€” Syft (recommended for comprehensive assessment) + +The State Hub's current ingest relies on hand-rolled lockfile parsers. +A more powerful alternative is **[Syft](https://github.com/anchore/syft)** +(Anchore, Apache 2.0): + +- Scans a directory and detects **50+ ecosystems** automatically +- Works even when lockfiles are absent (uses manifest files to derive deps) +- Outputs standard **SPDX** or **CycloneDX** JSON +- Handles: Python, Node, Rust, Go, Java, Ruby, PHP, .NET, Ansible collections, + Terraform providers, OS packages in container images + +```bash +# Install +curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin + +# Scan a directory +syft dir:/home/worsch/railiance-bootstrap -o cyclonedx-json > sbom.json +``` + +Syft integration is tracked as **EP-CUST-002** in the Extension Points +catalogue. When implemented, `make ingest-sbom-syft` will replace the +hand-rolled parsers for comprehensive coverage. + +--- + +## Inter-repo task communication + +When the State Hub or custodian identifies a compliance gap in a registered repo, +the task is communicated through two channels: + +1. **State Hub task** β€” created in the relevant domain workstream with + `[repo:]` in the title. Visible via `get_state_summary()` at the + start of any domain session. + +2. **Workplan file** β€” a `workplans/-.md` file is created in the + target repo itself (ADR-001 convention). When you open that repo in Claude + Code, the session protocol surfaces it. + +When working in a registered repo, always run `get_state_summary()` at session +start β€” the state hub surfaces pending tasks for your domain automatically. + +--- + +## Ingest commands + +```bash +# Auto-detect lockfile at repo root +make ingest-sbom REPO= REPO_PATH=/path/to/repo + +# Scan entire tree β€” required for multi-ecosystem repos +make ingest-sbom REPO= SCAN=1 REPO_PATH=/path/to/repo + +# Explicit lockfile +make ingest-sbom REPO= LOCKFILE=/path/to/uv.lock + +# Dry run (parse but do not submit) +.venv/bin/python scripts/ingest_sbom.py --repo --scan --repo-path /path --dry-run +``` + +--- + +## Checking compliance + +```bash +# View all repos and their SBOM status +# Dashboard β†’ Repos (http://127.0.0.1:3000/repos) + +# API: check last_sbom_at per repo +curl -s http://127.0.0.1:8000/repos/ | python3 -c " +import json, sys +for r in json.load(sys.stdin): + status = r['last_sbom_at'] or '⚠ NOT INGESTED' + print(f'{r[\"slug\"]:30} {status}') +" + +# API: licence risk summary +curl -s http://127.0.0.1:8000/sbom/report/licences/ | python3 -m json.tool +``` diff --git a/dashboard/src/sbom.md b/dashboard/src/sbom.md index fc6b024..9b7716b 100644 --- a/dashboard/src/sbom.md +++ b/dashboard/src/sbom.md @@ -40,6 +40,12 @@ const isCopyleft = spdx => spdx && COPYLEFT_KW.some(k => spdx.toUpperCase().inc # SBOM +```js +import {withDocHelp} from "./components/doc-overlay.js"; +const _h1 = document.querySelector("#observablehq-main h1"); +if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/sbom"); } +``` + ## Overview ```js From 00272842ca4d30f8e20e4eac49a9813596c0a6cc Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 1 Mar 2026 20:05:16 +0100 Subject: [PATCH 059/198] fix(template): rewrite session protocol to produce concrete orientation output The previous template only defined a First Session Protocol (triggered when no workstreams existed). When workstreams did exist, get_state_summary() was called but no output was defined, causing registered-repo Claude sessions to produce nothing useful. New 3-step normal session protocol: - Step 1: get_state_summary() + get_next_steps() - Step 2: scan workplans/*.md for active tasks (todo/in_progress) - Step 3: output orientation brief covering active workstreams, pending tasks for this repo (from workplans/ + [repo:] state hub tasks), suggested next action, and SBOM status Also strengthens First Session Protocol, ADR-001 workplan convention section, and SBOM ingest section (adds SCAN=1 REPO_PATH= flags). Co-Authored-By: Claude Sonnet 4.6 --- scripts/project_claude_md.template | 144 ++++++++++++++++------------- 1 file changed, 78 insertions(+), 66 deletions(-) diff --git a/scripts/project_claude_md.template b/scripts/project_claude_md.template index a028081..5ff042c 100644 --- a/scripts/project_claude_md.template +++ b/scripts/project_claude_md.template @@ -8,69 +8,84 @@ Hub topic ID: `{TOPIC_ID}` The State Hub runs locally at http://127.0.0.1:8000. The MCP server (`state-hub`) exposes tools for reading and writing state without touching the API directly. +--- + ### Session Protocol -**On receiving your first message β€” before writing any response text β€” call -`get_state_summary()` immediately.** Do not greet, do not ask what to do. -Call the tool first, then respond based on what you find. +**On receiving your first message β€” before writing any response text β€” execute +this orientation sequence. Do not greet, do not ask what to do first.** -**At the start of every session:** -1. Call `get_state_summary()` β€” orients you to active workstreams, blocking decisions, - and recent progress. If it fails, the API is likely offline: - ``` - cd ~/the-custodian/state-hub && make api - ``` -2. Call `get_next_steps()` β€” surfaces contextual suggestions from recently resolved - decisions and cleared workstream dependencies. Act on these before starting new work. -3. Check whether this domain has any open workstreams in the summary. - - **If workstreams exist:** review blocking decisions before starting work. - - **If no workstreams exist:** follow the First Session Protocol below. +**Step 1 β€” Call the State Hub** +``` +get_state_summary() # orientation: workstreams, decisions, recent progress +get_next_steps() # contextual suggestions from resolved decisions +``` +If the call fails, the API is offline: `cd ~/the-custodian/state-hub && make api` + +**Step 2 β€” Scan local workplans** + +Read every file matching `workplans/*.md` in this repo. For each one with +`status: active`, extract and note: +- The workplan title and ID +- All tasks whose `status` is `todo` or `in_progress` + +**Step 3 β€” Present orientation to the user** + +Output a concise brief covering: +1. **Active workstreams** (from state hub) for the `{DOMAIN}` domain β€” title, + task counts, any blocking decisions +2. **Pending tasks for this repo** β€” from local `workplans/` files (Step 2) + plus any state hub tasks with `[repo:{REPO_SLUG}]` in their title +3. **Suggested next action** β€” the highest-priority open item across both sources +4. **SBOM status** β€” is `last_sbom_at` set for this repo? If not, note it as a gap + +If there are no workstreams at all: follow the First Session Protocol below. **During work:** - Use `record_decision()` for any decision that affects direction or dependencies. - Use `add_progress_event()` for notable events (milestones, blockers, insights). -- Use `resolve_decision()` to close a decision once the choice is made β€” this is one - of the two sanctioned write operations in the hub. +- Use `resolve_decision()` to close a decision once the choice is made. > **Design boundary:** The State Hub is a *read model*. Two write operations are -> permanently sanctioned: **Resolving Decisions** and **Suggesting Next Steps** (v0.2). -> The bootstrap tools (`create_workstream`, `create_task`, `update_task_status`) are -> only for First Session Protocol. Formal work structure β€” requirements, workplans, -> milestones, tasks β€” belongs in the domain repo, not managed through the hub. +> permanently sanctioned: **Resolving Decisions** and **Suggesting Next Steps**. +> The bootstrap tools (`create_workstream`, `create_task`, `update_task_status`) +> are only for First Session Protocol. Formal work structure β€” workplans, tasks β€” +> belongs in the domain repo as files (ADR-001), not managed through the hub alone. **At the end of every session:** - Call `add_progress_event()` with a summary of what was accomplished or decided. Include `topic_id: {TOPIC_ID}` and the relevant `workstream_id`. +--- + ### First Session Protocol -Triggered when `get_state_summary()` shows **no workstreams** for the `{DOMAIN}` topic. -This means the project is registered but work has not yet been structured. +Triggered when `get_state_summary()` shows **no workstreams** for the `{DOMAIN}` +topic. The project is registered but work has not yet been structured. **Step 1 β€” Understand the project (read, don't write)** -- `canon/projects/{DOMAIN}/project_charter_v0.1.md` β€” purpose, scope, success criteria -- `canon/projects/{DOMAIN}/roadmap_v0.1.md` β€” planned phases -- Scan the repo root: README, directory structure, any existing code or docs +- `~/the-custodian/canon/projects/{DOMAIN}/project_charter_v0.1.md` β€” purpose, scope +- `~/the-custodian/canon/projects/{DOMAIN}/roadmap_v0.1.md` β€” planned phases +- Scan the repo root: README, directory structure, existing code or docs **Step 2 β€” Survey in-progress work** -- Look for TODOs, open branches, half-finished files, or notes +- Look for TODOs, open branches, half-finished files, notes - Note what is already done vs. what is clearly started but incomplete **Step 3 β€” Propose workstreams to Bernd** -Based on what you found, propose 1–3 workstreams. Each workstream should be: -- A coherent strand of work lasting weeks to months (not a single task) -- Named clearly enough that its scope is obvious -- Anchored to a phase in the roadmap if possible +Propose 1–3 workstreams β€” each a coherent strand of work lasting weeks to months, +named clearly, anchored to a roadmap phase. **Wait for approval before creating.** -Present the proposals and **wait for approval before creating anything**. - -**Step 4 β€” Create and populate (after approval)** +**Step 4 β€” Create workplan file first, then DB record** +Per ADR-001, work items originate as files in the repo: +``` +workplans/-WP-NNNN-.md ← write this first +``` +Then register in the hub: ``` create_workstream(topic_id="{TOPIC_ID}", title="...", owner="...", description="...") create_task(workstream_id="", title="...", priority="high|medium|low") -# repeat for each task in the workstream ``` -Aim for 3–7 tasks per workstream at this stage. Tasks should be concrete and actionable. **Step 5 β€” Record the setup** ``` @@ -82,12 +97,28 @@ add_progress_event( ) ``` +--- + +### Workplan Convention (ADR-001) + +Work items MUST originate as files in this repo before being registered in the hub. + +**File location:** `workplans/-.md` +**Frontmatter required:** `id`, `type: workplan`, `domain`, `repo`, `status`, +`state_hub_workstream_id`, `state_hub_task_id` (per task) + +When the custodian creates a task targeting this repo from another session, it: +1. Creates a state hub task with `[repo:{REPO_SLUG}]` in the title +2. Creates a workplan file in this repo's `workplans/` +3. You will see both at session start via the orientation sequence above + +--- + ### Contribution Tracking -This project tracks upstream contributions in `contrib/` β€” bug reports, feature -requests, extension-point proposals, and upstream PRs β€” as canonical Markdown files. +Track upstream contributions in `contrib/` β€” bug reports (BR), feature requests +(FR), extension-point proposals (EP), upstream PRs (UPR). -**Directory layout:** ``` contrib/ bug-reports/ # br-YYYY-MM-DD--org--repo--slug.md @@ -99,46 +130,27 @@ contrib/ Templates: `~/the-custodian/canon/standards/contrib-templates/` Convention: `~/the-custodian/canon/standards/contribution-convention_v0.1.md` -**Register a contribution in the State Hub:** -``` -register_contribution( - type="upr", # br | fr | ep | upr - title="Add injectTocTop to Observable Framework", - target_org="observablehq", - target_repo="framework", - body_path="contrib/upstream-prs/2026-02-26--observablehq--framework--inject.md", - related_workstream_id="", -) -``` - -**Update status when upstream responds:** ``` +register_contribution(type="br|fr|ep|upr", title="...", target_org="...", + target_repo="...", body_path="contrib/...", related_workstream_id="") update_contribution_status(contribution_id="", status="submitted") -# then: acknowledged β†’ accepted β†’ merged ``` -**List all contributions for this domain:** -``` -get_contributions(target_repo="framework") -``` +--- ### SBOM -Software Bill of Materials for this repo is tracked in the State Hub. - -**Ingest the current lockfile:** +After updating dependencies, re-ingest the SBOM: ```bash cd ~/the-custodian/state-hub -make ingest-sbom REPO={REPO_SLUG} +make ingest-sbom REPO={REPO_SLUG} SCAN=1 REPO_PATH=$(pwd) ``` -**Check licence risk:** -``` -get_licence_report() -``` +Check compliance: `http://localhost:3000/repos` +Standard: `~/the-custodian/canon/standards/sbom-convention_v0.1.md` -**View SBOM dashboard:** `http://localhost:3000/sbom` +--- ### Quick Reference -See `~/the-custodian/state-hub/mcp_server/TOOLS.md` for a compact tool reference. +`~/the-custodian/state-hub/mcp_server/TOOLS.md` β€” compact MCP tool reference From 98e991b49f6f3fdbf40a120068dbf79f0216fc4e Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 1 Mar 2026 20:13:31 +0100 Subject: [PATCH 060/198] fix(template): use reliable workplan discovery in step 2 Glob with pattern 'workplans/*.md' from repo root fails silently. Changed instruction to Glob(pattern="**/*.md", path="workplans/") with Bash ls as fallback. Co-Authored-By: Claude Sonnet 4.6 --- scripts/project_claude_md.template | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/project_claude_md.template b/scripts/project_claude_md.template index 5ff042c..8d42352 100644 --- a/scripts/project_claude_md.template +++ b/scripts/project_claude_md.template @@ -24,8 +24,9 @@ If the call fails, the API is offline: `cd ~/the-custodian/state-hub && make api **Step 2 β€” Scan local workplans** -Read every file matching `workplans/*.md` in this repo. For each one with -`status: active`, extract and note: +Read every `.md` file under `workplans/`. Use `Glob(pattern="**/*.md", path="workplans/")` +or Bash `ls workplans/` to discover them. For each file with `status: active`, +extract and note: - The workplan title and ID - All tasks whose `status` is `todo` or `in_progress` From ba89ebfa67b6b9d7b39857a17724cb90a7ff3aab Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 1 Mar 2026 20:52:07 +0100 Subject: [PATCH 061/198] feat(canon): add inter-repo communication standard with todo taxonomy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes the repo boundary rule and a formal vocabulary for classifying work items by scope: - Task: neutral state hub data entity - Todo: a task scoped to the current session's repo/domain - Internal todo: addressed within this repo by this agent - Ecosystem todo: work for another registered repo β†’ state hub task [repo:] - Third-party todo: work for an upstream repo β†’ contribution artifact (BR/FR/EP/UPR) New dashboard doc: /docs/inter-repo-communication β€” defines the boundary rule, the full terminology, ecosystem and third-party todo workflows, and a decision table for classifying any piece of work found during a session. Also: - sbom.md: replace verbose inter-repo section with a 3-line summary + link - observablehq.config.js: add "Inter-Repo Communication" to Reference nav - project_claude_md.template: add "### Repo Boundary Rule" section; fix Workplan Convention section (removing incorrect claim that the custodian writes workplan files in other repos β€” that is the target repo's job) Cross-repo: created state hub task [repo:railiance-bootstrap] for that repo's agent to apply the boundary rule and workplan convention fix to its own CLAUDE.md (task 78d43cb0, workstream 59155efb). Co-Authored-By: Claude Sonnet 4.6 --- dashboard/observablehq.config.js | 1 + .../src/docs/inter-repo-communication.md | 152 ++++++++++++++++++ dashboard/src/docs/sbom.md | 17 +- scripts/project_claude_md.template | 24 ++- 4 files changed, 178 insertions(+), 16 deletions(-) create mode 100644 dashboard/src/docs/inter-repo-communication.md diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index 2b0ff54..94ffd25 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -23,6 +23,7 @@ export default { { name: "Decision Health", path: "/docs/decisions-kpi" }, { name: "Progress Log", path: "/docs/progress-log" }, { name: "SBOM", path: "/docs/sbom" }, + { name: "Inter-Repo Communication", path: "/docs/inter-repo-communication" }, ], }, ], diff --git a/dashboard/src/docs/inter-repo-communication.md b/dashboard/src/docs/inter-repo-communication.md new file mode 100644 index 0000000..143d690 --- /dev/null +++ b/dashboard/src/docs/inter-repo-communication.md @@ -0,0 +1,152 @@ +--- +title: Inter-Repo Communication +--- + +# Inter-Repo Task Communication + +This document defines the boundary rule for Claude Code agents within this +ecosystem, the terminology for classifying work items by their scope, and the +workflows for routing work to the correct owner. + +--- + +## The Repo Boundary Rule + +A Claude Code agent is bounded to the repository it was started in. + +| Permitted | Prohibited | +|-----------|-----------| +| Read, write, and commit files anywhere inside the repo root | Write files or make commits in any other repository | +| Create state hub tasks targeting another repo | Create workplan files in another repo on its behalf | +| Create contribution artifacts for third-party repos | Directly edit code that originates from another repo | + +**Why this matters:** each registered repo has its own Claude agent that owns +its working directory. Cross-repo direct writes bypass that agent's awareness, +context, and responsibility. The agent for the target repo is the one with the +full session context for that codebase β€” it should create its own workplan and +make its own decisions about how to carry out the work. + +When you identify work that belongs to another repo, route it through the +appropriate coordination channel (see below). Do not write the files yourself. + +--- + +## Terminology + +### Task + +A **task** is the state hub data entity encapsulating a suggested or required +piece of work. Tasks live in the state hub database, are always scoped to a +workstream, and are the universal unit of cross-repo coordination. Tasks are +neutral β€” they describe *what* should be done, not *who* does it or *where*. + +### Todo + +A **todo** is a task viewed from the perspective of a specific repo or domain +session. When you run the session protocol and review open work, you are +reviewing your todos: the tasks that are relevant to the current scope. + +Todos are classified by where the work belongs: + +``` +Todo +β”œβ”€β”€ Internal todo β€” addressable within the current repo by this agent +└── External todo β€” requires action outside the current repo + β”œβ”€β”€ Ecosystem todo β€” target is a repo registered in the Custodian ecosystem + └── Third-party todo β€” target is an upstream repo we don't own or control +``` + +| Class | Definition | Mechanism | +|-------|-----------|-----------| +| **Internal todo** | Work fully addressable within this repo | Create workplan, begin work | +| **Ecosystem todo** | Work for another registered repo | State hub task with `[repo:]` | +| **Third-party todo** | Work for an upstream repo we don't own | Contribution artifact (BR/FR/EP/UPR) | + +--- + +## Ecosystem Todo Workflow + +Use this when you identify work that belongs to another repo registered in the +Custodian State Hub. + +### Step 1 β€” Create a state hub task in the target domain's workstream + +```python +create_task( + workstream_id="", + title="[repo:] Brief description of the required work", + priority="medium", # low | medium | high | critical + description="Full context: why this work is needed and what the expected outcome is" +) +``` + +The `[repo:]` prefix in the title is **mandatory**. It is the signal +that the target repo's session protocol uses to surface this task automatically. + +### Step 2 β€” The target repo's agent picks it up + +When a Claude Code session opens in the target repo: + +1. `get_state_summary()` runs at session start +2. The session protocol scans for tasks containing `[repo:]` in their title +3. Matching tasks appear in the orientation brief +4. The target repo's agent creates a workplan file locally (ADR-001) and begins work + +**Do not create the workplan file in the target repo yourself.** The target +repo's agent is responsible for its own workplans. Your job is to place the task +where that agent will find it. + +--- + +## Third-Party Todo Workflow + +Use this when you identify work for an upstream repo β€” a library, tool, or +framework you use but do not own. + +1. Create a contribution artifact in the current repo's `contrib/` directory: + - `bug-reports/br-YYYY-MM-DD------.md` + - `feature-requests/fr-YYYY-MM-DD------.md` + - `extension-points/EP--NNN------.md` + - `upstream-prs/upr-YYYY-MM-DD------.md` +2. Register it with the state hub: + ```python + register_contribution(type="br|fr|ep|upr", title="...", target_org="...", + target_repo="...", body_path="contrib/...", related_workstream_id="") + ``` +3. When submitted upstream, close the loop: + ```python + update_contribution_status(contribution_id="", status="submitted") + ``` + +Templates: `~/the-custodian/canon/standards/contrib-templates/` +Convention: `~/the-custodian/canon/standards/contribution-convention_v0.1.md` + +--- + +## Session Protocol: Surfacing Todos + +The session orientation protocol (every repo's CLAUDE.md) surfaces todos from +two sources: + +**Internal todos** (Step 2 of orientation) β€” workplan files in `workplans/` +with `status: active`, tasks with `status: todo` or `in_progress`. + +**Ecosystem todos targeting this repo** (Step 1 of orientation) β€” +`get_state_summary()` returns all open tasks across all workstreams. The session +protocol filters for tasks with `[repo:]` in their title and surfaces +them in the orientation brief. + +Both sources are combined in Step 3 (orientation output). + +--- + +## Decision Table + +| Situation | Classification | Action | +|-----------|---------------|--------| +| Found a bug in this repo | Internal todo | Fix it; no cross-repo coordination needed | +| Found a config gap in another registered repo | Ecosystem todo | `create_task(..., title="[repo:] ...")` | +| Identified a feature needed in a library you use | Third-party todo | Create FR artifact in `contrib/feature-requests/` | +| Found a bug in an upstream tool | Third-party todo | Create BR artifact in `contrib/bug-reports/` | +| Want to propose a patch to an upstream repo | Third-party todo | Create UPR artifact in `contrib/upstream-prs/` | +| Identified an extension opportunity in an upstream repo | Third-party todo | Create EP artifact in `contrib/extension-points/` | diff --git a/dashboard/src/docs/sbom.md b/dashboard/src/docs/sbom.md index 788614b..a60694f 100644 --- a/dashboard/src/docs/sbom.md +++ b/dashboard/src/docs/sbom.md @@ -157,19 +157,12 @@ hand-rolled parsers for comprehensive coverage. ## Inter-repo task communication -When the State Hub or custodian identifies a compliance gap in a registered repo, -the task is communicated through two channels: +When a compliance gap is identified in a registered repo, the finding is routed +as an **ecosystem todo**: a state hub task with `[repo:]` in the title, +created in the target domain's workstream. The target repo's session protocol +surfaces it automatically at next session start. -1. **State Hub task** β€” created in the relevant domain workstream with - `[repo:]` in the title. Visible via `get_state_summary()` at the - start of any domain session. - -2. **Workplan file** β€” a `workplans/-.md` file is created in the - target repo itself (ADR-001 convention). When you open that repo in Claude - Code, the session protocol surfaces it. - -When working in a registered repo, always run `get_state_summary()` at session -start β€” the state hub surfaces pending tasks for your domain automatically. +See the full standard: [`/docs/inter-repo-communication`](/docs/inter-repo-communication) --- diff --git a/scripts/project_claude_md.template b/scripts/project_claude_md.template index 8d42352..30c787d 100644 --- a/scripts/project_claude_md.template +++ b/scripts/project_claude_md.template @@ -59,6 +59,22 @@ If there are no workstreams at all: follow the First Session Protocol below. --- +### Repo Boundary Rule + +This agent is responsible for files **in this repo only**. + +- **Do not** write files or make commits in any other repository +- **Do not** create workplan files in other repos on their behalf +- When you identify work for another registered repo (**ecosystem todo**): + create a state hub task with `[repo:]` in the title β€” the other repo's + agent will see it at session start and create its own workplan +- When you identify work for an upstream repo (**third-party todo**): + create a contribution artifact in `contrib/` and register it + +Terminology and workflows: `http://localhost:3000/docs/inter-repo-communication` + +--- + ### First Session Protocol Triggered when `get_state_summary()` shows **no workstreams** for the `{DOMAIN}` @@ -108,10 +124,10 @@ Work items MUST originate as files in this repo before being registered in the h **Frontmatter required:** `id`, `type: workplan`, `domain`, `repo`, `status`, `state_hub_workstream_id`, `state_hub_task_id` (per task) -When the custodian creates a task targeting this repo from another session, it: -1. Creates a state hub task with `[repo:{REPO_SLUG}]` in the title -2. Creates a workplan file in this repo's `workplans/` -3. You will see both at session start via the orientation sequence above +When another domain's agent identifies work for this repo, it creates a state hub +task with `[repo:{REPO_SLUG}]` in the title (an **ecosystem todo**). You will +see it at session start via `get_state_summary()`. When you pick it up, create +the corresponding workplan file in `workplans/` (ADR-001) and begin work. --- From 6d97a992ae904973cac52e6eef0c7b0f2034e67e Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 1 Mar 2026 21:07:56 +0100 Subject: [PATCH 062/198] feat(dashboard): collapse Reference nav section by default Observable Framework 1.13.3 supports collapsible: true on nav sections, rendering them as
elements. Collapsed by default; auto-expands when any page within the section is active. Co-Authored-By: Claude Sonnet 4.6 --- dashboard/observablehq.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index 94ffd25..69889a6 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -15,6 +15,7 @@ export default { { name: "SBOM", path: "/sbom" }, { name: "Reference", + collapsible: true, pages: [ { name: "Live Data", path: "/docs/live-data" }, { name: "Workstreams", path: "/docs/workstreams" }, From a3338c3a23bc617305ada5cfa68b7c08488661a7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 1 Mar 2026 21:09:32 +0100 Subject: [PATCH 063/198] chore(dashboard): sort Reference nav pages alphabetically Co-Authored-By: Claude Sonnet 4.6 --- dashboard/observablehq.config.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index 69889a6..570bcf1 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -17,14 +17,14 @@ export default { name: "Reference", collapsible: true, pages: [ - { name: "Live Data", path: "/docs/live-data" }, - { name: "Workstreams", path: "/docs/workstreams" }, - { name: "Workstream Health", path: "/docs/workstream-health-index" }, - { name: "Decisions", path: "/docs/decisions" }, { name: "Decision Health", path: "/docs/decisions-kpi" }, + { name: "Decisions", path: "/docs/decisions" }, + { name: "Inter-Repo Communication", path: "/docs/inter-repo-communication" }, + { name: "Live Data", path: "/docs/live-data" }, { name: "Progress Log", path: "/docs/progress-log" }, { name: "SBOM", path: "/docs/sbom" }, - { name: "Inter-Repo Communication", path: "/docs/inter-repo-communication" }, + { name: "Workstream Health", path: "/docs/workstream-health-index" }, + { name: "Workstreams", path: "/docs/workstreams" }, ], }, ], From 70c8e3cd51165afccbbb4566747c6e77f749fa2e Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 1 Mar 2026 22:05:31 +0100 Subject: [PATCH 064/198] feat(mcp): add get_domain_summary() for low-token domain session orientation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_state_summary() returns ~10k tokens β€” too expensive for routine domain repo sessions that only need their own workstreams and decisions. New get_domain_summary(domain_slug): - 5 targeted API calls: topics (filter), workstreams (topic+status), decisions (topic+pending), progress (topic, limit 5), repos (domain, slug+SBOM only) - Returns: topic, active workstreams, blocking decisions, 5 recent events, repo SBOM status β€” all scoped to one domain - Estimated ~80-90% token reduction vs get_state_summary() get_state_summary() preserved unchanged for cross-domain / custodian sessions. Updated its docstring to note the large response and point to get_domain_summary. Template updated: Step 1 now calls get_domain_summary("{DOMAIN}") instead of get_state_summary() + get_next_steps(). TOOLS.md updated with usage guidance. Co-Authored-By: Claude Sonnet 4.6 --- mcp_server/TOOLS.md | 3 ++- mcp_server/server.py | 43 ++++++++++++++++++++++++++++++ scripts/project_claude_md.template | 3 +-- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/mcp_server/TOOLS.md b/mcp_server/TOOLS.md index 9a060b0..c6483d3 100644 --- a/mcp_server/TOOLS.md +++ b/mcp_server/TOOLS.md @@ -24,7 +24,8 @@ Do not use them as a substitute for formal work definition inside the domain rep | Tool | Key Args | When to use | |------|----------|-------------| -| `get_state_summary()` | β€” | **Session start.** Full snapshot: totals, blocking decisions, blocked tasks, open workstreams, last 20 events. | +| `get_domain_summary(domain_slug)` | `domain_slug`: e.g. `"railiance"` | **Domain session start.** Scoped snapshot: active workstreams, blocking decisions, last 5 events, repo SBOM status β€” ~10% of get_state_summary() token cost. | +| `get_state_summary()` | β€” | **Cross-domain work / custodian sessions.** Full snapshot: totals, all blocking decisions, all blocked tasks, all open workstreams, last 20 events. Large (~10k tokens). | | `get_topic(slug)` | `slug`: e.g. `"markitect"` | Deep-dive on one topic + its workstreams + recent events. | | `list_blocked_tasks(workstream_id?)` | optional filter | Surface all impediments, optionally scoped to one workstream. | | `list_pending_decisions(topic_id?)` | optional filter | Decisions holding up work, sorted by deadline. | diff --git a/mcp_server/server.py b/mcp_server/server.py index fcf089e..f60059a 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -24,6 +24,8 @@ mcp = FastMCP( instructions=( "Custodian State Hub: tracks topics, workstreams, tasks, decisions, and progress events. " "Start every session with get_state_summary() for orientation. " + "When working inside a single registered domain repo, prefer get_domain_summary(domain_slug) " + "β€” it returns the same actionable data scoped to that domain at ~10% of the token cost. " "All writes emit a progress_event automatically." ), ) @@ -120,10 +122,51 @@ def get_state_summary() -> str: Returns a full snapshot: topic/workstream/task/decision totals, blocking decisions, blocked tasks, open workstreams, and the 20 most recent events. + + NOTE: This response is large (~10k tokens). When working inside a single + registered domain repo, use get_domain_summary(domain_slug) instead β€” + same actionable data scoped to one domain at ~10% of the token cost. """ return json.dumps(_get("/state/summary"), indent=2) +@mcp.tool() +def get_domain_summary(domain_slug: str) -> str: + """Lightweight session orientation for a single domain. + + Use this instead of get_state_summary() when working in a registered + domain repo β€” returns only what is relevant to the specified domain, + typically 80-90% fewer tokens than the full summary. + + Args: + domain_slug: the domain slug, e.g. "railiance", "markitect" + + Returns: topic, active workstreams, open blocking decisions for this + topic, 5 most recent progress events, and repo SBOM status for this domain. + """ + topics = _get("/topics") + topic = next((t for t in topics if t.get("domain_slug") == domain_slug), None) + if not topic: + return json.dumps({"error": f"No topic found for domain '{domain_slug}'"}) + + topic_id = topic["id"] + + workstreams = _get("/workstreams", {"topic_id": topic_id, "status": "active"}) + blocking = _get("/decisions", {"decision_type": "pending", "topic_id": topic_id}) + recent = _get("/progress", {"topic_id": topic_id, "limit": 5}) + repos = _get("/repos", {"domain": domain_slug}) + + return json.dumps({ + "domain": domain_slug, + "topic_id": topic_id, + "topic_title": topic["title"], + "workstreams": workstreams, + "blocking_decisions": blocking, + "recent_progress": recent, + "repos": [{"slug": r["slug"], "last_sbom_at": r.get("last_sbom_at")} for r in repos], + }, indent=2) + + @mcp.tool() def get_topic(slug: str) -> str: """Return a topic (with workstreams) by slug, plus its recent progress events.""" diff --git a/scripts/project_claude_md.template b/scripts/project_claude_md.template index 30c787d..d91a120 100644 --- a/scripts/project_claude_md.template +++ b/scripts/project_claude_md.template @@ -17,8 +17,7 @@ this orientation sequence. Do not greet, do not ask what to do first.** **Step 1 β€” Call the State Hub** ``` -get_state_summary() # orientation: workstreams, decisions, recent progress -get_next_steps() # contextual suggestions from resolved decisions +get_domain_summary("{DOMAIN}") # workstreams, blocking decisions, recent progress, SBOM status ``` If the call fails, the API is offline: `cd ~/the-custodian/state-hub && make api` From 947c2e88241f13752d097abbe648d0f3da120c19 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 1 Mar 2026 23:46:26 +0100 Subject: [PATCH 065/198] feat(dashboard): nav restructure, full context-help coverage, 11 new ref docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navigation: - New order: Overview Β· Todo Β· Domains Β· Repos Β· Workstreams (collapsible, open:false, with atomic sub-entries: Decisions, Tasks, Debt, Extends, Dependencies) Β· Contributions Β· SBOM Β· Progress Β· Reference (collapsible) - Reference section gains path:/reference landing page; all 18 doc pages listed in nav (alphabetical) and in reference.md table New pages: - todo.md β€” Internal / Ecosystem / Third-party todo classification - dependencies.md β€” dependency edge table derived from state/summary - reference.md β€” Reference landing page with full doc index New reference doc pages (11): contributions, debt, dependencies, domains, extensions, overview, repos, tasks, todo + reference (meta) already added previously doc-overlay.js β€” lazy bubblehelp tooltip: - _titleCache Map + _fetchDocTitle(docPath): on first hover of any ? button, fetches the target doc page, parses

, sets btn.title - Native browser tooltip appears exactly on the ? circle on subsequent hover Context-help wired on all 14 dashboard pages: - h1 withDocHelp added to: index, todo, domains, repos, tasks, techdept, extensions, dependencies (contributions/workstreams/decisions/sbom/ progress/reference were already wired) - domains.md + repos.md: added missing withDocHelp import and live-data link - tasks/techdept/extensions: removed duplicate _h1 const that caused SyntaxError: Identifier '_h1' has already been declared Co-Authored-By: Claude Sonnet 4.6 --- dashboard/observablehq.config.js | 55 ++++-- dashboard/src/components/doc-overlay.js | 24 ++- dashboard/src/contributions.md | 5 + dashboard/src/dependencies.md | 158 ++++++++++++++++ dashboard/src/docs/contributions.md | 120 ++++++++++++ dashboard/src/docs/debt.md | 91 +++++++++ dashboard/src/docs/dependencies.md | 90 +++++++++ dashboard/src/docs/domains.md | 82 +++++++++ dashboard/src/docs/extensions.md | 102 ++++++++++ dashboard/src/docs/overview.md | 84 +++++++++ dashboard/src/docs/reference.md | 119 ++++++++++++ dashboard/src/docs/repos.md | 83 +++++++++ dashboard/src/docs/tasks.md | 95 ++++++++++ dashboard/src/docs/todo.md | 71 +++++++ dashboard/src/domains.md | 5 + dashboard/src/extensions.md | 6 +- dashboard/src/index.md | 3 + dashboard/src/reference.md | 47 +++++ dashboard/src/repos.md | 6 + dashboard/src/tasks.md | 6 +- dashboard/src/techdept.md | 6 +- dashboard/src/todo.md | 235 ++++++++++++++++++++++++ 22 files changed, 1468 insertions(+), 25 deletions(-) create mode 100644 dashboard/src/dependencies.md create mode 100644 dashboard/src/docs/contributions.md create mode 100644 dashboard/src/docs/debt.md create mode 100644 dashboard/src/docs/dependencies.md create mode 100644 dashboard/src/docs/domains.md create mode 100644 dashboard/src/docs/extensions.md create mode 100644 dashboard/src/docs/overview.md create mode 100644 dashboard/src/docs/reference.md create mode 100644 dashboard/src/docs/repos.md create mode 100644 dashboard/src/docs/tasks.md create mode 100644 dashboard/src/docs/todo.md create mode 100644 dashboard/src/reference.md create mode 100644 dashboard/src/todo.md diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index 570bcf1..91cf8c8 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -2,29 +2,54 @@ export default { root: "src", title: "Custodian State Hub", pages: [ + // ── Overview ────────────────────────────────────────────────────────────── { name: "Overview", path: "/" }, - { name: "Workstreams", path: "/workstreams" }, - { name: "Tasks", path: "/tasks" }, - { name: "Decisions", path: "/decisions" }, - { name: "Progress", path: "/progress" }, + { name: "Todo", path: "/todo" }, + // ── Organizational Entity Views ─────────────────────────────────────────── { name: "Domains", path: "/domains" }, - { name: "Repos", path: "/repos" }, - { name: "Extension Points", path: "/extensions" }, - { name: "Technical Debt", path: "/techdept" }, + { name: "Repos", path: "/repos" }, + { + name: "Workstreams", + path: "/workstreams", + collapsible: true, + open: false, + pages: [ + { name: "Decisions", path: "/decisions" }, + { name: "Tasks", path: "/tasks" }, + { name: "Debt", path: "/techdept" }, + { name: "Extends", path: "/extensions" }, + { name: "Dependencies", path: "/dependencies" }, + ], + }, + // ── Functional Report Views ──────────────────────────────────────────────── { name: "Contributions", path: "/contributions" }, - { name: "SBOM", path: "/sbom" }, + { name: "SBOM", path: "/sbom" }, + { name: "Progress", path: "/progress" }, + // ── Reference ───────────────────────────────────────────────────────────── { name: "Reference", + path: "/reference", collapsible: true, + open: false, pages: [ - { name: "Decision Health", path: "/docs/decisions-kpi" }, - { name: "Decisions", path: "/docs/decisions" }, + { name: "Contributions", path: "/docs/contributions" }, + { name: "Decision Health", path: "/docs/decisions-kpi" }, + { name: "Decisions", path: "/docs/decisions" }, + { name: "Dependencies", path: "/docs/dependencies" }, + { name: "Domains", path: "/docs/domains" }, + { name: "Extension Points", path: "/docs/extensions" }, { name: "Inter-Repo Communication", path: "/docs/inter-repo-communication" }, - { name: "Live Data", path: "/docs/live-data" }, - { name: "Progress Log", path: "/docs/progress-log" }, - { name: "SBOM", path: "/docs/sbom" }, - { name: "Workstream Health", path: "/docs/workstream-health-index" }, - { name: "Workstreams", path: "/docs/workstreams" }, + { name: "Live Data", path: "/docs/live-data" }, + { name: "Overview", path: "/docs/overview" }, + { name: "Progress Log", path: "/docs/progress-log" }, + { name: "Reference & Context Help", path: "/docs/reference" }, + { name: "Repos", path: "/docs/repos" }, + { name: "SBOM", path: "/docs/sbom" }, + { name: "Tasks", path: "/docs/tasks" }, + { name: "Technical Debt", path: "/docs/debt" }, + { name: "Todo", path: "/docs/todo" }, + { name: "Workstream Health", path: "/docs/workstream-health-index" }, + { name: "Workstreams", path: "/docs/workstreams" }, ], }, ], diff --git a/dashboard/src/components/doc-overlay.js b/dashboard/src/components/doc-overlay.js index bc501e0..54493b7 100644 --- a/dashboard/src/components/doc-overlay.js +++ b/dashboard/src/components/doc-overlay.js @@ -12,7 +12,21 @@ * The ? button is invisible until the user hovers over the element. */ -const _STYLE_ID = "doc-overlay-styles"; +const _STYLE_ID = "doc-overlay-styles"; +const _titleCache = new Map(); + +async function _fetchDocTitle(docPath) { + if (_titleCache.has(docPath)) return _titleCache.get(docPath); + try { + const res = await fetch(docPath); + if (!res.ok) return null; + const parser = new DOMParser(); + const doc = parser.parseFromString(await res.text(), "text/html"); + const title = doc.querySelector("h1")?.textContent?.trim() ?? null; + if (title) _titleCache.set(docPath, title); + return title; + } catch { return null; } +} function _ensureStyles() { if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return; @@ -195,6 +209,14 @@ export function withDocHelp(element, docPath) { btn.setAttribute("aria-label", "Open documentation"); btn.addEventListener("click", e => { e.stopPropagation(); _openOverlay(docPath); }); + // Lazy-load the h1 of the target doc page as a native tooltip (bubblehelp) + btn.addEventListener("mouseenter", async () => { + if (btn.dataset.titleFetched) return; + btn.dataset.titleFetched = "1"; + const title = await _fetchDocTitle(docPath); + if (title) btn.title = title; + }, {once: true}); + element.append(btn); return element; } diff --git a/dashboard/src/contributions.md b/dashboard/src/contributions.md index 9c759e4..0915195 100644 --- a/dashboard/src/contributions.md +++ b/dashboard/src/contributions.md @@ -33,12 +33,17 @@ const _ts = contribState.ts; ```js import {injectTocTop} from "./components/toc-sidebar.js"; +import {withDocHelp} from "./components/doc-overlay.js"; const _liveEl = html`
● ${_ok ? `Live Β· ${_ts?.toLocaleTimeString()}` : html`API offline`}
`; +withDocHelp(_liveEl, "/docs/live-data"); injectTocTop("live-indicator", _liveEl); + +const _h1 = document.querySelector("#observablehq-main h1"); +if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/contributions"); } ``` ```js diff --git a/dashboard/src/dependencies.md b/dashboard/src/dependencies.md new file mode 100644 index 0000000..de7164f --- /dev/null +++ b/dashboard/src/dependencies.md @@ -0,0 +1,158 @@ +--- +title: Dependencies +--- + +```js +const API = "http://127.0.0.1:8000"; +const POLL = 15_000; +``` + +```js +// Fetch workstreams + topics + summary (summary carries dep edges on open_workstreams) +const depState = (async function*() { + while (true) { + let wsMap = {}, edges = [], ok = false; + try { + const [rw, rto, rs] = await Promise.all([ + fetch(`${API}/workstreams/`), + fetch(`${API}/topics/`), + fetch(`${API}/state/summary`), + ]); + ok = rw.ok && rto.ok && rs.ok; + if (ok) { + const [wsList, topicList, summary] = await Promise.all([ + rw.json(), rto.json(), rs.json(), + ]); + const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); + wsMap = Object.fromEntries(wsList.map(w => [w.id, { + ...w, + domain: topicMap[w.topic_id]?.domain_slug ?? "unknown", + }])); + // Build directed edge list from open_workstreams depends_on arrays + for (const ow of (summary.open_workstreams ?? [])) { + for (const depId of (ow.depends_on ?? [])) { + edges.push({from_id: ow.id, to_id: depId}); + } + } + } + } catch {} + yield {wsMap, edges, ok, ts: new Date()}; + await new Promise(res => setTimeout(res, POLL)); + } +})(); +``` + +```js +const wsMap = depState.wsMap ?? {}; +const edges = depState.edges ?? []; +const _ok = depState.ok ?? false; +const _ts = depState.ts; +``` + +# Dependencies + +```js +import {injectTocTop} from "./components/toc-sidebar.js"; +import {withDocHelp} from "./components/doc-overlay.js"; + +// ── KPI sidebar card ────────────────────────────────────────────────────────── +const _wsWithDeps = new Set([...edges.map(e => e.from_id), ...edges.map(e => e.to_id)]); +const _kpiBox = html`
+
Dependencies
+
+ edges +
${edges.length}
+
+
+ workstreams involved +
${_wsWithDeps.size}
+
+
`; + +const _liveEl = html`
+ ● + ${_ok + ? `Live Β· updated ${_ts?.toLocaleTimeString()}` + : html`Offline β€” run: make api`} +
`; + +const _h1 = document.querySelector("#observablehq-main h1"); +if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/dependencies"); } + +injectTocTop("dep-kpi-box", _kpiBox); +injectTocTop("live-indicator", _liveEl); +``` + +Directed edges between active workstreams. An edge **A β†’ B** means A cannot +fully proceed until B reaches a satisfactory state. + +```js +if (edges.length === 0) { + display(html`

No dependency edges registered.

`); +} else { + const rows = edges.map(e => { + const from = wsMap[e.from_id]; + const to = wsMap[e.to_id]; + return { + from_domain: from?.domain ?? "β€”", + from_title: from?.title ?? e.from_id, + from_status: from?.status ?? "β€”", + to_domain: to?.domain ?? "β€”", + to_title: to?.title ?? e.to_id, + to_status: to?.status ?? "β€”", + }; + }); + + display(html` + + + + + + + + + + + ${rows.map(r => html` + + + + + + + + + `)} +
Depends-on domainDepends-on workstreamBlocked-by domainBlocked-by workstreamStatus
${r.from_domain}${r.from_title}β†’${r.to_domain}${r.to_title}${r.to_status}
`); +} +``` + + diff --git a/dashboard/src/docs/contributions.md b/dashboard/src/docs/contributions.md new file mode 100644 index 0000000..2e35dae --- /dev/null +++ b/dashboard/src/docs/contributions.md @@ -0,0 +1,120 @@ +--- +title: Contributions β€” Reference +--- + +# Contributions β€” Reference + +Contributions track **outbound upstream work** β€” things the Custodian has +identified that belong in a repo it does not own or control. Each contribution +is a structured artifact filed locally in the repo's `contrib/` directory and +registered in the state hub so it is never lost. + +--- + +## Contribution types + +| Type | Full name | Use when | +|------|-----------|----------| +| `br` | Bug Report | You found a defect in an upstream tool or library | +| `fr` | Feature Request | You need functionality that upstream does not yet provide | +| `ep` | Extension Point | You identified a future enhancement opportunity in upstream code | +| `upr` | Upstream PR | You have written (or are writing) a patch for an upstream repo | + +--- + +## Lifecycle + +``` +draft β†’ submitted β†’ acknowledged β†’ accepted β†’ merged + β†˜ β†˜ + rejected withdrawn +``` + +| Status | Meaning | +|--------|---------| +| **draft** | Artifact written locally; not yet sent upstream | +| **submitted** | Filed as a GitHub issue, PR, or email β€” awaiting upstream response | +| **acknowledged** | Upstream has seen it and responded (e.g. triaged, commented) | +| **accepted** | Upstream agreed to take action | +| **merged** | PR accepted and merged; issue resolved | +| **rejected** | Upstream declined; record kept for future reference | +| **withdrawn** | We decided not to pursue it | + +Transitions are enforced by the API β€” you cannot skip stages arbitrarily. +`submitted_at` is stamped automatically when status moves to `submitted`; +`resolved_at` is stamped when status moves to `merged`, `rejected`, or `withdrawn`. + +--- + +## Relation to the Todo classification + +Contributions map directly to the **Third-party** class in the inter-repo +communication taxonomy: + +| Todo class | Mechanism | +|------------|-----------| +| Internal | Workplan file + task in this repo's workstream | +| Ecosystem | State hub task with `[repo:]` prefix | +| **Third-party** | **Contribution artifact in `contrib/` + state hub registration** | + +Contributions in `draft`, `submitted`, or `acknowledged` status appear as +open Third-party todos on the [Todo](/todo) page. + +--- + +## File layout + +Each artifact lives in the current repo under `contrib/`: + +``` +contrib/ + bug-reports/ br-YYYY-MM-DD------.md + feature-requests/ fr-YYYY-MM-DD------.md + extension-points/ EP--NNN------.md + upstream-prs/ upr-YYYY-MM-DD------.md +``` + +Templates live in `~/the-custodian/canon/standards/contrib-templates/`. +Convention details: `~/the-custodian/canon/standards/contribution-convention_v0.1.md`. + +--- + +## Adding a contribution + +**1. Write the artifact file** using the appropriate template. + +**2. Register it in the state hub** via MCP: + +``` +register_contribution( + type = "fr", + title = "Add sidebar TOC injection API", + target_org = "observablehq", + target_repo = "framework", + body_path = "contrib/feature-requests/fr-2026-02-26--observablehq--framework--toc.md", + related_workstream_id = "" +) +``` + +**3. Close the loop** when you file it upstream: + +``` +update_contribution_status(contribution_id="", status="submitted") +``` + +**4. Keep updating** as upstream responds β€” `acknowledged`, `accepted`, `merged`. + +--- + +## Kanban board + +The Contributions page groups artifacts by status column. Only columns with at +least one entry are shown. The **⚠ follow-up banner** appears when any +contribution has been in `submitted` or `acknowledged` for an extended period +without further movement β€” a prompt to check in with upstream. + +--- + +*Contributions are append-only. Rejected or withdrawn artifacts are retained as +institutional memory β€” they explain why certain approaches were tried and +dropped.* diff --git a/dashboard/src/docs/debt.md b/dashboard/src/docs/debt.md new file mode 100644 index 0000000..977f533 --- /dev/null +++ b/dashboard/src/docs/debt.md @@ -0,0 +1,91 @@ +--- +title: Technical Debt β€” Reference +--- + +# Technical Debt β€” Reference + +The Technical Debt page tracks known quality compromises across all six project +domains β€” intentional shortcuts, design weaknesses, missing tests, and similar +issues that reduce codebase health but have been consciously deferred. + +--- + +## Debt types + +| Type | Examples | +|------|---------| +| **design** | Architectural decisions that should be revisited | +| **implementation** | Hacky or fragile code that works but shouldn't stay | +| **test** | Missing or incomplete test coverage | +| **docs** | Missing or outdated documentation | +| **dependencies** | Pinned old versions, unused packages, missing lockfiles | +| **performance** | Known bottlenecks not yet worth addressing | +| **security** | Hardcoded values, missing input validation, weak auth | +| **other** | Anything that doesn't fit the above | + +--- + +## Severity levels + +| Severity | Meaning | +|----------|---------| +| **critical** | Blocks release or poses an active risk | +| **high** | Should be resolved before the next major milestone | +| **medium** | Normal triage priority | +| **low** | Nice-to-fix; acceptable to defer indefinitely | + +--- + +## Statuses + +| Status | Meaning | +|--------|---------| +| **open** | Known and unaddressed | +| **in_progress** | Being actively worked on | +| **deferred** | Acknowledged but intentionally postponed | +| **resolved** | Fixed | +| **wont_fix** | Accepted as permanent β€” documented for future reference | + +Items are sorted by status (open β†’ in_progress β†’ deferred β†’ resolved β†’ wont_fix) +then by severity (critical β†’ high β†’ medium β†’ low) within each group. + +--- + +## Filters + +| Filter | Effect | +|--------|--------| +| **Status** | Multi-select | +| **Severity** | Multi-select | +| **Domain** | Multi-select | +| **Type** | Multi-select | + +--- + +## Registering debt + +Via MCP: + +``` +register_technical_debt( + domain = "custodian", + title = "Hard-coded API URL in data loaders", + debt_type = "implementation", + severity = "high", + description = "All data loaders use http://127.0.0.1:8000 directly. Should read from an env var or config.", + location = "state-hub/dashboard/src/data/*.json.py", + workstream_id = "" # optional +) +``` + +``` +update_td_status(td_uuid="", status="resolved") +``` + +--- + +## Human-readable IDs + +Each debt item carries a human-readable ID in the form `TD--NNN` +(e.g. `TD-CUST-001`). IDs are optional at creation and auto-assigned if omitted. +They appear in the table for easy reference in commit messages and comments. diff --git a/dashboard/src/docs/dependencies.md b/dashboard/src/docs/dependencies.md new file mode 100644 index 0000000..2dd56ed --- /dev/null +++ b/dashboard/src/docs/dependencies.md @@ -0,0 +1,90 @@ +--- +title: Dependencies β€” Reference +--- + +# Dependencies β€” Reference + +The Dependencies page shows the directed dependency graph between active +workstreams β€” which workstreams are waiting on others to reach a satisfactory +state before they can fully proceed. + +--- + +## What is a dependency edge? + +A dependency edge **A β†’ B** means workstream A cannot fully proceed until +workstream B is in a satisfactory state (typically `completed` or `archived`). + +Edges are used to model real sequencing constraints: for example, a shared +library must reach a stable release before downstream domains can build on it. +The Custodian's dependency order is: + +``` +Railiance β†’ Markitect β†’ Coulomb.social β†’ Personhood / Foerster β†’ Custodian +``` + +--- + +## Edge table + +Each row shows: + +| Column | Meaning | +|--------|---------| +| **Depends-on domain** | Domain of the dependent workstream (the one waiting) | +| **Depends-on workstream** | Title of the workstream that has the dependency | +| **β†’** | Direction arrow | +| **Blocked-by domain** | Domain of the prerequisite workstream | +| **Blocked-by workstream** | Title of the workstream that must complete first | +| **Status** | Current status of the prerequisite (green = active, grey = completed) | + +--- + +## KPI sidebar card + +Shows the total number of edges and the number of distinct workstreams involved +in at least one dependency relationship. + +--- + +## Registering a dependency + +Via MCP: + +``` +create_dependency( + from_workstream_id = "", + to_workstream_id = "", + description = "Cannot build auth layer until shared-library API is stable" +) +``` + +Via REST: + +```bash +curl -X POST http://127.0.0.1:8000/workstreams//dependencies/ \ + -H "Content-Type: application/json" \ + -d '{"to_workstream_id": "", "description": "..."}' +``` + +To list dependencies for a workstream: + +``` +list_dependencies(workstream_id="") +``` + +--- + +## Cycle detection + +The Workstream Health Index (WHI) includes a **Cycle Penalty Index (CPI)** +metric that detects circular dependencies using depth-first search. If CPI = 1, +a cycle exists and the WHI is penalised by 50%. The WHI KPI card on the +[Workstreams](/workstreams) page will display a cycle alert. + +--- + +## Data source + +Dependency edges are derived from the `depends_on` arrays on `open_workstreams` +in `GET /state/summary`. Polls every **15 seconds**. diff --git a/dashboard/src/docs/domains.md b/dashboard/src/docs/domains.md new file mode 100644 index 0000000..2bc9b8a --- /dev/null +++ b/dashboard/src/docs/domains.md @@ -0,0 +1,82 @@ +--- +title: Domains β€” Reference +--- + +# Domains β€” Reference + +The Domains page shows all registered project domains and the repositories +associated with each one. Domains are the top-level organisational unit of the +Custodian ecosystem. + +--- + +## What is a domain? + +A domain corresponds to one of the six tracked project areas: + +| Slug | Project | +|------|---------| +| `custodian` | The Custodian agent system itself | +| `railiance` | DevOps & infrastructure reliability | +| `markitect` | Knowledge artifact management | +| `coulomb_social` | Co-creation marketplace | +| `personhood` | Rights & obligations framework | +| `foerster_capabilities` | Agency capability taxonomy | + +Each domain has a slug (URL-friendly identifier), a human-readable name, an +optional description, and a status. + +--- + +## Domain statuses + +| Status | Meaning | +|--------|---------| +| **active** | Live domain β€” topics, workstreams, and tasks are being tracked | +| **archived** | Soft-deleted; no active work. Fails to archive if active topics exist | + +--- + +## KPI row + +Four counters at the top of the page: + +| Counter | Meaning | +|---------|---------| +| Total domains | All registered domains regardless of status | +| Active | Domains with status `active` | +| Total repos | Sum of all registered repositories across all domains | +| Newest domain | Name of the most recently created domain | + +--- + +## Domain cards + +One card per domain showing: + +- **Slug** β€” monospace identifier +- **Status badge** β€” green `active` or grey `archived` +- **Name** β€” display name +- **Description** β€” first 160 characters +- **Repos** β€” list of registered repositories for this domain, each showing name, local path, and remote URL + +--- + +## Managing domains + +Via MCP: + +``` +create_domain(slug="my_project", name="My Project", description="…") +rename_domain(slug="old_slug", new_slug="new_slug", new_name="New Name") +archive_domain(slug="my_project") # fails if active topics exist +``` + +Via Makefile: + +```bash +make add-domain SLUG=my_project NAME="My Project" +make rename-domain OLD_SLUG=my_project NEW_SLUG=myproject NEW_NAME="My Project" +``` + +*Domains are never hard-deleted β€” only archived.* diff --git a/dashboard/src/docs/extensions.md b/dashboard/src/docs/extensions.md new file mode 100644 index 0000000..08a3615 --- /dev/null +++ b/dashboard/src/docs/extensions.md @@ -0,0 +1,102 @@ +--- +title: Extension Points β€” Reference +--- + +# Extension Points β€” Reference + +The Extension Points page tracks known future enhancement opportunities across +all six domains β€” design forks the system *could* take, parked deliberately +for later consideration rather than acted on immediately. + +--- + +## What is an extension point? + +An extension point (EP) captures a place in the design where additional +capability could be added β€” an API surface that could be extended, a schema +that could grow, an integration that could be built. Recording an EP +acknowledges the opportunity without committing to it. + +Extension points are distinct from technical debt: debt is a known compromise +that should be fixed; EPs are optional future directions that may or may not +be pursued. + +--- + +## EP types + +| Type | Examples | +|------|---------| +| **api** | New endpoints, query parameters, response fields | +| **schema** | New tables, columns, relationships | +| **mcp** | New MCP tools or resources | +| **dashboard** | New pages, charts, or components | +| **architecture** | Structural changes to the system design | +| **integration** | Connections to external systems | +| **other** | Anything that doesn't fit the above | + +--- + +## Statuses + +| Status | Meaning | +|--------|---------| +| **open** | Identified, not yet acted on | +| **in_progress** | Being implemented as part of an active workstream | +| **addressed** | The capability has been built | +| **deferred** | Intentionally postponed | +| **wont_fix** | Decided not to pursue β€” kept for documentation | + +Items are sorted by status (open β†’ in_progress β†’ deferred β†’ addressed β†’ wont_fix) +then by priority (critical β†’ high β†’ medium β†’ low). + +--- + +## Priorities + +| Priority | Meaning | +|----------|---------| +| **critical** | Needed to unblock other work | +| **high** | High-value enhancement for the near term | +| **medium** | Would be useful but not urgent | +| **low** | Speculative or long-horizon idea | + +--- + +## Filters + +| Filter | Effect | +|--------|--------| +| **Status** | Multi-select | +| **Priority** | Multi-select | +| **Domain** | Multi-select | +| **Type** | Multi-select | + +--- + +## Registering an extension point + +Via MCP: + +``` +register_extension_point( + domain = "custodian", + title = "Configurable poll interval per dashboard page", + ep_type = "dashboard", + priority = "low", + description = "Each page hard-codes POLL = 15_000. An env var or per-page config would allow slowing down low-priority pages to reduce API load.", + location = "state-hub/dashboard/src/*.md", + workstream_id = "" # optional +) +``` + +``` +update_ep_status(ep_uuid="", status="addressed") +``` + +--- + +## Human-readable IDs + +Each EP carries an ID in the form `EP--NNN` (e.g. `EP-CUST-001`). +IDs are optional at creation and auto-assigned if omitted. diff --git a/dashboard/src/docs/overview.md b/dashboard/src/docs/overview.md new file mode 100644 index 0000000..d0bf8e5 --- /dev/null +++ b/dashboard/src/docs/overview.md @@ -0,0 +1,84 @@ +--- +title: Overview β€” Reference +--- + +# Overview β€” Reference + +The Overview page is the operational home screen of the Custodian State Hub. +It shows the live health of the entire ecosystem at a glance β€” active work, +blocking decisions, and system-derived next-step suggestions. + +--- + +## Sections + +### Open Workstreams by Domain + +A horizontal stacked bar chart showing every active workstream across all six +domains. Each bar is broken into four task-status segments: + +| Colour | Segment | +|--------|---------| +| green | done | +| blue | in progress | +| orange-red | blocked | +| light grey | todo | + +The left axis shows domain labels (one per group of workstreams). The `done/total` +count is printed to the right of each bar. Workstreams with no tasks yet show +a grey "β€” no tasks yet" label. + +### Contribution & SBOM Health + +Three summary cards linked to the Contributions and SBOM pages: + +| Card | Shows | +|------|-------| +| **Contributions** | Total artifact count; orange warning if any are awaiting upstream response | +| **Licence Risk** | Count of SBOM packages with copyleft licences in direct dependencies | +| **SBOM** | Breakdown by contribution type (BR / FR / EP / UPR) | + +### Status + +Four metric cards: + +| Card | Meaning | +|------|---------| +| **Active Workstreams** | Count of non-completed, non-archived workstreams | +| **Blocking Decisions** | Pending decisions with status `open` or `escalated` β€” orange border if > 0 | +| **Blocked Tasks** | Click to expand the list with blocking reasons | +| **Events Today** | Progress events created on today's date | + +### What's next? + +System-derived action suggestions from `GET /state/next_steps`. Suggestions are +generated when a decision is resolved or a workstream dependency is cleared, and +they point to the first open task in the relevant workstream. These are derived +on request and never persisted. + +### Blocking Decisions + +Inline resolution form for each pending decision. Expand a card, enter a +rationale and "decided by" name, and click **Record & close**. The decision is +resolved via `POST /decisions/{id}/resolve` and disappears from the list +without a page reload. + +### Registered Projects + +Table of projects registered with `make register-project`, sourced from +`milestone` progress events whose summary starts with +`"Project registered with State Hub:"`. + +### Recent Activity + +Last 20 progress events across all domains, showing time, event type, author, +and summary. + +--- + +## Data source + +Polls `GET /state/summary` every **15 seconds**. Blocking decisions are fetched +separately via `GET /decisions/?decision_type=pending` and only re-fetched +after a successful resolve action β€” this prevents the inline form from being +wiped on every poll. diff --git a/dashboard/src/docs/reference.md b/dashboard/src/docs/reference.md new file mode 100644 index 0000000..951c844 --- /dev/null +++ b/dashboard/src/docs/reference.md @@ -0,0 +1,119 @@ +--- +title: Reference & Context Help β€” Reference +--- + +# Reference & Context Help + +The **Reference** section is a collection of in-depth documentation pages +explaining the data model, design conventions, and mechanics of each dashboard +view. Reference pages are readable as standalone articles and also surfaced +inline via the **? context-help button** on dashboard pages. + +--- + +## The ? context-help button + +Every dashboard page exposes one or more **?** buttons β€” small circular +controls that open the relevant reference page in an overlay without leaving +the current view. + +### Where ? buttons appear + +| Location | Opens | +|----------|-------| +| Page **h1** heading | Reference page for that dashboard view | +| **KPI sidebar cards** | Reference page for the specific metric shown | +| **Live indicator** | [Live Data](/docs/live-data) β€” poll interval, offline recovery | + +### How to use it + +1. Hover over the element β€” the **?** button fades in at the top-right corner. +2. Click **?** β€” the reference page opens in a modal overlay. +3. Read the docs, then dismiss with **βœ• close**, **Esc**, or by clicking the + backdrop. + +The overlay does not interrupt the live data polling loop β€” the dashboard +continues refreshing in the background while the overlay is open. + +--- + +## Overlay behaviour + +| Detail | Value | +|--------|-------| +| Size | `min(780px, 92vw)` wide Β· `82vh` tall | +| Content | Observable Framework page rendered in an `