From 07a082b7b02ba1194487c80888bdb4ff6c1f84e3 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 28 Feb 2026 15:20:15 +0100 Subject: [PATCH] =?UTF-8?q?feat(state-hub):=20implement=20v0.5=20=E2=80=94?= =?UTF-8?q?=20dynamic=20domains=20&=20multi-repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class `domains` DB table, and adds a `managed_repos` table for multi-repo support per domain. P1 — Domain as a DB entity: - Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain ENUM column to domain_id FK, drops the domain ENUM type - Domain ORM model (api/models/domain.py) + Pydantic schemas - Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/, rename and archive endpoints with EP/TD cascade on rename - Topic model updated: domain_id FK + @property domain_slug for backwards-compatible JSON serialization (field renamed domain → domain_slug) - TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup P2 — Multi-repo support: - ManagedRepo ORM model (api/models/managed_repo.py) + schemas - Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive - Makefile: add-domain, rename-domain, add-repo, list-repos targets - register_project.sh: verify domain via /domains/ API + POST /repos/ P3 — MCP tools & live validation: - 6 new MCP tools: list_domains, create_domain, rename_domain, archive_domain, list_domain_repos, register_repo - EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request DB lookup — returns 422 with list of valid slugs on unknown domain - State summary: adds domains: list[DomainSummary] (slug, name, repo_count, active_workstream_count, ep_count, td_count) - TOOLS.md updated with domain management section P4 — Dashboard: - New domains.md page with KPI row + domain cards + repo lists - domains.json.py + repos.json.py data loaders - Domains page added to observablehq.config.js nav - workstreams.md, extensions.md, techdept.md: domain_slug fix + dynamic domain list loaded from /domains/ API (no longer hardcoded) Co-Authored-By: Claude Sonnet 4.6 --- state-hub/Makefile | 30 ++- state-hub/api/main.py | 5 +- state-hub/api/models/__init__.py | 8 +- state-hub/api/models/domain.py | 26 +++ state-hub/api/models/managed_repo.py | 31 +++ state-hub/api/models/topic.py | 28 +-- state-hub/api/routers/domains.py | 178 ++++++++++++++++++ state-hub/api/routers/extension_points.py | 13 ++ state-hub/api/routers/repos.py | 99 ++++++++++ state-hub/api/routers/state.py | 75 +++++++- state-hub/api/routers/technical_debt.py | 13 ++ state-hub/api/routers/topics.py | 24 ++- state-hub/api/schemas/domain.py | 61 ++++++ state-hub/api/schemas/extension_point.py | 4 - state-hub/api/schemas/managed_repo.py | 37 ++++ state-hub/api/schemas/state.py | 2 + state-hub/api/schemas/topic.py | 8 +- state-hub/dashboard/observablehq.config.js | 1 + state-hub/dashboard/src/data/domains.json.py | 15 ++ state-hub/dashboard/src/data/repos.json.py | 15 ++ state-hub/dashboard/src/domains.md | 149 +++++++++++++++ state-hub/dashboard/src/extensions.md | 7 +- state-hub/dashboard/src/techdept.md | 7 +- state-hub/dashboard/src/workstreams.md | 9 +- state-hub/mcp_server/TOOLS.md | 17 +- state-hub/mcp_server/server.py | 118 ++++++++++++ ..._v0_5_dynamic_domains_and_managed_repos.py | 141 ++++++++++++++ state-hub/scripts/register_project.sh | 93 ++++++--- state-hub/scripts/seed.py | 51 +++-- workplans/CUST-WP-0005-dynamic-domains.md | 25 +-- 30 files changed, 1205 insertions(+), 85 deletions(-) create mode 100644 state-hub/api/models/domain.py create mode 100644 state-hub/api/models/managed_repo.py create mode 100644 state-hub/api/routers/domains.py create mode 100644 state-hub/api/routers/repos.py create mode 100644 state-hub/api/schemas/domain.py create mode 100644 state-hub/api/schemas/managed_repo.py create mode 100644 state-hub/dashboard/src/data/domains.json.py create mode 100644 state-hub/dashboard/src/data/repos.json.py create mode 100644 state-hub/dashboard/src/domains.md create mode 100644 state-hub/migrations/versions/b1c2d3e4f5a6_v0_5_dynamic_domains_and_managed_repos.py diff --git a/state-hub/Makefile b/state-hub/Makefile index 77f1630..60451f1 100644 --- a/state-hub/Makefile +++ b/state-hub/Makefile @@ -1,4 +1,4 @@ -.PHONY: install install-cli db db-tools migrate seed api dashboard check start clean register-project validate-adr +.PHONY: install install-cli db db-tools migrate seed api dashboard check start clean register-project validate-adr add-domain rename-domain add-repo list-repos COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env @@ -45,6 +45,34 @@ register-project: @test -n "$(PROJECT_PATH)" || (echo "ERROR: PROJECT_PATH is required."; exit 1) scripts/register_project.sh "$(DOMAIN)" "$(PROJECT_PATH)" +## Add a second repo to an existing domain: make add-repo DOMAIN=railiance REPO_PATH=/home/worsch/railiance-infra +add-repo: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1) + @test -n "$(REPO_PATH)" || (echo "ERROR: REPO_PATH is required."; exit 1) + scripts/register_project.sh "$(DOMAIN)" "$(REPO_PATH)" --additional + +## Create a new domain: make add-domain DOMAIN=my_domain NAME="My Domain" +add-domain: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required (slug)."; exit 1) + @test -n "$(NAME)" || (echo "ERROR: NAME is required (display name)."; exit 1) + curl -sf -X POST http://127.0.0.1:8000/domains/ \ + -H "Content-Type: application/json" \ + -d "{\"slug\": \"$(DOMAIN)\", \"name\": \"$(NAME)\"}" | python3 -m json.tool + +## Rename a domain: make rename-domain DOMAIN=old_slug NEW_SLUG=new_slug NEW_NAME="New Name" +rename-domain: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN (old slug) is required."; exit 1) + @test -n "$(NEW_SLUG)" || (echo "ERROR: NEW_SLUG is required."; exit 1) + @test -n "$(NEW_NAME)" || (echo "ERROR: NEW_NAME is required."; exit 1) + curl -sf -X PATCH http://127.0.0.1:8000/domains/$(DOMAIN)/rename \ + -H "Content-Type: application/json" \ + -d "{\"new_slug\": \"$(NEW_SLUG)\", \"new_name\": \"$(NEW_NAME)\"}" | python3 -m json.tool + +## List repos for a domain: make list-repos DOMAIN=railiance +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 + ## 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/state-hub/api/main.py b/state-hub/api/main.py index e4e11da..2ca2dad 100644 --- a/state-hub/api/main.py +++ b/state-hub/api/main.py @@ -5,6 +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 @asynccontextmanager @@ -16,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.1.0", + version="0.5.0", lifespan=lifespan, ) @@ -27,6 +28,8 @@ app.add_middleware( allow_headers=["Content-Type"], ) +app.include_router(domains.router) +app.include_router(repos.router) app.include_router(topics.router) app.include_router(workstreams.router) app.include_router(workstream_dependencies.router) diff --git a/state-hub/api/models/__init__.py b/state-hub/api/models/__init__.py index 562f0f8..3eaafa3 100644 --- a/state-hub/api/models/__init__.py +++ b/state-hub/api/models/__init__.py @@ -1,5 +1,6 @@ from api.models.base import Base -from api.models.topic import Topic, TopicStatus, Domain +from api.models.domain import Domain +from api.models.topic import Topic, TopicStatus from api.models.workstream import Workstream, WorkstreamStatus from api.models.workstream_dependency import WorkstreamDependency from api.models.task import Task, TaskStatus, TaskPriority @@ -7,10 +8,12 @@ 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 +from api.models.managed_repo import ManagedRepo __all__ = [ "Base", - "Topic", "TopicStatus", "Domain", + "Domain", + "Topic", "TopicStatus", "Workstream", "WorkstreamStatus", "WorkstreamDependency", "Task", "TaskStatus", "TaskPriority", @@ -18,4 +21,5 @@ __all__ = [ "ProgressEvent", "ExtensionPoint", "EPStatus", "TechnicalDebt", "TDStatus", + "ManagedRepo", ] diff --git a/state-hub/api/models/domain.py b/state-hub/api/models/domain.py new file mode 100644 index 0000000..3f1d422 --- /dev/null +++ b/state-hub/api/models/domain.py @@ -0,0 +1,26 @@ +import uuid + +from sqlalchemy import 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 Domain(Base, TimestampMixin): + __tablename__ = "domains" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + slug: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") + + topics: Mapped[list["Topic"]] = relationship( # noqa: F821 + "Topic", back_populates="domain", lazy="selectin" + ) + repos: Mapped[list["ManagedRepo"]] = relationship( # noqa: F821 + "ManagedRepo", back_populates="domain", lazy="selectin" + ) diff --git a/state-hub/api/models/managed_repo.py b/state-hub/api/models/managed_repo.py new file mode 100644 index 0000000..dcf6f75 --- /dev/null +++ b/state-hub/api/models/managed_repo.py @@ -0,0 +1,31 @@ +import uuid + +from sqlalchemy import 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 ManagedRepo(Base, TimestampMixin): + __tablename__ = "managed_repos" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + domain_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("domains.id", ondelete="RESTRICT"), nullable=False, index=True + ) + slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(200), nullable=False) + local_path: Mapped[str | None] = mapped_column(Text, nullable=True) + remote_url: Mapped[str | None] = mapped_column(Text, nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") + topic_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True + ) + + domain: Mapped["Domain"] = relationship( # noqa: F821 + "Domain", back_populates="repos", lazy="selectin" + ) diff --git a/state-hub/api/models/topic.py b/state-hub/api/models/topic.py index 2a06026..6a35073 100644 --- a/state-hub/api/models/topic.py +++ b/state-hub/api/models/topic.py @@ -1,7 +1,7 @@ import enum import uuid -from sqlalchemy import Enum, String, Text +from sqlalchemy import Enum, ForeignKey, String, Text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -14,15 +14,6 @@ class TopicStatus(str, enum.Enum): 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" @@ -32,11 +23,19 @@ class Topic(Base, TimestampMixin): 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) + domain_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("domains.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) status: Mapped[TopicStatus] = mapped_column( Enum(TopicStatus), nullable=False, default=TopicStatus.active ) + domain: Mapped["Domain"] = relationship( # noqa: F821 + "Domain", back_populates="topics", lazy="selectin" + ) workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821 "Workstream", back_populates="topic", lazy="selectin" ) @@ -46,3 +45,10 @@ class Topic(Base, TimestampMixin): progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821 "ProgressEvent", back_populates="topic", lazy="selectin" ) + + @property + def domain_slug(self) -> str | None: + """Returns the domain slug string for serialization.""" + if self.domain is not None: + return self.domain.slug + return None diff --git a/state-hub/api/routers/domains.py b/state-hub/api/routers/domains.py new file mode 100644 index 0000000..37b4636 --- /dev/null +++ b/state-hub/api/routers/domains.py @@ -0,0 +1,178 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.domain import Domain +from api.models.extension_point import ExtensionPoint +from api.models.managed_repo import ManagedRepo +from api.models.technical_debt import TechnicalDebt +from api.models.topic import Topic +from api.models.workstream import Workstream, WorkstreamStatus +from api.schemas.domain import DomainCreate, DomainDetail, DomainRead, DomainRename, DomainUpdate, RepoStub + +router = APIRouter(prefix="/domains", tags=["domains"]) + + +@router.get("/", response_model=list[DomainRead]) +async def list_domains( + status: str | None = Query(None, description="active | archived | all"), + session: AsyncSession = Depends(get_session), +) -> list[Domain]: + q = select(Domain).order_by(Domain.name) + if status and status != "all": + q = q.where(Domain.status == status) + elif status is None: + q = q.where(Domain.status == "active") + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=DomainRead, status_code=status.HTTP_201_CREATED) +async def create_domain( + body: DomainCreate, + session: AsyncSession = Depends(get_session), +) -> Domain: + existing = await session.execute(select(Domain).where(Domain.slug == body.slug)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail=f"Domain slug '{body.slug}' already exists") + domain = Domain(slug=body.slug, name=body.name, description=body.description) + session.add(domain) + await session.commit() + await session.refresh(domain) + return domain + + +@router.get("/{slug}/", response_model=DomainDetail) +async def get_domain( + slug: str, + session: AsyncSession = Depends(get_session), +) -> DomainDetail: + domain = await _get_domain_by_slug(slug, session) + + # Count topics + topic_count_row = await session.execute( + select(func.count()).select_from(Topic).where(Topic.domain_id == domain.id) + ) + topic_count = topic_count_row.scalar_one() + + # Count active workstreams (via topics) + topic_ids_row = await session.execute( + select(Topic.id).where(Topic.domain_id == domain.id) + ) + topic_ids = [r[0] for r in topic_ids_row.all()] + + ws_count = 0 + if topic_ids: + ws_count_row = await session.execute( + select(func.count()).select_from(Workstream) + .where(Workstream.topic_id.in_(topic_ids)) + .where(Workstream.status == WorkstreamStatus.active) + ) + ws_count = ws_count_row.scalar_one() + + # Count EPs and TDs (domain is a string column there) + ep_count_row = await session.execute( + select(func.count()).select_from(ExtensionPoint) + .where(ExtensionPoint.domain == slug) + ) + ep_count = ep_count_row.scalar_one() + + td_count_row = await session.execute( + select(func.count()).select_from(TechnicalDebt) + .where(TechnicalDebt.domain == slug) + ) + td_count = td_count_row.scalar_one() + + # Repos + repos_row = await session.execute( + select(ManagedRepo).where(ManagedRepo.domain_id == domain.id) + .where(ManagedRepo.status == "active") + .order_by(ManagedRepo.name) + ) + repos = list(repos_row.scalars().all()) + + return DomainDetail( + id=domain.id, + slug=domain.slug, + name=domain.name, + description=domain.description, + status=domain.status, + created_at=domain.created_at, + updated_at=domain.updated_at, + topic_count=topic_count, + workstream_count=ws_count, + ep_count=ep_count, + td_count=td_count, + repos=[RepoStub.model_validate(r) for r in repos], + ) + + +@router.patch("/{slug}/rename", response_model=DomainRead) +async def rename_domain( + slug: str, + body: DomainRename, + session: AsyncSession = Depends(get_session), +) -> Domain: + domain = await _get_domain_by_slug(slug, session) + + if body.new_slug != slug: + conflict = await session.execute(select(Domain).where(Domain.slug == body.new_slug)) + if conflict.scalar_one_or_none(): + raise HTTPException(status_code=409, detail=f"Slug '{body.new_slug}' already taken") + + old_slug = domain.slug + domain.slug = body.new_slug + domain.name = body.new_name + + # Cascade slug rename to EP/TD string columns + if old_slug != body.new_slug: + await session.execute( + ExtensionPoint.__table__.update() + .where(ExtensionPoint.domain == old_slug) + .values(domain=body.new_slug) + ) + await session.execute( + TechnicalDebt.__table__.update() + .where(TechnicalDebt.domain == old_slug) + .values(domain=body.new_slug) + ) + + await session.commit() + await session.refresh(domain) + return domain + + +@router.patch("/{slug}/archive", response_model=DomainRead) +async def archive_domain( + slug: str, + session: AsyncSession = Depends(get_session), +) -> Domain: + domain = await _get_domain_by_slug(slug, session) + + # Reject if any active topics exist for this domain + active_topics = await session.execute( + select(func.count()).select_from(Topic) + .where(Topic.domain_id == domain.id) + .where(Topic.status == "active") + ) + if active_topics.scalar_one() > 0: + raise HTTPException( + status_code=409, + detail="Cannot archive domain with active topics. Archive or reassign topics first.", + ) + + domain.status = "archived" + await session.commit() + await session.refresh(domain) + return domain + + +async def _get_domain_by_slug(slug: str, session: AsyncSession) -> Domain: + result = await session.execute(select(Domain).where(Domain.slug == slug)) + domain = result.scalar_one_or_none() + if domain is None: + raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found") + return domain diff --git a/state-hub/api/routers/extension_points.py b/state-hub/api/routers/extension_points.py index e4c2a52..4de9d96 100644 --- a/state-hub/api/routers/extension_points.py +++ b/state-hub/api/routers/extension_points.py @@ -5,12 +5,19 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session +from api.models.domain import Domain 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"]) +async def _get_valid_domain_slugs(session: AsyncSession) -> set[str]: + """Return the set of active domain slugs from the DB.""" + rows = await session.execute(select(Domain.slug).where(Domain.status == "active")) + return {r[0] for r in rows.all()} + + @router.get("/", response_model=list[EPRead]) async def list_eps( domain: str | None = None, @@ -35,6 +42,12 @@ async def create_ep( body: EPCreate, session: AsyncSession = Depends(get_session), ) -> ExtensionPoint: + valid_domains = await _get_valid_domain_slugs(session) + if body.domain not in valid_domains: + raise HTTPException( + status_code=422, + detail=f"Unknown domain '{body.domain}'. Valid domains: {sorted(valid_domains)}", + ) ep = ExtensionPoint(**body.model_dump()) session.add(ep) await session.commit() diff --git a/state-hub/api/routers/repos.py b/state-hub/api/routers/repos.py new file mode 100644 index 0000000..469ef2a --- /dev/null +++ b/state-hub/api/routers/repos.py @@ -0,0 +1,99 @@ +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.domain import Domain +from api.models.managed_repo import ManagedRepo +from api.schemas.managed_repo import RepoCreate, RepoRead, RepoUpdate + +router = APIRouter(prefix="/repos", tags=["repos"]) + + +@router.get("/", response_model=list[RepoRead]) +async def list_repos( + domain: str | None = None, + session: AsyncSession = Depends(get_session), +) -> list[ManagedRepo]: + q = select(ManagedRepo).order_by(ManagedRepo.name) + if domain: + domain_row = await session.execute(select(Domain).where(Domain.slug == domain)) + domain_obj = domain_row.scalar_one_or_none() + if domain_obj is None: + raise HTTPException(status_code=404, detail=f"Domain '{domain}' not found") + q = q.where(ManagedRepo.domain_id == domain_obj.id) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=RepoRead, status_code=status.HTTP_201_CREATED) +async def register_repo( + body: RepoCreate, + session: AsyncSession = Depends(get_session), +) -> ManagedRepo: + domain_row = await session.execute(select(Domain).where(Domain.slug == body.domain_slug)) + domain_obj = domain_row.scalar_one_or_none() + if domain_obj is None: + raise HTTPException(status_code=404, detail=f"Domain '{body.domain_slug}' not found") + + existing = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == body.slug)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail=f"Repo slug '{body.slug}' already exists") + + repo = ManagedRepo( + domain_id=domain_obj.id, + slug=body.slug, + name=body.name, + local_path=body.local_path, + remote_url=body.remote_url, + description=body.description, + topic_id=body.topic_id, + ) + session.add(repo) + await session.commit() + await session.refresh(repo) + return repo + + +@router.get("/{slug}/", response_model=RepoRead) +async def get_repo( + slug: str, + session: AsyncSession = Depends(get_session), +) -> ManagedRepo: + return await _get_repo_by_slug(slug, session) + + +@router.patch("/{slug}/", response_model=RepoRead) +async def update_repo( + slug: str, + body: RepoUpdate, + session: AsyncSession = Depends(get_session), +) -> ManagedRepo: + repo = await _get_repo_by_slug(slug, session) + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(repo, field, value) + await session.commit() + await session.refresh(repo) + return repo + + +@router.patch("/{slug}/archive", response_model=RepoRead) +async def archive_repo( + slug: str, + session: AsyncSession = Depends(get_session), +) -> ManagedRepo: + repo = await _get_repo_by_slug(slug, session) + repo.status = "archived" + await session.commit() + await session.refresh(repo) + return repo + + +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/state-hub/api/routers/state.py b/state-hub/api/routers/state.py index f217eda..28ab4c2 100644 --- a/state-hub/api/routers/state.py +++ b/state-hub/api/routers/state.py @@ -7,12 +7,17 @@ from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session, engine 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.task import Task, TaskPriority, TaskStatus +from api.models.technical_debt import TechnicalDebt 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.domain import DomainSummary from api.schemas.progress_event import ProgressEventRead from api.schemas.state import ( DecisionTotals, @@ -167,6 +172,9 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm next_steps = await _derive_next_steps(session) + # Domain summary stats + domain_summaries = await _build_domain_summaries(session) + return StateSummary( generated_at=datetime.now(tz=timezone.utc), totals=totals, @@ -175,6 +183,7 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm blocked_tasks=[TaskRead.model_validate(t) for t in blocked], recent_progress=[ProgressEventRead.model_validate(e) for e in recent], next_steps=next_steps, + domains=domain_summaries, open_workstreams=[ WorkstreamWithDeps( **WorkstreamRead.model_validate(w).model_dump(), @@ -191,6 +200,53 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm ) +async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]: + """Compute per-domain stats for the state summary.""" + domains_rows = await session.execute( + select(Domain).where(Domain.status == "active").order_by(Domain.name) + ) + domains = list(domains_rows.scalars().all()) + + # Repo counts per domain + repo_counts = {r[0]: r[1] for r in await session.execute( + select(ManagedRepo.domain_id, func.count()) + .where(ManagedRepo.status == "active") + .group_by(ManagedRepo.domain_id) + )} + + # Active workstream counts per domain (join through topics) + ws_per_domain = {} + for domain_id, cnt in await session.execute( + select(Topic.domain_id, func.count(Workstream.id)) + .join(Workstream, Workstream.topic_id == Topic.id) + .where(Workstream.status == WorkstreamStatus.active) + .group_by(Topic.domain_id) + ): + ws_per_domain[domain_id] = cnt + + # EP counts per domain slug + ep_counts = {r[0]: r[1] for r in await session.execute( + select(ExtensionPoint.domain, func.count()).group_by(ExtensionPoint.domain) + )} + + # TD counts per domain slug + td_counts = {r[0]: r[1] for r in await session.execute( + select(TechnicalDebt.domain, func.count()).group_by(TechnicalDebt.domain) + )} + + return [ + DomainSummary( + slug=d.slug, + name=d.name, + repo_count=repo_counts.get(d.id, 0), + active_workstream_count=ws_per_domain.get(d.id, 0), + ep_count=ep_counts.get(d.slug, 0), + td_count=td_counts.get(d.slug, 0), + ) + for d in domains + ] + + _PRIORITY_RANK = { TaskPriority.critical: 0, TaskPriority.high: 1, @@ -231,10 +287,10 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]: 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 + domain_slug = await _get_domain_slug_for_workstream(ws, session) steps.append(NextStep( type="resolved_decision", - domain=topic.domain if topic else None, + domain=domain_slug, workstream_id=ws.id if ws else None, workstream_title=ws.title if ws else None, workstream_slug=ws.slug if ws else None, @@ -282,7 +338,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]: 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) + 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 @@ -290,7 +346,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]: ) steps.append(NextStep( type="dependency_cleared", - domain=topic.domain if topic else None, + domain=domain_slug, workstream_id=from_ws.id, workstream_title=from_ws.title, workstream_slug=from_ws.slug, @@ -306,6 +362,17 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]: return steps +async def _get_domain_slug_for_workstream(ws: Workstream | None, session: AsyncSession) -> str | None: + """Get the domain slug for a workstream via its topic.""" + if ws is None or ws.topic_id is None: + return None + topic = await session.get(Topic, ws.topic_id) + if topic is None or topic.domain_id is None: + return None + domain = await session.get(Domain, topic.domain_id) + return domain.slug if domain else None + + @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. diff --git a/state-hub/api/routers/technical_debt.py b/state-hub/api/routers/technical_debt.py index 5082328..d3fbb48 100644 --- a/state-hub/api/routers/technical_debt.py +++ b/state-hub/api/routers/technical_debt.py @@ -5,12 +5,19 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session +from api.models.domain import Domain 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"]) +async def _get_valid_domain_slugs(session: AsyncSession) -> set[str]: + """Return the set of active domain slugs from the DB.""" + rows = await session.execute(select(Domain.slug).where(Domain.status == "active")) + return {r[0] for r in rows.all()} + + @router.get("/", response_model=list[TDRead]) async def list_td( domain: str | None = None, @@ -38,6 +45,12 @@ async def create_td( body: TDCreate, session: AsyncSession = Depends(get_session), ) -> TechnicalDebt: + valid_domains = await _get_valid_domain_slugs(session) + if body.domain not in valid_domains: + raise HTTPException( + status_code=422, + detail=f"Unknown domain '{body.domain}'. Valid domains: {sorted(valid_domains)}", + ) td = TechnicalDebt(**body.model_dump()) session.add(td) await session.commit() diff --git a/state-hub/api/routers/topics.py b/state-hub/api/routers/topics.py index 660e5fa..7ed18b4 100644 --- a/state-hub/api/routers/topics.py +++ b/state-hub/api/routers/topics.py @@ -5,12 +5,22 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session +from api.models.domain import Domain from api.models.topic import Topic, TopicStatus from api.schemas.topic import TopicCreate, TopicRead, TopicUpdate, TopicWithWorkstreams router = APIRouter(prefix="/topics", tags=["topics"]) +async def _resolve_domain_id(domain_slug: str, session: AsyncSession) -> uuid.UUID: + """Resolve a domain slug to its UUID. Raises 404 if not found.""" + result = await session.execute(select(Domain).where(Domain.slug == domain_slug)) + domain = result.scalar_one_or_none() + if domain is None: + raise HTTPException(status_code=404, detail=f"Domain '{domain_slug}' not found") + return domain.id + + @router.get("/", response_model=list[TopicRead]) async def list_topics( status: TopicStatus | None = None, @@ -29,7 +39,14 @@ async def create_topic( body: TopicCreate, session: AsyncSession = Depends(get_session), ) -> Topic: - topic = Topic(**body.model_dump()) + domain_id = await _resolve_domain_id(body.domain, session) + topic = Topic( + slug=body.slug, + title=body.title, + description=body.description, + domain_id=domain_id, + status=body.status, + ) session.add(topic) await session.commit() await session.refresh(topic) @@ -56,7 +73,10 @@ async def update_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(): + updates = body.model_dump(exclude_unset=True) + if "domain" in updates: + topic.domain_id = await _resolve_domain_id(updates.pop("domain"), session) + for field, value in updates.items(): setattr(topic, field, value) await session.commit() await session.refresh(topic) diff --git a/state-hub/api/schemas/domain.py b/state-hub/api/schemas/domain.py new file mode 100644 index 0000000..07f085d --- /dev/null +++ b/state-hub/api/schemas/domain.py @@ -0,0 +1,61 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class DomainCreate(BaseModel): + slug: str + name: str + description: str | None = None + + +class DomainUpdate(BaseModel): + name: str | None = None + description: str | None = None + status: str | None = None + + +class DomainRename(BaseModel): + new_slug: str + new_name: str + + +class RepoStub(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + slug: str + name: str + local_path: str | None = None + remote_url: str | None = None + status: str + + +class DomainRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + slug: str + name: str + description: str | None = None + status: str + created_at: datetime + updated_at: datetime + + +class DomainDetail(DomainRead): + """Domain with entity counts and repo list.""" + topic_count: int = 0 + workstream_count: int = 0 + ep_count: int = 0 + td_count: int = 0 + repos: list[RepoStub] = [] + + +class DomainSummary(BaseModel): + """Lightweight domain stats for the state summary.""" + slug: str + name: str + repo_count: int = 0 + active_workstream_count: int = 0 + ep_count: int = 0 + td_count: int = 0 diff --git a/state-hub/api/schemas/extension_point.py b/state-hub/api/schemas/extension_point.py index 86c1918..0cdc53e 100644 --- a/state-hub/api/schemas/extension_point.py +++ b/state-hub/api/schemas/extension_point.py @@ -5,10 +5,6 @@ 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"} diff --git a/state-hub/api/schemas/managed_repo.py b/state-hub/api/schemas/managed_repo.py new file mode 100644 index 0000000..bf9ae69 --- /dev/null +++ b/state-hub/api/schemas/managed_repo.py @@ -0,0 +1,37 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class RepoCreate(BaseModel): + domain_slug: str + slug: str + name: str + local_path: str | None = None + remote_url: str | None = None + description: str | None = None + topic_id: uuid.UUID | None = None + + +class RepoUpdate(BaseModel): + name: str | None = None + local_path: str | None = None + remote_url: str | None = None + description: str | None = None + topic_id: uuid.UUID | None = None + + +class RepoRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + domain_id: uuid.UUID + slug: str + name: str + local_path: str | None = None + remote_url: str | None = None + description: str | None = None + status: str + topic_id: uuid.UUID | None = None + created_at: datetime + updated_at: datetime diff --git a/state-hub/api/schemas/state.py b/state-hub/api/schemas/state.py index bad27e1..a695f72 100644 --- a/state-hub/api/schemas/state.py +++ b/state-hub/api/schemas/state.py @@ -4,6 +4,7 @@ from datetime import datetime from pydantic import BaseModel from api.schemas.decision import DecisionRead +from api.schemas.domain import DomainSummary from api.schemas.progress_event import ProgressEventRead from api.schemas.task import TaskRead from api.schemas.topic import TopicWithWorkstreams @@ -75,3 +76,4 @@ class StateSummary(BaseModel): recent_progress: list[ProgressEventRead] open_workstreams: list[WorkstreamWithDeps] next_steps: list[NextStep] = [] + domains: list[DomainSummary] = [] diff --git a/state-hub/api/schemas/topic.py b/state-hub/api/schemas/topic.py index 39385c9..b0a1e6b 100644 --- a/state-hub/api/schemas/topic.py +++ b/state-hub/api/schemas/topic.py @@ -3,22 +3,22 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict -from api.models.topic import Domain, TopicStatus +from api.models.topic import TopicStatus class TopicCreate(BaseModel): slug: str title: str description: str | None = None - domain: Domain + domain: str # domain slug — resolved to domain_id in the router status: TopicStatus = TopicStatus.active class TopicUpdate(BaseModel): title: str | None = None description: str | None = None - domain: Domain | None = None status: TopicStatus | None = None + domain: str | None = None # domain slug — resolved to domain_id in the router class WorkstreamStub(BaseModel): @@ -37,7 +37,7 @@ class TopicRead(BaseModel): slug: str title: str description: str | None = None - domain: Domain + domain_slug: str | None = None # resolved from FK relationship via @property status: TopicStatus created_at: datetime updated_at: datetime diff --git a/state-hub/dashboard/observablehq.config.js b/state-hub/dashboard/observablehq.config.js index 6fe4d16..6588057 100644 --- a/state-hub/dashboard/observablehq.config.js +++ b/state-hub/dashboard/observablehq.config.js @@ -7,6 +7,7 @@ export default { { name: "Tasks", path: "/tasks" }, { name: "Decisions", path: "/decisions" }, { name: "Progress", path: "/progress" }, + { name: "Domains", path: "/domains" }, { name: "Extension Points", path: "/extensions" }, { name: "Technical Debt", path: "/techdept" }, { diff --git a/state-hub/dashboard/src/data/domains.json.py b/state-hub/dashboard/src/data/domains.json.py new file mode 100644 index 0000000..70526d7 --- /dev/null +++ b/state-hub/dashboard/src/data/domains.json.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Observable data loader: fetches /domains/ 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}/domains/?status=all", 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), "domains": []})) diff --git a/state-hub/dashboard/src/data/repos.json.py b/state-hub/dashboard/src/data/repos.json.py new file mode 100644 index 0000000..963e475 --- /dev/null +++ b/state-hub/dashboard/src/data/repos.json.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Observable data loader: fetches /repos/ 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}/repos/", 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), "repos": []})) diff --git a/state-hub/dashboard/src/domains.md b/state-hub/dashboard/src/domains.md new file mode 100644 index 0000000..6857f2f --- /dev/null +++ b/state-hub/dashboard/src/domains.md @@ -0,0 +1,149 @@ +--- +title: Domains +--- + +```js +const API = "http://127.0.0.1:8000"; +const POLL = 15_000; +``` + +```js +const domainsState = (async function*() { + while (true) { + let domains = [], repos = [], ok = false; + try { + const [rd, rr] = await Promise.all([ + fetch(`${API}/domains/?status=all`), + fetch(`${API}/repos/`), + ]); + ok = rd.ok && rr.ok; + if (ok) { + [domains, repos] = await Promise.all([rd.json(), rr.json()]); + } + } catch {} + yield {domains, repos, ok, ts: new Date()}; + await new Promise(res => setTimeout(res, POLL)); + } +})(); +``` + +```js +const domains = domainsState.domains ?? []; +const repos = domainsState.repos ?? []; +const _ok = domainsState.ok ?? false; +const _ts = domainsState.ts; +``` + +# Domains + +```js +import {injectTocTop} from "./components/toc-sidebar.js"; +import {openEntityModal} from "./components/entity-modal.js"; + +// ── Live indicator ───────────────────────────────────────────────────────────── +const _liveEl = html`
+ + ${_ok + ? `Live · updated ${_ts?.toLocaleTimeString()}` + : html`Offline — run: make api`} +
`; +injectTocTop("live-indicator", _liveEl); + +// ── KPI row ──────────────────────────────────────────────────────────────────── +const activeDomains = domains.filter(d => d.status === "active"); +const archivedDomains = domains.filter(d => d.status === "archived"); +const newestDomain = [...domains].sort((a, b) => b.created_at?.localeCompare(a.created_at ?? "") ?? 0)[0]; + +display(html`
+
+
${domains.length}
+
total domains
+
+
+
${activeDomains.length}
+
active
+
+
+
${repos.length}
+
total repos
+
+
+
${newestDomain?.name ?? "—"}
+
newest domain
+
+
`); +``` + +## Domain Cards + +```js +// Build repo index by domain_id +const reposByDomain = {}; +for (const repo of repos) { + if (!reposByDomain[repo.domain_id]) reposByDomain[repo.domain_id] = []; + reposByDomain[repo.domain_id].push(repo); +} + +if (domains.length === 0) { + display(html`

