feat(state-hub): implement v0.5 — dynamic domains & multi-repo

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 15:20:15 +01:00
parent c3efb099f1
commit fcd0f06536
29 changed files with 1192 additions and 73 deletions

View File

@@ -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 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) @test -n "$(PROJECT_PATH)" || (echo "ERROR: PROJECT_PATH is required."; exit 1)
scripts/register_project.sh "$(DOMAIN)" "$(PROJECT_PATH)" 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] ## Check a repo for ADR-001 compliance: make validate-adr REPO=/path/to/repo [DOMAIN=custodian]
validate-adr: validate-adr:
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make validate-adr REPO=<path> [DOMAIN=<slug>]"; exit 1) @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make validate-adr REPO=<path> [DOMAIN=<slug>]"; exit 1)

View File

@@ -5,6 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from api.database import engine 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 decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
from api.routers import domains, repos
@asynccontextmanager @asynccontextmanager
@@ -16,7 +17,7 @@ async def lifespan(app: FastAPI):
app = FastAPI( app = FastAPI(
title="Custodian State Hub", title="Custodian State Hub",
description="Local-first state API for the Custodian agent system.", description="Local-first state API for the Custodian agent system.",
version="0.1.0", version="0.5.0",
lifespan=lifespan, lifespan=lifespan,
) )
@@ -27,6 +28,8 @@ app.add_middleware(
allow_headers=["Content-Type"], allow_headers=["Content-Type"],
) )
app.include_router(domains.router)
app.include_router(repos.router)
app.include_router(topics.router) app.include_router(topics.router)
app.include_router(workstreams.router) app.include_router(workstreams.router)
app.include_router(workstream_dependencies.router) app.include_router(workstream_dependencies.router)

View File

@@ -1,5 +1,6 @@
from api.models.base import Base 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 import Workstream, WorkstreamStatus
from api.models.workstream_dependency import WorkstreamDependency from api.models.workstream_dependency import WorkstreamDependency
from api.models.task import Task, TaskStatus, TaskPriority 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.progress_event import ProgressEvent
from api.models.extension_point import ExtensionPoint, EPStatus from api.models.extension_point import ExtensionPoint, EPStatus
from api.models.technical_debt import TechnicalDebt, TDStatus from api.models.technical_debt import TechnicalDebt, TDStatus
from api.models.managed_repo import ManagedRepo
__all__ = [ __all__ = [
"Base", "Base",
"Topic", "TopicStatus", "Domain", "Domain",
"Topic", "TopicStatus",
"Workstream", "WorkstreamStatus", "Workstream", "WorkstreamStatus",
"WorkstreamDependency", "WorkstreamDependency",
"Task", "TaskStatus", "TaskPriority", "Task", "TaskStatus", "TaskPriority",
@@ -18,4 +21,5 @@ __all__ = [
"ProgressEvent", "ProgressEvent",
"ExtensionPoint", "EPStatus", "ExtensionPoint", "EPStatus",
"TechnicalDebt", "TDStatus", "TechnicalDebt", "TDStatus",
"ManagedRepo",
] ]

26
api/models/domain.py Normal file
View File

@@ -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"
)

View File

@@ -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"
)

View File

@@ -1,7 +1,7 @@
import enum import enum
import uuid import uuid
from sqlalchemy import Enum, String, Text from sqlalchemy import Enum, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -14,15 +14,6 @@ class TopicStatus(str, enum.Enum):
archived = "archived" 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): class Topic(Base, TimestampMixin):
__tablename__ = "topics" __tablename__ = "topics"
@@ -32,11 +23,19 @@ class Topic(Base, TimestampMixin):
slug: Mapped[str] = mapped_column(String(100), unique=True, 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) title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True) 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( status: Mapped[TopicStatus] = mapped_column(
Enum(TopicStatus), nullable=False, default=TopicStatus.active 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 workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821
"Workstream", back_populates="topic", lazy="selectin" "Workstream", back_populates="topic", lazy="selectin"
) )
@@ -46,3 +45,10 @@ class Topic(Base, TimestampMixin):
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821 progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
"ProgressEvent", back_populates="topic", lazy="selectin" "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

178
api/routers/domains.py Normal file
View File

