Add state-hub v0.1 — local-first state service for the Custodian

Implements the first live layer of the Custodian cognitive infrastructure:
PostgreSQL schema, FastAPI REST API, FastMCP stdio server, and Observable
Framework telemetry dashboard.

- state-hub/: full stack (docker-compose, FastAPI, Alembic, MCP server, dashboard)
- 5 DB tables: topics, workstreams, tasks, decisions, progress_events
- 11 MCP tools + 5 resources registered in .mcp.json
- Observable dashboard: Overview, Workstreams, Decisions, Progress pages
- CLAUDE.md: session protocol (get_state_summary / add_progress_event ritual)
- ~/.claude/CLAUDE.md: global cross-project reference to the hub
- scripts/pull_image.py: WSL2 TLS-resilient Docker image downloader

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 17:47:49 +01:00
commit 0ea2788943
48 changed files with 8567 additions and 0 deletions

15
api/schemas/__init__.py Normal file
View File

@@ -0,0 +1,15 @@
from api.schemas.topic import TopicCreate, TopicUpdate, TopicRead, TopicWithWorkstreams
from api.schemas.workstream import WorkstreamCreate, WorkstreamUpdate, WorkstreamRead
from api.schemas.task import TaskCreate, TaskUpdate, TaskRead
from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead
from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead
from api.schemas.state import StateSummary, Totals, TopicTotals, WorkstreamTotals, TaskTotals, DecisionTotals
__all__ = [
"TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams",
"WorkstreamCreate", "WorkstreamUpdate", "WorkstreamRead",
"TaskCreate", "TaskUpdate", "TaskRead",
"DecisionCreate", "DecisionUpdate", "DecisionRead",
"ProgressEventCreate", "ProgressEventRead",
"StateSummary", "Totals", "TopicTotals", "WorkstreamTotals", "TaskTotals", "DecisionTotals",
]

58
api/schemas/decision.py Normal file
View File

@@ -0,0 +1,58 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, model_validator
from api.models.decision import DecisionStatus, DecisionType
class DecisionCreate(BaseModel):
topic_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
title: str
description: str | None = None
decision_type: DecisionType = DecisionType.pending
status: DecisionStatus = DecisionStatus.open
rationale: str | None = None
decided_by: str | None = None
decided_at: datetime | None = None
deadline: datetime | None = None
escalation_note: str | None = None
@model_validator(mode="after")
def topic_or_workstream_required(self) -> "DecisionCreate":
if self.topic_id is None and self.workstream_id is None:
raise ValueError("At least one of topic_id or workstream_id must be set")
return self
class DecisionUpdate(BaseModel):
title: str | None = None
description: str | None = None
decision_type: DecisionType | None = None
status: DecisionStatus | None = None
rationale: str | None = None
decided_by: str | None = None
decided_at: datetime | None = None
deadline: datetime | None = None
escalation_note: str | None = None
superseded_by: uuid.UUID | None = None
class DecisionRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
topic_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
title: str
description: str | None = None
decision_type: DecisionType
status: DecisionStatus
rationale: str | None = None
decided_by: str | None = None
decided_at: datetime | None = None
deadline: datetime | None = None
escalation_note: str | None = None
superseded_by: uuid.UUID | None = None
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,32 @@
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict
class ProgressEventCreate(BaseModel):
topic_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
task_id: uuid.UUID | None = None
decision_id: uuid.UUID | None = None
event_type: str
summary: str
detail: dict[str, Any] | None = None
author: str | None = None
session_id: str | None = None
class ProgressEventRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
topic_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
task_id: uuid.UUID | None = None
decision_id: uuid.UUID | None = None
event_type: str
summary: str
detail: dict[str, Any] | None = None
author: str | None = None
session_id: str | None = None
created_at: datetime

58
api/schemas/state.py Normal file
View File

