feat(goals): add domain/repo goal tracking and update_workstream MCP tool

- Migration c5d6e7f8a9b0: domain_goals and repo_goals tables, repo_goal_id FK on workstreams
- DomainGoal: one active per domain (partial unique index), status active/archived/superseded
- RepoGoal: integer priority, status active/paused/completed/archived, optional domain_goal_id link
- WorkstreamUpdate schema and router extended with repo_goal_id and repo_goal_id filter
- 6 new MCP goal tools: create_domain_goal, get_domain_goals, activate_domain_goal, create_repo_goal, get_repo_goals, update_repo_goal
- update_workstream MCP tool: patch any subset of workstream fields (title, description, owner, due_date, repo_goal_id, status)
- get_domain_summary extended with goal_guidance (needs_workplan, alignment_warnings) signals
- Dashboard goals.md page and docs/goals.md reference page
- CLAUDE.md template updated to act on goal_guidance signals at session start
- CUST-WP-0010 workplan for this feature

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 00:15:29 +01:00
parent 74872e0b6a
commit 35800a9f8f
21 changed files with 1289 additions and 10 deletions

View File

@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from api.database import engine
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
from api.routers import domains, repos, contributions, sbom, policy
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals
@asynccontextmanager
@@ -38,6 +38,8 @@ app.include_router(decisions.router)
app.include_router(extension_points.router)
app.include_router(technical_debt.router)
app.include_router(progress.router)
app.include_router(domain_goals.router)
app.include_router(repo_goals.router)
app.include_router(contributions.router)
app.include_router(sbom.router)
app.include_router(state.router)

View File

@@ -1,6 +1,9 @@
from api.models.base import Base
from api.models.domain import Domain
from api.models.domain_goal import DomainGoal, DomainGoalStatus
from api.models.topic import Topic, TopicStatus
from api.models.managed_repo import ManagedRepo
from api.models.repo_goal import RepoGoal, RepoGoalStatus
from api.models.workstream import Workstream, WorkstreamStatus
from api.models.workstream_dependency import WorkstreamDependency
from api.models.task import Task, TaskStatus, TaskPriority
@@ -8,7 +11,6 @@ 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
from api.models.contribution import Contribution, ContributionType, ContributionStatus
from api.models.sbom_snapshot import SBOMSnapshot
from api.models.sbom_entry import SBOMEntry, Ecosystem
@@ -16,7 +18,10 @@ from api.models.sbom_entry import SBOMEntry, Ecosystem
__all__ = [
"Base",
"Domain",
"DomainGoal", "DomainGoalStatus",
"Topic", "TopicStatus",
"ManagedRepo",
"RepoGoal", "RepoGoalStatus",
"Workstream", "WorkstreamStatus",
"WorkstreamDependency",
"Task", "TaskStatus", "TaskPriority",
@@ -24,7 +29,6 @@ __all__ = [
"ProgressEvent",
"ExtensionPoint", "EPStatus",
"TechnicalDebt", "TDStatus",
"ManagedRepo",
"Contribution", "ContributionType", "ContributionStatus",
"SBOMSnapshot",
"SBOMEntry", "Ecosystem",

View File

@@ -24,3 +24,6 @@ class Domain(Base, TimestampMixin):
repos: Mapped[list["ManagedRepo"]] = relationship( # noqa: F821
"ManagedRepo", back_populates="domain", lazy="selectin"
)
goals: Mapped[list["DomainGoal"]] = relationship( # noqa: F821
"DomainGoal", back_populates="domain", lazy="selectin"
)

View File

@@ -0,0 +1,41 @@
import enum
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 DomainGoalStatus(str, enum.Enum):
active = "active"
archived = "archived"
superseded = "superseded"
class DomainGoal(Base, TimestampMixin):
__tablename__ = "domain_goals"
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
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default=DomainGoalStatus.active.value, server_default="active"
)
domain: Mapped["Domain"] = relationship( # noqa: F821
"Domain", back_populates="goals", lazy="selectin"
)
repo_goals: Mapped[list["RepoGoal"]] = relationship( # noqa: F821
"RepoGoal", back_populates="domain_goal", lazy="selectin"
)
@property
def domain_slug(self) -> str:
return self.domain.slug if self.domain is not None else ""

View File

@@ -35,6 +35,10 @@ class ManagedRepo(Base, TimestampMixin):
"Domain", back_populates="repos", lazy="selectin"
)
goals: Mapped[list["RepoGoal"]] = relationship( # noqa: F821
"RepoGoal", back_populates="repo", lazy="selectin"
)
@property
def domain_slug(self) -> str:
return self.domain.slug if self.domain is not None else ""

View File

@@ -0,0 +1,49 @@
import enum
import uuid
from sqlalchemy import ForeignKey, Integer, 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 RepoGoalStatus(str, enum.Enum):
active = "active"
paused = "paused"
completed = "completed"
archived = "archived"
class RepoGoal(Base, TimestampMixin):
__tablename__ = "repo_goals"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
repo_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="RESTRICT"), nullable=False, index=True
)
domain_goal_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("domain_goals.id", ondelete="SET NULL"), nullable=True, index=True
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=100, server_default="100")
status: Mapped[str] = mapped_column(
String(20), nullable=False, default=RepoGoalStatus.active.value, server_default="active"
)
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
"ManagedRepo", back_populates="goals", lazy="selectin"
)
domain_goal: Mapped["DomainGoal"] = relationship( # noqa: F821
"DomainGoal", back_populates="repo_goals", lazy="selectin"
)
workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821
"Workstream", back_populates="repo_goal", lazy="selectin"
)
@property
def repo_slug(self) -> str:
return self.repo.slug if self.repo is not None else ""

View File

