From 651df73e3a5eadf6be89ef489ad3270e6180df37 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 9 Mar 2026 00:15:29 +0100 Subject: [PATCH] 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 --- api/main.py | 4 +- api/models/__init__.py | 8 +- api/models/domain.py | 3 + api/models/domain_goal.py | 41 +++ api/models/managed_repo.py | 4 + api/models/repo_goal.py | 49 +++ api/models/workstream.py | 7 + api/routers/domain_goals.py | 114 ++++++ api/routers/repo_goals.py | 79 +++++ api/routers/workstreams.py | 5 +- api/schemas/domain_goal.py | 31 ++ api/schemas/repo_goal.py | 37 ++ api/schemas/workstream.py | 3 + dashboard/observablehq.config.js | 2 + dashboard/src/docs/goals.md | 136 ++++++++ dashboard/src/goals.md | 325 ++++++++++++++++++ mcp_server/TOOLS.md | 3 +- mcp_server/server.py | 271 ++++++++++++++- migrations/versions/c5d6e7f8a9b0_add_goals.py | 85 +++++ scripts/project_claude_md.template | 15 +- 20 files changed, 1212 insertions(+), 10 deletions(-) create mode 100644 api/models/domain_goal.py create mode 100644 api/models/repo_goal.py create mode 100644 api/routers/domain_goals.py create mode 100644 api/routers/repo_goals.py create mode 100644 api/schemas/domain_goal.py create mode 100644 api/schemas/repo_goal.py create mode 100644 dashboard/src/docs/goals.md create mode 100644 dashboard/src/goals.md create mode 100644 migrations/versions/c5d6e7f8a9b0_add_goals.py diff --git a/api/main.py b/api/main.py index 2b5e9a5..4327b86 100644 --- a/api/main.py +++ b/api/main.py @@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from api.database import engine from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies -from api.routers import domains, repos, 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) diff --git a/api/models/__init__.py b/api/models/__init__.py index 2d02a4a..e8c925d 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -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", diff --git a/api/models/domain.py b/api/models/domain.py index 3f1d422..a33fa6d 100644 --- a/api/models/domain.py +++ b/api/models/domain.py @@ -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" + ) diff --git a/api/models/domain_goal.py b/api/models/domain_goal.py new file mode 100644 index 0000000..c1ad44c --- /dev/null +++ b/api/models/domain_goal.py @@ -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 "" diff --git a/api/models/managed_repo.py b/api/models/managed_repo.py index 1d68c38..b67b1da 100644 --- a/api/models/managed_repo.py +++ b/api/models/managed_repo.py @@ -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 "" diff --git a/api/models/repo_goal.py b/api/models/repo_goal.py new file mode 100644 index 0000000..743a832 --- /dev/null +++ b/api/models/repo_goal.py @@ -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 "" diff --git a/api/models/workstream.py b/api/models/workstream.py index 0882f6b..ebe669d 100644 --- a/api/models/workstream.py +++ b/api/models/workstream.py @@ -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" ) diff --git a/api/routers/domain_goals.py b/api/routers/domain_goals.py new file mode 100644 index 0000000..ead673e --- /dev/null +++ b/api/routers/domain_goals.py @@ -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 diff --git a/api/routers/repo_goals.py b/api/routers/repo_goals.py new file mode 100644 index 0000000..f836b91 --- /dev/null +++ b/api/routers/repo_goals.py @@ -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 diff --git a/api/routers/workstreams.py b/api/routers/workstreams.py index 32eb9b8..29a72ef 100644 --- a/api/routers/workstreams.py +++ b/api/routers/workstreams.py @@ -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()) diff --git a/api/schemas/domain_goal.py b/api/schemas/domain_goal.py new file mode 100644 index 0000000..04b87e6 --- /dev/null +++ b/api/schemas/domain_goal.py @@ -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 diff --git a/api/schemas/repo_goal.py b/api/schemas/repo_goal.py new file mode 100644 index 0000000..b2f69ca --- /dev/null +++ b/api/schemas/repo_goal.py @@ -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 diff --git a/api/schemas/workstream.py b/api/schemas/workstream.py index 8e0ccc0..70dbb11 100644 --- a/api/schemas/workstream.py +++ b/api/schemas/workstream.py @@ -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 diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index fe7e419..0aabd77 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -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" }, diff --git a/dashboard/src/docs/goals.md b/dashboard/src/docs/goals.md new file mode 100644 index 0000000..c6198b8 --- /dev/null +++ b/dashboard/src/docs/goals.md @@ -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": ""}`. + +--- + +## 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. diff --git a/dashboard/src/goals.md b/dashboard/src/goals.md new file mode 100644 index 0000000..d3eba71 --- /dev/null +++ b/dashboard/src/goals.md @@ -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`
+ + ${_ok + ? `Live · updated ${_ts?.toLocaleTimeString()}` + : html`Offline — run: make api`} +
`; +withDocHelp(_liveEl, "/docs/live-data"); + +// ── KPI sidebar card ──────────────────────────────────────────────────────────── +const _kpiBox = html`
+
Goals
+
+ domains with active goal +
+
${domainsWithActiveGoal.length} / ${domains.length}
+
+
+
+ active repo goals +
+
${totalActiveRepoGoals}
+
+
+ ${domainsWithoutGoal.length > 0 ? html` +
+
no active goal
+
${domainsWithoutGoal.map(d => html`
${d.slug}
`)}
+
` : ""} +
`; + +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`
+
+ #${rg.priority} + ${repo?.slug ?? rg.repo_id.slice(0,8)} + ${rg.status} +
+
${rg.title}
+
${rg.description}
+
goal id: ${rg.id.slice(0,8)}…
+
`; +} + +// ── 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`
+
+ ${domain.slug} + ${domain.name} +
+ + ${activeGoal ? html` + +
+
+ Domain Goal + active +
+
${activeGoal.title}
+
${activeGoal.description}
+
+ goal id: ${activeGoal.id.slice(0,8)}… · + set ${new Date(activeGoal.created_at).toLocaleDateString()} +
+ + ${(repoGoalsByDomainGoal[activeGoal.id] ?? []).length > 0 ? html` +
+ + ${(repoGoalsByDomainGoal[activeGoal.id] ?? []).map(renderRepoGoalCard)} +
` : html` +
No repository goals linked to this domain goal yet.
`} +
+ ` : html` +
+ No active goal set for this domain. +
`} + + ${secondaryGoals.length > 0 ? html` + +
+ + ${secondaryGoals.length} secondary goal${secondaryGoals.length === 1 ? "" : "s"} + (${[...new Set(secondaryGoals.map(g => g.status))].join(", ")}) + +
+ ${secondaryGoals.map(g => html` +
+
+ Domain Goal + ${g.status} +
+
${g.title}
+
${g.description}
+
+ goal id: ${g.id.slice(0,8)}… · + set ${new Date(g.created_at).toLocaleDateString()} +
+ ${(repoGoalsByDomainGoal[g.id] ?? []).length > 0 ? html` +
+ + ${(repoGoalsByDomainGoal[g.id] ?? []).map(renderRepoGoalCard)} +
` : ""} +
`)} +
+
` : ""} + +
`; +} + +// ── Main render ──────────────────────────────────────────────────────────────── +if (!_ok) { + display(html`