No domains found. API may be offline.

`); +} else { + display(html`
${domains.map(d => { + const domainRepos = reposByDomain[d.id] ?? []; + return html`
+
+ ${d.slug} + ${d.status} +
+
${d.name}
+ ${d.description ? html`
${d.description.slice(0, 160)}${d.description.length > 160 ? " …" : ""}
` : ""} +
+ ${domainRepos.length === 0 + ? html`no repos registered` + : domainRepos.map(r => html`
+ ${r.name} + ${r.local_path ? html`${r.local_path}` : ""} + ${r.remote_url ? html`${r.remote_url.replace(/^https?:\/\//, "")}` : ""} +
`) + } +
+
`; + })}
`); +} +``` + + diff --git a/state-hub/dashboard/src/extensions.md b/state-hub/dashboard/src/extensions.md index 17c56db..3ed20ab 100644 --- a/state-hub/dashboard/src/extensions.md +++ b/state-hub/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/state-hub/dashboard/src/techdept.md b/state-hub/dashboard/src/techdept.md index 4c4d965..7517571 100644 --- a/state-hub/dashboard/src/techdept.md +++ b/state-hub/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/state-hub/dashboard/src/workstreams.md b/state-hub/dashboard/src/workstreams.md index 2a3e86d..d3c2608 100644 --- a/state-hub/dashboard/src/workstreams.md +++ b/state-hub/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/state-hub/mcp_server/TOOLS.md b/state-hub/mcp_server/TOOLS.md index f1b2900..9a060b0 100644 --- a/state-hub/mcp_server/TOOLS.md +++ b/state-hub/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/state-hub/mcp_server/server.py b/state-hub/mcp_server/server.py index 79118d9..8a95615 100644 --- a/state-hub/mcp_server/server.py +++ b/state-hub/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/state-hub/migrations/versions/b1c2d3e4f5a6_v0_5_dynamic_domains_and_managed_repos.py b/state-hub/migrations/versions/b1c2d3e4f5a6_v0_5_dynamic_domains_and_managed_repos.py new file mode 100644 index 0000000..d9077da --- /dev/null +++ b/state-hub/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/state-hub/scripts/register_project.sh b/state-hub/scripts/register_project.sh index 4c80feb..b027978 100755 --- a/state-hub/scripts/register_project.sh +++ b/state-hub/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/state-hub/scripts/seed.py b/state-hub/scripts/seed.py index 94066b8..df0e893 100644 --- a/state-hub/scripts/seed.py +++ b/state-hub/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.") diff --git a/workplans/CUST-WP-0005-dynamic-domains.md b/workplans/CUST-WP-0005-dynamic-domains.md index 61adc6e..0a02a9b 100644 --- a/workplans/CUST-WP-0005-dynamic-domains.md +++ b/workplans/CUST-WP-0005-dynamic-domains.md @@ -3,12 +3,13 @@ id: CUST-WP-0005 type: workplan title: "State Hub v0.5 — Dynamic Domains & Multi-Repo" domain: custodian -status: active +status: completed owner: custodian topic_slug: custodian state_hub_workstream_id: 2271eb55-62ca-4bcc-8d9f-f9aacd6922d6 created: "2026-02-28" updated: "2026-02-28" +completed: "2026-02-28" --- # State Hub v0.5 — Dynamic Domains & Multi-Repo @@ -44,7 +45,7 @@ should be open. ```task id: CUST-WP-0005-T01 state_hub_task_id: 456a0252-9a34-43a6-8244-c3a2caf95d51 -status: todo +status: done priority: high ``` @@ -71,7 +72,7 @@ Downgrade: reverse all steps (recreate enum, repopulate, drop domain_id). ```task id: CUST-WP-0005-T02 state_hub_task_id: eddff1ae-b263-4d3e-af95-91b4c3c2ecea -status: todo +status: done priority: high ``` @@ -95,7 +96,7 @@ New file: `state-hub/api/schemas/domain.py` ```task id: CUST-WP-0005-T03 state_hub_task_id: b91cd0b5-c647-41e5-abf5-98f6f5eb3333 -status: todo +status: done priority: high ``` @@ -115,7 +116,7 @@ Register in `state-hub/api/main.py`. ```task id: CUST-WP-0005-T04 state_hub_task_id: 0825fa45-ec3a-43b5-8482-4a819db75c6a -status: todo +status: done priority: medium ``` @@ -139,7 +140,7 @@ Update state router: any `Domain.custodian` etc. references → slug string. ```task id: CUST-WP-0005-T05 state_hub_task_id: 50dc7eec-2b45-4a0d-b99e-27aca8a55a67 -status: todo +status: done priority: high ``` @@ -161,7 +162,7 @@ Add to `models/__init__.py` and register router. ```task id: CUST-WP-0005-T06 state_hub_task_id: daca1636-0187-4fb7-a515-8a2cf84f89d9 -status: todo +status: done priority: medium ``` @@ -183,7 +184,7 @@ Schemas: `RepoCreate`, `RepoRead`, `RepoUpdate`. ```task id: CUST-WP-0005-T07 state_hub_task_id: 930fd3fa-2e1d-401c-9e33-0378f33cb14d -status: todo +status: done priority: medium ``` @@ -211,7 +212,7 @@ Update `state-hub/scripts/project_claude_md.template`: ```task id: CUST-WP-0005-T08 state_hub_task_id: e99e6bd7-f6f2-496f-acbf-d2b013f37c73 -status: todo +status: done priority: medium ``` @@ -231,7 +232,7 @@ Update `state-hub/mcp_server/TOOLS.md` to document all 6 new tools. ```task id: CUST-WP-0005-T09 state_hub_task_id: f2b352b6-4b9e-47b3-b629-48485245390e -status: todo +status: done priority: low ``` @@ -255,7 +256,7 @@ Update global CLAUDE.md and project CLAUDE.md template to note ```task id: CUST-WP-0005-T10 state_hub_task_id: ddb91669-5297-45d9-8b5e-10213135a96d -status: todo +status: done priority: low ``` @@ -279,7 +280,7 @@ Add to `observablehq.config.js` nav. ```task id: CUST-WP-0005-T11 state_hub_task_id: 3fec152c-00e1-4cdb-b6e5-26136c8cb6c1 -status: todo +status: done priority: low ```