@@ -40,9 +40,16 @@ class Workstream(Base, TimestampMixin):
nullable=True,
index=True,
)
repo_goal_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("repo_goals.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
topic: Mapped["Topic"] = relationship("Topic", back_populates="workstreams") # noqa: F821
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
repo_goal: Mapped["RepoGoal"] = relationship("RepoGoal", back_populates="workstreams", lazy="selectin") # noqa: F821
tasks: Mapped[list["Task"]] = relationship( # noqa: F821
"Task", back_populates="workstream", lazy="selectin"
)

View File

@@ -0,0 +1,114 @@
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.domain_goal import DomainGoal, DomainGoalStatus # noqa: F401 (DomainGoalStatus used in activate)
from api.schemas.domain_goal import DomainGoalCreate, DomainGoalRead, DomainGoalUpdate
router = APIRouter(prefix="/domain-goals", tags=["domain-goals"])
async def _resolve_domain(domain_slug: str, session: AsyncSession) -> Domain:
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
@router.get("/", response_model=list[DomainGoalRead])
async def list_domain_goals(
domain_slug: str | None = None,
status: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[DomainGoal]:
q = select(DomainGoal)
if domain_slug:
domain = await _resolve_domain(domain_slug, session)
q = q.where(DomainGoal.domain_id == domain.id)
if status:
q = q.where(DomainGoal.status == status)
q = q.order_by(DomainGoal.created_at.desc())
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=DomainGoalRead, status_code=status.HTTP_201_CREATED)
async def create_domain_goal(
body: DomainGoalCreate,
session: AsyncSession = Depends(get_session),
) -> DomainGoal:
if body.status == DomainGoalStatus.active:
# Archive any existing active goal for this domain
existing = await session.execute(
select(DomainGoal).where(
DomainGoal.domain_id == body.domain_id,
DomainGoal.status == DomainGoalStatus.active,
)
)
for old in existing.scalars().all():
old.status = DomainGoalStatus.superseded
goal = DomainGoal(**body.model_dump())
session.add(goal)
await session.commit()
await session.refresh(goal)
return goal
@router.get("/{goal_id}", response_model=DomainGoalRead)
async def get_domain_goal(
goal_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> DomainGoal:
goal = await session.get(DomainGoal, goal_id)
if goal is None:
raise HTTPException(status_code=404, detail="Domain goal not found")
return goal
@router.patch("/{goal_id}", response_model=DomainGoalRead)
async def update_domain_goal(
goal_id: uuid.UUID,
body: DomainGoalUpdate,
session: AsyncSession = Depends(get_session),
) -> DomainGoal:
goal = await session.get(DomainGoal, goal_id)
if goal is None:
raise HTTPException(status_code=404, detail="Domain goal not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(goal, field, value)
await session.commit()
await session.refresh(goal)
return goal
@router.post("/{goal_id}/activate", response_model=DomainGoalRead)
async def activate_domain_goal(
goal_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> DomainGoal:
"""Set this goal as the active domain goal, superseding any currently active one."""
goal = await session.get(DomainGoal, goal_id)
if goal is None:
raise HTTPException(status_code=404, detail="Domain goal not found")
# Supersede any other active goal for this domain
existing = await session.execute(
select(DomainGoal).where(
DomainGoal.domain_id == goal.domain_id,
DomainGoal.status == DomainGoalStatus.active,
DomainGoal.id != goal_id,
)
)
for old in existing.scalars().all():
old.status = DomainGoalStatus.superseded
goal.status = DomainGoalStatus.active
await session.commit()
await session.refresh(goal)
return goal

View File

@@ -0,0 +1,79 @@
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.managed_repo import ManagedRepo
from api.models.repo_goal import RepoGoal, RepoGoalStatus
from api.schemas.repo_goal import RepoGoalCreate, RepoGoalRead, RepoGoalUpdate
router = APIRouter(prefix="/repo-goals", tags=["repo-goals"])
async def _resolve_repo(repo_slug: str, session: AsyncSession) -> ManagedRepo:
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == repo_slug))
repo = result.scalar_one_or_none()
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{repo_slug}' not found")
return repo
@router.get("/", response_model=list[RepoGoalRead])
async def list_repo_goals(
repo_slug: str | None = None,
domain_goal_id: uuid.UUID | None = None,
status: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[RepoGoal]:
q = select(RepoGoal)
if repo_slug:
repo = await _resolve_repo(repo_slug, session)
q = q.where(RepoGoal.repo_id == repo.id)
if domain_goal_id:
q = q.where(RepoGoal.domain_goal_id == domain_goal_id)
if status:
q = q.where(RepoGoal.status == status)
q = q.order_by(RepoGoal.priority.asc(), RepoGoal.created_at.asc())
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=RepoGoalRead, status_code=status.HTTP_201_CREATED)
async def create_repo_goal(
body: RepoGoalCreate,
session: AsyncSession = Depends(get_session),
) -> RepoGoal:
goal = RepoGoal(**body.model_dump())
session.add(goal)
await session.commit()
await session.refresh(goal)
return goal
@router.get("/{goal_id}", response_model=RepoGoalRead)
async def get_repo_goal(
goal_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> RepoGoal:
goal = await session.get(RepoGoal, goal_id)
if goal is None:
raise HTTPException(status_code=404, detail="Repo goal not found")
return goal
@router.patch("/{goal_id}", response_model=RepoGoalRead)
async def update_repo_goal(
goal_id: uuid.UUID,
body: RepoGoalUpdate,
session: AsyncSession = Depends(get_session),
) -> RepoGoal:
goal = await session.get(RepoGoal, goal_id)
if goal is None:
raise HTTPException(status_code=404, detail="Repo goal not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(goal, field, value)
await session.commit()
await session.refresh(goal)
return goal

View File

@@ -15,6 +15,7 @@ router = APIRouter(prefix="/workstreams", tags=["workstreams"])
async def list_workstreams(
topic_id: uuid.UUID | None = None,
repo_id: uuid.UUID | None = None,
repo_goal_id: uuid.UUID | None = None,
status: WorkstreamStatus | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Workstream]:
@@ -23,9 +24,11 @@ async def list_workstreams(
q = q.where(Workstream.topic_id == topic_id)
if repo_id:
q = q.where(Workstream.repo_id == repo_id)
if repo_goal_id:
q = q.where(Workstream.repo_goal_id == repo_goal_id)
if status:
q = q.where(Workstream.status == status)
q = q.order_by(Workstream.created_at)
q = q.order_by(Workstream.updated_at.desc())
result = await session.execute(q)
return list(result.scalars().all())

View File

@@ -0,0 +1,31 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from api.models.domain_goal import DomainGoalStatus
class DomainGoalCreate(BaseModel):
domain_id: uuid.UUID
title: str
description: str
status: str = DomainGoalStatus.active.value
class DomainGoalUpdate(BaseModel):
title: str | None = None
description: str | None = None
status: str | None = None
class DomainGoalRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
domain_id: uuid.UUID
domain_slug: str
title: str
description: str
status: str
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,37 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from api.models.repo_goal import RepoGoalStatus
class RepoGoalCreate(BaseModel):
repo_id: uuid.UUID
domain_goal_id: uuid.UUID | None = None
title: str
description: str
priority: int = 100
status: str = RepoGoalStatus.active.value
class RepoGoalUpdate(BaseModel):
title: str | None = None
description: str | None = None
priority: int | None = None
status: str | None = None
domain_goal_id: uuid.UUID | None = None
class RepoGoalRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
repo_id: uuid.UUID
repo_slug: str
domain_goal_id: uuid.UUID | None = None
title: str
description: str
priority: int
status: str
created_at: datetime
updated_at: datetime

View File

@@ -16,6 +16,7 @@ class WorkstreamCreate(BaseModel):
owner: str | None = None
due_date: date | None = None
repo_id: uuid.UUID | None = None # GEMS primary: the owning repository
repo_goal_id: uuid.UUID | None = None
class WorkstreamUpdate(BaseModel):
@@ -25,6 +26,7 @@ class WorkstreamUpdate(BaseModel):
owner: str | None = None
due_date: date | None = None
repo_id: uuid.UUID | None = None
repo_goal_id: uuid.UUID | None = None
class WorkstreamRead(BaseModel):
@@ -32,6 +34,7 @@ class WorkstreamRead(BaseModel):
id: uuid.UUID
topic_id: uuid.UUID
repo_id: uuid.UUID | None = None
repo_goal_id: uuid.UUID | None = None
slug: str
title: str
description: str | None = None

View File

@@ -8,6 +8,7 @@ export default {
// ── Organizational Entity Views ───────────────────────────────────────────
{ name: "Domains", path: "/domains" },
{ name: "Repos", path: "/repos" },
{ name: "Goals", path: "/goals" },
{
name: "Workstreams",
path: "/workstreams",
@@ -47,6 +48,7 @@ export default {
{ name: "Decisions", path: "/docs/decisions" },
{ name: "Dependencies", path: "/docs/dependencies" },
{ name: "Domains", path: "/docs/domains" },
{ name: "Goals", path: "/docs/goals" },
{ name: "Extension Points", path: "/docs/extensions" },
{ name: "Inter-Repo Communication", path: "/docs/inter-repo-communication" },
{ name: "Interventions", path: "/docs/interventions" },

View File

@@ -0,0 +1,136 @@
---
title: Goals — Reference
---
# Goals — Reference
The Goals page shows strategic intent at two levels — **domain** and **repository** — and how they relate. It provides context for why workstreams exist and what they collectively deliver.
---
## The two goal levels
### Domain Goal
A domain goal captures the **high-level strategic intent** for an entire domain (e.g., railiance, markitect). It answers the question: *"What are we ultimately trying to achieve here?"*
Key properties:
- Only **one domain goal can be active** per domain at any time.
- When a new goal is activated, the previous one is automatically marked **superseded** — it is retained as history, not deleted.
- Goals can also be manually set to **archived** to park them permanently.
### Repository Goal
A repository goal refines a domain goal into **actionable scope** for a specific repository. It answers: *"What does this repo need to deliver to advance the domain goal?"*
Key properties:
- A repo can have **multiple active goals** with different **priorities**.
- Priority is an integer — lower number means higher priority (e.g., `10` takes precedence over `100`).
- Each repo goal should be linked to its parent domain goal via `domain_goal_id`, though unlinked goals are also supported.
- Goals can shift between `active`, `paused`, `completed`, and `archived` as work evolves.
---
## Status lifecycle
### Domain goals
```
active ──→ superseded (auto, when a newer goal is activated)
active ──→ archived (manual, via PATCH /domain-goals/{id})
```
Only one goal per domain can hold `active` status. Activating a goal via `POST /domain-goals/{id}/activate` supersedes whatever was active before.
### Repository goals
```
active ──→ paused (work deprioritised, not abandoned)
active ──→ completed (the goal was achieved)
active ──→ archived (scope dropped or obsolete)
paused ──→ active (work resumed)
```
Completed and archived goals are preserved as history. They appear in the secondary accordion on the Goals page under the domain goal they were linked to.
---
## Page layout
The Goals page groups everything by domain:
```
┌─ railiance ──────────────────────────────────────────────────────┐
│ ┌─ Domain Goal ──────────────────────────── [active] ─────────┐ │
│ │ Three-Phoenix Secure Kubernetes Infrastructure │ │
│ │ Improve the railiance repositories so that I can … │ │
│ │ ───────────────────────────────────────────────────────── │ │
│ │ Repository Goals │ │
│ │ ┌─ #10 railiance-bootstrap ─────────── [active] ────────┐ │ │
│ │ │ Secure Single-Server Bootstrap at HostEurope │ │ │
│ │ │ Bootstrap a new server securely at hosteurope … │ │ │
│ │ └────────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ▶ 0 secondary goals │
└────────────────────────────────────────────────────────────────────┘
```
- The **active domain goal** is the primary card with a green left border.
- **Repository goals** are nested inside it, sorted by priority (lowest number first).
- **Secondary goals** (superseded and archived domain goals, with their repo goals) are collapsed into an accordion below the active goal. Click to expand.
- Domains that have no active goal are shown at the bottom and highlighted amber in the KPI box.
- **Unlinked repository goals** — active repo goals not associated with any domain goal — appear in a separate section at the bottom of the page.
---
## KPI sidebar
| Metric | Meaning |
|---|---|
| **domains with active goal** | How many of your active domains have a current strategic intent set |
| **active repo goals** | Total active repo goals across all repos |
| **no active goal** | Domains missing a goal — shown in amber when non-zero |
---
## MCP tools
| Tool | Description |
|---|---|
| `create_domain_goal(domain_slug, title, description)` | Create a new active goal for a domain (supersedes previous active) |
| `get_domain_goals(domain_slug, status?)` | List domain goals, optionally filtered by status |
| `activate_domain_goal(goal_id)` | Make an existing goal active, superseding the current one |
| `create_repo_goal(repo_slug, title, description, domain_goal_id?, priority?)` | Create a repo goal, optionally linked to a domain goal |
| `get_repo_goals(repo_slug, status?)` | List repo goals for a repository |
| `update_repo_goal(goal_id, ...)` | Update title, description, priority, status, or domain link |
---
## REST endpoints
| Method | Path | Effect |
|---|---|---|
| `GET` | `/domain-goals/` | List domain goals (filter: `domain_slug`, `status`) |
| `POST` | `/domain-goals/` | Create a domain goal |
| `GET` | `/domain-goals/{id}` | Get a single domain goal |
| `PATCH` | `/domain-goals/{id}` | Update title, description, or status |
| `POST` | `/domain-goals/{id}/activate` | Activate a goal (supersedes current active) |
| `GET` | `/repo-goals/` | List repo goals (filter: `repo_slug`, `domain_goal_id`, `status`) |
| `POST` | `/repo-goals/` | Create a repo goal |
| `GET` | `/repo-goals/{id}` | Get a single repo goal |
| `PATCH` | `/repo-goals/{id}` | Update any field |
---
## Linking workstreams to repo goals
Workstreams carry an optional `repo_goal_id` field. Setting it traces *why* a workstream exists — which specific repo goal it contributes to. This connection is currently recorded in the DB but is not yet visualised in the Workstreams page.
To set the link when creating a workstream via `create_workstream`, pass `repo_goal_id`. To update an existing one, use `PATCH /workstreams/{id}/` with `{"repo_goal_id": "<uuid>"}`.
---
## Design rationale
Goals are intentionally separate from workstreams. A workstream is a unit of *deliverable work*; a goal is a statement of *strategic intent*. Goals are stable and long-lived; workstreams are created, completed, and replaced as work advances. The goal hierarchy (domain → repo → workstream) provides the context needed to understand why any given piece of work exists.

View File

@@ -0,0 +1,325 @@
---
title: Goals
---
```js
const API = "http://127.0.0.1:8000";
const POLL = 20_000;
```
```js
const goalsState = (async function*() {
while (true) {
let domains = [], domainGoals = [], repoGoals = [], repos = [], ok = false;
try {
const [rd, rdg, rrg, rr] = await Promise.all([
fetch(`${API}/domains/?status=active`),
fetch(`${API}/domain-goals/`),
fetch(`${API}/repo-goals/`),
fetch(`${API}/repos/`),
]);
ok = rd.ok && rdg.ok && rrg.ok && rr.ok;
if (ok) {
[domains, domainGoals, repoGoals, repos] = await Promise.all([
rd.json(), rdg.json(), rrg.json(), rr.json(),
]);
}
} catch {}
yield {domains, domainGoals, repoGoals, repos, ok, ts: new Date()};
await new Promise(res => setTimeout(res, POLL));
}
})();
```
```js
const domains = goalsState.domains ?? [];
const domainGoals = goalsState.domainGoals ?? [];
const repoGoals = goalsState.repoGoals ?? [];
const repos = goalsState.repos ?? [];
const _ok = goalsState.ok ?? false;
const _ts = goalsState.ts;
```
```js
// ── Indexes ────────────────────────────────────────────────────────────────────
const repoById = Object.fromEntries(repos.map(r => [r.id, r]));
const domainById = Object.fromEntries(domains.map(d => [d.id, d]));
// Domain goals keyed by domain_id; active first, then superseded, then archived
const goalsByDomain = {};
for (const g of domainGoals) {
if (!goalsByDomain[g.domain_id]) goalsByDomain[g.domain_id] = [];
goalsByDomain[g.domain_id].push(g);
}
const STATUS_ORDER = {active: 0, superseded: 1, archived: 2};
for (const id of Object.keys(goalsByDomain)) {
goalsByDomain[id].sort((a, b) =>
(STATUS_ORDER[a.status] ?? 9) - (STATUS_ORDER[b.status] ?? 9)
);
}
// Repo goals keyed by domain_goal_id (primary) and repo_id (for unlinked)
const repoGoalsByDomainGoal = {};
const unlinkedRepoGoals = []; // active repo goals with no domain_goal_id
for (const rg of repoGoals) {
if (rg.domain_goal_id) {
if (!repoGoalsByDomainGoal[rg.domain_goal_id]) repoGoalsByDomainGoal[rg.domain_goal_id] = [];
repoGoalsByDomainGoal[rg.domain_goal_id].push(rg);
} else if (rg.status === "active") {
unlinkedRepoGoals.push(rg);
}
}
// Sort repo goals within each domain goal by priority asc
for (const id of Object.keys(repoGoalsByDomainGoal)) {
repoGoalsByDomainGoal[id].sort((a, b) => a.priority - b.priority);
}
// KPI
const domainsWithActiveGoal = domains.filter(d => (goalsByDomain[d.id] ?? []).some(g => g.status === "active"));
const domainsWithoutGoal = domains.filter(d => !(goalsByDomain[d.id] ?? []).some(g => g.status === "active"));
const totalActiveRepoGoals = repoGoals.filter(g => g.status === "active").length;
```
# Goals
```js
import {injectTocTop} from "./components/toc-sidebar.js";
import {withDocHelp} from "./components/doc-overlay.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>`;
withDocHelp(_liveEl, "/docs/live-data");
// ── KPI sidebar card ────────────────────────────────────────────────────────────
const _kpiBox = html`<div class="kpi-infobox">
<div class="kpi-infobox-title">Goals</div>
<div class="kpi-row">
<span class="kpi-row-label">domains with active goal</span>
<div class="kpi-row-right">
<div class="kpi-row-value">${domainsWithActiveGoal.length} / ${domains.length}</div>
</div>
</div>
<div class="kpi-row">
<span class="kpi-row-label">active repo goals</span>
<div class="kpi-row-right">
<div class="kpi-row-value">${totalActiveRepoGoals}</div>
</div>
</div>
${domainsWithoutGoal.length > 0 ? html`
<div class="kpi-block" style="border-top:1px solid var(--theme-foreground-faint,#eee);padding-top:0.4rem;margin-top:0.1rem">
<div class="kpi-row-label" style="color:#b45309;margin-bottom:0.25rem">no active goal</div>
<div class="kpi-slug-list">${domainsWithoutGoal.map(d => html`<div class="kpi-slug-item">${d.slug}</div>`)}</div>
</div>` : ""}
</div>`;
injectTocTop("goals-kpi-box", _kpiBox);
injectTocTop("live-indicator", _liveEl);
const _h1 = document.querySelector("#observablehq-main h1");
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/goals"); }
```
Strategic intent, organised by domain. Each domain has one **active** goal at a time; prior goals are retained as history. Repository goals inherit from a domain goal and refine it into actionable scope for a specific repo.
---
```js
// ── Repo goal card renderer ────────────────────────────────────────────────────
function renderRepoGoalCard(rg) {
const repo = repoById[rg.repo_id];
const STATUS_COLORS = {
active: {border: "#3b82f6", badge_bg: "#dbeafe", badge_fg: "#1e40af"},
paused: {border: "#f59e0b", badge_bg: "#fef3c7", badge_fg: "#92400e"},
completed: {border: "#22c55e", badge_bg: "#dcfce7", badge_fg: "#166534"},
archived: {border: "#94a3b8", badge_bg: "#f1f5f9", badge_fg: "#475569"},
};
const c = STATUS_COLORS[rg.status] ?? STATUS_COLORS.archived;
return html`<div class="repo-goal-card" style="border-left-color:${c.border}">
<div class="rg-header">
<span class="rg-priority" title="Priority (lower = higher priority)">#${rg.priority}</span>
<span class="rg-repo">${repo?.slug ?? rg.repo_id.slice(0,8)}</span>
<span class="status-badge" style="background:${c.badge_bg};color:${c.badge_fg}">${rg.status}</span>
</div>
<div class="rg-title">${rg.title}</div>
<div class="rg-desc">${rg.description}</div>
<div class="rg-meta">goal id: <code>${rg.id.slice(0,8)}…</code></div>
</div>`;
}
// ── Domain section renderer ────────────────────────────────────────────────────
function renderDomainSection(domain) {
const goals = goalsByDomain[domain.id] ?? [];
const activeGoal = goals.find(g => g.status === "active");
const secondaryGoals = goals.filter(g => g.status !== "active");
return html`<section class="domain-section">
<div class="domain-header">
<span class="domain-chip">${domain.slug}</span>
<span class="domain-name">${domain.name}</span>
</div>
${activeGoal ? html`
<!-- ── Active domain goal ─────────────────────────────────────────── -->
<div class="domain-goal-card dg-active">
<div class="dg-header">
<span class="dg-level-label">Domain Goal</span>
<span class="status-badge badge-active">active</span>
</div>
<div class="dg-title">${activeGoal.title}</div>
<div class="dg-desc">${activeGoal.description}</div>
<div class="dg-meta">
goal id: <code>${activeGoal.id.slice(0,8)}…</code> ·
set ${new Date(activeGoal.created_at).toLocaleDateString()}
</div>
${(repoGoalsByDomainGoal[activeGoal.id] ?? []).length > 0 ? html`
<div class="rg-section">
<div class="rg-section-label">Repository Goals</div>
${(repoGoalsByDomainGoal[activeGoal.id] ?? []).map(renderRepoGoalCard)}
</div>` : html`
<div class="rg-section rg-empty">No repository goals linked to this domain goal yet.</div>`}
</div>
` : html`
<div class="dg-empty">
<span class="dg-no-goal-label">No active goal set for this domain.</span>
</div>`}
${secondaryGoals.length > 0 ? html`
<!-- ── Secondary goals (superseded / archived) ────────────────────── -->
<details class="secondary-goals-details">
<summary class="secondary-goals-summary">
${secondaryGoals.length} secondary goal${secondaryGoals.length === 1 ? "" : "s"}
(${[...new Set(secondaryGoals.map(g => g.status))].join(", ")})
</summary>
<div class="secondary-goals-list">
${secondaryGoals.map(g => html`
<div class="domain-goal-card dg-secondary">
<div class="dg-header">
<span class="dg-level-label">Domain Goal</span>
<span class="status-badge badge-${g.status}">${g.status}</span>
</div>
<div class="dg-title">${g.title}</div>
<div class="dg-desc">${g.description}</div>
<div class="dg-meta">
goal id: <code>${g.id.slice(0,8)}…</code> ·
set ${new Date(g.created_at).toLocaleDateString()}
</div>
${(repoGoalsByDomainGoal[g.id] ?? []).length > 0 ? html`
<div class="rg-section rg-section-secondary">
<div class="rg-section-label">Repository Goals</div>
${(repoGoalsByDomainGoal[g.id] ?? []).map(renderRepoGoalCard)}
</div>` : ""}
</div>`)}
</div>
</details>` : ""}
</section>`;
}
// ── Main render ────────────────────────────────────────────────────────────────
if (!_ok) {
display(html`<p class="dim">API offline — run <code>make api</code> from state-hub/.</p>`);
} else if (domains.length === 0) {
display(html`<p class="dim">No active domains found.</p>`);
} else {
// Domains with active goal first, then those without
const sorted = [
...domainsWithActiveGoal.sort((a, b) => a.slug.localeCompare(b.slug)),
...domainsWithoutGoal.sort((a, b) => a.slug.localeCompare(b.slug)),
];
display(html`<div class="goals-root">${sorted.map(renderDomainSection)}</div>`);
}
```
```js
// ── Unlinked active repo goals ─────────────────────────────────────────────────
if (unlinkedRepoGoals.length > 0) {
display(html`
<hr/>
<h2>Unlinked Repository Goals</h2>
<p class="dim" style="margin-bottom:1rem">Active repo goals not yet associated with a domain goal.</p>
<div class="rg-list-unlinked">${unlinkedRepoGoals.map(renderRepoGoalCard)}</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 infobox ──────────────────────────────────────────────────────────── */
.kpi-infobox { background: var(--theme-background-alt, #f9f9f9); border: 1px solid var(--theme-foreground-faint, #e0e0e0); border-radius: 10px; padding: 0.75rem 1rem; position: relative; box-shadow: 0 1px 6px rgba(0,0,0,0.07); margin-bottom: 1.25rem; }
.kpi-infobox-title { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--theme-foreground-muted, #888); margin-bottom: 0.55rem; }
.kpi-row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 0.3rem 0; }
.kpi-row + .kpi-row { border-top: 1px solid var(--theme-foreground-faint, #eee); }
.kpi-row-label { font-size: 0.8rem; color: var(--theme-foreground-muted, #666); white-space: nowrap; }
.kpi-row-right { text-align: right; }
.kpi-row-value { font-size: 1.25rem; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.1; }
.kpi-block { }
.kpi-slug-list { display: flex; flex-direction: column; gap: 0.15rem; }
.kpi-slug-item { font-family: monospace; font-size: 0.78rem; color: #b45309; }
/* ── Layout ───────────────────────────────────────────────────────────────── */
.goals-root { display: flex; flex-direction: column; gap: 2rem; }
/* ── Domain section ───────────────────────────────────────────────────────── */
.domain-section { border: 1px solid var(--theme-foreground-faint, #e0e0e0); border-radius: 12px; overflow: hidden; }
.domain-header { display: flex; align-items: center; gap: 0.75rem; padding: 0.65rem 1.1rem; background: var(--theme-background-alt); border-bottom: 1px solid var(--theme-foreground-faint, #e0e0e0); }
.domain-chip { font-family: monospace; font-size: 0.8rem; background: var(--theme-background); border: 1px solid var(--theme-foreground-faint, #ddd); border-radius: 4px; padding: 0.1rem 0.5rem; color: var(--theme-foreground-muted); }
.domain-name { font-size: 0.95rem; font-weight: 700; }
/* ── Domain goal card ─────────────────────────────────────────────────────── */
.domain-goal-card { padding: 1rem 1.2rem; }
.dg-active { border-left: 4px solid #22c55e; background: var(--theme-background); }
.dg-secondary { border-left: 4px solid #94a3b8; background: var(--theme-background-alt); opacity: 0.85; }
.dg-empty { padding: 0.8rem 1.2rem; color: var(--theme-foreground-muted); font-style: italic; font-size: 0.85rem; }
.dg-no-goal-label { }
.dg-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; }
.dg-level-label { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--theme-foreground-muted); background: var(--theme-background-alt); border: 1px solid var(--theme-foreground-faint, #ddd); border-radius: 4px; padding: 0.1rem 0.4rem; }
.dg-title { font-size: 1.05rem; font-weight: 700; margin-bottom: 0.35rem; line-height: 1.3; }
.dg-desc { font-size: 0.85rem; color: var(--theme-foreground-muted); line-height: 1.5; margin-bottom: 0.5rem; }
.dg-meta { font-size: 0.72rem; color: var(--theme-foreground-faint); }
/* ── Status badges ────────────────────────────────────────────────────────── */
.status-badge { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 8px; font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
.badge-active { background: #dcfce7; color: #166534; }
.badge-superseded { background: #f3e8ff; color: #6b21a8; }
.badge-archived { background: #f1f5f9; color: #475569; }
.badge-paused { background: #fef3c7; color: #92400e; }
.badge-completed { background: #dcfce7; color: #166534; }
/* ── Repo goal section ────────────────────────────────────────────────────── */
.rg-section { border-top: 1px dashed var(--theme-foreground-faint, #ddd); margin-top: 0.8rem; padding-top: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; }
.rg-section-secondary { opacity: 0.85; }
.rg-section-label { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--theme-foreground-muted); margin-bottom: 0.35rem; }
.rg-empty { font-size: 0.8rem; font-style: italic; color: var(--theme-foreground-faint); }
/* ── Repo goal card ───────────────────────────────────────────────────────── */
.repo-goal-card { border-left: 3px solid #3b82f6; border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.55rem 0.9rem; }
.rg-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.3rem; font-size: 0.75rem; }
.rg-priority { font-weight: 700; color: var(--theme-foreground-muted); font-family: monospace; min-width: 2rem; }
.rg-repo { font-family: monospace; font-size: 0.75rem; background: var(--theme-background); border: 1px solid var(--theme-foreground-faint, #ddd); border-radius: 3px; padding: 0.05rem 0.35rem; color: var(--theme-foreground-muted); }
.rg-title { font-size: 0.9rem; font-weight: 700; margin-bottom: 0.25rem; }
.rg-desc { font-size: 0.8rem; color: var(--theme-foreground-muted); line-height: 1.45; margin-bottom: 0.3rem; }
.rg-meta { font-size: 0.7rem; color: var(--theme-foreground-faint); }
/* ── Secondary goals accordion ───────────────────────────────────────────── */
.secondary-goals-details { border-top: 1px solid var(--theme-foreground-faint, #eee); }
.secondary-goals-summary { padding: 0.6rem 1.2rem; font-size: 0.8rem; color: var(--theme-foreground-muted); cursor: pointer; list-style: none; user-select: none; }
.secondary-goals-summary::-webkit-details-marker { display: none; }
.secondary-goals-summary::before { content: "▶ "; font-size: 0.65rem; }
details[open] .secondary-goals-summary::before { content: "▼ "; }
.secondary-goals-list { padding: 0 0 0.5rem 0; display: flex; flex-direction: column; gap: 0.5rem; }
/* ── Unlinked repo goals ──────────────────────────────────────────────────── */
.rg-list-unlinked { display: flex; flex-direction: column; gap: 0.5rem; max-width: 720px; }
/* ── Utility ─────────────────────────────────────────────────────────────── */
.dim { color: gray; font-style: italic; }
</style>

View File

@@ -54,7 +54,8 @@ Do not use them as a substitute for formal work definition inside the domain rep
| `create_workstream(topic_id, title, ...)` | `slug?`; `owner?`; `description?`; `due_date?` | Creates workstream under a topic. Use `get_state_summary()` to find topic IDs. |
| `create_task(workstream_id, title, ...)` | `priority`: low/medium/high/critical; `assignee?`; `due_date?` | Creates task under a workstream. |
| `update_task_status(task_id, status, ...)` | `status`: todo/in_progress/blocked/done/cancelled; `blocking_reason` required when blocked | |
| `update_workstream_status(workstream_id, status)` | `status`: active/blocked/completed/archived | |
| `update_workstream_status(workstream_id, status)` | `status`: active/blocked/completed/archived | Thin shortcut — use `update_workstream` for full field control. |
| `update_workstream(workstream_id, ...)` | `title?`; `description?`; `owner?`; `due_date?`; `repo_goal_id?`; `status?` | Patch any subset of workstream fields. Pass empty string for `repo_goal_id` to clear the link. |
---

View File

@@ -142,7 +142,8 @@ def get_domain_summary(domain_slug: str) -> str:
domain_slug: the domain slug, e.g. "railiance", "markitect"
Returns: topic, active workstreams, open blocking decisions for this
topic, 5 most recent progress events, and repo SBOM status for this domain.
topic, 5 most recent progress events, repo SBOM status, and goal guidance
(needs_workplan signals + alignment warnings).
"""
topics = _get("/topics")
topic = next((t for t in topics if t.get("domain_slug") == domain_slug), None)
@@ -156,7 +157,77 @@ def get_domain_summary(domain_slug: str) -> str:
recent = _get("/progress", {"topic_id": topic_id, "limit": 5})
repos = _get("/repos", {"domain": domain_slug})
return json.dumps({
# ── Goal guidance ──────────────────────────────────────────────────────────
# Fetch active repo goals per repo, then cross-reference with workstreams.
repo_by_id = {r["id"]: r for r in repos}
ws_by_repo_goal: dict[str, list] = {}
for ws in workstreams:
if ws.get("repo_goal_id"):
ws_by_repo_goal.setdefault(ws["repo_goal_id"], []).append(ws)
# repo_id → list of active workstreams (for alignment check)
ws_by_repo: dict[str, list] = {}
for ws in workstreams:
if ws.get("repo_id"):
ws_by_repo.setdefault(ws["repo_id"], []).append(ws)
needs_workplan: list[dict] = [] # active goal with no linked workstream
alignment_warnings: list[dict] = [] # workstreams not linked to active goal
for repo in repos:
repo_slug = repo["slug"]
repo_id = repo["id"]
active_goals = _get("/repo-goals", {"repo_slug": repo_slug, "status": "active"})
if not active_goals:
continue
active_goal_ids = {g["id"] for g in active_goals}
for goal in active_goals:
linked = ws_by_repo_goal.get(goal["id"], [])
if not linked:
needs_workplan.append({
"repo_slug": repo_slug,
"goal_id": goal["id"],
"goal_title": goal["title"],
"goal_description": goal["description"],
"priority": goal["priority"],
"action": (
f"No workstream is linked to repo goal '{goal['title']}'. "
"Create a workplan file in workplans/ and register a workstream "
f"with repo_goal_id='{goal['id']}' to start delivering this goal."
),
})
# Check if repo has active workstreams not tied to any active goal
repo_ws = ws_by_repo.get(repo_id, [])
unlinked_ws = [
ws for ws in repo_ws
if ws.get("repo_goal_id") not in active_goal_ids
]
if unlinked_ws:
# Most recently updated workstream = the one to suggest continuing
recent_ws = max(unlinked_ws, key=lambda w: w.get("updated_at", ""))
alignment_warnings.append({
"repo_slug": repo_slug,
"recent_workstream_id": recent_ws["id"],
"recent_workstream_title": recent_ws["title"],
"active_goal_titles": [g["title"] for g in active_goals],
"message": (
f"Workstream '{recent_ws['title']}' is not linked to the current "
f"repo goal(s) for {repo_slug}. "
"Continue this workstream if the work is still relevant, but verify "
"alignment with the active goal before committing to new tasks."
),
})
goal_guidance: dict = {}
if needs_workplan or alignment_warnings:
goal_guidance = {
"needs_workplan": needs_workplan,
"alignment_warnings": alignment_warnings,
}
result: dict = {
"domain": domain_slug,
"topic_id": topic_id,
"topic_title": topic["title"],
@@ -164,7 +235,10 @@ def get_domain_summary(domain_slug: str) -> str:
"blocking_decisions": blocking,
"recent_progress": recent,
"repos": [{"slug": r["slug"], "last_sbom_at": r.get("last_sbom_at")} for r in repos],
}, indent=2)
}
if goal_guidance:
result["goal_guidance"] = goal_guidance
return json.dumps(result, indent=2)
@mcp.tool()
@@ -516,6 +590,44 @@ def update_workstream_status(workstream_id: str, status: str) -> str:
return json.dumps(ws, indent=2)
@mcp.tool()
def update_workstream(
workstream_id: str,
title: str | None = None,
description: str | None = None,
owner: str | None = None,
due_date: str | None = None,
repo_goal_id: str | None = None,
status: str | None = None,
) -> str:
"""Update fields on an existing workstream.
Args:
workstream_id: UUID of the workstream
title: new title (optional)
description: new description (optional)
owner: new owner (optional)
due_date: ISO date string YYYY-MM-DD (optional)
repo_goal_id: UUID of the repo goal to link (optional; pass empty string to clear)
status: active | blocked | completed | archived (optional)
"""
payload: dict = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if owner is not None:
payload["owner"] = owner
if due_date is not None:
payload["due_date"] = due_date
if status is not None:
payload["status"] = status
if repo_goal_id is not None:
payload["repo_goal_id"] = repo_goal_id if repo_goal_id else None
ws = _patch(f"/workstreams/{workstream_id}", payload)
return json.dumps(ws, indent=2)
# ---------------------------------------------------------------------------
# Next-steps suggestion tool (S2.3) — sanctioned write use case #2
# ---------------------------------------------------------------------------
@@ -1151,6 +1263,159 @@ def get_licence_report() -> str:
return json.dumps(_get("/sbom/report/licences"), indent=2)
# ---------------------------------------------------------------------------
# Domain goals & repo goals (v0.7)
# ---------------------------------------------------------------------------
@mcp.tool()
def create_domain_goal(domain_slug: str, title: str, description: str) -> str:
"""Create a new domain goal and make it active (superseding any existing active goal).
A domain goal captures the high-level strategic intent for a domain. Only one
domain goal can be active at a time; creating a new active one supersedes the
previous active goal.
Args:
domain_slug: Slug of the domain (e.g. 'railiance', 'markitect')
title: Short goal title
description: Full description of the goal and its boundary conditions
"""
domains = _get("/domains", {"status": "active"})
domain = next((d for d in domains if d["slug"] == domain_slug), None)
if not domain:
return json.dumps({"error": f"Domain '{domain_slug}' not found"})
goal = _post("/domain-goals", {
"domain_id": domain["id"],
"title": title,
"description": description,
"status": "active",
})
_post("/progress", {
"event_type": "goal_created",
"summary": f"Domain goal created [{domain_slug}]: {title}",
"detail": {"goal_id": goal["id"], "domain_slug": domain_slug},
})
return json.dumps(goal, indent=2)
@mcp.tool()
def get_domain_goals(domain_slug: str, status: str | None = None) -> str:
"""List domain goals for a domain, optionally filtered by status.
Args:
domain_slug: Slug of the domain (e.g. 'railiance')
status: active | archived | superseded (omit for all)
"""
return json.dumps(_get("/domain-goals", {"domain_slug": domain_slug, "status": status}), indent=2)
@mcp.tool()
def activate_domain_goal(goal_id: str) -> str:
"""Set a domain goal as the active goal, superseding any currently active one.
Args:
goal_id: UUID of the domain goal to activate
"""
goal = _post(f"/domain-goals/{goal_id}/activate", {})
_post("/progress", {
"event_type": "goal_activated",
"summary": f"Domain goal activated: {goal['title']}",
"detail": {"goal_id": goal_id, "domain_slug": goal.get("domain_slug")},
})
return json.dumps(goal, indent=2)
@mcp.tool()
def create_repo_goal(
repo_slug: str,
title: str,
description: str,
domain_goal_id: str | None = None,
priority: int = 100,
) -> str:
"""Create a new repository goal.
Repository goals capture what needs to be achieved in a specific repository.
Multiple active repo goals can coexist; priority (lower number = higher priority)
determines ordering. Optionally link to the parent domain goal.
Args:
repo_slug: Slug of the repository (e.g. 'railiance-bootstrap')
title: Short goal title
description: Full description including boundary conditions and scope
domain_goal_id: UUID of the parent domain goal (optional)
priority: Integer priority — lower numbers = higher priority (default 100)
"""
repos = _get("/repos")
repo = next((r for r in repos if r["slug"] == repo_slug), None)
if not repo:
return json.dumps({"error": f"Repo '{repo_slug}' not found"})
goal = _post("/repo-goals", {
"repo_id": repo["id"],
"title": title,
"description": description,
"domain_goal_id": domain_goal_id,
"priority": priority,
"status": "active",
})
_post("/progress", {
"event_type": "goal_created",
"summary": f"Repo goal created [{repo_slug}]: {title}",
"detail": {"goal_id": goal["id"], "repo_slug": repo_slug, "priority": priority},
})
return json.dumps(goal, indent=2)
@mcp.tool()
def get_repo_goals(repo_slug: str, status: str | None = None) -> str:
"""List repository goals for a repo, ordered by priority.
Args:
repo_slug: Slug of the repository (e.g. 'railiance-bootstrap')
status: active | paused | completed | archived (omit for all)
"""
return json.dumps(_get("/repo-goals", {"repo_slug": repo_slug, "status": status}), indent=2)
@mcp.tool()
def update_repo_goal(
goal_id: str,
title: str | None = None,
description: str | None = None,
priority: int | None = None,
status: str | None = None,
domain_goal_id: str | None = None,
) -> str:
"""Update a repository goal (title, description, priority, status, or domain link).
Args:
goal_id: UUID of the repo goal
title: New title (optional)
description: New description (optional)
priority: New priority integer — lower = higher priority (optional)
status: active | paused | completed | archived (optional)
domain_goal_id: Link or re-link to a domain goal UUID (optional)
"""
updates: dict = {}
if title is not None:
updates["title"] = title
if description is not None:
updates["description"] = description
if priority is not None:
updates["priority"] = priority
if status is not None:
updates["status"] = status
if domain_goal_id is not None:
updates["domain_goal_id"] = domain_goal_id
goal = _patch(f"/repo-goals/{goal_id}", updates)
_post("/progress", {
"event_type": "goal_updated",
"summary": f"Repo goal updated: {goal['title']}",
"detail": {"goal_id": goal_id, "changes": list(updates.keys())},
})
return json.dumps(goal, indent=2)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,85 @@
"""Add domain_goals and repo_goals; add repo_goal_id FK to workstreams
Revision ID: c5d6e7f8a9b0
Revises: b4c5d6e7f8a9
Create Date: 2026-03-08 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "c5d6e7f8a9b0"
down_revision: Union[str, None] = "b4c5d6e7f8a9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"domain_goals",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("domain_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("title", sa.String(500), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["domain_id"], ["domains.id"], ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_domain_goals_domain_id", "domain_goals", ["domain_id"])
op.create_index("ix_domain_goals_status", "domain_goals", ["status"])
# Partial unique index: only one active goal per domain at a time
op.execute(
"CREATE UNIQUE INDEX ix_domain_goals_one_active "
"ON domain_goals (domain_id) WHERE status = 'active'"
)
op.create_table(
"repo_goals",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("repo_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("domain_goal_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("title", sa.String(500), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("priority", sa.Integer(), nullable=False, server_default="100"),
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["repo_id"], ["managed_repos.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["domain_goal_id"], ["domain_goals.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_repo_goals_repo_id", "repo_goals", ["repo_id"])
op.create_index("ix_repo_goals_domain_goal_id", "repo_goals", ["domain_goal_id"])
op.create_index("ix_repo_goals_status", "repo_goals", ["status"])
op.create_index("ix_repo_goals_priority", "repo_goals", ["priority"])
op.add_column(
"workstreams",
sa.Column(
"repo_goal_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("repo_goals.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index("ix_workstreams_repo_goal_id", "workstreams", ["repo_goal_id"])
def downgrade() -> None:
op.drop_index("ix_workstreams_repo_goal_id", table_name="workstreams")
op.drop_column("workstreams", "repo_goal_id")
op.drop_index("ix_repo_goals_priority", table_name="repo_goals")
op.drop_index("ix_repo_goals_status", table_name="repo_goals")
op.drop_index("ix_repo_goals_domain_goal_id", table_name="repo_goals")
op.drop_index("ix_repo_goals_repo_id", table_name="repo_goals")
op.drop_table("repo_goals")
op.execute("DROP INDEX IF EXISTS ix_domain_goals_one_active")
op.drop_index("ix_domain_goals_status", table_name="domain_goals")
op.drop_index("ix_domain_goals_domain_id", table_name="domain_goals")
op.drop_table("domain_goals")

View File

@@ -36,8 +36,19 @@ Output a concise brief covering:
task counts, any blocking decisions
2. **Pending tasks for this repo** — from local `workplans/` files (Step 2)
plus any state hub tasks with `[repo:{REPO_SLUG}]` in their title
3. **Suggested next action** — the highest-priority open item across both sources
4. **SBOM status** — is `last_sbom_at` set for this repo? If not, note it as a gap
3. **Goal guidance** — if the summary contains a `goal_guidance` key, act on it:
- **`needs_workplan`** entries: for each active repo goal with no linked workstream,
surface it as the top suggested action — *"Repo goal '{title}' has no workplan yet.
Suggest: create workplans/{REPO_SLUG}-WP-NNNN-<slug>.md and register a workstream
with repo_goal_id='{goal_id}'"*. Treat this as higher priority than continuing
existing work unless Bernd says otherwise.
- **`alignment_warnings`** entries: if active workstreams exist but are not linked
to the current repo goal, name the most recently active one and note:
*"Current work on '{recent_workstream_title}' may not be aligned with the active
goal '{active_goal_title}'. Continue unless you hear otherwise — but flag it."*
4. **Suggested next action** — the highest-priority open item across all sources,
with goal alignment taken into account
5. **SBOM status** — is `last_sbom_at` set for this repo? If not, note it as a gap
If there are no workstreams at all: follow the First Session Protocol below.

View File

@@ -0,0 +1,77 @@
---
id: CUST-WP-0010
type: workstream
title: Domain and Repository Goals
domain: custodian
status: completed
owner: custodian
topic_slug: the-custodian
state_hub_workstream_id: ""
created: 2026-03-08
updated: 2026-03-08
state_hub_workstream_id: "def8455a-9de4-4891-a15a-5b09ca98ab1e"
---
# Domain and Repository Goals
## Purpose
Capture strategic intent at two levels — domain and repository — as first-class
entities in the State Hub. Domain goals express the high-level direction for a
domain; repository goals refine that into actionable targets for a specific repo.
Workstreams can be linked to a repo goal so the goal hierarchy is visible when
planning and reviewing progress.
## Schema
### `domain_goals`
- One active goal per domain at a time (partial unique index enforced by DB)
- `status`: active | archived | superseded
- Activating a new goal auto-supersedes the previous active one
### `repo_goals`
- Multiple active goals per repo, ordered by `priority` (integer, lower = higher)
- Optional FK to `domain_goal_id` — traces which domain goal drove this repo goal
- `status`: active | paused | completed | archived
### `workstreams.repo_goal_id`
- Nullable FK added so workstreams can be tagged to a specific repo goal
## MCP Tools Added
- `create_domain_goal(domain_slug, title, description)`
- `get_domain_goals(domain_slug, status?)`
- `activate_domain_goal(goal_id)`
- `create_repo_goal(repo_slug, title, description, domain_goal_id?, priority?)`
- `get_repo_goals(repo_slug, status?)`
- `update_repo_goal(goal_id, ...)`
## Seed Data
**railiance domain goal** (active):
> "Improve the railiance repositories so that I can securely set up a three
> phoenix server setup to provide for the coulomb infrastructure with proper
> IT-Security, Backup and Update management in a fully automated way."
**railiance-bootstrap repo goal** (active, priority 10):
> "Bootstrap a new server securely at hosteurope that can function as the
> second kubernetes host for the railiance setup. Focus on security and single
> server; defer automated provisioning (railiance-hosts) for now."
## Tasks
### Implement Goals schema, API, and MCP tools
```task
id: T01
status: done
priority: high
state_hub_task_id: "051fd9e1-d15c-4665-adc9-704c4d2b24ea"
```
### Seed railiance example goals
```task
id: T02
status: done
priority: high
state_hub_task_id: "103b46d9-6241-4ef1-9063-054478900b5c"
```