diff --git a/api/main.py b/api/main.py index f0949cf..e4e11da 100644 --- a/api/main.py +++ b/api/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from api.database import engine -from api.routers import decisions, progress, state, tasks, topics, workstreams, workstream_dependencies +from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies @asynccontextmanager @@ -32,6 +32,8 @@ app.include_router(workstreams.router) app.include_router(workstream_dependencies.router) app.include_router(tasks.router) 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(state.router) diff --git a/api/models/__init__.py b/api/models/__init__.py index f9c2107..562f0f8 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -5,6 +5,8 @@ from api.models.workstream_dependency import WorkstreamDependency from api.models.task import Task, TaskStatus, TaskPriority 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 __all__ = [ "Base", @@ -14,4 +16,6 @@ __all__ = [ "Task", "TaskStatus", "TaskPriority", "Decision", "DecisionType", "DecisionStatus", "ProgressEvent", + "ExtensionPoint", "EPStatus", + "TechnicalDebt", "TDStatus", ] diff --git a/api/models/extension_point.py b/api/models/extension_point.py new file mode 100644 index 0000000..745e898 --- /dev/null +++ b/api/models/extension_point.py @@ -0,0 +1,47 @@ +import enum +import uuid + +from sqlalchemy import Enum, 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 EPStatus(str, enum.Enum): + open = "open" + in_progress = "in_progress" + addressed = "addressed" + deferred = "deferred" + wont_fix = "wont_fix" + + +class ExtensionPoint(Base, TimestampMixin): + __tablename__ = "extension_points" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + ep_id: Mapped[str | None] = mapped_column( + String(30), nullable=True, unique=True, index=True + ) # human-readable ref, e.g. EP-CUST-001 + domain: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + location: Mapped[str | None] = mapped_column(String(500), nullable=True) + ep_type: Mapped[str] = mapped_column( + String(50), nullable=False, default="other" + ) # api | schema | mcp | dashboard | architecture | integration | other + status: Mapped[EPStatus] = mapped_column( + Enum(EPStatus, name="epstatus"), nullable=False, default=EPStatus.open + ) + priority: Mapped[str] = mapped_column(String(20), nullable=False, default="medium") + topic_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True + ) + workstream_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True + ) + + topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821 + workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821 diff --git a/api/models/technical_debt.py b/api/models/technical_debt.py new file mode 100644 index 0000000..f7c954d --- /dev/null +++ b/api/models/technical_debt.py @@ -0,0 +1,47 @@ +import enum +import uuid + +from sqlalchemy import Enum, 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 TDStatus(str, enum.Enum): + open = "open" + in_progress = "in_progress" + resolved = "resolved" + deferred = "deferred" + wont_fix = "wont_fix" + + +class TechnicalDebt(Base, TimestampMixin): + __tablename__ = "technical_debt" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + td_id: Mapped[str | None] = mapped_column( + String(30), nullable=True, unique=True, index=True + ) # human-readable ref, e.g. TD-CUST-001 + domain: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + location: Mapped[str | None] = mapped_column(String(500), nullable=True) + debt_type: Mapped[str] = mapped_column( + String(50), nullable=False, default="other" + ) # design | implementation | test | docs | dependencies | performance | security | other + severity: Mapped[str] = mapped_column(String(20), nullable=False, default="medium") + status: Mapped[TDStatus] = mapped_column( + Enum(TDStatus, name="tdstatus"), nullable=False, default=TDStatus.open + ) + topic_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True + ) + workstream_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True + ) + + topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821 + workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821 diff --git a/api/routers/extension_points.py b/api/routers/extension_points.py new file mode 100644 index 0000000..e4c2a52 --- /dev/null +++ b/api/routers/extension_points.py @@ -0,0 +1,83 @@ +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.extension_point import EPStatus, ExtensionPoint +from api.schemas.extension_point import EPCreate, EPRead, EPUpdate + +router = APIRouter(prefix="/extension-points", tags=["extension-points"]) + + +@router.get("/", response_model=list[EPRead]) +async def list_eps( + domain: str | None = None, + status: EPStatus | None = None, + ep_type: str | None = None, + session: AsyncSession = Depends(get_session), +) -> list[ExtensionPoint]: + q = select(ExtensionPoint) + if domain: + q = q.where(ExtensionPoint.domain == domain) + if status: + q = q.where(ExtensionPoint.status == status) + if ep_type: + q = q.where(ExtensionPoint.ep_type == ep_type) + q = q.order_by(ExtensionPoint.created_at) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=EPRead, status_code=status.HTTP_201_CREATED) +async def create_ep( + body: EPCreate, + session: AsyncSession = Depends(get_session), +) -> ExtensionPoint: + ep = ExtensionPoint(**body.model_dump()) + session.add(ep) + await session.commit() + await session.refresh(ep) + return ep + + +@router.get("/{ep_id}", response_model=EPRead) +async def get_ep( + ep_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> ExtensionPoint: + ep = await session.get(ExtensionPoint, ep_id) + if ep is None: + raise HTTPException(status_code=404, detail="Extension point not found") + return ep + + +@router.patch("/{ep_id}", response_model=EPRead) +async def update_ep( + ep_id: uuid.UUID, + body: EPUpdate, + session: AsyncSession = Depends(get_session), +) -> ExtensionPoint: + ep = await session.get(ExtensionPoint, ep_id) + if ep is None: + raise HTTPException(status_code=404, detail="Extension point not found") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(ep, field, value) + await session.commit() + await session.refresh(ep) + return ep + + +@router.delete("/{ep_id}", response_model=EPRead) +async def defer_ep( + ep_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> ExtensionPoint: + ep = await session.get(ExtensionPoint, ep_id) + if ep is None: + raise HTTPException(status_code=404, detail="Extension point not found") + ep.status = EPStatus.deferred + await session.commit() + await session.refresh(ep) + return ep diff --git a/api/routers/technical_debt.py b/api/routers/technical_debt.py new file mode 100644 index 0000000..5082328 --- /dev/null +++ b/api/routers/technical_debt.py @@ -0,0 +1,86 @@ +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.technical_debt import TDStatus, TechnicalDebt +from api.schemas.technical_debt import TDCreate, TDRead, TDUpdate + +router = APIRouter(prefix="/technical-debt", tags=["technical-debt"]) + + +@router.get("/", response_model=list[TDRead]) +async def list_td( + domain: str | None = None, + status: TDStatus | None = None, + debt_type: str | None = None, + severity: str | None = None, + session: AsyncSession = Depends(get_session), +) -> list[TechnicalDebt]: + q = select(TechnicalDebt) + if domain: + q = q.where(TechnicalDebt.domain == domain) + if status: + q = q.where(TechnicalDebt.status == status) + if debt_type: + q = q.where(TechnicalDebt.debt_type == debt_type) + if severity: + q = q.where(TechnicalDebt.severity == severity) + q = q.order_by(TechnicalDebt.created_at) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=TDRead, status_code=status.HTTP_201_CREATED) +async def create_td( + body: TDCreate, + session: AsyncSession = Depends(get_session), +) -> TechnicalDebt: + td = TechnicalDebt(**body.model_dump()) + session.add(td) + await session.commit() + await session.refresh(td) + return td + + +@router.get("/{td_id}", response_model=TDRead) +async def get_td( + td_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> TechnicalDebt: + td = await session.get(TechnicalDebt, td_id) + if td is None: + raise HTTPException(status_code=404, detail="Technical debt item not found") + return td + + +@router.patch("/{td_id}", response_model=TDRead) +async def update_td( + td_id: uuid.UUID, + body: TDUpdate, + session: AsyncSession = Depends(get_session), +) -> TechnicalDebt: + td = await session.get(TechnicalDebt, td_id) + if td is None: + raise HTTPException(status_code=404, detail="Technical debt item not found") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(td, field, value) + await session.commit() + await session.refresh(td) + return td + + +@router.delete("/{td_id}", response_model=TDRead) +async def defer_td( + td_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> TechnicalDebt: + td = await session.get(TechnicalDebt, td_id) + if td is None: + raise HTTPException(status_code=404, detail="Technical debt item not found") + td.status = TDStatus.deferred + await session.commit() + await session.refresh(td) + return td diff --git a/api/schemas/__init__.py b/api/schemas/__init__.py index 16d68ee..139016d 100644 --- a/api/schemas/__init__.py +++ b/api/schemas/__init__.py @@ -4,6 +4,8 @@ from api.schemas.task import TaskCreate, TaskUpdate, TaskRead from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead from api.schemas.state import StateSummary, Totals, TopicTotals, WorkstreamTotals, TaskTotals, DecisionTotals +from api.schemas.extension_point import EPCreate, EPUpdate, EPRead +from api.schemas.technical_debt import TDCreate, TDUpdate, TDRead __all__ = [ "TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams", @@ -12,4 +14,6 @@ __all__ = [ "DecisionCreate", "DecisionUpdate", "DecisionRead", "ProgressEventCreate", "ProgressEventRead", "StateSummary", "Totals", "TopicTotals", "WorkstreamTotals", "TaskTotals", "DecisionTotals", + "EPCreate", "EPUpdate", "EPRead", + "TDCreate", "TDUpdate", "TDRead", ] diff --git a/api/schemas/extension_point.py b/api/schemas/extension_point.py new file mode 100644 index 0000000..86c1918 --- /dev/null +++ b/api/schemas/extension_point.py @@ -0,0 +1,53 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from api.models.extension_point import EPStatus + +VALID_DOMAINS = { + "custodian", "railiance", "markitect", + "coulomb_social", "personhood", "foerster_capabilities", +} +VALID_PRIORITIES = {"low", "medium", "high", "critical"} + + +class EPCreate(BaseModel): + ep_id: str | None = None + domain: str + title: str + description: str | None = None + location: str | None = None + ep_type: str = "other" + status: EPStatus = EPStatus.open + priority: str = "medium" + topic_id: uuid.UUID | None = None + workstream_id: uuid.UUID | None = None + + +class EPUpdate(BaseModel): + title: str | None = None + description: str | None = None + location: str | None = None + ep_type: str | None = None + status: EPStatus | None = None + priority: str | None = None + workstream_id: uuid.UUID | None = None + + +class EPRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + ep_id: str | None = None + domain: str + title: str + description: str | None = None + location: str | None = None + ep_type: str + status: EPStatus + priority: str + topic_id: uuid.UUID | None = None + workstream_id: uuid.UUID | None = None + created_at: datetime + updated_at: datetime diff --git a/api/schemas/technical_debt.py b/api/schemas/technical_debt.py new file mode 100644 index 0000000..615240d --- /dev/null +++ b/api/schemas/technical_debt.py @@ -0,0 +1,49 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from api.models.technical_debt import TDStatus + +VALID_SEVERITIES = {"low", "medium", "high", "critical"} + + +class TDCreate(BaseModel): + td_id: str | None = None + domain: str + title: str + description: str | None = None + location: str | None = None + debt_type: str = "other" + severity: str = "medium" + status: TDStatus = TDStatus.open + topic_id: uuid.UUID | None = None + workstream_id: uuid.UUID | None = None + + +class TDUpdate(BaseModel): + title: str | None = None + description: str | None = None + location: str | None = None + debt_type: str | None = None + severity: str | None = None + status: TDStatus | None = None + workstream_id: uuid.UUID | None = None + + +class TDRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + td_id: str | None = None + domain: str + title: str + description: str | None = None + location: str | None = None + debt_type: str + severity: str + status: TDStatus + topic_id: uuid.UUID | None = None + workstream_id: uuid.UUID | None = None + created_at: datetime + updated_at: datetime diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index 800335c..6fe4d16 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -7,6 +7,8 @@ export default { { name: "Tasks", path: "/tasks" }, { name: "Decisions", path: "/decisions" }, { name: "Progress", path: "/progress" }, + { name: "Extension Points", path: "/extensions" }, + { name: "Technical Debt", path: "/techdept" }, { name: "Reference", pages: [ diff --git a/dashboard/src/extensions.md b/dashboard/src/extensions.md new file mode 100644 index 0000000..119cd22 --- /dev/null +++ b/dashboard/src/extensions.md @@ -0,0 +1,251 @@ +--- +title: Extension Points +--- + +```js +const API = "http://127.0.0.1:8000"; +const POLL = 15_000; +``` + +```js +const epState = (async function*() { + while (true) { + let data = [], ok = false; + try { + const [re, rw, rt] = await Promise.all([ + fetch(`${API}/extension-points/`), + fetch(`${API}/workstreams/`), + fetch(`${API}/topics/`), + ]); + ok = re.ok && rw.ok && rt.ok; + if (ok) { + const [epList, wsList, topicList] = await Promise.all([re.json(), rw.json(), rt.json()]); + const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); + const wsMap = Object.fromEntries(wsList.map(w => [w.id, { + ...w, domain: topicMap[w.topic_id]?.domain ?? "unknown", + }])); + data = epList.map(e => ({ + ...e, + workstream_title: wsMap[e.workstream_id]?.title ?? null, + })).sort((a, b) => { + const pr = {critical: 0, high: 1, medium: 2, low: 3}; + const st = {open: 0, in_progress: 1, deferred: 2, addressed: 3, wont_fix: 4}; + const sd = (st[a.status] ?? 9) - (st[b.status] ?? 9); + return sd !== 0 ? sd : (pr[a.priority] ?? 9) - (pr[b.priority] ?? 9); + }); + } + } catch {} + yield {data, ok, ts: new Date()}; + await new Promise(res => setTimeout(res, POLL)); + } +})(); +``` + +```js +const data = epState.data ?? []; +const _ok = epState.ok ?? false; +const _ts = epState.ts; +``` + +```js +import {MultiSelect} from "./components/multiselect.js"; + +const STATUSES = ["open", "in_progress", "addressed", "deferred", "wont_fix"]; +const PRIORITIES = ["critical", "high", "medium", "low"]; +const DOMAINS = ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"]; +const EP_TYPES = ["api", "schema", "mcp", "dashboard", "architecture", "integration", "other"]; + +const _filtersForm = Inputs.form( + { + status: MultiSelect(STATUSES, {label: "Status", placeholder: "All statuses"}), + priority: MultiSelect(PRIORITIES, {label: "Priority", placeholder: "All priorities"}), + domain: MultiSelect(DOMAINS, {label: "Domain", placeholder: "All domains"}), + ep_type: MultiSelect(EP_TYPES, {label: "Type", placeholder: "All types"}), + }, + { + template: ({status, priority, domain, ep_type}) => html`
+ ${status}${priority}${domain}${ep_type} +
`, + } +); +``` + +```js +const filters = Generators.input(_filtersForm); +``` + +```js +const filtered = data.filter(e => + (filters.status.length === 0 || filters.status.includes(e.status)) && + (filters.priority.length === 0 || filters.priority.includes(e.priority)) && + (filters.domain.length === 0 || filters.domain.includes(e.domain)) && + (filters.ep_type.length === 0 || filters.ep_type.includes(e.ep_type)) +); +``` + +# Extension Points + +```js +import {injectTocTop} from "./components/toc-sidebar.js"; +import {withDocHelp} from "./components/doc-overlay.js"; + +// ── KPI sidebar ─────────────────────────────────────────────────────────────── +const _open = data.filter(e => e.status === "open" || e.status === "in_progress"); +const _addressed = data.filter(e => e.status === "addressed"); +const _byType = EP_TYPES.map(t => [t, data.filter(e => e.ep_type === t && e.status === "open").length]) + .filter(([,n]) => n > 0); + +const _kpiBox = html`
+
Extension Points
+
+ open +
${_open.length}
+
+
+ addressed +
${_addressed.length}
+
+ ${_byType.length > 0 ? html` +
+ ${_byType.map(([t, n]) => html`
+ ${t} + ${n} +
`)} +
` : ""} +
`; + +// ── Live indicator ───────────────────────────────────────────────────────────── +const _liveEl = html`
+ + ${_ok + ? `Live · updated ${_ts?.toLocaleTimeString()}` + : html`Offline — run: make api`} +
`; +withDocHelp(_liveEl, "/docs/live-data"); + +injectTocTop("ep-kpi-box", _kpiBox); +injectTocTop("live-indicator", _liveEl); + +const _h1 = document.querySelector("#observablehq-main h1"); +if (_h1) _h1.style.position = "relative"; +``` + +## By Type & Status + +```js +import * as Plot from "npm:@observablehq/plot"; + +const TYPE_COLOR = { + api: "#3b82f6", schema: "#8b5cf6", mcp: "#ec4899", + dashboard: "#f59e0b", architecture: "#10b981", integration: "#6366f1", other: "#94a3b8", +}; +const STATUS_COLOR = { + open: "#3b82f6", in_progress: "#f59e0b", addressed: "#22c55e", deferred: "#94a3b8", wont_fix: "#e2e8f0", +}; + +const byType = EP_TYPES + .map(t => ({type: t, count: filtered.filter(e => e.ep_type === t).length})) + .filter(d => d.count > 0); + +const byStatus = STATUSES + .map(s => ({status: s, count: filtered.filter(e => e.status === s).length})) + .filter(d => d.count > 0); + +if (filtered.length === 0) { + display(html`

No extension points match the current filter.

`); +} else { + display(html`
+ ${Plot.plot({ + marks: [ + Plot.barX(byType, {y: "type", x: "count", fill: d => TYPE_COLOR[d.type] ?? "#94a3b8", tip: true}), + Plot.ruleX([0]), + ], + marginLeft: 100, width: 340, title: "By type", + })} + ${Plot.plot({ + marks: [ + Plot.barX(byStatus, {y: "status", x: "count", fill: d => STATUS_COLOR[d.status] ?? "#94a3b8", tip: true}), + Plot.ruleX([0]), + ], + marginLeft: 90, width: 300, title: "By status", + })} +
`); +} +``` + +## All Extension Points + +```js +display(_filtersForm); +display(html`

${filtered.length} extension points shown.

`); + +display(html`
${filtered.map(ep => html` +
+
+ ${ep.ep_id ? html`${ep.ep_id}` : ""} + ${ep.ep_type} + ${ep.status.replace("_", " ")} + ${ep.priority} + ${ep.domain} + ${ep.workstream_title ? html`${ep.workstream_title}` : ""} +
+
${ep.title}
+ ${ep.description ? html`
${ep.description.slice(0, 240)}${ep.description.length > 240 ? "…" : ""}
` : ""} + ${ep.location ? html`
${ep.location}
` : ""} +
+`)} +
`); +``` + + diff --git a/dashboard/src/techdept.md b/dashboard/src/techdept.md new file mode 100644 index 0000000..dc0ceaa --- /dev/null +++ b/dashboard/src/techdept.md @@ -0,0 +1,285 @@ +--- +title: Technical Debt +--- + +```js +const API = "http://127.0.0.1:8000"; +const POLL = 15_000; +``` + +```js +const tdState = (async function*() { + while (true) { + let data = [], ok = false; + try { + const [rt, rw, rto] = await Promise.all([ + fetch(`${API}/technical-debt/`), + fetch(`${API}/workstreams/`), + fetch(`${API}/topics/`), + ]); + ok = rt.ok && rw.ok && rto.ok; + if (ok) { + const [tdList, wsList, topicList] = await Promise.all([rt.json(), rw.json(), rto.json()]); + const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); + const wsMap = Object.fromEntries(wsList.map(w => [w.id, { + ...w, domain: topicMap[w.topic_id]?.domain ?? "unknown", + }])); + data = tdList.map(t => ({ + ...t, + workstream_title: wsMap[t.workstream_id]?.title ?? null, + })).sort((a, b) => { + const sv = {critical: 0, high: 1, medium: 2, low: 3}; + const st = {open: 0, in_progress: 1, deferred: 2, resolved: 3, wont_fix: 4}; + const sd = (st[a.status] ?? 9) - (st[b.status] ?? 9); + return sd !== 0 ? sd : (sv[a.severity] ?? 9) - (sv[b.severity] ?? 9); + }); + } + } catch {} + yield {data, ok, ts: new Date()}; + await new Promise(res => setTimeout(res, POLL)); + } +})(); +``` + +```js +const data = tdState.data ?? []; +const _ok = tdState.ok ?? false; +const _ts = tdState.ts; +``` + +```js +import {MultiSelect} from "./components/multiselect.js"; + +const STATUSES = ["open", "in_progress", "resolved", "deferred", "wont_fix"]; +const SEVERITIES = ["critical", "high", "medium", "low"]; +const DOMAINS = ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"]; +const DEBT_TYPES = ["design", "implementation", "test", "docs", "dependencies", "performance", "security", "other"]; + +const _filtersForm = Inputs.form( + { + status: MultiSelect(STATUSES, {label: "Status", placeholder: "All statuses"}), + severity: MultiSelect(SEVERITIES, {label: "Severity", placeholder: "All severities"}), + domain: MultiSelect(DOMAINS, {label: "Domain", placeholder: "All domains"}), + debt_type: MultiSelect(DEBT_TYPES, {label: "Type", placeholder: "All types"}), + }, + { + template: ({status, severity, domain, debt_type}) => html`
+ ${status}${severity}${domain}${debt_type} +
`, + } +); +``` + +```js +const filters = Generators.input(_filtersForm); +``` + +```js +const filtered = data.filter(t => + (filters.status.length === 0 || filters.status.includes(t.status)) && + (filters.severity.length === 0 || filters.severity.includes(t.severity)) && + (filters.domain.length === 0 || filters.domain.includes(t.domain)) && + (filters.debt_type.length === 0 || filters.debt_type.includes(t.debt_type)) +); +``` + +# Technical Debt + +```js +import {injectTocTop} from "./components/toc-sidebar.js"; +import {withDocHelp} from "./components/doc-overlay.js"; + +// ── KPI sidebar ─────────────────────────────────────────────────────────────── +const _open = data.filter(t => t.status === "open" || t.status === "in_progress"); +const _critical = data.filter(t => t.severity === "critical" && t.status === "open"); +const _high = data.filter(t => t.severity === "high" && t.status === "open"); +const _resolved = data.filter(t => t.status === "resolved"); +const _total = data.filter(t => t.status !== "wont_fix").length; +const _resolvedPct = _total > 0 ? Math.round(_resolved.length / _total * 100) : 0; + +const _kpiBox = html`
+
Tech Debt
+
+ open +
${_open.length}
+
+
+ critical +
+
${_critical.length}
+
+
+
+ high +
+
${_high.length}
+
+
+
+ resolved +
+
${_resolved.length}
+
${_resolvedPct}% of total
+
+
+
`; + +// ── Live indicator ───────────────────────────────────────────────────────────── +const _liveEl = html`
+ + ${_ok + ? `Live · updated ${_ts?.toLocaleTimeString()}` + : html`Offline — run: make api`} +
`; +withDocHelp(_liveEl, "/docs/live-data"); + +injectTocTop("td-kpi-box", _kpiBox); +injectTocTop("live-indicator", _liveEl); + +const _h1 = document.querySelector("#observablehq-main h1"); +if (_h1) _h1.style.position = "relative"; +``` + +## By Type & Severity + +```js +import * as Plot from "npm:@observablehq/plot"; + +const SEVERITY_COLOR = {critical: "#dc2626", high: "#ea580c", medium: "#3b82f6", low: "#94a3b8"}; +const TYPE_COLOR = { + design: "#8b5cf6", implementation: "#3b82f6", test: "#f59e0b", + docs: "#10b981", dependencies: "#6366f1", performance: "#ec4899", + security: "#dc2626", other: "#94a3b8", +}; + +const bySeverity = SEVERITIES + .map(s => ({severity: s, count: filtered.filter(t => t.severity === s && t.status !== "resolved" && t.status !== "wont_fix").length})) + .filter(d => d.count > 0); + +const byType = DEBT_TYPES + .map(t => ({type: t, count: filtered.filter(d => d.debt_type === t).length})) + .filter(d => d.count > 0); + +if (filtered.length === 0) { + display(html`

No technical debt items match the current filter.

`); +} else { + display(html`
+ ${bySeverity.length > 0 ? Plot.plot({ + marks: [ + Plot.barX(bySeverity, {y: "severity", x: "count", fill: d => SEVERITY_COLOR[d.severity] ?? "#94a3b8", tip: true}), + Plot.ruleX([0]), + ], + marginLeft: 80, width: 300, title: "Open by severity", + }) : ""} + ${byType.length > 0 ? Plot.plot({ + marks: [ + Plot.barX(byType, {y: "type", x: "count", fill: d => TYPE_COLOR[d.type] ?? "#94a3b8", tip: true}), + Plot.ruleX([0]), + ], + marginLeft: 110, width: 360, title: "By type", + }) : ""} +
`); +} +``` + +## Critical & High + +```js +const _urgent = filtered.filter(t => (t.severity === "critical" || t.severity === "high") && t.status === "open"); + +if (_urgent.length === 0) { + display(html`

No critical or high severity open items in current filter. ✓

`); +} else { + display(html`
${_urgent.map(t => html` +
+
+ ${t.td_id ? html`${t.td_id}` : ""} + ${t.severity} + ${t.debt_type} + ${t.domain} + ${t.workstream_title ? html`${t.workstream_title}` : ""} +
+
${t.title}
+ ${t.description ? html`
${t.description.slice(0, 240)}${t.description.length > 240 ? "…" : ""}
` : ""} + ${t.location ? html`
${t.location}
` : ""} +
+ `)}
`); +} +``` + +## All Technical Debt + +```js +display(_filtersForm); +display(html`

${filtered.length} items shown.

`); + +display(html`
${filtered.map(t => html` +
+
+ ${t.td_id ? html`${t.td_id}` : ""} + ${t.severity} + ${t.debt_type} + ${t.status.replace("_", " ")} + ${t.domain} + ${t.workstream_title ? html`${t.workstream_title}` : ""} +
+
${t.title}
+ ${t.description ? html`
${t.description.slice(0, 240)}${t.description.length > 240 ? "…" : ""}
` : ""} + ${t.location ? html`
${t.location}
` : ""} +
+`)} +
`); +``` + + diff --git a/mcp_server/server.py b/mcp_server/server.py index bae547e..c491e38 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -468,6 +468,167 @@ def list_dependencies(workstream_id: str) -> str: return json.dumps({"depends_on": depends_on, "blocks": blocks}, indent=2) +# --------------------------------------------------------------------------- +# Extension points & technical debt +# --------------------------------------------------------------------------- + +@mcp.tool() +def register_extension_point( + domain: str, + title: str, + ep_type: str, + description: str | None = None, + location: str | None = None, + priority: str = "medium", + ep_id: str | None = None, + topic_id: str | None = None, + workstream_id: str | None = None, +) -> str: + """Register a discovered extension point — optional future functionality not yet committed. + + Extension points capture design forks: things the system *could* do that + have been noticed and parked for deliberate later consideration. + + Args: + domain: one of custodian | railiance | markitect | coulomb_social | personhood | foerster_capabilities + title: short description of the extension + ep_type: api | schema | mcp | dashboard | architecture | integration | other + description: longer explanation of what the extension would add + location: file:line or module where the extension point was noticed + priority: low | medium | high | critical + ep_id: optional human-readable ID, e.g. EP-CUST-001 (auto-assigned if omitted) + topic_id: UUID of related topic + workstream_id: UUID of related workstream + """ + ep = _post("/extension-points", { + "domain": domain, "title": title, "ep_type": ep_type, + "description": description, "location": location, + "priority": priority, "ep_id": ep_id, + "topic_id": topic_id, "workstream_id": workstream_id, + }) + _post("/progress", { + "summary": f"Extension point registered: [{ep.get('ep_id') or ep['id'][:8]}] {title} ({ep_type}, {domain})", + "event_type": "extension_point", + "detail": {"id": ep["id"], "ep_id": ep.get("ep_id"), "ep_type": ep_type, "domain": domain}, + }) + return json.dumps(ep, indent=2) + + +@mcp.tool() +def list_extension_points( + domain: str | None = None, + status: str | None = None, + ep_type: str | None = None, +) -> str: + """List extension points, optionally filtered. + + Args: + domain: filter by domain + status: open | in_progress | addressed | deferred | wont_fix + ep_type: api | schema | mcp | dashboard | architecture | integration | other + """ + return json.dumps(_get("/extension-points", { + "domain": domain, "status": status, "ep_type": ep_type, + }), indent=2) + + +@mcp.tool() +def update_ep_status(ep_uuid: str, status: str) -> str: + """Update the status of an extension point. + + Args: + ep_uuid: UUID of the extension point + status: open | in_progress | addressed | deferred | wont_fix + """ + ep = _patch(f"/extension-points/{ep_uuid}", {"status": status}) + _post("/progress", { + "summary": f"Extension point status → {status}: {ep['title']}", + "event_type": "extension_point", + "detail": {"id": ep_uuid, "status": status}, + }) + return json.dumps(ep, indent=2) + + +@mcp.tool() +def register_technical_debt( + domain: str, + title: str, + debt_type: str, + description: str | None = None, + location: str | None = None, + severity: str = "medium", + td_id: str | None = None, + topic_id: str | None = None, + workstream_id: str | None = None, +) -> str: + """Register a technical debt item — a known quality compromise to address later. + + Technical debt captures intentional or discovered shortcuts, design + weaknesses, missing tests, and similar issues that reduce codebase health. + + Args: + domain: one of custodian | railiance | markitect | coulomb_social | personhood | foerster_capabilities + title: short description of the debt + debt_type: design | implementation | test | docs | dependencies | performance | security | other + description: what the issue is and what the correct fix would be + location: file:line or module where the debt lives + severity: low | medium | high | critical + td_id: optional human-readable ID, e.g. TD-CUST-001 + topic_id: UUID of related topic + workstream_id: UUID of related workstream + """ + td = _post("/technical-debt", { + "domain": domain, "title": title, "debt_type": debt_type, + "description": description, "location": location, + "severity": severity, "td_id": td_id, + "topic_id": topic_id, "workstream_id": workstream_id, + }) + _post("/progress", { + "summary": f"Technical debt registered: [{td.get('td_id') or td['id'][:8]}] {title} ({debt_type}, {severity}, {domain})", + "event_type": "technical_debt", + "detail": {"id": td["id"], "td_id": td.get("td_id"), "debt_type": debt_type, "severity": severity, "domain": domain}, + }) + return json.dumps(td, indent=2) + + +@mcp.tool() +def list_technical_debt( + domain: str | None = None, + status: str | None = None, + debt_type: str | None = None, + severity: str | None = None, +) -> str: + """List technical debt items, optionally filtered. + + Args: + domain: filter by domain + status: open | in_progress | resolved | deferred | wont_fix + debt_type: design | implementation | test | docs | dependencies | performance | security | other + severity: low | medium | high | critical + """ + return json.dumps(_get("/technical-debt", { + "domain": domain, "status": status, + "debt_type": debt_type, "severity": severity, + }), indent=2) + + +@mcp.tool() +def update_td_status(td_uuid: str, status: str) -> str: + """Update the status of a technical debt item. + + Args: + td_uuid: UUID of the technical debt item + status: open | in_progress | resolved | deferred | wont_fix + """ + td = _patch(f"/technical-debt/{td_uuid}", {"status": status}) + _post("/progress", { + "summary": f"Technical debt status → {status}: {td['title']}", + "event_type": "technical_debt", + "detail": {"id": td_uuid, "status": status}, + }) + return json.dumps(td, indent=2) + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- diff --git a/migrations/versions/a3f1c2d4e5b6_add_extension_points_and_technical_debt.py b/migrations/versions/a3f1c2d4e5b6_add_extension_points_and_technical_debt.py new file mode 100644 index 0000000..3629d52 --- /dev/null +++ b/migrations/versions/a3f1c2d4e5b6_add_extension_points_and_technical_debt.py @@ -0,0 +1,72 @@ +"""add extension_points and technical_debt tables + +Revision ID: a3f1c2d4e5b6 +Revises: 0b547c153153 +Create Date: 2026-02-27 00:00:00.000000 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "a3f1c2d4e5b6" +down_revision: Union[str, None] = "0b547c153153" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + epstatus = postgresql.ENUM( + "open", "in_progress", "addressed", "deferred", "wont_fix", + name="epstatus", create_type=True, + ) + tdstatus = postgresql.ENUM( + "open", "in_progress", "resolved", "deferred", "wont_fix", + name="tdstatus", create_type=True, + ) + + op.create_table( + "extension_points", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("ep_id", sa.String(30), nullable=True, unique=True), + sa.Column("domain", sa.String(50), nullable=False), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("location", sa.String(500), nullable=True), + sa.Column("ep_type", sa.String(50), nullable=False, server_default="other"), + sa.Column("status", epstatus, nullable=False, server_default="open"), + sa.Column("priority", sa.String(20), nullable=False, server_default="medium"), + sa.Column("topic_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("topics.id", ondelete="SET NULL"), nullable=True), + sa.Column("workstream_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_extension_points_ep_id", "extension_points", ["ep_id"]) + op.create_index("ix_extension_points_domain", "extension_points", ["domain"]) + + op.create_table( + "technical_debt", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("td_id", sa.String(30), nullable=True, unique=True), + sa.Column("domain", sa.String(50), nullable=False), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("location", sa.String(500), nullable=True), + sa.Column("debt_type", sa.String(50), nullable=False, server_default="other"), + sa.Column("severity", sa.String(20), nullable=False, server_default="medium"), + sa.Column("status", tdstatus, nullable=False, server_default="open"), + sa.Column("topic_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("topics.id", ondelete="SET NULL"), nullable=True), + sa.Column("workstream_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_technical_debt_td_id", "technical_debt", ["td_id"]) + op.create_index("ix_technical_debt_domain", "technical_debt", ["domain"]) + + +def downgrade() -> None: + op.drop_table("technical_debt") + op.drop_table("extension_points") + op.execute("DROP TYPE IF EXISTS tdstatus") + op.execute("DROP TYPE IF EXISTS epstatus")