@@ -0,0 +1,58 @@
from datetime import datetime
from pydantic import BaseModel
from api.schemas.decision import DecisionRead
from api.schemas.progress_event import ProgressEventRead
from api.schemas.task import TaskRead
from api.schemas.topic import TopicWithWorkstreams
from api.schemas.workstream import WorkstreamRead
class TopicTotals(BaseModel):
active: int = 0
paused: int = 0
archived: int = 0
total: int = 0
class WorkstreamTotals(BaseModel):
active: int = 0
blocked: int = 0
completed: int = 0
archived: int = 0
total: int = 0
class TaskTotals(BaseModel):
todo: int = 0
in_progress: int = 0
blocked: int = 0
done: int = 0
cancelled: int = 0
total: int = 0
class DecisionTotals(BaseModel):
open: int = 0
resolved: int = 0
escalated: int = 0
superseded: int = 0
total: int = 0
class Totals(BaseModel):
topics: TopicTotals
workstreams: WorkstreamTotals
tasks: TaskTotals
decisions: DecisionTotals
class StateSummary(BaseModel):
generated_at: datetime
totals: Totals
topics: list[TopicWithWorkstreams]
blocking_decisions: list[DecisionRead]
blocked_tasks: list[TaskRead]
recent_progress: list[ProgressEventRead]
open_workstreams: list[WorkstreamRead]

51
api/schemas/task.py Normal file
View File

@@ -0,0 +1,51 @@
import uuid
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, model_validator
from api.models.task import TaskPriority, TaskStatus
class TaskCreate(BaseModel):
workstream_id: uuid.UUID
title: str
description: str | None = None
status: TaskStatus = TaskStatus.todo
priority: TaskPriority = TaskPriority.medium
assignee: str | None = None
due_date: date | None = None
blocking_reason: str | None = None
parent_task_id: uuid.UUID | None = None
class TaskUpdate(BaseModel):
title: str | None = None
description: str | None = None
status: TaskStatus | None = None
priority: TaskPriority | None = None
assignee: str | None = None
due_date: date | None = None
blocking_reason: str | None = None
parent_task_id: uuid.UUID | None = None
@model_validator(mode="after")
def blocking_reason_required_when_blocked(self) -> "TaskUpdate":
if self.status == TaskStatus.blocked and not self.blocking_reason:
raise ValueError("blocking_reason is required when status is blocked")
return self
class TaskRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
workstream_id: uuid.UUID
title: str
description: str | None = None
status: TaskStatus
priority: TaskPriority
assignee: str | None = None
due_date: date | None = None
blocking_reason: str | None = None
parent_task_id: uuid.UUID | None = None
created_at: datetime
updated_at: datetime

47
api/schemas/topic.py Normal file
View File

@@ -0,0 +1,47 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from api.models.topic import Domain, TopicStatus
class TopicCreate(BaseModel):
slug: str
title: str
description: str | None = None
domain: Domain
status: TopicStatus = TopicStatus.active
class TopicUpdate(BaseModel):
title: str | None = None
description: str | None = None
domain: Domain | None = None
status: TopicStatus | None = None
class WorkstreamStub(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
slug: str
title: str
status: str
owner: str | None = None
due_date: datetime | None = None
class TopicRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
slug: str
title: str
description: str | None = None
domain: Domain
status: TopicStatus
created_at: datetime
updated_at: datetime
class TopicWithWorkstreams(TopicRead):
workstreams: list[WorkstreamStub] = []

38
api/schemas/workstream.py Normal file
View File

@@ -0,0 +1,38 @@
import uuid
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict
from api.models.workstream import WorkstreamStatus
class WorkstreamCreate(BaseModel):
topic_id: uuid.UUID
slug: str
title: str
description: str | None = None
status: WorkstreamStatus = WorkstreamStatus.active
owner: str | None = None
due_date: date | None = None
class WorkstreamUpdate(BaseModel):
title: str | None = None
description: str | None = None
status: WorkstreamStatus | None = None
owner: str | None = None
due_date: date | None = None
class WorkstreamRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
topic_id: uuid.UUID
slug: str
title: str
description: str | None = None
status: WorkstreamStatus
owner: str | None = None
due_date: date | None = None
created_at: datetime
updated_at: datetime