@@ -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

View File

@@ -5,12 +5,19 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session from api.database import get_session
from api.models.domain import Domain
from api.models.extension_point import EPStatus, ExtensionPoint from api.models.extension_point import EPStatus, ExtensionPoint
from api.schemas.extension_point import EPCreate, EPRead, EPUpdate from api.schemas.extension_point import EPCreate, EPRead, EPUpdate
router = APIRouter(prefix="/extension-points", tags=["extension-points"]) 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]) @router.get("/", response_model=list[EPRead])
async def list_eps( async def list_eps(
domain: str | None = None, domain: str | None = None,
@@ -35,6 +42,12 @@ async def create_ep(
body: EPCreate, body: EPCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> ExtensionPoint: ) -> 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()) ep = ExtensionPoint(**body.model_dump())
session.add(ep) session.add(ep)
await session.commit() await session.commit()

99
api/routers/repos.py Normal file
View File

@@ -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

View File

@@ -7,12 +7,17 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session, engine from api.database import get_session, engine
from api.models.decision import Decision, DecisionStatus, DecisionType 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.progress_event import ProgressEvent
from api.models.task import Task, TaskPriority, TaskStatus 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.topic import Topic, TopicStatus
from api.models.workstream import Workstream, WorkstreamStatus from api.models.workstream import Workstream, WorkstreamStatus
from api.models.workstream_dependency import WorkstreamDependency from api.models.workstream_dependency import WorkstreamDependency
from api.schemas.decision import DecisionRead from api.schemas.decision import DecisionRead
from api.schemas.domain import DomainSummary
from api.schemas.progress_event import ProgressEventRead from api.schemas.progress_event import ProgressEventRead
from api.schemas.state import ( from api.schemas.state import (
DecisionTotals, DecisionTotals,
@@ -167,6 +172,9 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm
next_steps = await _derive_next_steps(session) next_steps = await _derive_next_steps(session)
# Domain summary stats
domain_summaries = await _build_domain_summaries(session)
return StateSummary( return StateSummary(
generated_at=datetime.now(tz=timezone.utc), generated_at=datetime.now(tz=timezone.utc),
totals=totals, 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], blocked_tasks=[TaskRead.model_validate(t) for t in blocked],
recent_progress=[ProgressEventRead.model_validate(e) for e in recent], recent_progress=[ProgressEventRead.model_validate(e) for e in recent],
next_steps=next_steps, next_steps=next_steps,
domains=domain_summaries,
open_workstreams=[ open_workstreams=[
WorkstreamWithDeps( WorkstreamWithDeps(
**WorkstreamRead.model_validate(w).model_dump(), **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 = { _PRIORITY_RANK = {
TaskPriority.critical: 0, TaskPriority.critical: 0,
TaskPriority.high: 1, TaskPriority.high: 1,
@@ -231,10 +287,10 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
if task.id in seen_task_ids: if task.id in seen_task_ids:
continue continue
ws = await session.get(Workstream, decision.workstream_id) 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( steps.append(NextStep(
type="resolved_decision", type="resolved_decision",
domain=topic.domain if topic else None, domain=domain_slug,
workstream_id=ws.id if ws else None, workstream_id=ws.id if ws else None,
workstream_title=ws.title if ws else None, workstream_title=ws.title if ws else None,
workstream_slug=ws.slug 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)) task = min(todo_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
if task.id in seen_task_ids: if task.id in seen_task_ids:
continue continue
topic = await session.get(Topic, from_ws.topic_id) domain_slug = await _get_domain_slug_for_workstream(from_ws, session)
blocker_slugs = ", ".join( blocker_slugs = ", ".join(
(await session.get(Workstream, tid)).slug (await session.get(Workstream, tid)).slug
for tid in to_ws_ids for tid in to_ws_ids
@@ -290,7 +346,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
) )
steps.append(NextStep( steps.append(NextStep(
type="dependency_cleared", type="dependency_cleared",
domain=topic.domain if topic else None, domain=domain_slug,
workstream_id=from_ws.id, workstream_id=from_ws.id,
workstream_title=from_ws.title, workstream_title=from_ws.title,
workstream_slug=from_ws.slug, workstream_slug=from_ws.slug,
@@ -306,6 +362,17 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
return steps 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]) @router.get("/next_steps", response_model=list[NextStep])
async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[NextStep]: async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[NextStep]:
"""Derive contextual next-action suggestions from current hub state. """Derive contextual next-action suggestions from current hub state.

View File

@@ -5,12 +5,19 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session from api.database import get_session
from api.models.domain import Domain
from api.models.technical_debt import TDStatus, TechnicalDebt from api.models.technical_debt import TDStatus, TechnicalDebt
from api.schemas.technical_debt import TDCreate, TDRead, TDUpdate from api.schemas.technical_debt import TDCreate, TDRead, TDUpdate
router = APIRouter(prefix="/technical-debt", tags=["technical-debt"]) 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]) @router.get("/", response_model=list[TDRead])
async def list_td( async def list_td(
domain: str | None = None, domain: str | None = None,
@@ -38,6 +45,12 @@ async def create_td(
body: TDCreate, body: TDCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> TechnicalDebt: ) -> 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()) td = TechnicalDebt(**body.model_dump())
session.add(td) session.add(td)
await session.commit() await session.commit()

View File

@@ -5,12 +5,22 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session from api.database import get_session
from api.models.domain import Domain
from api.models.topic import Topic, TopicStatus from api.models.topic import Topic, TopicStatus
from api.schemas.topic import TopicCreate, TopicRead, TopicUpdate, TopicWithWorkstreams from api.schemas.topic import TopicCreate, TopicRead, TopicUpdate, TopicWithWorkstreams
router = APIRouter(prefix="/topics", tags=["topics"]) 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]) @router.get("/", response_model=list[TopicRead])
async def list_topics( async def list_topics(
status: TopicStatus | None = None, status: TopicStatus | None = None,
@@ -29,7 +39,14 @@ async def create_topic(
body: TopicCreate, body: TopicCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> Topic: ) -> 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) session.add(topic)
await session.commit() await session.commit()
await session.refresh(topic) await session.refresh(topic)
@@ -56,7 +73,10 @@ async def update_topic(
topic = await session.get(Topic, topic_id) topic = await session.get(Topic, topic_id)
if topic is None: if topic is None:
raise HTTPException(status_code=404, detail="Topic not found") 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) setattr(topic, field, value)
await session.commit() await session.commit()
await session.refresh(topic) await session.refresh(topic)

61
api/schemas/domain.py Normal file
View File

@@ -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

View File

@@ -5,10 +5,6 @@ from pydantic import BaseModel, ConfigDict
from api.models.extension_point import EPStatus from api.models.extension_point import EPStatus
VALID_DOMAINS = {
"custodian", "railiance", "markitect",
"coulomb_social", "personhood", "foerster_capabilities",
}
VALID_PRIORITIES = {"low", "medium", "high", "critical"} VALID_PRIORITIES = {"low", "medium", "high", "critical"}

View File

@@ -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

View File

@@ -4,6 +4,7 @@ from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel
from api.schemas.decision import DecisionRead from api.schemas.decision import DecisionRead
from api.schemas.domain import DomainSummary
from api.schemas.progress_event import ProgressEventRead from api.schemas.progress_event import ProgressEventRead
from api.schemas.task import TaskRead from api.schemas.task import TaskRead
from api.schemas.topic import TopicWithWorkstreams from api.schemas.topic import TopicWithWorkstreams
@@ -75,3 +76,4 @@ class StateSummary(BaseModel):
recent_progress: list[ProgressEventRead] recent_progress: list[ProgressEventRead]
open_workstreams: list[WorkstreamWithDeps] open_workstreams: list[WorkstreamWithDeps]
next_steps: list[NextStep] = [] next_steps: list[NextStep] = []
domains: list[DomainSummary] = []

View File

@@ -3,22 +3,22 @@ from datetime import datetime
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from api.models.topic import Domain, TopicStatus from api.models.topic import TopicStatus
class TopicCreate(BaseModel): class TopicCreate(BaseModel):
slug: str slug: str
title: str title: str
description: str | None = None description: str | None = None
domain: Domain domain: str # domain slug — resolved to domain_id in the router
status: TopicStatus = TopicStatus.active status: TopicStatus = TopicStatus.active
class TopicUpdate(BaseModel): class TopicUpdate(BaseModel):
title: str | None = None title: str | None = None
description: str | None = None description: str | None = None
domain: Domain | None = None
status: TopicStatus | None = None status: TopicStatus | None = None
domain: str | None = None # domain slug — resolved to domain_id in the router
class WorkstreamStub(BaseModel): class WorkstreamStub(BaseModel):
@@ -37,7 +37,7 @@ class TopicRead(BaseModel):
slug: str slug: str
title: str title: str
description: str | None = None description: str | None = None
domain: Domain domain_slug: str | None = None # resolved from FK relationship via @property
status: TopicStatus status: TopicStatus
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@@ -7,6 +7,7 @@ export default {
{ name: "Tasks", path: "/tasks" }, { name: "Tasks", path: "/tasks" },
{ name: "Decisions", path: "/decisions" }, { name: "Decisions", path: "/decisions" },
{ name: "Progress", path: "/progress" }, { name: "Progress", path: "/progress" },
{ name: "Domains", path: "/domains" },
{ name: "Extension Points", path: "/extensions" }, { name: "Extension Points", path: "/extensions" },
{ name: "Technical Debt", path: "/techdept" }, { name: "Technical Debt", path: "/techdept" },
{ {

View File

@@ -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": []}))

View File

@@ -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": []}))

149
dashboard/src/domains.md Normal file
View File

@@ -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`<div class="live-indicator">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok
? `Live · updated ${_ts?.toLocaleTimeString()}`
: html`<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`;
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`<div class="kpi-row-top">
<div class="kpi-card">
<div class="kpi-card-value">${domains.length}</div>
<div class="kpi-card-label">total domains</div>
</div>
<div class="kpi-card">
<div class="kpi-card-value">${activeDomains.length}</div>
<div class="kpi-card-label">active</div>
</div>
<div class="kpi-card">
<div class="kpi-card-value">${repos.length}</div>
<div class="kpi-card-label">total repos</div>
</div>
<div class="kpi-card">
<div class="kpi-card-value">${newestDomain?.name ?? "—"}</div>
<div class="kpi-card-label">newest domain</div>
</div>
</div>`);
```
## 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`<p class="dim">No domains found. API may be offline.</p>`);
} else {
display(html`<div class="domain-grid">${domains.map(d => {
const domainRepos = reposByDomain[d.id] ?? [];
return html`<div class="domain-card domain-status-${d.status} entity-row"
title="Click to view details">
<div class="domain-card-header">
<span class="domain-slug">${d.slug}</span>
<span class="domain-status-badge domain-status-badge-${d.status}">${d.status}</span>
</div>
<div class="domain-name">${d.name}</div>
${d.description ? html`<div class="domain-desc">${d.description.slice(0, 160)}${d.description.length > 160 ? " …" : ""}</div>` : ""}
<div class="domain-repos">
${domainRepos.length === 0
? html`<span class="no-repos">no repos registered</span>`
: domainRepos.map(r => html`<div class="repo-row">
<span class="repo-name">${r.name}</span>
${r.local_path ? html`<code class="repo-path">${r.local_path}</code>` : ""}
${r.remote_url ? html`<a class="repo-url" href=${r.remote_url} target="_blank">${r.remote_url.replace(/^https?:\/\//, "")}</a>` : ""}
</div>`)
}
</div>
</div>`;
})}</div>`);
}
```
<style>
/* ── Live indicator ───────────────────────────────────────────────────────── */
.live-indicator { font-size: 0.8rem; color: gray; position: relative; padding: 0.55rem 1.8rem 0.55rem 0.7rem; margin-bottom: 0.75rem; }
/* ── KPI row ─────────────────────────────────────────────────────────────── */
.kpi-row-top { display: flex; flex-wrap: wrap; gap: 1rem; margin-bottom: 1.5rem; }
.kpi-card { background: var(--theme-background-alt); border: 1px solid var(--theme-foreground-faint, #e0e0e0); border-radius: 10px; padding: 0.75rem 1.25rem; min-width: 120px; text-align: center; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.kpi-card-value { font-size: 1.6rem; font-weight: 700; line-height: 1.2; }
.kpi-card-label { font-size: 0.72rem; color: var(--theme-foreground-muted, #888); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 0.2rem; }
/* ── Domain grid ─────────────────────────────────────────────────────────── */
.domain-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
.domain-card { border: 1px solid var(--theme-foreground-faint, #e0e0e0); border-radius: 10px; padding: 1rem 1.2rem; background: var(--theme-background-alt); }
.domain-card.entity-row { cursor: default; }
.domain-status-archived { opacity: 0.6; }
.domain-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; }
.domain-slug { font-family: monospace; font-size: 0.8rem; color: var(--theme-foreground-muted); background: var(--theme-background); border: 1px solid var(--theme-foreground-faint, #ddd); border-radius: 4px; padding: 0.1rem 0.4rem; }
.domain-status-badge { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; padding: 0.1rem 0.45rem; border-radius: 8px; letter-spacing: 0.04em; }
.domain-status-badge-active { background: #dcfce7; color: #166534; }
.domain-status-badge-archived { background: #f1f5f9; color: #64748b; }
.domain-name { font-size: 1.1rem; font-weight: 700; margin-bottom: 0.3rem; }
.domain-desc { font-size: 0.82rem; color: var(--theme-foreground-muted); line-height: 1.4; margin-bottom: 0.6rem; }
/* ── Repo list ───────────────────────────────────────────────────────────── */
.domain-repos { border-top: 1px solid var(--theme-foreground-faint, #eee); padding-top: 0.5rem; margin-top: 0.5rem; display: flex; flex-direction: column; gap: 0.4rem; }
.no-repos { font-size: 0.78rem; color: var(--theme-foreground-faint); font-style: italic; }
.repo-row { display: flex; flex-direction: column; gap: 0.1rem; }
.repo-name { font-size: 0.85rem; font-weight: 600; }
.repo-path { font-size: 0.72rem; color: var(--theme-foreground-muted); }
.repo-url { font-size: 0.72rem; color: var(--theme-foreground-focus); text-decoration: none; }
.repo-url:hover { text-decoration: underline; }
/* ── Utility ─────────────────────────────────────────────────────────────── */
.dim { color: gray; font-style: italic; }
</style>

View File

@@ -22,7 +22,7 @@ const epState = (async function*() {
const [epList, wsList, topicList] = await Promise.all([re.json(), rw.json(), rt.json()]); const [epList, wsList, topicList] = await Promise.all([re.json(), rw.json(), rt.json()]);
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); const topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
const wsMap = Object.fromEntries(wsList.map(w => [w.id, { 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 => ({ data = epList.map(e => ({
...e, ...e,
@@ -52,7 +52,10 @@ import {MultiSelect} from "./components/multiselect.js";
const STATUSES = ["open", "in_progress", "addressed", "deferred", "wont_fix"]; const STATUSES = ["open", "in_progress", "addressed", "deferred", "wont_fix"];
const PRIORITIES = ["critical", "high", "medium", "low"]; 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 EP_TYPES = ["api", "schema", "mcp", "dashboard", "architecture", "integration", "other"];
const _filtersForm = Inputs.form( const _filtersForm = Inputs.form(

View File

@@ -22,7 +22,7 @@ const tdState = (async function*() {
const [tdList, wsList, topicList] = await Promise.all([rt.json(), rw.json(), rto.json()]); const [tdList, wsList, topicList] = await Promise.all([rt.json(), rw.json(), rto.json()]);
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); const topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
const wsMap = Object.fromEntries(wsList.map(w => [w.id, { 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 => ({ data = tdList.map(t => ({
...t, ...t,
@@ -52,7 +52,10 @@ import {MultiSelect} from "./components/multiselect.js";
const STATUSES = ["open", "in_progress", "resolved", "deferred", "wont_fix"]; const STATUSES = ["open", "in_progress", "resolved", "deferred", "wont_fix"];
const SEVERITIES = ["critical", "high", "medium", "low"]; 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 DEBT_TYPES = ["design", "implementation", "test", "docs", "dependencies", "performance", "security", "other"];
const _filtersForm = Inputs.form( const _filtersForm = Inputs.form(

View File

@@ -24,7 +24,7 @@ const wsState = (async function*() {
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); const topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
data = wsList.map(w => ({ data = wsList.map(w => ({
...w, ...w,
domain: topicMap[w.topic_id]?.domain ?? "unknown", domain: topicMap[w.topic_id]?.domain_slug ?? "unknown",
topic_title: topicMap[w.topic_id]?.title ?? "—", topic_title: topicMap[w.topic_id]?.title ?? "—",
})); }));
// open_workstreams from summary carry depends_on / blocks lists // open_workstreams from summary carry depends_on / blocks lists
@@ -214,8 +214,11 @@ if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/workstreams"
```js ```js
import {MultiSelect} from "./components/multiselect.js"; import {MultiSelect} from "./components/multiselect.js";
// Static options — no dependency on `data`, so selections survive polls // Load domain slugs from API (dynamic — works with new domains after v0.5)
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 STATUSES = ["active", "blocked", "completed", "archived"]; const STATUSES = ["active", "blocked", "completed", "archived"];
// Create filter form without displaying — shown below the chart // Create filter form without displaying — shown below the chart

View File

@@ -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 ## 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`
--- ---

View File

@@ -630,6 +630,124 @@ def update_td_status(td_uuid: str, status: str) -> str:
return json.dumps(td, indent=2) 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 # ADR-001 compliance validation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -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")

View File

@@ -1,33 +1,39 @@
#!/usr/bin/env bash #!/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> <project_path> # Usage: scripts/register_project.sh <domain> <project_path> [--additional]
# domain: one of custodian|railiance|markitect|coulomb_social|personhood|foerster_capabilities # domain: slug of an active domain (e.g. custodian, railiance)
# project_path: absolute path to the project directory # project_path: absolute path to the project directory
# --additional: add a second repo to an existing domain; skip CLAUDE.md
# #
# Example: # Example:
# scripts/register_project.sh railiance /home/worsch/railiance # scripts/register_project.sh railiance /home/worsch/railiance
# scripts/register_project.sh railiance /home/worsch/railiance-infra --additional
# #
# What it does: # What it does:
# 1. Verify the API is reachable # 1. Verify the API is reachable
# 2. Look up the topic ID for the domain # 2. Verify the domain exists via GET /domains/{slug}/
# 3. Check that state-hub is in ~/.claude.json; warn if missing # 3. Look up the topic ID for the domain (first active topic)
# 4. Write $project_path/CLAUDE.md from the template (skip if exists) # 4. Check that state-hub is in ~/.claude.json; warn if missing
# 5. POST a progress event recording the registration # 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 set -euo pipefail
DOMAIN="${1:-}" DOMAIN="${1:-}"
PROJECT_PATH="${2:-}" PROJECT_PATH="${2:-}"
ADDITIONAL="${3:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STATE_HUB_DIR="$(dirname "$SCRIPT_DIR")" STATE_HUB_DIR="$(dirname "$SCRIPT_DIR")"
API_BASE="${API_BASE:-http://127.0.0.1:8000}" API_BASE="${API_BASE:-http://127.0.0.1:8000}"
# ── Validate args ────────────────────────────────────────────────────────────── # ── Validate args ──────────────────────────────────────────────────────────────
if [[ -z "$DOMAIN" || -z "$PROJECT_PATH" ]]; then if [[ -z "$DOMAIN" || -z "$PROJECT_PATH" ]]; then
echo "Usage: $0 <domain> <project_path>" echo "Usage: $0 <domain> <project_path> [--additional]"
echo " domain: custodian|railiance|markitect|coulomb_social|personhood|foerster_capabilities" echo " domain: slug of an active domain in the State Hub"
echo " project_path: absolute path to project directory" echo " project_path: absolute path to project directory"
echo " --additional: register a second repo; skip CLAUDE.md generation"
exit 1 exit 1
fi fi
@@ -37,6 +43,7 @@ if [[ ! -d "$PROJECT_PATH" ]]; then
fi fi
PROJECT_NAME="$(basename "$PROJECT_PATH")" 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 ─────────────────────────────────────────────────── # ── Step 1: API health check ───────────────────────────────────────────────────
echo "==> Checking API at $API_BASE ..." echo "==> Checking API at $API_BASE ..."
@@ -48,14 +55,27 @@ if ! curl -sf "$API_BASE/state/health" > /dev/null; then
fi fi
echo " API OK" 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=\"<display 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' ..." echo "==> Looking up topic for domain '$DOMAIN' ..."
TOPICS_JSON="$(curl -sf "$API_BASE/topics/?status=active")" TOPICS_JSON="$(curl -sf "$API_BASE/topics/?status=active")"
TOPIC_ID="$(echo "$TOPICS_JSON" | python3 -c " TOPIC_ID="$(echo "$TOPICS_JSON" | python3 -c "
import json, sys import json, sys
topics = json.load(sys.stdin) 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: if not match:
print('NOT_FOUND') print('NOT_FOUND')
else: else:
@@ -63,13 +83,13 @@ else:
" "$DOMAIN")" " "$DOMAIN")"
if [[ "$TOPIC_ID" == "NOT_FOUND" ]]; then if [[ "$TOPIC_ID" == "NOT_FOUND" ]]; then
echo "ERROR: No active topic found for domain '$DOMAIN'." echo "WARNING: No active topic found for domain '$DOMAIN'. CLAUDE.md will omit topic_id."
echo " Known domains: custodian railiance markitect coulomb_social personhood foerster_capabilities" TOPIC_ID=""
exit 1 else
echo " topic_id: $TOPIC_ID"
fi fi
echo " topic_id: $TOPIC_ID"
# ── Step 3: Check MCP registration ──────────────────────────────────────────── # ── Step 4: Check MCP registration ────────────────────────────────────────────
echo "==> Checking MCP server registration ..." echo "==> Checking MCP server registration ..."
MCP_OK="$(python3 -c " MCP_OK="$(python3 -c "
import json import json
@@ -85,10 +105,6 @@ else:
if [[ "$MCP_OK" == "MISSING_FILE" ]]; then if [[ "$MCP_OK" == "MISSING_FILE" ]]; then
echo "WARNING: ~/.claude.json not found. MCP server is not registered." 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 elif [[ "$MCP_OK" == "NOT_REGISTERED" ]]; then
echo "WARNING: 'state-hub' not found in ~/.claude.json." echo "WARNING: 'state-hub' not found in ~/.claude.json."
echo " To register, see CLAUDE.md MCP Server Registration section." echo " To register, see CLAUDE.md MCP Server Registration section."
@@ -96,11 +112,13 @@ else
echo " MCP OK" echo " MCP OK"
fi fi
# ── Step 4: Write CLAUDE.md ──────────────────────────────────────────────────── # ── Step 5: Write CLAUDE.md ────────────────────────────────────────────────────
CLAUDE_MD="$PROJECT_PATH/CLAUDE.md" CLAUDE_MD="$PROJECT_PATH/CLAUDE.md"
TEMPLATE="$SCRIPT_DIR/project_claude_md.template" 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." echo "==> CLAUDE.md already exists at $CLAUDE_MD — skipping."
else else
echo "==> Writing CLAUDE.md to $CLAUDE_MD ..." echo "==> Writing CLAUDE.md to $CLAUDE_MD ..."
@@ -112,12 +130,35 @@ else
echo " Written." echo " Written."
fi 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 ..." echo "==> Recording registration event ..."
EVENT_JSON="$(python3 -c " EVENT_JSON="$(python3 -c "
import json import json
payload = { payload = {
'topic_id': '$TOPIC_ID', $([ -n '$TOPIC_ID' ] && echo "'topic_id': '$TOPIC_ID',")
'event_type': 'milestone', 'event_type': 'milestone',
'summary': 'Project registered with State Hub: $PROJECT_NAME ($DOMAIN)', 'summary': 'Project registered with State Hub: $PROJECT_NAME ($DOMAIN)',
'author': 'custodian', 'author': 'custodian',
@@ -125,6 +166,7 @@ payload = {
'project_path': '$PROJECT_PATH', 'project_path': '$PROJECT_PATH',
'claude_md': '$CLAUDE_MD', 'claude_md': '$CLAUDE_MD',
'domain': '$DOMAIN', 'domain': '$DOMAIN',
'repo_slug': '$REPO_SLUG',
}, },
} }
print(json.dumps(payload)) print(json.dumps(payload))
@@ -139,7 +181,8 @@ echo ""
echo "Registration complete!" echo "Registration complete!"
echo " Project: $PROJECT_NAME" echo " Project: $PROJECT_NAME"
echo " Domain: $DOMAIN" 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 " CLAUDE.md: $CLAUDE_MD"
echo "" echo ""
echo "Next: restart Claude Code for the MCP server to be available in this project." echo "Next: restart Claude Code for the MCP server to be available in this project."

View File

@@ -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 asyncio
import sys import sys
from pathlib import Path from pathlib import Path
@@ -10,7 +10,17 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from api.database import async_session_factory, engine 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 = [ TOPICS = [
{ {
@@ -20,7 +30,7 @@ TOPICS = [
"Master agent system: transgenerational cognitive infrastructure for " "Master agent system: transgenerational cognitive infrastructure for "
"co-creating and stewarding knowledge across all domains." "co-creating and stewarding knowledge across all domains."
), ),
"domain": Domain.custodian, "domain_slug": "custodian",
}, },
{ {
"slug": "railiance", "slug": "railiance",
@@ -29,7 +39,7 @@ TOPICS = [
"DevOps & infrastructure reliability. Dependency for all other projects; " "DevOps & infrastructure reliability. Dependency for all other projects; "
"provides the deployment and operational backbone." "provides the deployment and operational backbone."
), ),
"domain": Domain.railiance, "domain_slug": "railiance",
}, },
{ {
"slug": "markitect", "slug": "markitect",
@@ -38,7 +48,7 @@ TOPICS = [
"Knowledge artifact management: structured authoring, versioning, and " "Knowledge artifact management: structured authoring, versioning, and "
"retrieval of canonical documents." "retrieval of canonical documents."
), ),
"domain": Domain.markitect, "domain_slug": "markitect",
}, },
{ {
"slug": "coulomb-social", "slug": "coulomb-social",
@@ -47,7 +57,7 @@ TOPICS = [
"Co-creation marketplace experiment: connecting people around shared " "Co-creation marketplace experiment: connecting people around shared "
"projects and complementary capabilities." "projects and complementary capabilities."
), ),
"domain": Domain.coulomb_social, "domain_slug": "coulomb_social",
}, },
{ {
"slug": "personhood", "slug": "personhood",
@@ -56,7 +66,7 @@ TOPICS = [
"Rights and obligations framework: defining digital personhood, consent " "Rights and obligations framework: defining digital personhood, consent "
"models, and data sovereignty." "models, and data sovereignty."
), ),
"domain": Domain.personhood, "domain_slug": "personhood",
}, },
{ {
"slug": "foerster-capabilities", "slug": "foerster-capabilities",
@@ -65,29 +75,48 @@ TOPICS = [
"Agency capability taxonomy inspired by Heinz von Foerster: mapping the " "Agency capability taxonomy inspired by Heinz von Foerster: mapping the "
"space of possible cognitive and social actions." "space of possible cognitive and social actions."
), ),
"domain": Domain.foerster_capabilities, "domain_slug": "foerster_capabilities",
}, },
] ]
async def seed() -> None: async def seed() -> None:
async with async_session_factory() as session: 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: for data in TOPICS:
existing = await session.execute( existing = await session.execute(
select(Topic).where(Topic.slug == data["slug"]) select(Topic).where(Topic.slug == data["slug"])
) )
if existing.scalar_one_or_none() is not None: if existing.scalar_one_or_none() is not None:
print(f" skip (already exists): {data['slug']}") print(f" skip topic (exists): {data['slug']}")
continue continue
domain = domain_by_slug[data["domain_slug"]]
topic = Topic( topic = Topic(
slug=data["slug"], slug=data["slug"],
title=data["title"], title=data["title"],
description=data["description"], description=data["description"],
domain=data["domain"], domain_id=domain.id,
status=TopicStatus.active, status=TopicStatus.active,
) )
session.add(topic) session.add(topic)
print(f" insert: {data['slug']}") print(f" insert topic: {data['slug']}")
await session.commit() await session.commit()
await engine.dispose() await engine.dispose()
print("Seed complete.") print("Seed complete.")