API offline — run make api from state-hub/.

`); +} else if (domains.length === 0) { + display(html`

No active domains found.

`); +} 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`
${sorted.map(renderDomainSection)}
`); +} +``` + +```js +// ── Unlinked active repo goals ───────────────────────────────────────────────── +if (unlinkedRepoGoals.length > 0) { + display(html` +
+

Unlinked Repository Goals

+

Active repo goals not yet associated with a domain goal.

+
${unlinkedRepoGoals.map(renderRepoGoalCard)}
+ `); +} +``` + + diff --git a/mcp_server/TOOLS.md b/mcp_server/TOOLS.md index 55dc76e..e50cf0d 100644 --- a/mcp_server/TOOLS.md +++ b/mcp_server/TOOLS.md @@ -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. | --- diff --git a/mcp_server/server.py b/mcp_server/server.py index d7b64f2..cf32ac4 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -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 # --------------------------------------------------------------------------- diff --git a/migrations/versions/c5d6e7f8a9b0_add_goals.py b/migrations/versions/c5d6e7f8a9b0_add_goals.py new file mode 100644 index 0000000..2176d26 --- /dev/null +++ b/migrations/versions/c5d6e7f8a9b0_add_goals.py @@ -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") diff --git a/scripts/project_claude_md.template b/scripts/project_claude_md.template index 54fe58a..849f330 100644 --- a/scripts/project_claude_md.template +++ b/scripts/project_claude_md.template @@ -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-.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.