Compare commits

...

200 Commits

Author SHA1 Message Date
193d873fdb feat: import extracted state hub implementation 2026-05-17 19:00:54 +02:00
66df41c48c docs: establish state hub baseline 2026-05-17 18:54:57 +02:00
a440120fa5 docs(state-hub): plan repo extraction 2026-05-17 18:48:31 +02:00
9dd71af8f9 feat(state-hub): CUST-WP-0040 — NATS lifecycle event publishing for activity-core
Makes the state hub an event publisher so activity-core can drive
maintenance automation declaratively via ActivityDefinitions, rather
than the hub creating tasks itself.

- api/events/: lazy JetStream publisher + EventEnvelope mirroring
  activity-core's contract; no-op when NATS_URL unset, fire-and-forget
  with logged failures so publishing never breaks an API request.
- Wired publishers on the five v1.0 lifecycle events:
    org.statehub.repo.registered        (POST /repos/)
    org.statehub.workstream.completed   (PATCH /workstreams/* on transition)
    org.statehub.decision.resolved      (POST /decisions/*/resolve)
    org.statehub.domain.goal.activated  (POST /domain-goals/*/activate)
    org.statehub.task.stale             (scripts/cleanup_stale_tasks.py)
- docs/nats-event-subjects.md: subject naming convention + catalog.
- docs/cron-migration.md: design stub for replacing custodian-sync
  systemd timer and cleanup-stale cron with ActivityDefinitions
  (depends on activity-core WP-0003).
- docs/activity-core-delegation.md: protocol, invariants, cutover plan.
- SCOPE.md: declares activity-core as downstream event consumer and
  restates that the state hub stays a read model, not a task factory.

Workplan: workplans/CUST-WP-0040-state-hub-nats-activity-core-integration.md
242 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 05:49:29 +02:00
7140174216 Update State Hub image build provenance 2026-05-15 15:02:30 +02:00
619fb72a78 perf(api): CUST-WP-0041 — DB indexes, TTL caches, noload on list endpoints
- Migration t7o8p9q0r1s2: indexes on tasks.status, tasks(workstream_id,status),
  workstreams.status, sbom_snapshots(repo_id,snapshot_at)
- workplan-index: 30 s TTL cache + ?refresh param (4171 ms → 16 ms on hit)
- /state/summary: 15 s TTL cache, bypassed on Cache-Control: no-cache
- /topics/: noload(workstreams, decisions, progress_events) (2382 ms → 115 ms)
- /domains/: noload(topics, repos, goals) (2252 ms → 39 ms)
- /repos/: noload(goals) (2222 ms → 599 ms first / fast on repeat)
- conftest: reset TTL caches between tests to prevent bleed-through

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:12:17 +02:00
90c5ea50f7 feat(dashboard): poll optimisation — T4, T5, T6
T4: workstreams.md and dependencies.md now call /state/deps instead of the
    full /state/summary — removes 2 heavy 10-table queries per 60 s cycle.

T5: index.md's 4 independent polling loops (summaryState, sbomSnapState,
    regsState, wsChartState) consolidated into a single pageState generator
    with one Promise.all batch and a shared backoff counter.

T6: config.js gains waitForVisible(ms) — pauses polling entirely while the
    tab is hidden and fires immediately on visibilitychange.  pollDelay()
    simplified (hidden-tab POLL_HIDDEN logic removed).  All 16 polling pages
    migrated from await sleep(pollDelay(...)) to await waitForVisible(pollDelay(...)).

CUST-WP-0039 complete — all 6 tasks done.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:58:18 +02:00
b832032cc3 feat(api): dashboard poll optimisation — T1, T2, T3
T1: Cache-Control max-age=60 on /topics/, /repos/, /domains/ list endpoints
    so repeated dashboard polls within a minute are served from browser cache.

T2: ETag middleware (md5 hash) on all JSON GET responses with conditional-GET
    (304 Not Modified) support; If-None-Match and ETag added to CORS headers.
    ETag registered inside CORS so 304s automatically carry CORS headers.

T3: GET /state/deps — lightweight dep-graph endpoint returning open workstreams
    with depends_on/blocks edges only, skipping the 10-table full-summary query.
    Prerequisite for T4 (switching workstreams.md and dependencies.md off /state/summary).

Workplan: CUST-WP-0039-dashboard-poll-optimization.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:26:30 +02:00
88ff588fb0 fix(api): restrict uvicorn --reload to source dirs only
Watching .venv/ (6k files) and dashboard/node_modules/ (6k files) was
causing sustained ~42% CPU on the uvicorn main process.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:26:48 +02:00
2484ed2815 Load limiting safeguards 2026-05-06 04:04:53 +02:00
47f6971c56 Updated repo onboarding 2026-05-04 19:18:10 +02:00
5429340f21 Improved workplan dependency management facilities 2026-05-04 11:45:24 +02:00
9d3963cead register project fix 2026-05-04 11:04:25 +02:00
a8228fc780 railiance-bootstrap to railiance-cluster rename cleanup 2026-05-03 16:30:07 +02:00
04bef63209 Locked in cytoscape.js as visualization for dep graph 2026-05-03 01:43:50 +02:00
26b9b95186 Codex state hub stuff added 2026-05-02 17:13:13 +02:00
e0f6a3b7a9 Overview shows workstreams by repo now and allows drilldown 2026-05-02 12:01:45 +02:00
e521f267ca Cleanup of documentation 2026-05-02 00:46:07 +02:00
a00f1b615b Task flow engine implementation 2026-05-02 00:21:14 +02:00
5502d1d535 Implemented foundation of task-flow-engine 2026-05-01 22:19:03 +02:00
c9695d51b1 Implemented Ad-Hoc Task handling 2026-05-01 21:27:52 +02:00
b8eb744e79 scope refactoring 2026-05-01 01:47:14 +02:00
fc725ec65f state-hub scope functionality work 2026-05-01 01:33:15 +02:00
45fb6e141d chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for the-custodian
2026-05-01 00:55:53 +02:00
548c3efe4a feat(state-hub): Interface Change Registry (CUST-WP-0033 T01-T06)
Adds first-class tracking for API and interface mutations across the
agent ecosystem. Breaking changes are documented, affected repos are
notified via inbox, and agents discover pending changes at session
start via the dispatch endpoint.

- Migration q4l5m6n7o8p9: interface_changes table
- Model/schema: InterfaceChange with draft→published→resolved lifecycle
- Router: POST/GET/PATCH /interface-changes/, /publish, /resolve actions
  (auto-notify affected repo agents on publish; progress event on origin)
- Dispatch: GET /repos/{slug}/dispatch now returns pending_interface_changes
- MCP tools: register_interface_change, list_interface_changes,
  publish_interface_change, resolve_interface_change
- Dashboard: /interface-changes page with type badges, planned calendar,
  published cards, and draft table
- EP-CUST-ICR-001 registered: webhook subscriptions (deliberately deferred)

First record: trailing-slash normalisation (2026-04-26), published,
affecting repo-registry — visible in repo-registry dispatch immediately.

223 tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:29:08 +02:00
768a8ba9c7 fix(api): normalize trailing slashes — no slash on param routes
Rule: trailing slash only on collection roots (/). Any route containing
a path parameter {…} uses no trailing slash. Applies across all routers,
scripts, Makefile, and tests. Fixes 307-redirect fragility on POST/PATCH
from naive clients (curl, Codex HTTP calls).

Also adds POST /repos/{slug}/sync — runs ADR-001 consistency check with
--fix via HTTP, so non-MCP agents (Codex) can self-service DB sync without
operator intervention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:13:01 +02:00
cadeb4a3b5 fix(sbom): resolve repo path from hub host_paths when --repo-path omitted
Previously defaulted to CWD ("."), causing ingest to silently scan the
state-hub directory instead of the target repo when called without
--repo-path. Now queries GET /repos/{slug}/ for host_paths[hostname]
and exits with a clear error if neither flag nor hub lookup succeeds.

Also deleted the incorrect SBOM snapshot for repo-registry (420 entries
that were actually state-hub packages).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:27:09 +02:00
9efa1f984d chore(deps): update uv.lock 2026-04-26 13:23:33 +02:00
1a8afaa371 feat(registration): add --codex flag and AGENTS.md template
- register_project.sh: parse --additional/--codex as named flags (not
  positional), skip MCP check in codex mode, generate AGENTS.md from
  agents-codex.template instead of CLAUDE.md + .claude/rules/
- agents-codex.template: new template for Codex repos — HTTP REST session
  protocol, inbox/progress curl examples, ADR-001 workplan convention
- Makefile: add register-codex-project target

Driven by onboarding repo-registry (first non-Claude-Code repo, first
repo under the capabilities domain).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:17:50 +02:00
4091b5c8ed Added missing api to the make guidance 2026-04-25 23:15:30 +02:00
6cbf2d2c56 feat(consistency): T04 push seal — closed-loop writeback for automated commits
Root cause of the 501-commit pile-up in inter-hub: fix_repo() created
git commits (brief updates, T03 writebacks) but never pushed them, so
the 15-minute timer accumulated local commits indefinitely. Once real
development landed on remote the repos diverged with no self-healing path.

Changes
-------
repo_sync.py (new module)
  Extracts all git lifecycle primitives: pull_ff, push_ff,
  count_remote_ahead (C-16 input), count_local_ahead (C-17/T04 input).
  Module docstring documents the push-seal invariant and stable state.

consistency_check.py
  - Imports primitives from repo_sync; thin _detect_behind_remote wrapper
    preserves backward compat for existing callers and tests.
  - C-17 backlog guard: if local has unpushed commits from a prior failed
    push, retry before making more; skip all writes if push still fails.
  - T04 push seal: unconditional push_ff() at end of every fix_repo() run.
  - _report_needs_action: ahead_of_remote param so repos with unpushed
    backlogs are not silently skipped as "clean" by fix_all_remote().
  - Domain-slug fallback: brief no longer degrades to "(unknown)" when all
    workplans are completed — falls back to any workstream for domain context.
  - Service switched from --all --fix to --remote --all (pulls before
    fixing, skips already-clean repos).

push-seal.md (new)
  Capability documentation: the problem, the invariant, all three checks
  (C-16/C-17/T04), stable-state description, API reference, and test map.

test_repo_sync.py (new, 32 tests)
  Full coverage of all four primitives via real git repos (tmp_path).
  Includes C-17 scenario, push-seal invariant, and four end-to-end
  loop-stability tests.

test_consistency_check.py
  Four new _report_needs_action cases for the ahead_of_remote parameter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:43:40 +02:00
34acc1cdcf Improved documentation of how to start everything 2026-04-20 00:04:46 +02:00
21b6a410c2 feat(token-events): auto-capture real token counts via PostToolUse hook
- Add PATCH /token-events/{id} endpoint to correct heuristic events
- Add `note` filter to GET /token-events/ list
- Add TokenEventPatch schema
- Add task_token_hook.py: PostToolUse hook that reads the Claude Code
  session transcript, computes per-task token delta, and replaces the
  heuristic token event with real measured counts (note="measured")
- Register hook in ~/.claude/settings.json on mcp__state-hub__update_task_status
  Covers both interactive sessions and ralph-workplan loops

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 22:38:45 +02:00
29fca2a0c6 make bridge target 2026-04-01 21:53:51 +02:00
09bbf62430 feat(capability-registry): CUST-WP-0031 domain capability registry
- Migration p3k4l5m6n7o8: nullable repo_id FK on capability_catalog
- PATCH /capability-catalog/{id} endpoint for back-filling repo attribution
- register_capability MCP tool accepts optional repo_slug
- get_domain_summary now includes compact capabilities list (type+title+repo_slug)
- New get_capability_profile MCP tool: domain → repos → capabilities tree
- 6 repo descriptions populated; 25 catalog entries attributed to repos
- 9 new capabilities registered for personhood, foerster_capabilities, coulomb_social
- TOOLS.md: Capability Catalog & Requests section with full tool reference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 17:23:45 +02:00
907e99e057 fix(dashboard): merge token + event charts into single dual-axis chart on Progress page
Replaces the two separate charts with one combined area+line chart.
Events use the left y-axis (steelblue); tokens use a normalized scale
with a right y-axis (amber) that formats values as k/M. When no token
data exists yet the right axis is omitted and a legend note explains.
Hover tooltips show actual values for both series.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 00:48:22 +02:00
063f58dfdd feat(dashboard): add tokens consumed per day chart to Progress page
Fetches /token-events/?limit=1000 in parallel with progress events and
renders a second area+line chart (amber) below the events-per-day chart,
aggregating tokens_in + tokens_out per calendar day over the same 30-day window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 00:42:09 +02:00
71ba017ce8 feat(dashboard): add repo filter, sort order, and max results controls to Token Cost page
Three reactive dropdowns below the Token Cost heading:
- Filter by repo: client-side filter via 3-level chain resolution
- Sort by: Tokens Total (default), Tokens In, Out, Event Count, Most Recent
- Show: 10/20/50/100/500 rows per table (default 20)

Applies uniformly to By Repo, By Workplan, and Top Tasks tables.
"Most Recent" derives last_event_at per group from the fetched events.
Truncated tables show a "Showing M of N" count below.

Completes CUST-WP-0030 T07–T09.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 00:02:17 +02:00
b28298a2ec feat(dashboard): entity list UX — REF column, name cells, detail pages (CUST-WP-0030)
- ref-cell.js: REF column component — click=copy deeplink, dblclick=open
- field-help.js: field registry + fieldRow helper with help-tip decoration;
  FK fields (task_id, workstream_id, repo_id) render as async-linked cells
  with entity-title bubble-help on hover
- GET /token-events/{id} endpoint + get-by-id tests
- GET /repos/by-id/{repo_id} UUID lookup endpoint
- Landing pages: /token-events/[id], /workstreams/[id], /repos/[slug], /tasks/[id]
- token-cost.md: REF + Name columns on all three tables; parallel fetch of
  workstreams/tasks for title resolution
- reference.md: entity detail page URL scheme documented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:35:35 +02:00
acb30978cd feat(token-tracking): repo aggregation via graph walk (task→workstream→repo)
By Repo now resolves via the full chain rather than requiring repo_id
directly on the token event:
  1. token_events.repo_id (direct)
  2. → workstreams.repo_id (via workstream_id)
  3. → task.workstream_id → workstreams.repo_id (via task_id)

Changes:
- Auto-populate repo_id on token events at creation time (both the
  token_events router and the tasks router)
- New GET /token-events/by-repo/ endpoint with RepoTokenSummary schema;
  returns tokens_in/out/total, event_count, by_model, by_note per repo
- Dashboard By Repo section uses /by-repo/ directly and shows repo_slug
  instead of a truncated UUID
- Backfilled the three existing events (userbased) with repo_id via SQL

185 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:05:23 +02:00
af3fdfde80 feat(token-tracking): introduce token note taxonomy (measured/userbased/workplan/heuristic)
Tier 1 (exact counts) now defaults to note="measured" instead of null,
signalling the counts were read from the Claude Code status bar.
Callers can pass note="userbased" when a human provided the numbers.

  measured  — agent read exact counts from the Claude Code status bar
  userbased — counts provided by a human
  workplan  — prorated from workplan total across task count
  heuristic — server fallback, 1000/500, no agent input

Added token_note field to TaskUpdate schema and exposed note param on
update_task_status and record_interactive_task MCP tools.
TOOLS.md documents the full taxonomy. 185 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:47:40 +02:00
8c87a9a799 feat(token-tracking): add record_interactive_task MCP tool
New tool for capturing ad-hoc work done outside formal workplans.
Finds or creates a persistent 'interactive-<repo>' workstream for the
repo, creates the task, marks it done, and records a token event using
the three-tier logic — all in a single call.

Seeded two example events on interactive-the-custodian:
  - Three-tier token recording on task done (8000/3500)
  - Add record_interactive_task MCP tool (4500/1800)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:36:51 +02:00
fdfd4365cd feat(token-tracking): three-tier token recording on task done
Token events are now always created when update_task_status is called
with status="done", using the best available data:

  Tier 1 (best): exact tokens_in + tokens_out passed by agent
  Tier 2:        workplan_tokens_in + workplan_tokens_out prorated
                 across workstream task count (note="workplan")
  Tier 3 (fallback): heuristic 1000 in / 500 out (note="heuristic")

Non-done status changes never create a token event.
MCP tool updated with workplan_tokens_in/out params and tiered docs.
Ralph-workplan skill files updated with the three-tier guidance.
184 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:28:18 +02:00
58e1bafce9 feat(token-tracking): record AI token consumption per task (CUST-WP-0029)
Introduces end-to-end token consumption tracking so agent work is
visible as a cost/effort metric alongside tasks and workplans.

- Migration o2j3k4l5m6n7: token_events table with FK indexes on
  task_id, workstream_id, repo_id, created_at
- ORM model, Pydantic schemas (TokenEventCreate, TokenEventRead with
  computed tokens_total, TokenSummary)
- Router: POST /token-events/, GET /token-events/ (7 filters),
  GET /token-events/summary/ (task|workstream|repo|commit|release scope)
- MCP tools: record_token_event, get_token_summary (formatted table)
- update_task_status enriched with optional tokens_in/tokens_out
  passthrough — one call creates status update + token event
- Dashboard token-cost.md page: by-repo bar, by-workplan table,
  by-model bar, top-10 tasks by tokens
- ralph-workplan skill updated with token reporting guidance and
  per-task heuristics for estimating counts
- Tests: test_token_events.py + test_token_passthrough.py (182 pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 17:46:46 +02:00
a486c63603 fix(consistency): prevent post-commit hook re-entrancy loop
The post-commit hook re-invokes fix-consistency, which commits writeback
changes, which re-triggers the hook — causing exponential process spawning.

Fix: pass GIT_CUSTODIAN_SYNC=1 in the env for all writeback git commits.
Update the post-commit hook (not tracked by git) to exit early when this
variable is set.

Also remove the --no-verify flag that was added as a failed attempt (it
only skips pre-commit/commit-msg, not post-commit hooks).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:55:07 +01:00
1f8ef7f88b feat(repos): git-fingerprint-based machine-independent repo identity
Add git_fingerprint (root commit SHA-1) to managed_repos as a stable,
machine-independent identifier — identical across every clone regardless
of checkout path, remote URL, or SSH alias.

- Migration n1i2j3k4l5m6: adds git_fingerprint column + non-unique index
  (non-unique to support repos that share ancestry via forks/splits)
- GET /repos/by-fingerprint?hash=<sha>[&remote_url=<url>]: lookup by
  fingerprint; optional remote_url disambiguates shared-ancestry repos
- GET /repos/by-remote?url=<url>: fallback lookup by remote URL
- consistency_check.py --here [PATH]: auto-detects repo slug from any
  local checkout via fingerprint (falls back to remote URL), then auto-
  registers host_paths[hostname] so subsequent runs need no override
- --all now includes repos with host_paths[current_hostname], not just
  those with local_path
- fix-consistency-here / check-consistency-here Makefile targets
- Fixed _api_get bug: httpx strips query strings when params={} is passed
- Backfilled fingerprints for 14 repos on this host

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:55:06 +01:00
3f96dc035d feat(mcp): add create_topic tool
POST /topics/ was already implemented in the REST API but had no MCP
wrapper, so agents couldn't create topics (e.g. inter_hub) via MCP.
Tool follows the same pattern as create_domain.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 01:32:52 +01:00
c5c8ee52eb fix(state-hub): fix mcp-http startup crash and remove legacy tunnel targets
- Add `Optional` to typing imports in mcp_server/server.py — it was used
  in 13 annotations but never imported, crashing FastMCP v3 at startup
- Remove legacy tunnel/tunnel-daemon/tunnel-loop/tunnel-status/tunnel-stop
  targets from Makefile; ops-bridge (tunnels-up/status/check) supersedes them

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 22:32:51 +01:00
fb6b786336 docs(dashboard): add technical reference page for Observable Framework dashboard
Documents the dashboard's architecture, framework choice rationale, data-fetching
strategies (static loaders + live polling), component library, page inventory,
and key features including the Workstream Health Index and entity modals.
Also registers the new page in the Reference nav and adds runbook section for
node overload / runaway agent process (INC-002) with hardening checklist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 00:09:18 +01:00
df2d14bae0 feat(brief): generate .custodian-brief.md per repo for offline worker orientation
Adds _write_custodian_brief() to consistency_check.py. After every fix_repo()
run, a .custodian-brief.md is written to the repo root with: domain, last-synced
timestamp, current repo goal, active workstreams with progress (done/total), and
the first 7 open tasks per workstream (blocked → in_progress → todo order) with
task IDs. The file is git-committed when content changes so remote workers (e.g.
CoulombCore) can pull it and orient without a live MCP connection.

Session protocol template and CLAUDE.md updated: read .custodian-brief.md first,
then call get_domain_summary() as an enhancement (skip if MCP unreachable).
This eliminates false "State hub is offline" alarms in subagents and remote workers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:48:36 +01:00
075b34945f feat(consistency): fix-consistency-remote works without REPO for all repos
Adds --remote CLI flag and fix_all_remote() function. When run without a
REPO argument, the target checks all registered repos and:
- Skips repos whose local path does not exist on this machine
- Skips repos that are already clean (no fixable issues, no FAILs, not
  behind remote, only C-08 background noise allowed)
- For repos that need work: git pull --ff-only then fix_repo()

Prints a summary of CLEAN (skipped) and NOT ON THIS HOST (skipped) repos
before the detailed fix reports.

Simplifies the Makefile target from shell-level curl+git to a single
uv run call using --remote. Same flag handles both single-repo and all-repos.

Also adds _git_pull() helper and 13 new tests (71 total in consistency suite).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 14:38:30 +01:00
e8bac88ba2 fix(consistency): correct behind-remote detection to not trigger on local-ahead
_detect_behind_remote was comparing HEAD != @{u} which incorrectly
triggered C-16 when the local repo had unpushed commits. Fixed to use
git rev-list --count HEAD..@{u} which only counts commits the remote
has that local lacks. Adds test_returns_false_when_local_ahead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 13:31:28 +01:00
505ace5617 feat(consistency): distributed multi-machine safety (CUST-WP-0026)
T01 — No-regress rule (C-15): fix-consistency now detects when a DB task
status is ahead of the workplan file (e.g. marked done on CoulombCore)
and emits C-15 WARN instead of regressing the DB back to the stale file
value. STATUS_ORDER ranking: todo(0) < in_progress/blocked(1) < done/cancelled(2).

T02 — Pull gate (C-16): fix_repo runs git fetch + rev-parse at the start
of every --fix run. If the local repo is behind its remote tracking branch,
all write operations are skipped and C-16 WARN is emitted. Best-effort:
offline/no-remote silently skips the check.

T03 — DB→file writeback: C-15 fix path patches the status field in the
matching task block and git-commits the change with a standard message.
--no-writeback flag disables writeback while keeping T01/T02 active.

T04 — CLAUDE.md + session-protocol.template updated with new guidance,
C-15/C-16 semantics, and fix-consistency-remote recommendation.

T05 — Makefile: fix-consistency-remote pulls then fixes in one step.

16 new tests; 155 passed total.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:19:23 +01:00
dff9806bb6 ops: establish ops/ directory with Gitea runbook and INC-001 incident report
- Create ops/runbooks/gitea-coulombcore.md — recovery checklist for Gitea
  on COULOMBCORE, documents containerd StartError pattern and CPU budget issue
- Create ops/incidents/2026-03-25-gitea-pgpool-crashloop.md — INC-001 post-mortem
  for 13-day Gitea outage (PGPool CrashLoopBackOff + rolling update CPU deadlock)
- Create ops/README.md — index for runbooks and incidents
- state-hub/dashboard/src/docs/connecting.md: add railiance01 tunnel config
  (was previously unsaved)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 11:30:44 +01:00
b3a44fb4f3 feat(capability-requests): add routing dispute & reroute workflow (CUST-WP-0027)
Adds a structured dispute mechanism when capability request routing is wrong:
- New `routing_disputed` status with four DB columns (dispute_reason, disputed_by,
  dispute_suggested_domain, disputed_at) via Alembic migration m0h1i2j3k4l5
- POST /capability-requests/{id}/dispute — any party can flag misrouting with a reason
  and optional suggested domain; notifies custodian + current fulfilling domain
- POST /capability-requests/{id}/reroute — custodian re-routes to correct domain via
  catalog_entry_id or direct slug; appends audit trail to routing_note; resets to requested
- Two new MCP tools: dispute_capability_routing and reroute_capability_request
- Dashboard: amber disputed-banner at top of Summary, routing_disputed Kanban column,
  dispute details (reason, suggested domain, raised-by) shown on disputed cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:58:52 +01:00
b6103d1f9f feat(dashboard): add Tools & Apps page with liveness probes
New page at /tools listing all connected applications grouped by
category: Local Services (State Hub API, KeePassXC, pgAdmin, ops-bridge),
Source Control (Gitea), Identity/Auth (KeyCape, Authelia, privacyIDEA,
LLDAP), and Dev Tooling (Claude Code, uv). Local services show live
green/red/grey status dots via no-cors fetch probes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 01:18:11 +01:00
8949e34e75 feat: add FOS/credential standards, big-picture guidance, and CUST-WP-0025 workplan
- canon/standards/credential-management_v0.1.md: single root-of-trust credential hierarchy standard
- canon/standards/federated-organization-standard_v1.0.md: FOS reference architecture (VSM-based)
- wiki/BigPictureGuidance.md: integration guidance for OAS + FOS orthogonal layers
- workplans/CUST-WP-0025-fos-hub-bootstrap.md: 4-phase plan (identity, hub-core extraction, ops-hub, fin-hub)
- state-hub/Makefile: treat exit 2 (warnings-only) as success in check-consistency targets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:48:13 +01:00
62d407cae7 feat(capability-requests): add routing_note, PATCH endpoint, word-boundary fix, and ops-bridge tunnel targets
- Add `routing_note` column (migration l9g0h1i2j3k4) to persist why a request was routed to a given domain
- Fix substring-match bug in `_route_capability`: use `\b` word-boundary regex so 'postgres' no longer matches inside 'postgresql'
- Include `title` in keyword scoring for better routing accuracy
- Return `routing_note` string from `_route_capability` and store it on the request
- Add `PATCH /capability-requests/{id}` endpoint + `CapabilityRequestPatch` schema to correct mutable metadata (catalog_entry_id, priority, blocking_task_id, fulfilling_workstream_id)
- Add `patch_capability_request` MCP tool wrapping the new endpoint
- Add 105 lines of routing tests (word-boundary, title-match, multi-entry scoring, broadcast fallback)
- Add `tunnels-up`, `tunnels-status`, `tunnels-check` Makefile targets for ops-bridge managed tunnels

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 03:47:54 +01:00
101c953e69 docs: add State Hub reference page and restructure reference index
New page (docs/state-hub.md) covers:
- Why: the invisible state problem across repos and agents
- What: Derived Data Store, Read Model, Agent Orchestration Layer,
  Cross-Repo Observatory — and what it is NOT
- Derived Data Store principle (ADR-003): fingerprint cache, rebuild
  guarantee, force-refresh
- Repository Orchestrator: session protocol, cross-domain coordination
  via messages + capability routing, Kaizen agents
- Architecture diagram (ASCII), technology choices, data model overview
- Running the hub, design principles, related docs

reference.md: add Architecture & Design section grouping state-hub,
TPSC, GDPR maturity, SCOPE.md, capabilities, and goals docs.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 02:01:58 +01:00
1ee0343f75 perf(doi): fingerprint-based DB cache for DoI results
Adds doi_cache table (migration k8f9a0b1c2d3). Results are stored after
each evaluation and reused on subsequent requests when the fingerprint
matches. Fingerprint covers repo.updated_at, latest TPSC snapshot_at,
latest goal updated_at, and mtime of SCOPE.md / CLAUDE.md / tpsc.yaml.

Behaviour:
- Summary (warm cache, nothing changed): ~0.4s (was 0.9s)
- Summary (one repo stale): ~0.9s (only stale repos recomputed)
- Single repo (cache hit): ~0.2s (was 40s for full check)
- Single repo ?force_refresh=true: ~2s (full C7/C13 subprocess check)

Total journey: 108s (original) → 6s → <1s → 0.2s (cached single repo)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 01:47:19 +01:00
245cd72ba3 perf(doi): eliminate HTTP self-calls in summary — 48 calls → 3 bulk DB queries
Root cause: C2/C9/C10 each made a full HTTP round-trip back to the API
(asyncio.to_thread → urllib → TCP → uvicorn → SQLAlchemy → DB) for every
repo. 16 repos × 3 calls = 48 self-calls at ~80-150ms each = ~6s total.

Fix: doi_engine.evaluate() accepts a prefetch dict. The summary endpoint
runs 3 bulk GROUP BY queries (domain status, TPSC snapshot counts, active
goal counts) and passes results directly — zero HTTP self-calls in summary
mode.

Result: /repos/doi/summary 6s → <1s (6× improvement on top of prior 13×).
Total improvement from original: 108s → <1s.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 01:37:40 +01:00
9ba1501b49 perf(dashboard): lazy-load DoI tiers on Repositories page
Page now renders in ~200ms. DoI badges and KPI card show a spinner
while the background fetch resolves (~6s), then update reactively
via Observable Mutable pattern (doiData / doiLoading).

Fast path: repos, SBOM, domains, workstreams — immediate render.
Slow path: /repos/doi/summary — background, non-blocking.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 01:31:48 +01:00
27e755815f perf(doi): 13x speedup for /repos/doi/summary (108s → ~6s)
Two fixes:
1. skip_consistency=True in summary mode — omits C7/C13 subprocess calls
   (consistency_check.py) which were the main bottleneck (32 spawns for 16 repos).
   Full check still available per-repo via GET /repos/{slug}/doi.
2. asyncio.gather — all repos evaluated in parallel instead of sequentially.

Also: rename Repositories page title from "Repos" to "Repositories".

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 01:29:27 +01:00
5eeeeeb6c4 feat(doi): Repository DoI automated gate and dashboard integration (CUST-WP-0024)
Implements the 14-criterion DoI checklist as a runnable gate with API,
MCP tools, CLI script, and dashboard integration.

Core components:
- api/doi_engine.py — async engine evaluating all 14 criteria (asyncio.to_thread
  for non-blocking HTTP self-calls), shared by API and CLI
- api/schemas/doi.py — DoICriterion, DoIReport, DoISummaryEntry schemas
- api/routers/repos.py — GET /repos/{slug}/doi + GET /repos/doi/summary
- scripts/check_doi.py — CLI: make check-doi REPO=<slug> / check-doi-all
- mcp_server/server.py — check_repo_doi(), get_doi_summary() tools

Dashboard (repos.md):
- DoI tier badge per repo (None/Core/Standard/Full) colour-coded red→green
- Domain block shows lowest DoI tier across its repos
- DoI KPI card in summary row
- DoI filter in All Repos Table
- Link to Repository DoI policy page

Also fixes: TPSC snapshots 500 error (missing nested selectinload for
catalog_entry relationship in list_snapshots endpoint).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 01:08:18 +01:00
61f07c08bb docs(policy): add heading to workstream-dod policy file
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 00:42:08 +01:00
33c58233bc docs(policy): add Repository Definition of Integrated (DoI)
Three-tier checklist defining what 'fully integrated with the state-hub'
means for a repository:
- Core (Registered): registered, domain assigned, path resolves, remote URL
- Standard (Integrated): SCOPE.md, CLAUDE.md, workplan convention, SBOM, TPSC
- Full (Fully Integrated): repo goal, capabilities declared, agents template,
  clean consistency check, host paths registered

Exposed via /policy/repo-doi (editable in dashboard) and linked under Policies.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 00:35:54 +01:00
9155d13887 docs(tpsc): add GDPR Maturity Model reference page
Full reference for the 7-level CNIL/IAPP CMMI-aligned scale used in TPSC:
source frameworks, per-level descriptions, suitability guidance, key GDPR
concepts (DPA, SCCs, adequacy, BCRs, Art.9), assignment decision tree,
and authoritative references.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 00:19:07 +01:00
60beb1ff35 feat(tpsc): Third-Party Services Catalog (CUST-WP-0023)
Introduces TPSC for tracking external service dependencies with GDPR
compliance maturity (CNIL/IAPP CMMI scale), pricing model, ToS, and
data retention information across all repos.

Primary data:
- canon/tpsc/{openai,anthropic,gemini,openrouter}-api.yaml — service definitions
- tpsc.yaml in each repo (llm-connect seeded with 4 services)

State-hub additions:
- Migration j7e8f9a0b1c2: tpsc_catalog + tpsc_snapshots + tpsc_entries
- api/models/tpsc.py, api/schemas/tpsc.py, api/routers/tpsc.py
- /tpsc/catalog/, /tpsc/ingest/, /tpsc/snapshots/, /tpsc/report/gdpr endpoints
- 4 MCP tools: register_service, list_services, ingest_tpsc_tool, get_gdpr_report
- scripts/ingest_tpsc.py + make ingest-tpsc[/-all] targets
- Dashboard: tpsc.md page + docs/tpsc.md

GDPR maturity scale: unknown | non_compliant | initial | developing | defined | managed | certified
Warnings triggered at: unknown, non_compliant, initial

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 00:15:26 +01:00
4e28cab297 fix(mcp): resolve repo paths with existence check before trusting hostname match
Stale host_paths entries (wrong username, old machine) were silently overriding
the correct local_path, causing FileNotFoundError on tools like list_kaizen_agents.

Extracts _resolve_repo_path(repo) helper that tries host_paths[hostname] first
but validates the path exists on disk before trusting it, then falls back to
local_path. Both candidates support ~ expansion. Applied to all 4 call sites:
_kaizen_agents_dir, validate_repo_adr, check_repo_consistency, ingest_sbom_tool.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 21:38:35 +01:00
d45234531b feat(capability-requests): add cross-domain capability catalog and request routing
Introduces a capability catalog (CUST-WP-0022) so domains can advertise what
they provide and agents can request capabilities from other domains with
auto-routing, lifecycle tracking, and task-unblocking on completion.

- New models: CapabilityCatalog, CapabilityRequest with full lifecycle
  (requested → accepted → in_progress → ready_for_review → completed/rejected/withdrawn)
- Migration i6d7e8f9a0b1: capability_catalog + capability_requests tables
- Router /capability-catalog and /capability-requests with accept/status endpoints
- 7 new MCP tools: register_capability, list_capabilities, request_capability,
  accept_capability_request, update_capability_request_status,
  list_capability_requests, get_capability_request
- StateSummary gains open_capability_requests count
- Dashboard: capability-requests.md page + docs/capabilities.md + docs/scope.md
- SCOPE.md: three seed capabilities documented (MCP registration, state tracking, SBOM)
- scope.template: Provided Capabilities section with example block
- scripts/ingest_capabilities.py + make ingest-capabilities[/-all] targets

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 21:07:50 +01:00
7bf3cf583a fix(dashboard): enrich repo-sync page with live SBOM snapshot stats
repos.json.py now fetches /sbom/snapshots/ alongside /repos/ and
annotates each repo with sbom_snapshot_count, sbom_entry_count, and a
last_sbom_at fallback derived from actual snapshot data. This prevents
"LastSBOM=never" when the denormalized field is out of sync.

repo-sync.md gains SBOM KPI tiles (ingested vs no-SBOM), color-coded
SBOM age column (same green/orange/red scale as state sync), and an
entry count column showing packages from the latest snapshot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 01:34:02 +01:00
bd1b01fdc0 feat(sbom): add go.sum parser to ingest_sbom.py
Parses go.sum lockfiles for Go projects. Reads go.mod alongside to
mark direct vs indirect dependencies. Deduplicates by (module, version),
skipping go.mod hash lines.

Used to ingest key-cape (netkingdom domain): 23 Go modules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 01:04:34 +01:00
1bcc46ea3f fix(dashboard): clear API-unreachable warning when API recovers
Always call display() for the warning element so Observable Framework
replaces it on each poll re-run. Previously the conditional display()
call left the warning rendered indefinitely once shown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 00:51:11 +01:00
b76849a60d docs(mcp): switch MCP transport stdio → SSE, update all references
MCP server is now a persistent SSE service on :8001 (make mcp-http),
independent of the Claude Code session. Re-registration is a single
claude mcp add-json command; no patch_mcp_cwd.py needed.

- Makefile: mcp-http is primary transport, add fuser restart + updated comment
- state-hub/README.md: stack table, MCP section, troubleshooting note updated
- CLAUDE.md (project): registration instructions rewritten for SSE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 00:05:56 +01:00
ebf7c544f6 refactor(makefile): rename backend → api, fold raw uvicorn target in
The old bare `api` target (uvicorn only) is subsumed into the new `api`
target (db + postgres-wait + migrate + fuser-restart + uvicorn). Updated
all doc references and cleaned up duplicate entries left by the rename.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 23:20:45 +01:00
8ec8b22c88 fix(makefile): use fuser port-kill instead of pkill pattern for restart
pkill -f matched the shell subprocess's own argv (which contains the
pattern as a -c argument), causing make to receive SIGTERM and abort.
fuser -k 8000/tcp / 3000/tcp targets only the process bound to the
port — no self-kill risk.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 23:18:31 +01:00
959449d82f refactor(makefile): rename start → backend, add restart logic for api and dashboard
- `make backend` replaces `make start`; polls postgres with nc (up to 10s)
  instead of fixed sleep, kills any running uvicorn before starting fresh
- `make dashboard` kills any running observable preview before restarting
- Update all references in CLAUDE.md, README.md, SCOPE.md, state-hub/README.md,
  and dashboard/src/docs/live-data.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 23:16:44 +01:00
15b72c6739 feat(mcp): add list_tasks(workstream_id) tool — resolves FR 7074fd47
Agents had no way to look up task UUIDs by workstream; they were stuck
unable to call update_task_status without already knowing the UUID.
list_tasks() wraps GET /tasks with workstream_id filter, returning
[{id, title, status, priority}] for all matching tasks.

FR raised by kaizen-agentic worker on COULOMBCORE while syncing
KAIZEN-WP-0002 task IDs. Marked merged in contributions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 23:01:22 +01:00
4feba3e8d2 feat(CUST-WP-0021): multi-host repo path hardening — all 5 tasks complete
- T01 (done prior): registered host_paths for bnt-lap001 (14 repos) and
  COULOMBCORE (6 repos) via POST /repos/{slug}/paths/
- T02: validate_repo_adr now accepts repo_slug (not raw path); resolves
  local path via host_paths[hostname] → local_path; clear error for
  unregistered/missing paths
- T03: ingest_sbom_tool lockfile_path is now optional and relative to
  resolved repo root; absolute paths accepted with deprecation warning
- T04: check_repo_consistency pre-flight guard — fetches repo, resolves
  path, returns clear error before spawning subprocess if path missing
- T05: TOOLS.md — updated validate_repo_adr row (slug not path);
  added Multi-Host & Remote Agent Usage section documenting design
  boundary, remote agent workflow, and update_repo_path usage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 22:53:25 +01:00
75d25e9d3b feat(tests): pytest-asyncio test suite — 119 tests across 3 modules
Infrastructure (T01):
- tests/conftest.py: sync schema setup (psycopg2), per-test table
  truncation, async ASGI client with get_session override
- pyproject.toml: [tool.pytest.ini_options] asyncio_mode=auto
- Makefile: make test target with TEST_DATABASE_URL

Core router tests (T02): 19 tests
- domains, topics, workstreams, tasks, decisions + state summary
- Caught real bug: topic router missing duplicate-slug 409 guard (fixed)

TD/EP/Contributions/SBOM tests (T03): 10 tests
- CRUD + status transitions + lifecycle guard + SBOM ingest

MCP smoke tests (T04): 12 tests
- get_state_summary, create_task, update_task_status,
  add_progress_event, flag_for_human HTTP shapes

CI gate (T05): make test documented in CLAUDE.md session protocol

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 12:00:06 +01:00
2522464ced fix(consistency_check): heading titles + workstream-aware task guards
- parse_task_blocks() now injects the nearest preceding ### heading
  text as `title` — tasks no longer stored with bare IDs as their title
- C-11 fix skips creating tasks when workstream is completed/archived
  (prevents duplicate task creation on repeated fix-consistency runs)
- C-12 is now fixable: auto-cancels open orphan DB tasks when the
  backing workstream is finished (completed/archived)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 08:05:07 +01:00
2d0ce8f943 feat(api): CUST-WP-0018 — API hardening & code quality
T01: Fix datetime.utcnow() → datetime.now(tz=timezone.utc) in MCP server
T02: Wrap _get/_post/_patch/_delete with try/except; return error dicts
T03: Log warnings when write_log skips missing project path
T04: Add priority + due_date_before filters to GET /tasks/
T05: Add owner + slug filters to GET /workstreams/
T06: Add offset param to GET /progress/ for proper pagination
T07: Low-severity bundle:
  - CORS origins from CORS_ORIGINS env var (TD-017)
  - seed.py upsert domains+topics on re-run (TD-011)
  - normalise filter bar CSS → filter-text-input everywhere (TD-016)
  - add 30.5 avg-days-per-month comment in decisions.md (TD-019)
  - TD-009, TD-018 already resolved by existing code

Closes CUST-WP-0018.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 02:17:04 +01:00
cb2c4f9a0c fix(mcp): accept JSON string for add_progress_event detail param
FastMCP validates dict | None strictly, rejecting a JSON string even if
parseable. Broaden to dict | str | None and coerce in the function body
so callers don't need to pre-parse the detail payload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 02:11:35 +01:00
b66291aac1 fix(dashboard): rename Repository → Repositories, Policy → Policies
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 02:08:54 +01:00
e9190c179f fix(dashboard): pin Overview as first nav entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 02:07:03 +01:00
b4e26fdc8f feat(dashboard): reorder nav — flat pages first (alpha), sections below (alpha), Reference last
Sub-pages within sections also sorted alphabetically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 02:06:23 +01:00
f0e9bb0143 feat(dashboard): CUST-WP-0019 — Repository nav section, config.js cleanup
T01: Restructure nav — "Repos" → collapsible "Repository" section with
     Repo Sync, SBOM, Debt as sub-pages; Debt moved out of Workstreams
T02: workstream-dod.md migrated from inline const API to config.js import
T03: todo.md suggestion filter (done in previous commit)

Closes CUST-WP-0019. Resolves UI suggestion c2fc284a.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 02:01:10 +01:00
0969f3258c feat(workplans): CUST-WP-0018/0019/0020 — API hardening, dashboard UX polish, test suite
Consolidates all open technical debt into three workplans:
- CUST-WP-0018: API hardening & code quality (TD-006–019 medium/high items)
- CUST-WP-0019: Dashboard UX polish (Repos nav restructure, config.js cleanup,
  todo filter fix for new suggestion workflow statuses)
- CUST-WP-0020: pytest test suite with real DB (TD-014)

Also fixes todo.md Suggestions filter: was checking status===open but new
suggestions enter with status=submitted; now excludes terminal statuses only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 01:55:37 +01:00
d9b9a0eaec feat(dashboard): extend suggestions to TOC right margin + 1s shift delay
- Shift+click now works on #observablehq-toc links, KPI boxes, and [id] elements
- _inferWidgetName detects TOC context and labels suggestions accordingly
- Click handler adds inToc branch alongside existing inSidebar
- _updateMode: 1-second setTimeout before activating highlight mode
  so normal Shift+typing doesn't flicker the UI; clears immediately on
  Shift release or window blur

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 01:42:11 +01:00
e7565ce789 feat(dashboard): extend shift+click suggestions to sidebar navigation
- Click handler: sidebar <a> and <summary> are no longer excluded;
  e.preventDefault() stops navigation while shift is held
- _inferWidgetName: sidebar-first branch returns nav link text,
  section heading text, or "Navigation" fallback
- pageName is "Navigation" for sidebar clicks (not the current page title)
- CSS: sidebar a and summary highlighted (dashed indigo outline + tint)
  when shift is held, same as main content widgets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 01:29:13 +01:00
1f1da56533 feat(suggestions): full suggestion workflow with per-step notes
DB migration h5c6d7e8f9a0:
- Extends tdstatus enum: submitted → analyse → plan → implement →
  test → review → finished (+ wont_fix remains)
- New td_notes table: td_id FK (CASCADE), step, author, content, created_at

API:
- TDNote model + TDNoteCreate/TDNoteRead schemas
- TDRead includes notes[] (selectin loaded)
- New routes: GET/POST /technical-debt/{id}/notes/
- list_td status filter accepts str (all enum values)

Modal: new submissions use status="submitted" instead of "open"

UI Feedback page revamp:
- Visual step-by-step stepper (submitted→analyse→plan→implement→test→review→finished)
- Per-step notes: view all notes, add note inline
- Action buttons: advance to next step, won't fix
- Review step highlighted as awaiting original suggester confirmation
- Closed items (finished/wont_fix) shown with last 2 notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:57:34 +01:00
7566851335 fix(dashboard): repair broken SBOM card on Overview
The card titled "SBOM" was displaying contribution type counts (FR/BR/EP/UPR)
which is unrelated to SBOM data. Added a sbomSnapState generator that fetches
/sbom/snapshots/ and shows: total tracked packages (sum of entry_count across
all snapshots), repos tracked, and copyleft risk count from the existing
licence_risk_count in the summary. Card turns orange if licenceRisk > 0.

Resolves suggestion b6775727.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:42:30 +01:00
6cd9f75d7e fix(dashboard): domain field name in TD payload; rename Improvements → Suggestions
- improvement-modal.js: API expects `domain` not `domain_slug` (422 fix)
- todo.md: section heading and KPI label renamed to "Suggestions"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:36:46 +01:00
71488729a1 fix(dashboard): inline improvement modal script via readFileSync in config
Observable Framework proxies all src/*.js files through its own bundler —
<script type="module"> imports from <head> resolve to circular re-export
shims and never execute. The fix: read improvement-modal.js at config load
time in Node.js, strip the ES module export keyword, and interpolate the
content as a plain <script> block in the head config. It runs on every page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:32:54 +01:00
f3568cb111 fix(dashboard): inject improvement modal via head config, not _footer.md
_footer.md is not a supported special file in Observable Framework 1.13.3
and was silently ignored. The preview server does serve src/*.js files at
their root-relative path, so the correct approach is a <script type="module">
in the head config — runs once on page load, persists across SPA navigation.
Removed _footer.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:28:06 +01:00
4d0941b524 fix(dashboard): robust shift-mode tracking via mousemove + element highlights
- updateMode() now subscribes to keydown, keyup AND mousemove so the
  body class stays in sync regardless of where focus is (mirrors the
  pattern from the working modifier-click demo)
- cursor: copy replaces crosshair (matches copy-affordance semantics)
- figure, h2–h4 and [data-widget-name] elements get a dashed indigo
  outline + subtle background tint when shift is held, so the user
  can see which elements are annotatable before clicking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:18:56 +01:00
46f4b0c25d feat(dashboard): shift+click trigger + Improvements section in Todo
improvement-modal.js:
- Replace contextmenu handler with click+shiftKey check — browser
  context menu is no longer intercepted
- Add keydown/keyup/blur listeners: holding Shift applies
  .impr-shift-mode to <body>, switching cursor to crosshair
  across the entire page as a visual affordance
- Update hint text to "Ctrl + Enter to submit · Escape to cancel"

todo.md:
- New "Improvements" section shows open dashboard-improvement TD items
  with a "review →" link to the UI Feedback page
- KPI sidebar row added for open improvement count (indigo when > 0)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:56:34 +01:00
b558610de6 feat(dashboard): right-click improvement modal + UI Feedback page
- improvement-modal.js: global contextmenu handler that opens a modal
  showing page/widget context; submits suggestions as TD items with
  debt_type="dashboard-improvement" to POST /technical-debt/
- _footer.md: shared Observable footer that auto-initialises the modal
  on every dashboard page
- ui-feedback.md: review/approval page — lists open suggestions with
  resolve / won't-fix / in-progress action buttons; archived items shown
  below; live-polled
- observablehq.config.js: "UI Feedback" added under Workstreams group

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:47:59 +01:00
fcf0515874 fix(consistency): C-14 ghost-duplicate check + CLAUDE.md sync rule
Root cause analysis: calling create_workstream() before writing the workplan
file creates a ghost workstream with repo_id=null. When fix-consistency later
runs on the file, it creates a second workstream and writes its ID into the
file — leaving the ghost permanently active and showing false partial progress
in the dashboard.

C-14: after checking file-backed workstreams, query active workstreams on the
same topic with repo_id=null. Flag any whose title matches a file-backed
workstream as a probable ghost duplicate.

CLAUDE.md: add explicit "workplan ↔ DB sync rule" prohibiting create_workstream()
for file-backed work. Write file first, then make fix-consistency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:23:24 +01:00
d86b3cec14 feat(CUST-WP-0017): scope-analyst agent + SCOPE.md template + coverage
T01: copy agent-scope-analyst.md to the-custodian/agents/
T02: add scope.template, prepend @SCOPE.md to claude-md.template,
     update register_project.sh to write SCOPE.md stub on new registration,
     add scope-analyst row to TOOLS.md
T03: SCOPE.md for the-custodian itself
Workplan: CUST-WP-0017 registered in state-hub

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:10:30 +01:00
8619cd2218 feat(CUST-WP-0016): kaizen-agentic integration — MCP tools, templates, direct install
- Fix /domains/{slug}/ 500: EP/TD queries now use domain_id FK (not string column)
- Remove dead cascade-slug code in rename_domain (FK handles it)
- MCP: list_kaizen_agents(category?) + get_kaizen_agent(name) via resolve_repo_path()
- TOOLS.md: Kaizen Agents section with discovery/load pattern
- agents.template: new project rule for consumer repos
- claude-md.template + register_project.sh: include agents.md in new-project scaffolding
- agents/: direct install of 6 curated agents for hub sessions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 22:24:30 +01:00
196e6c5aed feat(register): modular @-import CLAUDE.md structure (ops-bridge pattern)
Replaces the monolithic project_claude_md.template with a directory of
7 focused rule files in scripts/project_rules/. register_project.sh now
generates .claude/rules/*.md + a thin CLAUDE.md index of @-imports,
matching the pattern established in ops-bridge.

Template files:
  claude-md.template          — 9-line @-import index
  repo-identity.template      — purpose, domain, slug, topic ID (machine-gen)
  session-protocol.template   — orient/inbox/workplans/brief/close (machine-gen)
  first-session.template      — bootstrap flow; delete once past FSP
  workplan-convention.template— prefix, location; delegates to global CLAUDE.md
  stack-and-commands.template — language/deps/commands (stub, manual)
  architecture.template       — design overview (stub, manual)
  repo-boundary.template      — what this repo does NOT own (stub, manual)

register_project.sh changes:
  - Generates .claude/rules/ from templates with variable substitution
  - Writes thin CLAUDE.md if none exists; appends suggestion comment if one does
  - Step 7: auto-registers this machine's local path via POST /repos/{slug}/paths/
  - project_claude_md.template deprecated to a redirect notice

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 18:35:02 +01:00
82552b8d59 feat(repos): multi-machine path support via host_paths
Adds a JSONB column `host_paths` to managed_repos mapping
hostname → absolute local path. Fixes the consistency-checker
failure when the same repo lives at different paths on different
machines (e.g. /home/worsch/marki-docx on the workstation vs
/home/tegwick/marki-docx on custodiancore).

Changes:
- Migration g4b5c6d7e8f9: adds host_paths JSONB (default {})
- Model: host_paths Mapped[dict] column
- Schemas: host_paths in RepoRead; new RepoPathRegister schema
- Router: POST /repos/{slug}/paths/ — merges one host entry
- consistency_check.py: resolve_repo_path() prefers host_paths
  [hostname] over local_path; --repo-path CLI override added
- MCP: update_repo_path(slug, path, host?) tool
- Makefile: register-path target; REPO_PATH passthrough on
  check-consistency and fix-consistency targets
- TOOLS.md: documents update_repo_path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 16:30:55 +01:00
d66f23026d feat(consistency): add C-13 workstream-auto-complete check
Detects when all DB tasks are done/cancelled but the workstream status
is still 'active' — the pattern where a worker completes tasks via MCP
but forgets to call update_workstream_status(). Auto-fixable via --fix.

Also extends the C-04/C-05 fix path to handle C-13 (same PATCH logic).

Motivated by marki-docx WP-0001/WP-0002 visibility gap (2026-03-16).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 09:03:20 +01:00
b8da3e6ae4 docs: add inbox check to project CLAUDE.md template (CUST-WP-0015)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 02:56:16 +01:00
4b3cb1b039 feat(CUST-WP-0015): implement agent inbox for inter-agent coordination
Adds a message-passing layer to state-hub so Claude instances can
coordinate across sessions without polling shared progress events.

- Migration f3a4b5c6d7e8: agent_messages table with thread support
- FastAPI router: POST/GET /messages/, thread view, mark-read, archive, reply
- 4 MCP tools: send_message, get_messages, mark_message_read, reply_to_message
- Observable dashboard: /inbox page with unread/read/archived sections + KPI
- CLAUDE.md updates: global, custodian, marki-docx, activity-core, template
- TOOLS.md: Agent Inbox tools section documented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 02:55:45 +01:00
5e7a72e144 feat(CUST-WP-0014): repo sync automation & Gitea inventory
- Migration e2f3a4b5c6d7: add last_state_synced_at to managed_repos
- consistency_check.py: PATCH last_state_synced_at after fix run;
  fix ~ treated as non-empty state_hub_task_id (C-03 vs C-11);
  fix _inject_task_id_into_block skipping injection when field exists
  with null value
- install_hooks.sh: idempotent post-commit hook installer for all
  registered repos (make install-hooks REPO= / install-hooks-all)
- gitea_inventory.py: compare coulomb Gitea org against state-hub
  registered repos — registered / unregistered / hub-only sections
- infra/README.md: document systemd user timer + crontab fallback
- systemd user timer: custodian-sync.{service,timer} runs
  fix-consistency-all every 15 min (enabled)
- dashboard/src/repo-sync.md: Repo Sync Health page — sync age table,
  unregistered Gitea repos, hub-only repos
- api/routers/repos.py: GET /repos/{slug}/dispatch endpoint returning
  active goal, pending tasks per workstream, human interventions
- mcp_server/server.py: get_repo_dispatch() MCP tool

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 01:41:16 +01:00
a2db606dcc docs(dashboard): add Ralph Workplan reference page
Covers installation, usage, workplan file format, task status lifecycle,
custodian naming conventions, COULOMBCORE usage, and manual cancellation.
Registered in Reference nav + reference.md index.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:29:48 +01:00
fbdc6dda80 docs(dashboard): add Connecting to the Hub reference page
Covers local setup, remote (COULOMBCORE) one-liner registration,
ops-bridge tunnel config, bridge states, MCP transport modes, and
adding new remote hosts. Registered in Reference nav + reference.md index.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:22:43 +01:00
f3fca3088f feat(ops-bridge): add HTTP/SSE MCP transport for remote Claude Code sessions
server.py: MCP_TRANSPORT and MCP_PORT env vars select transport at startup
  (default: stdio — no behaviour change for local use)
Makefile: `make mcp-http` starts SSE server on 127.0.0.1:8001

Remote registration (one-liner on COULOMBCORE after tunnel is up):
  claude mcp add-json -s user state-hub \
    '{"type":"sse","url":"http://127.0.0.1:18001/sse"}'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:13:14 +01:00
7b7b725f8b fix(consistency): fix C-04 status vocabulary mismatch + surface PATCH errors
Root cause: workplan files use "done" (task vocabulary) but the DB workstream
API only accepts "completed". The PATCH was silently failing with 422.

Fixes:
- Add FILE_TO_DB_WORKSTREAM_STATUS map and normalise_workstream_status()
- Normalise file status before C-04 comparison: done↔completed is no longer
  spurious drift
- Normalise file status before PATCHing: always send DB-valid "completed"
- _api_patch now returns {"_error": ...} instead of None on failure, so the
  fix loop reports FAILED entries rather than silently dropping them
- 9 new tests in TestNormaliseWorkstreamStatus (42 total)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 21:57:11 +01:00
c8f08b803d test(CUST-WP-0008): add unit tests for consistency_check.py pure layer
33 offline tests covering: parse_frontmatter, parse_task_blocks,
get_tasks_from_workplan, ConsistencyReport severity filtering,
render_text output, and report_to_dict serialisation.

Closes the DoD automated-tests gap for the Consistency Engine workstream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 21:31:50 +01:00
f06cad2ac7 docs(policy): define workstream Definition of Done criteria
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 20:59:45 +01:00
df083b1840 feat(sbom): CUST-WP-0013 — expand SBOM infra to terraform, ansible, and tool manifests
- Migration d6e7f8a9b0c1: add terraform, ansible, tool to Ecosystem enum
- ingest_sbom.py: new Ansible Galaxy requirements.yml parser (collections + roles)
- ingest_sbom.py: new sbom-tools.yaml manifest parser (agent-generated tool deps)
- ingest_sbom.py: promote .terraform.lock.hcl parser from ecosystem=other → terraform
- ingest_sbom.py: detect_all() runs all four parsers in one comprehensive scan
- capture_sbom_tools.py: agent-assisted tool manifest generator (claude -p)
- prompts/sbom-capture-agent.md: parameterised prompt for repo tool discovery
- Makefile: capture-tools target; ingest-sbom updated docs and DRY_RUN support
- 29 unit tests covering all new parsers and detect_all() behaviour
- canon/standards/sbom-convention_v0.1.md: updated with four-mechanism model and workflow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 04:40:26 +01:00
4a8942f310 fix(dashboard): resolve button calls /resolve endpoint, not PATCH
PATCH /decisions/{id}/ is a blind field-setter with no decided_at logic.
POST /decisions/{id}/resolve is the correct endpoint — it auto-sets
decided_at and emits a decision_resolved progress event.

Fixes: resolved decisions showing last in the sorted list because
decided_at was never populated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 22:16:17 +01:00
4393a501e6 fix(dashboard): scope decided_at sort to resolved/superseded only
Previous fix applied the decided_at branch to all status groups,
causing open decisions without decided_at (e.g. COULOMBCORE decision)
to sort last behind any open decision that had decided_at set.

Now: decided_at desc only for resolved/superseded; open/escalated
use deadline asc → created_at desc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 22:09:43 +01:00
aef86a1934 fix(dashboard): reverse-chronological sort within decision status groups
Within resolved/superseded: most recently decided_at first.
Within open/escalated: soonest deadline first, then most recently
created_at (previously had no created_at fallback).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 22:06:13 +01:00
9f744dd7f3 feat(ep-td+dashboard): complete CUST-WP-0004 EP/TD tracking workstream
EP catalogue (all domains):
- EP-RAIL-001 ep_id patched (schema fix: add ep_id to EPUpdate)
- EP-RAIL-003 (git bare-repo mirrors) and EP-RAIL-004 (offsite secondary
  backup) registered from railiance-cluster/docs/backup-restore.md
- EP-CUST-003..007 ep_ids assigned to existing custodian EPs
- EP-CUST-008 (State Hub API auth) and EP-CUST-009 (update_workstream MCP
  tool) registered as new custodian extension points

TD catalogue (railiance — first 5 items):
- TD-RAIL-001: backup cron runs as root without audit trail (high/security)
- TD-RAIL-002: k3s kubeconfig world-readable mode 644 (medium/security)
- TD-RAIL-003: no Ansible role unit tests (medium/test)
- TD-RAIL-004: age key extracted via awk — fragile (medium/impl)
- TD-RAIL-005: etcd snapshot retention uncoordinated (low/impl)

Dashboard (T08 + T10):
- Extract API URL and POLL to src/components/config.js; all 15 pages
  now import from the shared module (contributions/goals keep custom POLL)
- Shared .kpi-infobox, .filter-bar, .filter-search/.filter-owner CSS
  moved to observablehq.config.js head <style> block; removed from 9 pages
- Build: 0 errors, 0 warnings

API (T09):
- progress.py: limit param now Query(100, le=1000) — prevents unbounded
  list requests; closes TD-CUST-004 for the only endpoint that had limit

CUST-WP-0004 marked completed (all 10 tasks done).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 01:40:52 +01:00
7b665a5d66 fix(api): add ep_id to EPUpdate schema so extension point IDs can be patched
EPUpdate was missing the ep_id field, making it impossible to assign a
human-readable ID to an existing EP via PATCH. The router already uses
model_dump(exclude_unset=True) + setattr so no router change needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 01:24:42 +01:00
00bb639efa feat(ops+workplans): fix tunnel targets, plan custodian migration, close legacy ADR-001 gaps
Tunnel (state-hub/Makefile):
- Replace interactive `make tunnel` (now non-blocking with -N flag)
- Add tunnel-daemon (autossh background), tunnel-loop (reconnect fallback),
  tunnel-status, tunnel-stop
- Default COULOMBCORE=tegwick@92.205.130.254; TUNNEL_PORT configurable
- Clarified server topology: COULOMBCORE=92.205.130.254 (old),
  Railiance01=92.205.62.239 (ThreePhoenix node 1)

Workplans:
- CUST-WP-0011: Migrate Custodian State Hub to ThreePhoenix cluster —
  9-task plan with hard pre-condition gates (3-node cluster, Longhorn HA,
  backup drill), data migration, 2-week stabilisation, WSL2 retirement
- CUST-WP-0000: Retroactive record for state-hub v0.1 (pre-ADR-001)
- CUST-WP-0000b: Retroactive record for state-hub v0.2 (pre-ADR-001)

Consistency: repo now ✓ PASS (0 fail, 18 warn — all pre-ADR-001 C-12 history)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 01:09:07 +01:00
4d552f5baa feat(state-hub): add make tunnel target for reverse SSH to State Hub
The tunnel command belongs here — it opens a reverse SSH tunnel so that
a remote host can reach the local State Hub at 127.0.0.1:8000.
Usage: make tunnel HOST=user@hostname

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 01:19:38 +01:00
651df73e3a 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>
2026-03-09 00:15:29 +01:00
4ab56494ad feat(dashboard): order Workstreams by Domain chart by most recent activity
Domains are sorted top-to-bottom by the latest updated_at across their
workstreams (most recently active domain first). Within a domain,
workstreams are also ordered by updated_at desc. Replaces alphabetical sort.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 09:24:06 +01:00
af25634f93 fix(template): replace get_state_summary with get_domain_summary in domain CLAUDE.md template
Avoids ~12.9k token response in domain repo sessions; get_domain_summary
returns the same actionable data scoped to the domain at ~10% of the cost.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 09:09:01 +01:00
0bdf4929fc feat(dashboard): Interventions page improvements and action-confirm modal
- Move Interventions under Workstreams in the navigator
- Add action-confirm.js: shared modal component for actions requiring a
  mandatory comment (survives live-poll re-renders, unlike inline DOM mutation)
- Wire action-confirm into Interventions (Mark done) and Decisions (Resolve)
- Fix Interventions completed section: fetch all tasks and filter client-side
  so resolved interventions (needs_human=false) still appear under Completed
- Add docs/interventions.md help page with ? button on the h1
- Replace all hardcoded "Bernd" with "human" across dashboard src and docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 23:15:06 +01:00
c792ab0bc0 feat(tasks): add needs_human intervention flag (CUST-WP-0009)
- Migration b4c5d6e7f8a9: adds needs_human (bool) + intervention_note (text) to tasks
- API: needs_human filter on GET /tasks/; 422 if flagged without note
- 3 MCP tools: flag_for_human, clear_human_flag, list_human_interventions
- Dashboard: interventions.md with amber cards and "Mark done" button
- Policy router + workstream DoD policy (workstream-dod.md)
- Workstream lifecycle docs page + workplan CUST-WP-0010
- CLAUDE.md: add step 4 (run fix-consistency after workplan writes)
- consistency_check.py: promote C-11 unlinked tasks from INFO to WARN

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 19:44:14 +01:00
5c1b7e7e1d feat(consistency): implement ADR-001 consistency checking engine (CUST-WP-0008)
Adds state-hub/scripts/consistency_check.py with C-01 through C-12 checks:
bidirectional file↔DB validation, --fix for auto-fixable issues, --all for all
repos, --json output, exit codes 0/1/2.

MCP tool: check_repo_consistency(repo_slug, fix=False)
Makefile: check-consistency, fix-consistency, check-consistency-all, fix-consistency-all

Auto-fixes applied across all repos:
- C-09: activity-core-foundation + activity-core-triggers-ops repo_id → activity-core
- C-04: railiance phase-0-operational-baseline status → completed
- C-05: railiance phase-0 title synced from file
- C-10/C-11: task status drifts resolved; state_hub_task_id injected into
  CUST-WP-0006 and CUST-WP-0007 task blocks

Remaining orphans reported for human review: repo-integration-activity-core,
infospace-s3-closeout, testdrive-jsui-publication, staged-promotion-lifecycle,
three-phoenix-ha-cluster, current-env-safety-net.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 08:16:00 +01:00
fc87e26b4b feat(gems): three-pass schema migration aligning state-hub with GEMS
Implements CUST-WP-0007. Resolves inconsistencies I-1, I-2, I-5, I-6
identified in the GEMS audit (GenericEntityModellingSystem.md).

Pass 1 (e1f2a3b4c5d6): domain_id FK on extension_points and
technical_debt (replaces raw string column); repo_id FK on contributions.
Fixes domain-filtering bugs in EP/TD dashboard pages.

Pass 2 (f2a3b4c5d6e7): repo_id nullable FK on workstreams, aligning
the GEMS primary attachment with ADR-001 (repo > topic). Dashboard
pages updated to prefer repo->domain over topic->domain.

Pass 3 (a3b4c5d6e7f8): SBOMSnapshot container entity (GEMS Complex
between Repository and SBOMEntry). Ingest is now additive — each call
creates a new snapshot; history is retained. List/report endpoints
filter to latest snapshot per repo via _latest_snapshot_ids_subquery().
New endpoints: GET /sbom/snapshots/, GET /sbom/snapshots/{id}/.
Dashboard gains a Snapshot History section.

Also adds GEMS analysis artefacts: wiki/GEMS-StateHub-TypeRegistry.md,
wiki/GEMS-StateHub-SWOT.md, workplans/CUST-WP-0006 (analysis),
workplans/CUST-WP-0007 (migration, now completed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 23:39:17 +01:00
62fbe884e3 feat(sbom): add custodian ingest-sbom + fix help button target
custodian_cli.py:
- new ingest-sbom subcommand: auto-detects repo slug from local_path
  registration, runs ingest_sbom.py --scan from the repo root
- --dry-run flag passes through to the underlying script
- --slug override for repos where path lookup fails

repos.md:
- ? button on "⚠ not ingested" now opens /docs/sbom (not /docs/repos)

docs/sbom.md:
- Ingest commands section now leads with `custodian ingest-sbom` (repo-root)
- make ingest-sbom kept as low-level alternative
- Per-ecosystem and gap-type references updated to new command

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 13:31:08 +01:00
944104307a feat(repos): add ? help button to SBOM "not ingested" cells
Each "⚠ not ingested" entry in the Coverage Map now shows a hoverable ?
button linking to /docs/repos (SBOM ingestion section).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 13:14:16 +01:00
c7f22fd199 docs(onboarding): mention /init to trigger integration in step 3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 11:16:08 +01:00
fe6704b9d0 feat(onboarding): redesign repo integration journey
custodian_cli.py:
- register-project now writes CLAUDE.custodian.md (suggestion) instead
  of overwriting CLAUDE.md; includes preamble with integration instructions
- registers repo via POST /repos/
- creates a "Repo Integration: {slug}" workstream in the domain's topic
  with 4 onboarding tasks (integrate CLAUDE.md, first workplan, SBOM,
  EPs/TDs); checks for existing workstream to be idempotent
- fixes {REPO_SLUG} template substitution (previously missing)

dashboard:
- repos.md: fetches workstreams; detects active repo-integration-* slugs;
  adds "Integrating" KPI card; shows ⚙ integrating badge per repo in
  coverage map and table; replaces "How to Ingest a Repo" with
  "Onboard a New Repo" 4-step panel with doc help button
- docs/repo-integration.md (new): full collaboration model doc — custodian
  as coach, repo agent as executor; journey, generated tasks, first session
  protocol, ongoing relationship
- docs/repos.md: links to new repo-integration doc; updates "What is a
  managed repo?" section; adds onboarding quick reference
- docs/reference.md: fix latent build error — code examples were in ```js
  fences (executed by OF); changed to ```javascript (display only)
- observablehq.config.js: adds "Repo Integration" to Reference nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 08:42:30 +01:00
8a9314ded6 feat(registration): write CLAUDE.custodian.md instead of overwriting CLAUDE.md
Instead of overwriting the target repo's CLAUDE.md, the registration
script now writes CLAUDE.custodian.md — a suggestion file with an
integration header. The repo's Claude agent integrates both files and
deletes the suggestion when done, preserving existing project conventions.

Also fix: `read` prompt now redirects from /dev/tty so the script
doesn't exit with code 1 when run non-interactively via make.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 01:30:28 +01:00
2d11bfa0ba feat(maintenance): add stale-task cleanup scheme
- scripts/cleanup_stale_tasks.py: daily script that cancels open tasks
  in completed/archived workstreams; handles 307 redirects; emits a
  cleanup progress event summarising results
- Makefile: add cleanup-stale target (also suitable for cron)
- ADR-001: append Workstream Closure Protocol section — mandatory closure
  review before marking workstream completed, with task classification
  table (done/cancelled/carry-forward) and Closure Review file format
- WP-0002 + WP-0005: append Closure Review sections documenting the
  2026-03-02 cleanup run (26 stale DB rows cancelled — all were legacy
  pre-ADR-001 DB-first records; file status was already done)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 00:32:35 +01:00
6ea8afb6ff fix(dashboard): hide escalation notes on resolved/superseded decisions
- `escalated` filter now excludes decisions with status resolved or
  superseded — a lingering escalation_note on a closed decision no
  longer triggers the warning box or shows the amber note on the card
- Resolves D1 Vault backend appearing to re-surface an escalation alert

Also resolved ADR-001 decision (was made/open, now made/resolved);
overview blocking-decision count is now 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 23:57:30 +01:00
947c2e8824 feat(dashboard): nav restructure, full context-help coverage, 11 new ref docs
Navigation:
- New order: Overview · Todo · Domains · Repos · Workstreams (collapsible,
  open:false, with atomic sub-entries: Decisions, Tasks, Debt, Extends,
  Dependencies) · Contributions · SBOM · Progress · Reference (collapsible)
- Reference section gains path:/reference landing page; all 18 doc pages
  listed in nav (alphabetical) and in reference.md table

New pages:
- todo.md — Internal / Ecosystem / Third-party todo classification
- dependencies.md — dependency edge table derived from state/summary
- reference.md — Reference landing page with full doc index

New reference doc pages (11):
  contributions, debt, dependencies, domains, extensions, overview,
  repos, tasks, todo + reference (meta) already added previously

doc-overlay.js — lazy bubblehelp tooltip:
- _titleCache Map + _fetchDocTitle(docPath): on first hover of any ?
  button, fetches the target doc page, parses <h1>, sets btn.title
- Native browser tooltip appears exactly on the ? circle on subsequent hover

Context-help wired on all 14 dashboard pages:
- h1 withDocHelp added to: index, todo, domains, repos, tasks, techdept,
  extensions, dependencies (contributions/workstreams/decisions/sbom/
  progress/reference were already wired)
- domains.md + repos.md: added missing withDocHelp import and live-data link
- tasks/techdept/extensions: removed duplicate _h1 const that caused
  SyntaxError: Identifier '_h1' has already been declared

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 23:46:26 +01:00
70c8e3cd51 feat(mcp): add get_domain_summary() for low-token domain session orientation
get_state_summary() returns ~10k tokens — too expensive for routine domain
repo sessions that only need their own workstreams and decisions.

New get_domain_summary(domain_slug):
- 5 targeted API calls: topics (filter), workstreams (topic+status), decisions
  (topic+pending), progress (topic, limit 5), repos (domain, slug+SBOM only)
- Returns: topic, active workstreams, blocking decisions, 5 recent events,
  repo SBOM status — all scoped to one domain
- Estimated ~80-90% token reduction vs get_state_summary()

get_state_summary() preserved unchanged for cross-domain / custodian sessions.
Updated its docstring to note the large response and point to get_domain_summary.

Template updated: Step 1 now calls get_domain_summary("{DOMAIN}") instead of
get_state_summary() + get_next_steps(). TOOLS.md updated with usage guidance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:05:31 +01:00
a3338c3a23 chore(dashboard): sort Reference nav pages alphabetically
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 21:09:32 +01:00
6d97a992ae feat(dashboard): collapse Reference nav section by default
Observable Framework 1.13.3 supports collapsible: true on nav sections,
rendering them as <details> elements. Collapsed by default; auto-expands
when any page within the section is active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 21:07:56 +01:00
ba89ebfa67 feat(canon): add inter-repo communication standard with todo taxonomy
Establishes the repo boundary rule and a formal vocabulary for classifying
work items by scope:

- Task: neutral state hub data entity
- Todo: a task scoped to the current session's repo/domain
  - Internal todo: addressed within this repo by this agent
  - Ecosystem todo: work for another registered repo → state hub task [repo:<slug>]
  - Third-party todo: work for an upstream repo → contribution artifact (BR/FR/EP/UPR)

New dashboard doc: /docs/inter-repo-communication — defines the boundary rule,
the full terminology, ecosystem and third-party todo workflows, and a decision
table for classifying any piece of work found during a session.

Also:
- sbom.md: replace verbose inter-repo section with a 3-line summary + link
- observablehq.config.js: add "Inter-Repo Communication" to Reference nav
- project_claude_md.template: add "### Repo Boundary Rule" section; fix
  Workplan Convention section (removing incorrect claim that the custodian
  writes workplan files in other repos — that is the target repo's job)

Cross-repo: created state hub task [repo:railiance-bootstrap] for that repo's
agent to apply the boundary rule and workplan convention fix to its own CLAUDE.md
(task 78d43cb0, workstream 59155efb).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 20:52:07 +01:00
98e991b49f fix(template): use reliable workplan discovery in step 2
Glob with pattern 'workplans/*.md' from repo root fails silently.
Changed instruction to Glob(pattern="**/*.md", path="workplans/")
with Bash ls as fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 20:13:31 +01:00
00272842ca fix(template): rewrite session protocol to produce concrete orientation output
The previous template only defined a First Session Protocol (triggered when no
workstreams existed). When workstreams did exist, get_state_summary() was called
but no output was defined, causing registered-repo Claude sessions to produce
nothing useful.

New 3-step normal session protocol:
- Step 1: get_state_summary() + get_next_steps()
- Step 2: scan workplans/*.md for active tasks (todo/in_progress)
- Step 3: output orientation brief covering active workstreams, pending tasks
  for this repo (from workplans/ + [repo:<slug>] state hub tasks), suggested
  next action, and SBOM status

Also strengthens First Session Protocol, ADR-001 workplan convention section,
and SBOM ingest section (adds SCAN=1 REPO_PATH= flags).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 20:05:16 +01:00
7caaec25a2 docs(sbom): add SBOM reference page + withDocHelp on SBOM dashboard
- docs/sbom.md: what SBOM is, lockfile semantics, 5-level maturity standard,
  gap types A–E, per-ecosystem guidance, Syft OSS tooling, inter-repo task
  communication convention, ingest commands, compliance check commands
- sbom.md: wire withDocHelp(h1, "/docs/sbom") — ? button on page title
- observablehq.config.js: add SBOM entry to Reference nav section

EP-CUST-002 registered: Syft-based comprehensive SBOM generation
Task 5f8cade5 created: [repo:railiance-bootstrap] Add Ansible lockfile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 19:29:20 +01:00
9bfb0c130a feat(dashboard): Repos page with coverage map; expose last_sbom_at on RepoRead
- RepoRead schema: add last_sbom_at + sbom_source fields (already in model,
  now surfaced in API response)
- repos.md: new dashboard page — KPI row (total/domains/ingested/gaps),
  domain-grouped coverage map with SBOM/EP/TD chips, per-repo table with
  gap highlighting, domain filter + gap-only toggle, ingest how-to section
- observablehq.config.js: add Repos after Domains in nav

Coverage state: 3 repos registered (custodian×1, railiance×2);
2 ingested (the-custodian + railiance-hosts), 1 gap (railiance-bootstrap
— infra-only, no lockfile, expected)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 18:53:25 +01:00
fae9151144 feat(sbom): add Terraform .terraform.lock.hcl parser; ingest railiance repos
- ingest_sbom.py: parse .terraform.lock.hcl provider blocks (name, version);
  ecosystem stored as 'other' until terraform added to DB ENUM
- Registered railiance-bootstrap + railiance-hosts under railiance domain
- railiance-hosts ingested: 2 Terraform providers (hashicorp/template 2.2.0,
  hetznercloud/hcloud 1.52.0)
- railiance-bootstrap: no lockfile (pure Ansible/shell — noted in convention)
- sbom-convention_v0.1.md: add Terraform + Ansible rows to lockfile table;
  update registered repos status table

Total SBOM: 422 packages across 2 repos (custodian + railiance-hosts)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 18:07:56 +01:00
4c157d43a8 feat(sbom): scan mode, domain grouping dashboard, SBOM convention doc
- ingest_sbom.py: add --scan flag (recursive lockfile discovery) +
  --lockfile repeatable for explicit multi-file ingestion; skip
  .venv/node_modules/.git/dist/etc; Makefile gains SCAN= and REPO_PATH= vars
- sbom.md: add /domains/ fetch; domain-level summary table; per-repo
  accordion with details/summary; domain filter on package table; dual-
  licence false-positive note; +1 KPI card (Domains Covered)
- canon/standards/sbom-convention_v0.1.md: authoritative lockfile table,
  ingest workflow (single/scan/explicit), snapshot semantics, direct-vs-
  transitive caveats, licence governance + copyleft escalation, update
  cadence, multi-repo domain pattern, planned enhancements

First ingest: the-custodian — 420 pkgs (88 python + 332 node), 13 licence
groups, 1 copyleft flag (jszip dual-licensed MIT OR GPL-3.0-or-later)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 16:15:40 +01:00
7d3487d4fe feat(state-hub): v0.3 registration workflow + ingest-sbom + CLAUDE.md template update
- scripts/ingest_sbom.py: lockfile parser + API poster for uv.lock, requirements.txt,
  package-lock.json, yarn.lock, Cargo.lock; auto-detects from repo root
- Makefile: make ingest-sbom REPO=<slug> [LOCKFILE=<path>] target
- scripts/register_project.sh: adds {REPO_SLUG} template substitution + optional
  SBOM ingest prompt at end of registration (non-fatal if venv not ready)
- scripts/project_claude_md.template: adds Contribution Tracking + SBOM sections
  documenting register_contribution(), update_contribution_status(), ingest-sbom,
  and the contrib/ directory layout
- workplans/CUST-WP-0002: all 15 tasks → done, status → completed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:28:49 +01:00
afac54ec09 feat(state-hub): v0.3 MCP tools + dashboard pages for contributions and SBOM
MCP server additions (5 tools + 3 resources):
- register_contribution(), update_contribution_status(), get_contributions()
- ingest_sbom_tool(repo_slug, lockfile_path) — shells out to ingest_sbom.py
- get_licence_report()
- state://contributions, state://sbom/aggregated, state://sbom/{repo_slug}

Dashboard pages:
- contributions.md — live-polled Kanban by status (draft→merged), filter bar
  (type/status/repo), KPI grid (total + per type), follow-up banner, full table
- sbom.md — licence distribution bar chart (Plot), copyleft risk section,
  package table with ecosystem/direct/dev filters, repo-slug resolution
- data/contributions.json.py, data/sbom.json.py — Observable data loaders
- index.md — added Contribution & SBOM Health KPI row (total, follow-up count,
  copyleft risk indicator; sourced from state summary fields)
- observablehq.config.js — added Contributions + SBOM to nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:28:41 +01:00
8d38110275 feat(state-hub): v0.3 schema — contributions + sbom_entries migrations, models, schemas, routers
Migrations (chain: b1c2d3e4f5a6 → c2d3e4f5a6b7 → d3e4f5a6b7c8):
- c2d3e4f5a6b7: contributions table (contributiontype BR/FR/EP/UPR enum,
  contributionstatus 7-state lifecycle, FKs to topics/workstreams)
- d3e4f5a6b7c8: sbom_entries table (ecosystem enum, snapshot-based replacement),
  + sbom_source + last_sbom_at columns on managed_repos

New models: Contribution (ContributionType, ContributionStatus), SBOMEntry (Ecosystem)
Modified: ManagedRepo (sbom_source, last_sbom_at columns)

New routers:
- /contributions/ — CRUD + lifecycle-guarded PATCH /status + soft-delete (withdrawn)
- /sbom/ — ingest (replace snapshot), list, per-repo view, licence report

Modified:
- /state/summary now includes contribution_counts and licence_risk_count
- main.py: registers contributions + sbom routers; bumps version to 0.6.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:28:27 +01:00
6edd39f4b8 fix(cli): auto-create topic when registering a brand-new domain
register-project now creates a topic automatically if the domain has
no active topic yet, instead of exiting with an error. This makes the
"create domain → register project" flow self-contained.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:40:32 +01:00
d734c50289 fix(cli): replace hardcoded VALID_DOMAINS with live /domains/ API lookup
The custodian CLI had a static VALID_DOMAINS list used as argparse
choices= and for in-process domain validation, preventing any domain
added after v0.5 from being used. Now fetches active domains from the
API at runtime. Also fixes t.get("domain") → t.get("domain_slug")
in two topic lookup sites.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:39:34 +01:00
fccb7b2375 fix(dashboard): replace stale t.domain with t.domain_slug across all pages
After the v0.5 migration TopicRead.domain was renamed to domain_slug.
index.md, decisions.md and tasks.md still referenced the old field,
causing every workstream domain to fall back to "unknown". Also
updated tasks.md to load the domain filter list dynamically from
/domains/ instead of the hardcoded 6-slug array.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:31:28 +01:00
eaad6b591c fix(state-hub): repair await-in-generator in _derive_next_steps
str.join() is synchronous and cannot consume a generator that uses await.
Build the blocker slugs list with an explicit async for loop instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:27:26 +01:00
fcd0f06536 feat(state-hub): implement v0.5 — dynamic domains & multi-repo
Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class
`domains` DB table, and adds a `managed_repos` table for multi-repo
support per domain.

P1 — Domain as a DB entity:
- Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain
  ENUM column to domain_id FK, drops the domain ENUM type
- Domain ORM model (api/models/domain.py) + Pydantic schemas
- Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/,
  rename and archive endpoints with EP/TD cascade on rename
- Topic model updated: domain_id FK + @property domain_slug for
  backwards-compatible JSON serialization (field renamed domain → domain_slug)
- TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup

P2 — Multi-repo support:
- ManagedRepo ORM model (api/models/managed_repo.py) + schemas
- Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive
- Makefile: add-domain, rename-domain, add-repo, list-repos targets
- register_project.sh: verify domain via /domains/ API + POST /repos/

P3 — MCP tools & live validation:
- 6 new MCP tools: list_domains, create_domain, rename_domain,
  archive_domain, list_domain_repos, register_repo
- EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request
  DB lookup — returns 422 with list of valid slugs on unknown domain
- State summary: adds domains: list[DomainSummary] (slug, name,
  repo_count, active_workstream_count, ep_count, td_count)
- TOOLS.md updated with domain management section

P4 — Dashboard:
- New domains.md page with KPI row + domain cards + repo lists
- domains.json.py + repos.json.py data loaders
- Domains page added to observablehq.config.js nav
- workstreams.md, extensions.md, techdept.md: domain_slug fix +
  dynamic domain list loaded from /domains/ API (no longer hardcoded)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
c3efb099f1 feat(custodian): add ADR-001 compliance validator
Scripts, Makefile target, and MCP tool for checking a repository
against ADR-001 (workplans as repo artefacts, state-hub as cache).

Checks performed:
  File-side: workplans/ dir exists, valid YAML frontmatter (required
  fields, type, status, id format), filename matches id, embedded
  task blocks have id/status/priority.

  State-hub cross-reference: state_hub_workstream_id references
  resolve to real DB records; orphan detection flags active DB
  workstreams with no backing workplan file.

Usage:
  make validate-adr REPO=<path> [DOMAIN=<slug>]
  validate_repo_adr(repo_path, domain_slug?)  # MCP tool

Running against the-custodian itself correctly surfaces the 4
pre-ADR-001 workstreams that still need workplan files written.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 12:00:09 +01:00
0546a1bb2a feat(dashboard): add entity detail modal and fixed-layout tables
Replace Inputs.table() with buildEntityTable() across workstreams and
tasks pages. Add click-to-detail modal (openEntityModal) on all entity
list views: workstreams, tasks, extension points, and technical debt.

- New component: src/components/entity-modal.js
  - openEntityModal(entity, type) — full-detail overlay (Esc/click-outside to close)
  - buildEntityTable(rows, cols, onRowClick) — table-layout:fixed, overflow-safe wrapper
  - CSS injected lazily; no separate stylesheet required

- Tables: table-layout:fixed keeps content within the content column;
  title col 32%, workstream col 14%, all cells ellipsis + title tooltip
- Cards (EP, TD): onclick → modal; workstream name span gets title tooltip
- Blocked task cards also wired to modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 18:28:44 +01:00
bd6e16394a chore: mark llm-shared-library workstream completed
All 9 tasks done (S1.1–S3.2). llm-connect is now a standalone
installable package at /home/worsch/llm-connect, integrated into
state-hub as its first consumer (S3.1, commit 444b35d).

Also tracks workstream-kpi.md spec (previously untracked).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 08:53:43 +01:00
e94d7d445b feat(state-hub): integrate llm-connect as dependency (S3.1)
Add llm-connect as an editable local dependency via [tool.uv.sources].
Creates tests/test_llm_connect_integration.py: 7 offline smoke tests
covering public symbol imports, MockLLMAdapter execute/reset, RunConfig
and LLMResponse fields, and ErrorLLMAdapter error propagation.

All 7 tests pass. Satisfies workstream llm-shared-library S3.1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 08:32:17 +01:00
f8e76deeaa feat(dashboard): replace title tooltips with <help-tip> web component
New custom element (src/components/help-tip.js):
- Floating card appears on hover/focus, appended to document.body
  (position:fixed) so it escapes overflow:hidden in the TOC sidebar
- Attributes: label (bold), description (body), doc (optional
  "Learn more →" link)
- Mouse-over-card grace period so the link stays reachable
- Correct viewport clamping (horizontal + prefer-above/fallback-below)

workstreams.md:
- WHI metric abbreviations (DD/BR/SPR/PEP/CDDR) now use <help-tip>
  with full name, one-sentence description, and doc link
- Domain breakdown labels show domain-scoped stats (open count,
  blocked%, runnable%) and a doc link
- Cycle ⚠ icon upgraded to <help-tip> with explanation
- Removed dotted underline; cursor:help comes from the element CSS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 08:11:09 +01:00
29a0368e6d feat(dashboard): add mouseover tooltips to WHI metric abbreviations
DD, BR, SPR, PEP, CDDR now show full name + one-sentence explanation
on hover via title attributes. Metric labels get a dotted underline
and cursor:help to signal they are hoverable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 08:03:19 +01:00
090a206f3d feat(state-hub): add Extension Points and Technical Debt tracking
New entity types (DB tables, API routers, Pydantic schemas, Alembic
migration a3f1c2d4e5b6):
- extension_points: ep_id, domain, title, ep_type, status, priority,
  location, description, topic_id, workstream_id
- technical_debt: td_id, domain, title, debt_type, severity, status,
  location, description, topic_id, workstream_id

MCP server: 6 new tools — register_extension_point, list_extension_points,
update_ep_status, register_technical_debt, list_technical_debt,
update_td_status (each write emits a progress_event)

Dashboard: two new pages (extensions.md, techdept.md) with KPI sidebar,
charts, urgent-items section, and filterable card lists. Both added to
nav in observablehq.config.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 07:29:51 +01:00
c0f6f01bff Add Tasks dashboard page
New page with:
- Data fetch: /tasks/ + /workstreams/ + /topics/ in parallel, enriched
  with domain and workstream_title per task
- Task Overview KPI card in TOC sidebar: open / blocked (red if >0) /
  in progress / done with % of total
- Status Distribution chart (horizontal bar, colour-coded by status)
- Blocked Tasks section: cards with priority badge, domain, workstream,
  blocking_reason highlighted in amber
- All Tasks: filterable table (status, priority, domain, assignee
  multiselect + text), sorted blocked→in_progress→todo→done, 25 rows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 01:04:19 +01:00
8decb6a4df Implement Workstream Health Index (WHI) KPI card
Add a live WHI card to the Workstreams page TOC sidebar. All six base
metrics from the spec (workstream-kpi.md) computed client-side from
existing data — no API or schema changes required.

Computation (workstreams.md):
- DD: dependency edges / open workstreams (normalised at DD_crit=1.0)
- BR: blocked workstreams / open workstreams
- SPR: max inbound deps on one incomplete workstream / open count
- PEP: active workstreams with all deps completed / open count
- CDDR: cross-domain edges / total edges
- CPI: DFS cycle detection (back-edge = 1, halves WHI as hard penalty)
- WHI = 0.30(1-DDnorm) + 0.25(1-BR) + 0.15(1-SPR) + 0.20·PEP + 0.10(1-CDDR)
- Per-domain breakdown using intra-domain edges only

Card UI: global WHI % with green/orange/red health label, sub-metric
rows with per-spec warning thresholds, cycle alert panel, per-domain
breakdown rows with coloured dots.

Also add src/docs/workstream-health-index.md reference page (formula,
thresholds, improvement guidance) and wire ? button on the card.
Add "Workstream Health" to Reference nav.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 00:03:27 +01:00
a3d989bfc8 Add Decisions and Workstreams reference docs with heading help wiring
- Remove residual constitution footnote from progress page header
- Create src/docs/decisions.md: types, statuses, resolution history chart,
  filter bar, card anatomy, Decision Health KPI, escalation protocol
- Create src/docs/workstreams.md: status distribution chart, filter bar,
  table columns, dependency graph, create/update patterns
- Wire withDocHelp(h1) on Decisions and Workstreams pages pointing to new docs
- Add both pages to Reference nav section in observablehq.config.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 18:12:12 +01:00
f829bed6b2 dashboard: add progress log documentation and ? button on page heading
- src/docs/progress-log.md: covers append-only constitution §5 guarantee,
  event structure (all fields), standard + convention event types, session
  protocol, MCP and curl usage, filters, and the 30-day volume chart
- progress.md: withDocHelp applied to #observablehq-main h1 → ? button
  appears on hover over the 'Progress Log' page heading
- observablehq.config.js: Progress Log added to Reference nav section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 18:03:05 +01:00
c780255eaf dashboard: move Open Workstreams by Domain chart to top of overview page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 17:49:12 +01:00
a8d2382e64 dashboard: add 'Event Log' subtitle above filtered table on progress page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 17:06:28 +01:00
a0b65ca803 dashboard: add 'All Workstreams' subtitle above filtered table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 16:50:44 +01:00
c986957fad dashboard: move charts to top of main content on workstreams and progress pages
workstreams: decouple filter form from display (Generators.input pattern);
Status Distribution chart now renders before the filter bar and table.
Dependencies section follows after.

progress: Event Volume chart moved above the filter controls and table;
no reactive changes needed (chart uses raw data, not filtered).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 16:49:33 +01:00
129a6cc919 dashboard: add card padding to live indicator; fix ? button vertical position
Replaces padding-right-only with full padding (0.55rem top/bottom, 0.7rem
left, 1.8rem right). The top padding gives the absolute-positioned ? button
a proper anchor and stops it sitting too low against the text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 16:42:00 +01:00
298a5184cd dashboard: add Reference nav section with Live Data and Decision Health docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 16:37:10 +01:00
816f1e25f1 dashboard: move live indicator to TOC sidebar on all pages; add live-data docs
- All four pages (index, workstreams, decisions, progress) now inject the
  live indicator into #observablehq-toc via injectTocTop("live-indicator", el)
  Left-aligned (no text-align: right), position:relative + padding-right for
  the ? button affordance

- decisions.md: splits the former combined "decisions-sidebar" widget into two
  separate injectTocTop calls — KPI box first (ends lower), live indicator
  second (ends at top); both now have their own stable ids

- withDocHelp(_liveEl, "/docs/live-data") wires the ? button on every page

- src/docs/live-data.md: new documentation page explaining poll interval (15s),
  indicator colour semantics, offline recovery, and which endpoints each page hits

- Removes the .live-bar CSS class from all pages; replaces with .live-indicator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 16:18:09 +01:00
902aafcfb1 dashboard: add toc-sidebar utility; move live indicator into TOC column
Extracts the TOC-injection pattern into a reusable component:

  src/components/toc-sidebar.js
    injectTocTop(id, element) — prepends an element to #observablehq-toc,
    removing any previous instance with the same id first so reactive cells
    can re-inject on each poll without accumulating duplicates.

decisions.md now uses injectTocTop to place a single widget (live
indicator + Decision Health KPI box) into the right-column sidebar,
removing the standalone live-indicator cell and the ad-hoc id/remove
pattern that was previously inlined.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 14:42:38 +01:00
5b743196db dashboard: remove stale KPI box before re-inserting on each poll
Assign a stable id to the KPI element so the previous instance can be
found and removed before the fresh one is prepended to the TOC sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 13:33:23 +01:00
e56e63b1f5 dashboard: move Decision Health card into TOC sidebar column
Inject the KPI box into #observablehq-toc (the framework's right-column
aside) instead of position:fixed. The TOC column already doesn't scroll,
so no CSS tricks are needed. Falls back to a float:right inline block if
the TOC element is absent.

- Remove .kpi-sidebar-outer and its fixed positioning
- Remove min-width/max-width from .kpi-infobox; add margin-bottom to
  separate it from the TOC links below

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 13:29:34 +01:00
aab8bb1bbb dashboard: fix KPI sidebar to fixed top-right position
- `.kpi-sidebar-outer` is now `position: fixed; top: 3.75rem; right: 1.5rem;`
  so the Decision Health box stays visible while scrolling
- Re-adds the live indicator as a standalone cell (was accidentally dropped
  when the combined `decisions-header` flex layout was removed)
- CSS: replaces `.decisions-header` block with `.kpi-sidebar-outer`;
  `.live-indicator` is now standalone (text-align right, margin-bottom)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 12:07:49 +01:00
3212a5be93 dashboard: prominent KPI infobox, doc-overlay component, decisions reference page
KPI infobox
- Replace slim kpi-bar with a boxed card (border, shadow, 195–240px) floating
  right in a flex header alongside the live indicator
- Rows: avg resolve time (last ≤5 resolved) + avg open age (all open)
- avg open age colored via CSS var --oc: red/orange/black per threshold
- "no open decisions" shown as muted italic when queue is empty

doc-overlay component (src/components/doc-overlay.js)
- withDocHelp(element, docPath) — adds absolute-positioned ? button
  that is invisible until the parent is hovered; click opens overlay
- Overlay: fixed backdrop + animated box with iframe; closes on Esc,
  backdrop click, or the close button
- CSS injected once via style tag (STYLE_ID guard, same pattern as MultiSelect)

? buttons wired up in decisions.md
- KPI infobox → /docs/decisions-kpi
- Cumulative chart (wrapped in position:relative div) → /docs/decisions-kpi
- Filter & List section header → /docs/decisions-kpi

Reference page (src/docs/decisions-kpi.md)
- Standalone Observable Framework page at /docs/decisions-kpi
- Documents: KPI card (avg resolve, avg open age, color thresholds),
  Resolution History chart (cumulative, period→resolution mapping, filter
  interaction, timestamp logic), Filter & List (type/status/search, card
  age badge, escalation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 11:48:47 +01:00
61c43af3a4 dashboard: decision age, KPI bar, and open-age health indicator
- KPI bar top-right: avg resolution time (last ≤5 resolved decisions)
  and avg open age with count; replaces old live-bar with kpi-bar row
- Color logic for avg-open-age KPI:
    red    — mean open age > avg resolve time
    orange — any single open decision exceeds avg resolve time
    black  — all open decisions younger than avg resolve time
- Decision cards: age badge in header showing "open Xd/h/w" for
  open/escalated or "took X" for resolved/superseded; orange when an
  open decision has aged past the avg resolve time baseline
- fmtDuration() helper: compact duration formatting (m/h/d/w/mo)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 07:50:54 +01:00
02b2542a2a dashboard: cumulative decisions chart with flexible period selector
- Replace static per-month bar chart with cumulative step-area chart
- Period selector: day / week / month / quarter / YTD / year / all
- Time resolution adapts to period:
    day → hours, week → days, month → weeks,
    quarter/YTD/year/all → months
- Chart respects the type/status/search filter (uses filtered, not data)
- Chart and period selector appear before the filter form and list
- Use Generators.input() to decouple filter form creation from its
  display position; display(_filtersForm) renders it below the chart
- Dots on chart mark buckets where decisions occurred; tip shows delta
- "all" period derives start from earliest decision in filtered set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 07:22:25 +01:00
a74bb9f732 Dashboard decisions: list view with MultiSelect filters
- Replace Pending/Made tab + Inputs.table with MultiSelect filters and
  a proper list view
- Filters: Type (pending/made), Status (open/escalated/resolved/superseded),
  title text search — all stable across polls (no data dependency)
- Each decision renders as a compact card: left border coloured by status
  (blue=open, amber=escalated, green=resolved, gray=superseded), type and
  status badges, domain context, deadline (red+overdue warning if past),
  full title, description/rationale snippet, resolved-by attribution
- Decisions sorted: escalated → open → resolved → superseded, then by
  deadline ascending within each group
- Fetch now includes topics alongside decisions for domain name join
- Escalation warning box and velocity chart retained

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 00:39:44 +01:00
154ec47046 Dashboard: reusable MultiSelect dropdown component for workstreams filters
Adds src/components/multiselect.js — a compact dropdown multi-select that is
Observable-compatible (exposes .value, dispatches bubbling input events) so it
works with view(), Inputs.form, and Generators.input without modification.

Component behaviour:
- Closed state: pill button showing "Label: All" (muted) or active selection
  (1-2 items shown by name, 3+ shown as "N of M"); blue border when active
- Open state: dropdown with per-item checkboxes + "Clear selection" link
  (only visible when something is selected); closes on outside click / Escape
- Styles injected once into document.head (STYLE_ID guard prevents duplicates)
- Uses CSS custom properties for light/dark mode compatibility

Workstreams page update:
- Domain and Status filters now use MultiSelect instead of Inputs.checkbox
- Filter bar layout reduced to a tight inline row (0.5rem gap)
- Owner text filter restyled to match trigger button height
- No changes to filter logic or downstream cells (filters.domain / .status
  are still string[] with empty = show all)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 00:19:58 +01:00
de936acd6d Dashboard workstreams: multi-select filters that survive data polls
- Replace single-select Domain/Status dropdowns with checkbox multi-selects
- Use Inputs.form() with a custom template to lay the three filters out
  side by side in a card-style filter bar
- Filter options are now static constants (DOMAINS, STATUSES) — no
  dependency on the polled data, so selections are never reset on refresh
- Empty selection = no filter applied (show all); any checked item = include
- Updated filtered computation and wsWithDeps to use filters.domain /
  filters.status array semantics

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 00:05:58 +01:00
da71a1bfac Dashboard: make status cards interactive links
- Active Workstreams → navigates to ./workstreams page
- Blocking Decisions → anchor-scrolls to #blocking-decisions section
- Blocked Tasks → click toggles inline panel showing each blocked task
  with workstream name and blocking reason; label toggles expand/collapse
- Events Today → anchor-scrolls to #recent-activity section
- All cards get hover lift effect (box-shadow + 1px translateY)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:43:44 +01:00
f34b49ebde Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary
S0 — Design boundary formalised across all integration surfaces:
- TOOLS.md restructured with Design Boundary section, Sanctioned Write Tools,
  and Bootstrap-Only Tools (create_workstream, create_task) with explicit note
- project_claude_md.template and railiance CLAUDE.md updated with boundary note
  and get_next_steps() in session start protocol
- Global ~/.claude/CLAUDE.md updated accordingly

S1 — Workstream dependency graph:
- WorkstreamDependency model (directed edge, CASCADE on delete, unique pair constraint)
- Alembic migration 0b547c153153; script.py.mako added (was missing)
- REST API: POST/GET /workstreams/{id}/dependencies/, DELETE …/{dep_id} (hard delete)
- StateSummary open_workstreams enriched with depends_on/blocks lists
- MCP tools: create_dependency(), list_dependencies()
- Dashboard workstreams page: Dependencies section with relationship cards
- Seeded: custodian-agent-runtime → llm-shared-library + phase-0-operational-baseline

S2 — Suggesting Next Steps (sanctioned write use case #2):
- GET /state/next_steps derives suggestions from recently resolved decisions
  (→ first open task in same workstream) and cleared dependencies
  (→ first todo task in now-unblocked workstream)
- StateSummary.next_steps included on every summary call
- MCP tool: get_next_steps()
- Dashboard: "What's next?" card grid above Registered Projects

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:33:14 +01:00
9965349135 Dashboard decisions: stable form inputs + copy to clipboard
Polling fix:
- Blocking decisions now use a Mutable (blockingDecisions) + refreshDecisions()
  instead of deriving from summary.blocking_decisions
- Cards only re-render on initial page load or after a successful resolve,
  not on every 15 s summary poll — so typing in the form is never interrupted
- On successful resolve, refreshDecisions() re-fetches the list; the resolved
  decision no longer matches the open/escalated filter so it disappears cleanly

Copy to clipboard:
- Small "Copy" button in the card header (next to deadline/escalation badges)
- Formats full decision as markdown: title, description, context, status, date
- Shows "✓ Copied" for 1.5 s, reverts to "Copy"; shows "⚠ Failed" on error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 09:47:52 +01:00
533fecd6e1 Add in-dashboard decision resolution with project log write
API:
- DecisionResolve schema (rationale, decided_by, write_log flag)
- POST /decisions/{id}/resolve — marks resolved, emits progress event,
  appends entry to DECISIONS.md in the project's registered directory
  (found via the topic's registration milestone event)

Dashboard:
- Replace Inputs.table for blocking decisions with full-text cards
- Each card shows title, full description (pre-wrap), rationale/context,
  escalation warning if present
- Expandable "Resolve →" section with rationale textarea, decided-by
  input, submit button that calls the resolve endpoint
- On success: collapses form, dims card, confirms log was written

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 09:34:35 +01:00
07742dd3f8 Dashboard: show domain on y-axis, workstream title inside bar
- y-axis tick labels now show domain name (only on the first
  workstream in each domain group, blank for subsequent ones)
- Workstream title rendered as text at x=0 inside the bar
- Titles truncated at 36/24 chars to avoid overflow
- marginLeft reduced to 160 (domain names are shorter than titles)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 00:58:34 +01:00
cabeefe070 Add per-workstream task counts to state summary and dashboard
API:
- WorkstreamWithTaskCounts schema extends WorkstreamRead with
  tasks_total/todo/in_progress/blocked/done fields
- /state/summary now includes these counts in open_workstreams via
  a single extra GROUP BY query (workstream_id, status)

Dashboard:
- Replace domain workstream-count bar with a horizontal stacked
  progress bar per workstream (done/in-progress/blocked/todo)
- Workstreams with no tasks show "no tasks yet" annotation
- Workstreams with tasks show "X/N done" label after the bar
- Sorted by domain then title so domains group naturally

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 00:50:43 +01:00
379a3b1a01 Fix MCP server httpx redirect handling
Add follow_redirects=True to httpx Client so 307 redirects (FastAPI
trailing-slash redirects) are followed transparently. Also add trailing
slash normalisation to _get and _patch to match existing _post behaviour,
so all three helpers hit the correct URLs on first attempt.

Requires Claude Code restart to take effect (MCP server is a subprocess
launched at startup).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 00:21:09 +01:00
cb73f98300 Remove hardcoded br from hint, inline the command 2026-02-25 00:16:37 +01:00
adb50aaf47 Tighten hint text to avoid linebreak 2026-02-24 23:59:38 +01:00
eaf46c012e Update getting-started hint: say Hi! to trigger first session 2026-02-24 23:57:12 +01:00
80e0c85281 Make first-message behaviour explicit in CLAUDE.md template
Add one-line imperative at the top of the Session Protocol:
  'On receiving your first message — before writing any response text —
  call get_state_summary() immediately.'

Previously Claude would wait for a substantive prompt before acting.
Now any first message (including 'start', 'go', or just Enter) triggers
the tool call immediately, after which the First Session Protocol takes over.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 23:48:28 +01:00
fda64c8eba Add First Session Protocol to project CLAUDE.md template
When get_state_summary() shows no workstreams for the domain, Claude
now has explicit instructions: read the canon charter + roadmap, survey
the repo for in-progress work, propose 1-3 workstreams to Bernd, wait
for approval, then create workstreams + tasks and record a milestone.

The "wait for approval before creating anything" gate keeps the human
in control while making the expected first-session behaviour unambiguous.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 23:43:39 +01:00
ebe7369249 Add create-workstream: MCP tool, CLI commands, dashboard hint
MCP server: add create_workstream(topic_id, title, slug?, owner?,
  description?, due_date?) — auto-generates slug from title if omitted;
  emits workstream_created progress event. Now 12 tools total.

CLI: add two new subcommands —
  custodian create-workstream --domain DOMAIN --title TITLE [--slug] [--owner] [--description]
  custodian create-task --workstream ID_OR_SLUG --title TITLE [--priority] [--assignee]
  create-task accepts workstream UUID or slug (resolves via API).

Dashboard: hint box below "Open Workstreams by Domain" chart listing
  registered domains that have zero workstreams, with the exact
  custodian create-workstream command to run.

TOOLS.md: updated tool count (11 → 12) and added create_workstream row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 23:35:54 +01:00
34b1114a01 Live dashboard: replace data loaders with client-side polling
CORS: add CORSMiddleware to FastAPI for localhost:3000 so browser fetch
works across ports without errors.

All four pages now use async generator cells that call the API directly
and re-yield every 15 s — no data loader cache, no manual cache clearing.

Each page shows a live status bar (● green/red · last updated time).
Offline state shows the `make api` hint inline.

index.md: add "Registered Projects" section — polls
  /progress/?event_type=milestone&limit=500 and filters for
  "Project registered with State Hub:" events; shows project name,
  domain, path, and registration timestamp.

workstreams.md: fix broken domain column — now fetches /workstreams/
  and /topics/ in parallel and joins on topic_id client-side.
  Previously the domain column showed "unknown" for all rows because
  WorkstreamRead schema doesn't include domain.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 23:19:26 +01:00
935d8a6b83 Add documentation: root README and state-hub/README
Root README covers: architecture, domain table with topic IDs, quick
start, project registration, Claude Code integration, governance
summary, roadmap, and design principles.

state-hub/README covers: full setup guide, Makefile targets, DB schema
with governance constraints, API summary (incl. /state/summary shape),
MCP server config, custodian CLI reference, dashboard pages, and WSL2
known issues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 23:00:20 +01:00
6492ae9891 Add custodian CLI — register-project and status subcommands
custodian register-project [--domain DOMAIN] [--path PATH]
  Defaults path to cwd; auto-detects domain from project charter if
  --domain is omitted. Does: API health → topic lookup → MCP check →
  CLAUDE.md from template → progress event.

custodian status
  Prints API health + summary totals + blocking decisions.

Installed via: make install-cli (symlinks .venv/bin/custodian → ~/.local/bin/)
Entry point declared in pyproject.toml [project.scripts].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 22:55:41 +01:00
ad87153f2f Implement registration UX wishlist W1–W6 (260224)
W1: Document user-scope MCP config location in ~/.claude/CLAUDE.md —
    adds verification and re-registration commands, warns against
    settings.json (saves ~12K tokens per registration session).

W2: scripts/register_project.sh + make register-project —
    5-step automation: API health → topic lookup → MCP check →
    CLAUDE.md from template → progress event.

W3: state-hub/scripts/project_claude_md.template —
    parameterised CLAUDE.md with {PROJECT_NAME}/{DOMAIN}/{TOPIC_ID}
    placeholders; used by register_project.sh.

W4: Add custodian_topic_id + domain to all 6 canon project charters —
    lets agents grep for topic IDs without touching the API.

W5: state-hub/mcp_server/TOOLS.md — compact 30-line tool reference
    card; replaces reading the full server.py (~350 lines).

W6: Switch .mcp.json to absolute path + PYTHONPATH env so cwd is not
    required; add scripts/patch_mcp_cwd.py for post-registration fix.
    Update ~/.claude.json to match (cwd kept for belt-and-suspenders).

W7 (SessionStart hook) deferred: no SessionStart hook type in Claude
    Code; PreToolUse with empty matcher fires before every tool call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 22:22:53 +01:00
0ea2788943 Add state-hub v0.1 — local-first state service for the Custodian
Implements the first live layer of the Custodian cognitive infrastructure:
PostgreSQL schema, FastAPI REST API, FastMCP stdio server, and Observable
Framework telemetry dashboard.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:47:49 +01:00
276 changed files with 46644 additions and 171 deletions

18
.dockerignore Normal file
View File

@@ -0,0 +1,18 @@
.venv
.pytest_cache
__pycache__
**/__pycache__
*.pyc
*.pyo
*.pyd
.env
.env.*
!.env.example
dashboard/node_modules
dashboard/dist
dashboard/src/.observablehq/cache
dashboard/.observablehq/cache
kubectl
tests
docs
infra

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
# Copy to .env and fill in values before running
POSTGRES_DB=custodian
POSTGRES_USER=custodian
POSTGRES_PASSWORD=changeme
DATABASE_URL=postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian
# pgAdmin (optional, only used with --profile tools)
PGADMIN_EMAIL=admin@local.dev
PGADMIN_PASSWORD=admin
# API
API_BASE=http://127.0.0.1:8000
# Gitea (for gitea_inventory.py)
GITEA_URL=http://92.205.130.254:32166
GITEA_TOKEN=

203
.gitignore vendored
View File

@@ -1,176 +1,41 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
# Local configuration and secrets
.env
.venv
.env.*
!.env.example
.claude/
# Python runtime and caches
.venv/
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
__pycache__/
**/__pycache__/
*.py[cod]
*.pyo
*.pyd
.pytest_cache/
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
.coverage
.coverage.*
htmlcov/
# PyPI configuration file
.pypirc
# Build/package output
build/
dist/
*.egg-info/
# Dashboard dependencies and generated output
dashboard/node_modules/
dashboard/dist/
dashboard/src/.observablehq/
dashboard/.observablehq/
# Local tools and machine-specific binaries
kubectl
# OS/editor noise
.DS_Store
Thumbs.db
.idea/
.vscode/

72
AGENTS.md Normal file
View File

@@ -0,0 +1,72 @@
# AGENTS.md
This repository is the standalone home for the Custodian State Hub service.
## Session Start
1. Read this file and `SCOPE.md`.
2. Read `.custodian-brief.md` if present.
3. If the State Hub API is reachable, query the local hub for orientation:
- `GET http://127.0.0.1:8000/state/summary`
- `GET http://127.0.0.1:8000/messages/?to_agent=hub&unread_only=true`
4. Mark relevant inbox messages read after acting on them.
5. Check `git status --short` before editing.
If the API is not reachable, continue from local files. The repo must remain
usable offline.
## Repository Boundary
State Hub owns:
- FastAPI app, models, schemas, routers, migrations
- MCP server and tool reference
- Observable dashboard
- consistency, registration, SBOM, token, image, and repo-sync scripts
- task-flow engine and flow definitions
- State Hub operational docs, tests, policies, prompts, and infra
The Custodian governance repo owns:
- canon, constitution, values, memory, and broad cross-domain governance
- bridge workplans that coordinate extraction from the old embedded layout
Do not write governance canon directly from this repo.
## Build And Test
After the implementation move, the expected command surface is:
```bash
make install
make db
make migrate
make test
make api
make mcp-http
make dashboard
```
When API routers, models, migrations, or consistency logic change, run the
relevant tests before closing the session. Prefer `make test` when the database
test prerequisites are available.
## Workplans
Use `workplans/` for State Hub-local workplans. New workplans should use:
```text
SHUB-WP-0001
```
For migrated Custodian-hosted plans, preserve existing `state_hub_workstream_id`
and task IDs when safe. Never call `create_workstream()` or `create_task()`
manually for a file-backed workplan before the file exists in this repo.
## Session Close
1. Add a progress event through State Hub if the API is reachable.
2. Run consistency sync for this repo once it is registered.
3. Record any decisions that change repo ownership, state model, API contracts,
or deployment topology.
4. Leave the worktree clear or explicitly report remaining uncommitted changes.

49
Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
FROM python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/app/.venv/bin:${PATH}"
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir uv
COPY pyproject.toml ./
RUN python - <<'PY' > /tmp/requirements.txt
import tomllib
with open("pyproject.toml", "rb") as f:
project = tomllib.load(f)["project"]
for dep in project["dependencies"]:
# llm-connect is currently a local editable test integration in this repo.
# The State Hub API/MCP runtime does not import it, and a container build
# must not depend on /home/worsch existing inside the image.
if dep == "llm-connect":
continue
print(dep)
PY
RUN uv venv /app/.venv \
&& uv pip install --python /app/.venv/bin/python --no-cache -r /tmp/requirements.txt
COPY alembic.ini ./
COPY api/ ./api/
COPY flows/ ./flows/
COPY mcp_server/ ./mcp_server/
COPY migrations/ ./migrations/
COPY policies/ ./policies/
COPY prompts/ ./prompts/
COPY scripts/ ./scripts/
COPY task_flow_engine/ ./task_flow_engine/
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/state/health', timeout=3).read()"
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]

284
Makefile Normal file
View File

@@ -0,0 +1,284 @@
.PHONY: install install-cli db db-tools migrate seed api dashboard check test clean register-project register-codex-project validate-adr add-domain rename-domain add-repo list-repos register-path cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
start:
@echo "# run in different terminals"
@echo "make db # docker compose up postgres"
@echo "make api # start backend api"
@echo "make mcp-http # start state-hub mcp service"
@echo "make dashboard # Observable dev server on :3000"
@echo "make bridges # Set up ssh bridges for cross machines access"
install:
uv sync
## Symlink the custodian CLI into ~/.local/bin so it's on PATH system-wide
install-cli: install
mkdir -p ~/.local/bin
ln -sf "$(shell pwd)/.venv/bin/custodian" ~/.local/bin/custodian
@echo "Installed: custodian → $$(readlink -f ~/.local/bin/custodian)"
@echo "Make sure ~/.local/bin is on your PATH:"
@echo " echo 'export PATH=\"\$$HOME/.local/bin:\$$PATH\"' >> ~/.bashrc && source ~/.bashrc"
db:
$(COMPOSE) up -d postgres
db-tools:
$(COMPOSE) --profile tools up -d
migrate:
uv run alembic upgrade head
seed:
uv run python scripts/seed.py
## Start (or restart) the MCP SSE server on :8001 — primary transport for Claude Code.
## Remote clients (e.g. COULOMBCORE) connect via the ops-bridge tunnel (port 18001).
## Registration: claude mcp add-json -s user state-hub '{"type":"sse","url":"http://127.0.0.1:8001/sse"}'
mcp-http:
@fuser -k 8001/tcp 2>/dev/null && echo "Stopped running MCP server" || true
MCP_TRANSPORT=sse MCP_PORT=8001 uv run python mcp_server/server.py
dashboard:
@fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true
cd dashboard && npm run dev
check:
curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool
test:
TEST_DATABASE_URL=postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian_test \
uv run pytest -x -q
## ops-bridge managed tunnels
## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge
tunnels-up:
bridge up
tunnels-status:
bridge status
## End-to-end check: verifies SSH process alive + remote port listening on COULOMBCORE.
## Exits non-zero if any tunnel is not fully operational.
tunnels-check:
bridge check
## Ensure all ops-bridge tunnels are up and healthy.
## Brings up any stopped/stale tunnels, shows final status, exits non-zero if anything is still down.
bridges:
@echo "==> Bringing up all tunnels..."
bridge up
@echo ""
@echo "==> Tunnel status:"
bridge status
@echo ""
@echo "==> Checking tunnel health..."
bridge check
## Start (or restart) the full backend — db + migrate + uvicorn.
## Stops uvicorn on :8000 if already running, then starts fresh.
api: db
@echo "Waiting for postgres..."; \
for i in 1 2 3 4 5 6 7 8 9 10; do \
nc -z 127.0.0.1 5432 2>/dev/null && break; \
sleep 1; \
done
$(MAKE) migrate
@fuser -k 8000/tcp 2>/dev/null && echo "Stopped running API" || true
uv run uvicorn api.main:app --reload --reload-dir api --reload-dir mcp_server --reload-dir task_flow_engine --host 127.0.0.1 --port 8000
## Register a project (Claude Code): make register-project DOMAIN=railiance PROJECT_PATH=/home/worsch/railiance
register-project:
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required. Usage: make register-project DOMAIN=<domain> PROJECT_PATH=<path>"; exit 1)
@test -n "$(PROJECT_PATH)" || (echo "ERROR: PROJECT_PATH is required."; exit 1)
scripts/register_project.sh "$(DOMAIN)" "$(PROJECT_PATH)"
## Register a Codex project (AGENTS.md + HTTP API): make register-codex-project DOMAIN=capabilities PROJECT_PATH=/home/worsch/my-repo
register-codex-project:
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required. Usage: make register-codex-project DOMAIN=<domain> PROJECT_PATH=<path>"; exit 1)
@test -n "$(PROJECT_PATH)" || (echo "ERROR: PROJECT_PATH is required."; exit 1)
scripts/register_project.sh "$(DOMAIN)" "$(PROJECT_PATH)" --codex
## Add a second repo to an existing domain: make add-repo DOMAIN=railiance REPO_PATH=/home/worsch/railiance-infra
add-repo:
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1)
@test -n "$(REPO_PATH)" || (echo "ERROR: REPO_PATH is required."; exit 1)
scripts/register_project.sh "$(DOMAIN)" "$(REPO_PATH)" --additional
## Create a new domain: make add-domain DOMAIN=my_domain NAME="My Domain"
add-domain:
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required (slug)."; exit 1)
@test -n "$(NAME)" || (echo "ERROR: NAME is required (display name)."; exit 1)
curl -sf -X POST http://127.0.0.1:8000/domains/ \
-H "Content-Type: application/json" \
-d "{\"slug\": \"$(DOMAIN)\", \"name\": \"$(NAME)\"}" | python3 -m json.tool
## Rename a domain: make rename-domain DOMAIN=old_slug NEW_SLUG=new_slug NEW_NAME="New Name"
rename-domain:
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN (old slug) is required."; exit 1)
@test -n "$(NEW_SLUG)" || (echo "ERROR: NEW_SLUG is required."; exit 1)
@test -n "$(NEW_NAME)" || (echo "ERROR: NEW_NAME is required."; exit 1)
curl -sf -X PATCH http://127.0.0.1:8000/domains/$(DOMAIN)/rename \
-H "Content-Type: application/json" \
-d "{\"new_slug\": \"$(NEW_SLUG)\", \"new_name\": \"$(NEW_NAME)\"}" | python3 -m json.tool
## Register this machine's local path for a repo: make register-path REPO=marki-docx PATH=/home/tegwick/marki-docx
register-path:
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make register-path REPO=<slug> PATH=<path>"; exit 1)
@test -n "$(PATH)" || (echo "ERROR: PATH is required. Usage: make register-path REPO=<slug> PATH=<path>"; exit 1)
curl -sf -X POST "http://127.0.0.1:8000/repos/$(REPO)/paths" \
-H "Content-Type: application/json" \
-d "{\"host\": \"$$(hostname)\", \"path\": \"$(PATH)\"}" | python3 -m json.tool
## List repos for a domain: make list-repos DOMAIN=railiance
list-repos:
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1)
curl -sf "http://127.0.0.1:8000/repos/?domain=$(DOMAIN)" | python3 -m json.tool
## Ingest SBOM data for a repo (all mechanisms: lockfiles + ansible + sbom-tools.yaml).
## Auto-detect all sources: make ingest-sbom REPO=the-custodian REPO_PATH=/home/worsch/the-custodian
## Single lockfile (explicit): make ingest-sbom REPO=the-custodian LOCKFILE=/path/to/uv.lock
## Dry-run (no submit): make ingest-sbom REPO=the-custodian REPO_PATH=... DRY_RUN=1
## Tip: run capture-tools first for repos with system-level tool dependencies.
ingest-sbom:
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
uv run python scripts/ingest_sbom.py --repo "$(REPO)" \
$(if $(LOCKFILE),--lockfile "$(LOCKFILE)") \
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
$(if $(DRY_RUN),--dry-run)
## Ingest capability declarations from SCOPE.md into the catalog.
## Usage: make ingest-capabilities REPO=the-custodian [REPO_PATH=/home/worsch/the-custodian]
## Or: make ingest-capabilities-all
## Add DRY_RUN=1 to preview without writing.
ingest-capabilities:
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
uv run python scripts/ingest_capabilities.py --repo "$(REPO)" \
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
$(if $(DRY_RUN),--dry-run)
ingest-capabilities-all:
uv run python scripts/ingest_capabilities.py --all \
$(if $(DRY_RUN),--dry-run)
## Check Repository Definition of Integrated (DoI) criteria for a repo.
## Usage: make check-doi REPO=llm-connect
## Or: make check-doi-all
## Add JSON=1 for machine-readable output.
check-doi:
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
uv run python scripts/check_doi.py --repo "$(REPO)" $(if $(JSON),--json)
check-doi-all:
uv run python scripts/check_doi.py --all $(if $(JSON),--json)
## Ingest tpsc.yaml service declarations from a repo into the TPSC catalog.
## Usage: make ingest-tpsc REPO=llm-connect
## Or: make ingest-tpsc-all
## Add DRY_RUN=1 to preview without writing.
ingest-tpsc:
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
uv run python scripts/ingest_tpsc.py --repo "$(REPO)" \
$(if $(DRY_RUN),--dry-run)
ingest-tpsc-all:
uv run python scripts/ingest_tpsc.py --all \
$(if $(DRY_RUN),--dry-run)
## Run SBOM capture agent for a repo — generates/updates sbom-tools.yaml.
## Usage: make capture-tools REPO=railiance-infra [REPO_PATH=/home/worsch/railiance-infra]
## Add DRY_RUN=1 to preview without writing.
capture-tools:
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
uv run python scripts/capture_sbom_tools.py --repo "$(REPO)" \
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
$(if $(DRY_RUN),--dry-run)
## Check a repo for ADR-001 compliance: make validate-adr REPO=/path/to/repo [DOMAIN=custodian]
validate-adr:
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make validate-adr REPO=<path> [DOMAIN=<slug>]"; exit 1)
uv run python scripts/validate_repo_adr.py "$(REPO)" $(if $(DOMAIN),--domain "$(DOMAIN)",)
## Check a single repo for ADR-001 consistency: make check-consistency REPO=the-custodian [REPO_PATH=/override]
## Exit 0 = clean, exit 2 = warnings only (treated as success), exit 1 = failures
check-consistency:
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make check-consistency REPO=<slug>"; exit 1)
uv run python scripts/consistency_check.py --repo "$(REPO)" \
$(if $(API_BASE),--api-base "$(API_BASE)",) \
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
## Check and auto-fix a single repo: make fix-consistency REPO=the-custodian [REPO_PATH=/override]
## Exit 0 = clean, exit 2 = warnings only (treated as success), exit 1 = failures
fix-consistency:
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make fix-consistency REPO=<slug>"; exit 1)
uv run python scripts/consistency_check.py --repo "$(REPO)" --fix \
$(if $(API_BASE),--api-base "$(API_BASE)",) \
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
## Pull then fix: single repo or all repos if REPO omitted
## make fix-consistency-remote — smart pull+fix all repos that need it
## make fix-consistency-remote REPO=slug — pull+fix one repo
fix-consistency-remote:
uv run python scripts/consistency_check.py \
$(if $(REPO),--repo "$(REPO)",--all) \
--remote \
$(if $(API_BASE),--api-base "$(API_BASE)",) \
$(if $(NO_WRITEBACK),--no-writeback,); \
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
## Infer repo slug from git remote URL and check: make check-consistency-here [REPO_PATH=/path/to/repo]
## Omit REPO_PATH to use the Python script's CWD (i.e. pass an empty --here flag).
check-consistency-here:
uv run python scripts/consistency_check.py \
--here $(if $(REPO_PATH),"$(REPO_PATH)",) \
$(if $(API_BASE),--api-base "$(API_BASE)",); \
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
## Infer repo slug from git remote URL and fix: make fix-consistency-here [REPO_PATH=/path/to/repo]
fix-consistency-here:
uv run python scripts/consistency_check.py \
--here $(if $(REPO_PATH),"$(REPO_PATH)",) \
--fix \
$(if $(API_BASE),--api-base "$(API_BASE)",); \
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
## Check all registered repos for ADR-001 consistency
check-consistency-all:
uv run python scripts/consistency_check.py --all $(if $(API_BASE),--api-base "$(API_BASE)",); \
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
## Check and auto-fix all registered repos
fix-consistency-all:
uv run python scripts/consistency_check.py --all --fix $(if $(API_BASE),--api-base "$(API_BASE)",); \
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
## Cancel open tasks belonging to completed/archived workstreams.
## Safe to run at any time; also suitable for a daily cron job.
## Cron example: 0 3 * * * cd ~/the-custodian/state-hub && make cleanup-stale
cleanup-stale:
uv run python scripts/cleanup_stale_tasks.py
## Install custodian post-commit sync hook into one repo: make install-hooks REPO=marki-docx
install-hooks:
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make install-hooks REPO=<slug>"; exit 1)
bash scripts/install_hooks.sh --repo "$(REPO)"
## Install custodian post-commit sync hook into all active registered repos
install-hooks-all:
bash scripts/install_hooks.sh --all
## Remove custodian post-commit sync hook from one repo: make remove-hooks REPO=marki-docx
remove-hooks:
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make remove-hooks REPO=<slug>"; exit 1)
bash scripts/install_hooks.sh --repo "$(REPO)" --remove
## Compare Gitea coulomb org repos against state-hub registered repos
## Requires GITEA_TOKEN in env or .env: make gitea-inventory GITEA_TOKEN=<token>
gitea-inventory:
uv run python scripts/gitea_inventory.py $(if $(JSON),--json)
clean:
$(COMPOSE) down -v

289
README.md
View File

@@ -1,3 +1,288 @@
# repo-seed
# State Hub
A git repository template to bootstrap coulomb projects from.
State Hub is the live coordination service for the Custodian ecosystem:
PostgreSQL persistence, FastAPI API, FastMCP server, Observable dashboard,
consistency tooling, and repo/workplan synchronization.
This repository is the standalone home for the service. It replaces the former
embedded implementation at:
```text
/home/worsch/the-custodian/state-hub
```
## Current Extraction State
The repo is being prepared by `CUST-WP-0043 - State Hub Repo Extraction`.
During extraction:
- The live implementation still exists in `the-custodian/state-hub/`.
- This repo owns the standalone baseline and will become authoritative after
the implementation move and verification gate.
- State Hub implementation work should land here once registration and
workplan re-homing are complete.
## Workplans
New State Hub-local workplans should use the prefix:
```text
SHUB-WP-0001
```
Legacy Custodian-hosted State Hub plans, such as `CUST-WP-0042`, may be carried
over with their existing State Hub IDs or bridged by a new `SHUB-WP-*`
continuation plan. Do not create duplicate workstreams manually; write the
workplan file first, then run consistency sync after this repo is registered.
---
## Stack
| Layer | Technology | Port |
|-------|-----------|------|
| Database | PostgreSQL 16-alpine (Docker) | `127.0.0.1:5432` |
| API | FastAPI + SQLAlchemy 2.0 async + asyncpg | `127.0.0.1:8000` |
| MCP server | FastMCP SSE | `127.0.0.1:8001` |
| Dashboard | Observable Framework | `127.0.0.1:3000` |
| CLI | `custodian` (Python, uv entry point) | — |
All services bind to `127.0.0.1` only — nothing exposed to the network.
---
## Setup
### Prerequisites
- Docker Engine
- Python 3.12+ with `uv` (`pip install uv`)
- Node.js 18+ (dashboard only)
### First-time
```bash
cd /home/worsch/state-hub
cp .env.example .env # edit POSTGRES_PASSWORD
make install # uv sync
make db # docker compose up postgres
make migrate # alembic upgrade head
make seed # insert 6 canonical topics
make api # db + migrate + uvicorn :8000 (restarts if running)
```
### Dashboard
```bash
make dashboard # Observable dev server on :3000
```
### Start Everything
To start all the infrastructure on separate consoles do:
```bash
make db # docker compose up postgres
make mcp-http # start state-hub mcp service
make dashboard # Observable dev server on :3000
make bridges # Set up ssh bridges for cross machines access
```
### CLI
```bash
make install-cli # symlink .venv/bin/custodian → ~/.local/bin
custodian status # API health + summary totals
custodian register-project # register cwd as a Custodian project
```
---
## Makefile Targets
| Target | What it does |
|--------|-------------|
| `make install` | `uv sync` — install Python deps + entry points |
| `make install-cli` | Symlink `custodian` to `~/.local/bin` |
| `make db` | Start postgres container |
| `make db-tools` | Start postgres + pgadmin (http://127.0.0.1:5050) |
| `make migrate` | `alembic upgrade head` |
| `make seed` | Insert 6 canonical topics |
| `make api` | `db` + wait + `migrate` + `uvicorn` (restarts if running) |
| `make dashboard` | Observable dev server (restarts if running) |
| `make check` | `curl /state/health` |
| `make register-project DOMAIN=x PROJECT_PATH=y` | Register a project |
| `make clean` | `docker compose down -v` (destroys DB volume) |
---
## Database Schema
Five tables in dependency order:
```
topics
└── workstreams
└── tasks (self-FK: parent_task_id)
└── progress_events
decisions (FK: topic_id, workstream_id — at least one required)
└── progress_events
```
### Enums
| Enum | Values |
|------|--------|
| `topic_status` | `active` · `paused` · `archived` |
| `workstream_status` | `active` · `blocked` · `completed` · `archived` |
| `task_status` | `todo` · `in_progress` · `blocked` · `done` · `cancelled` |
| `task_priority` | `low` · `medium` · `high` · `critical` |
| `decision_type` | `made` · `pending` |
| `decision_status` | `open` · `resolved` · `escalated` · `superseded` |
| `domain` | `custodian` · `railiance` · `markitect` · `coulomb_social` · `personhood` · `foerster_capabilities` |
### Governance constraints encoded in schema
- No hard DELETE endpoints — only soft: `archived`, `cancelled`, `superseded`
- `progress_events` has no `updated_at` and no DELETE endpoint (append-only per constitution §5)
- `decisions` with financial/legal keywords + `pending` type → auto-set `escalation_note` (§4)
---
## API
Interactive docs at http://127.0.0.1:8000/docs once the API is running.
### Key endpoint: `/state/summary`
Returns a full snapshot in one call — used by both the MCP server and dashboard:
```json
{
"generated_at": "...",
"totals": {
"topics": { "active": 6, "paused": 0, "archived": 0, "total": 6 },
"workstreams": { "active": 1, "blocked": 0, "completed": 1, "total": 2 },
"tasks": { "todo": 9, "in_progress": 0, "blocked": 0, "done": 11, "total": 20 },
"decisions": { "open": 1, "resolved": 0, "escalated": 0, "total": 1 }
},
"topics": [...], // topics with nested workstream stubs
"blocking_decisions": [...], // pending decisions only
"blocked_tasks": [...],
"recent_progress": [...], // last 20 events
"open_workstreams": [...]
}
```
### Router summary
| Prefix | Operations |
|--------|-----------|
| `/topics` | CRUD (soft-delete: `archived`) |
| `/workstreams` | CRUD (soft-delete: `archived`) |
| `/tasks` | CRUD (soft-delete: `cancelled`); `PATCH` updates status |
| `/decisions` | CRUD (soft-delete: `superseded`); auto-escalation |
| `/progress` | `GET` list + `POST` append — no DELETE |
| `/state/summary` | Full snapshot |
| `/state/health` | DB connectivity check |
---
## MCP Server
Runs as a persistent SSE service on `:8001`, independent of the Claude Code session.
Restart it anytime without restarting Claude Code.
```bash
make mcp-http # start (or restart) the MCP SSE server on :8001
```
Registered at user scope in `~/.claude.json`:
```json
{ "type": "sse", "url": "http://127.0.0.1:8001/sse" }
```
To re-register from scratch:
```bash
claude mcp remove state-hub -s user 2>/dev/null || true
claude mcp add-json -s user state-hub '{"type":"sse","url":"http://127.0.0.1:8001/sse"}'
```
See `mcp_server/TOOLS.md` for the full tool reference card (30 lines, faster than reading `server.py`).
### Tools at a glance
**Query** (read-only): `get_state_summary` · `get_topic` · `list_blocked_tasks` · `list_pending_decisions` · `get_recent_progress`
**Mutate** (each auto-emits a progress event): `create_task` · `update_task_status` · `record_decision` · `resolve_decision` · `add_progress_event` · `update_workstream_status`
**Resources**: `state://summary` · `state://topics` · `state://workstreams/{topic_slug}` · `state://decisions/blocking` · `state://tasks/blocked`
---
## `custodian` CLI
Installed into `.venv/bin/custodian` by `uv sync`; symlinked to `~/.local/bin` by `make install-cli`.
```
custodian register-project [--domain DOMAIN] [--path PATH]
```
- `--path` defaults to current working directory
- `--domain` is auto-detected from `project_charter_v*.md` frontmatter if omitted
```
custodian status
```
Prints API health, totals, and any blocking decisions.
### What `register-project` does
1. Verifies the API is reachable (fails fast with `make api` hint)
2. Looks up the topic ID for the domain via `/topics/?status=active`
3. Checks that `state-hub` is in `~/.claude.json`
4. Writes `$PROJECT_PATH/CLAUDE.md` from `scripts/project_claude_md.template`
5. Posts a `milestone` progress event recording the registration
---
## Project Registration Scripts
| Script | Purpose |
|--------|---------|
| `scripts/register_project.sh` | Shell version of `custodian register-project` |
| `scripts/patch_mcp_cwd.py` | Legacy: patched `cwd` for the old stdio registration (no longer needed) |
| `scripts/project_claude_md.template` | CLAUDE.md template with `{PROJECT_NAME}`, `{DOMAIN}`, `{TOPIC_ID}` |
| `scripts/seed.py` | Insert the 6 canonical topics into a fresh database |
| `scripts/pull_image.py` | WSL2 workaround: pull Docker images via Python urllib with Range-request chunking |
---
## Dashboard
Four pages at http://127.0.0.1:3000 (dev) or built with `npm run build`:
| Page | Content |
|------|---------|
| **Overview** | Status cards, task-by-status chart, recent activity feed, decisions due within 7 days |
| **Workstreams** | Filterable table by domain/status/owner; selected workstream task list; progress timeline |
| **Decisions** | Pending tab (with escalation highlights) and Made tab; resolution velocity chart |
| **Progress** | Append-only event feed with author badges; 30-day event volume chart |
Data loaders (`src/data/*.json.py`) are Python scripts that call the local API. They run at dev-server start and on `npm run build`. Clear the cache if data appears stale:
```bash
rm -rf dashboard/src/.observablehq/cache/
```
---
## Known Issues / WSL2 Notes
- **TLS bad record MAC on large downloads**: WSL2 corrupts packets on big TCP transfers. Use `scripts/pull_image.py` instead of `docker pull` for future image pulls.
- **MCP server is now SSE, not stdio**: Re-registration is `claude mcp add-json -s user state-hub '{"type":"sse","url":"http://127.0.0.1:8001/sse"}'`. The `patch_mcp_cwd.py` script and `.mcp.json` config are legacy artifacts from the old stdio setup.
- **AsyncSession concurrency**: SQLAlchemy 2.0 async sessions don't support concurrent operations. All queries in `/state/summary` run sequentially on a single session.

48
SCOPE.md Normal file
View File

@@ -0,0 +1,48 @@
# SCOPE
## One-Liner
State Hub is the local-first coordination service for Custodian workstreams,
tasks, decisions, progress events, repo metadata, MCP tooling, and dashboard
telemetry.
## In Scope
- State Hub FastAPI service
- PostgreSQL schema and Alembic migrations
- FastMCP server and tool reference
- Observable dashboard
- repo registration and consistency synchronization
- task-flow engine and flow definitions
- SBOM, contribution, capability, TPSC, DoI, token, and interface-change tracking
- State Hub tests, operational docs, policies, prompts, and local infra
## Out Of Scope
- Custodian canon and constitution content
- non-State-Hub domain implementation work
- external publication or legal/financial commitments
- plaintext secrets
- generic hub-core extraction unless a dedicated workplan owns it
- renaming State Hub to Dev Hub unless a dedicated workplan owns it
## Current Extraction Note
This repo is being established as the standalone State Hub home under
`CUST-WP-0043`. Until the extraction verification gate passes, the prior live
implementation remains at:
```text
/home/worsch/the-custodian/state-hub
```
After verification, this repository becomes the authoritative implementation
tree and the embedded copy should be removed or replaced with a pointer.
## Workplan Convention
New State Hub-local workplans use `SHUB-WP-####`.
Migrated legacy State Hub workplans may temporarily retain `CUST-WP-####`
identifiers when preserving existing State Hub workstream and task IDs is the
least confusing path.

39
alembic.ini Normal file
View File

@@ -0,0 +1,39 @@
[alembic]
script_location = migrations
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+psycopg2://custodian:changeme@127.0.0.1:5432/custodian
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

16
api/config.py Normal file
View File

@@ -0,0 +1,16 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
database_url: str = "postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian"
api_base: str = "http://127.0.0.1:8000"
debug: bool = False
settings = Settings()

24
api/database.py Normal file
View File

@@ -0,0 +1,24 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from api.config import settings
engine = create_async_engine(
settings.database_url,
echo=settings.debug,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_factory() as session:
yield session

536
api/doi_engine.py Normal file
View File

@@ -0,0 +1,536 @@
"""DoI engine — evaluates all 14 Repository Definition of Integrated criteria.
Shared by the API endpoint (async) and the CLI check script (asyncio.run).
All checks use only the repo dict from /repos/{slug} + HTTP calls to the API
+ local filesystem reads. No direct DB access.
"""
from __future__ import annotations
import asyncio
import json
import re
import socket
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Literal
import yaml
CriterionStatus = Literal["pass", "fail", "warn", "skip"]
Tier = Literal["none", "core", "standard", "full"]
# Criteria that belong to each tier (in check order)
CORE_IDS = {"C1", "C2", "C3", "C4"}
STANDARD_IDS = {"C5a", "C5b", "C5c", "C6", "C7", "C8", "C9"}
FULL_IDS = {"C10", "C11", "C12", "C13", "C14"}
STANDARD_SCOPE_SECTIONS = [
"One-liner",
"Core Idea",
"In Scope",
"Out of Scope",
"Relevant When",
"Not Relevant When",
"Current State",
"How It Fits",
"Terminology",
"Related / Overlapping",
"Provided Capabilities",
]
_CAPABILITY_BLOCK_RE = re.compile(r"```capability\s*\n(.*?)```", re.DOTALL | re.IGNORECASE)
_H2_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
@dataclass
class CriterionResult:
id: str
label: str
tier: str
status: CriterionStatus
detail: str = ""
@dataclass
class DoIReport:
repo_slug: str
tier: Tier
core_pass: bool
standard_pass: bool
full_pass: bool
criteria: list[CriterionResult] = field(default_factory=list)
checked_at: str = field(default_factory=lambda: datetime.now(tz=timezone.utc).isoformat())
def evaluate_scope_health(repo: dict) -> list[dict[str, Any]]:
"""Return machine-readable SCOPE.md health issues for C5a/C5b/C5c.
The returned records intentionally mirror DoI criterion IDs while carrying
section-level hints that downstream repo-scoping can use to refresh only
the affected parts of SCOPE.md.
"""
repo_path = _resolve_path(repo)
if not repo_path:
return [
{
"id": "C5a",
"label": "SCOPE.md present",
"status": "skip",
"detail": "Local path unavailable",
"missing_sections": [],
"invalid_capability_blocks": [],
"needs_refresh_sections": [],
},
{
"id": "C5b",
"label": "SCOPE.md standard sections",
"status": "skip",
"detail": "Local path unavailable",
"missing_sections": [],
"invalid_capability_blocks": [],
"needs_refresh_sections": [],
},
{
"id": "C5c",
"label": "SCOPE.md capability blocks",
"status": "skip",
"detail": "Local path unavailable",
"missing_sections": [],
"invalid_capability_blocks": [],
"needs_refresh_sections": [],
},
]
scope_path = Path(repo_path) / "SCOPE.md"
if not scope_path.exists():
return [
{
"id": "C5a",
"label": "SCOPE.md present",
"status": "fail",
"detail": "SCOPE.md not found at repo root",
"missing_sections": STANDARD_SCOPE_SECTIONS.copy(),
"invalid_capability_blocks": [],
"needs_refresh_sections": STANDARD_SCOPE_SECTIONS.copy(),
},
{
"id": "C5b",
"label": "SCOPE.md standard sections",
"status": "skip",
"detail": "SCOPE.md absent",
"missing_sections": STANDARD_SCOPE_SECTIONS.copy(),
"invalid_capability_blocks": [],
"needs_refresh_sections": STANDARD_SCOPE_SECTIONS.copy(),
},
{
"id": "C5c",
"label": "SCOPE.md capability blocks",
"status": "skip",
"detail": "SCOPE.md absent",
"missing_sections": [],
"invalid_capability_blocks": [],
"needs_refresh_sections": ["Provided Capabilities"],
},
]
text = scope_path.read_text()
issues: list[dict[str, Any]] = [{
"id": "C5a",
"label": "SCOPE.md present",
"status": "pass",
"detail": "",
"missing_sections": [],
"invalid_capability_blocks": [],
"needs_refresh_sections": [],
}]
headings = {h.strip() for h in _H2_RE.findall(text)}
missing_sections = [section for section in STANDARD_SCOPE_SECTIONS if section not in headings]
if missing_sections:
issues.append({
"id": "C5b",
"label": "SCOPE.md standard sections",
"status": "warn",
"detail": f"Missing H2 section(s): {', '.join(missing_sections)}",
"missing_sections": missing_sections,
"invalid_capability_blocks": [],
"needs_refresh_sections": missing_sections,
})
else:
issues.append({
"id": "C5b",
"label": "SCOPE.md standard sections",
"status": "pass",
"detail": f"All {len(STANDARD_SCOPE_SECTIONS)} standard sections present",
"missing_sections": [],
"invalid_capability_blocks": [],
"needs_refresh_sections": [],
})
capability_blocks = _CAPABILITY_BLOCK_RE.findall(text)
valid_blocks = 0
invalid_blocks: list[dict[str, Any]] = []
for index, block in enumerate(capability_blocks, start=1):
try:
parsed = yaml.safe_load(block) or {}
if isinstance(parsed, dict) and parsed.get("type") and parsed.get("title"):
valid_blocks += 1
else:
invalid_blocks.append({
"index": index,
"reason": "Capability block must be YAML with type and title",
})
except yaml.YAMLError as exc:
invalid_blocks.append({"index": index, "reason": str(exc)})
if valid_blocks > 0:
issues.append({
"id": "C5c",
"label": "SCOPE.md capability blocks",
"status": "pass",
"detail": f"{valid_blocks} valid capability block(s)",
"missing_sections": [],
"invalid_capability_blocks": invalid_blocks,
"needs_refresh_sections": [],
})
else:
detail = "No fenced capability block found"
if invalid_blocks:
detail = "No valid capability block found"
issues.append({
"id": "C5c",
"label": "SCOPE.md capability blocks",
"status": "warn",
"detail": detail,
"missing_sections": [],
"invalid_capability_blocks": invalid_blocks,
"needs_refresh_sections": ["Provided Capabilities"],
})
return issues
def compute_fingerprint(
repo: dict,
latest_tpsc_snap_at: str | None,
latest_goal_updated_at: str | None,
) -> str:
"""Compute a pipe-joined fingerprint of all inputs that affect DoI criteria.
If any component changes, the fingerprint changes and the cache is invalidated:
- repo.updated_at → covers last_sbom_at, remote_url, host_paths, domain changes
- latest_tpsc_snap_at → C9 (TPSC snapshot exists)
- latest_goal_updated_at → C10 (active repo goal)
- mtime of SCOPE.md, CLAUDE.md, tpsc.yaml → C5, C6, C9, C11, C12
"""
parts = [
str(repo.get("updated_at") or ""),
str(latest_tpsc_snap_at or ""),
str(latest_goal_updated_at or ""),
]
repo_path = _resolve_path(repo)
if repo_path:
for fname in ("SCOPE.md", "CLAUDE.md", "tpsc.yaml"):
f = Path(repo_path) / fname
try:
parts.append(f"{fname}:{f.stat().st_mtime:.3f}")
except FileNotFoundError:
parts.append(f"{fname}:absent")
return "|".join(parts)
def _resolve_path(repo: dict) -> str:
hostname = socket.gethostname()
host_paths = repo.get("host_paths") or {}
candidates = []
if host_paths.get(hostname):
candidates.append(host_paths[hostname])
if repo.get("local_path"):
candidates.append(repo["local_path"])
for raw in candidates:
p = Path(raw).expanduser()
if p.is_dir():
return str(p)
return ""
def resolve_repo_path(repo: dict) -> str:
"""Resolve the repo path using the same host-aware rules as DoI checks."""
return _resolve_path(repo)
def _get_sync(api_base: str, path: str, params: dict | None = None) -> object:
url = f"{api_base}{path}"
if params:
q = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
if q:
url = f"{url}?{q}"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=5) as r:
return json.loads(r.read())
except Exception:
return None
async def _get(api_base: str, path: str, params: dict | None = None) -> object:
"""Async wrapper — runs blocking urllib in a thread so the event loop stays free."""
return await asyncio.to_thread(_get_sync, api_base, path, params)
async def _run_consistency(repo_slug: str, api_base: str) -> tuple[int, int, int]:
"""Run consistency_check.py and return (fail, warn, info) counts."""
script = Path(__file__).parent.parent / "scripts" / "consistency_check.py"
proc = await asyncio.create_subprocess_exec(
"uv", "run", "python", str(script),
"--repo", repo_slug,
"--api-base", api_base,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(Path(__file__).parent.parent),
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
text = stdout.decode()
fail = warn = info = 0
for line in text.splitlines():
if "Summary:" in line:
parts = line.split("|")
for p in parts:
p = p.strip()
if "fail" in p:
try: fail = int(p.split()[0])
except ValueError: pass
elif "warn" in p:
try: warn = int(p.split()[0])
except ValueError: pass
elif "info" in p:
try: info = int(p.split()[0])
except ValueError: pass
return fail, warn, info
async def evaluate(
repo: dict,
api_base: str = "http://127.0.0.1:8000",
skip_consistency: bool = False,
prefetch: dict | None = None,
) -> DoIReport:
"""Evaluate all 14 DoI criteria for a repo.
Args:
repo: Repo dict (slug, domain_slug, local_path, remote_url, host_paths, last_sbom_at).
api_base: API base URL — only used when prefetch is absent.
skip_consistency: Skip C7/C13 subprocess calls (used in summary mode).
prefetch: Optional pre-fetched bulk data to avoid HTTP self-calls:
{
"domain_status": {"custodian": "active", ...}, # slug → status
"tpsc_snap_counts": {"llm-connect": 1, ...}, # repo_slug → count
"active_goal_counts": {"llm-connect": 0, ...}, # repo_slug → count
}
"""
slug = repo.get("slug", "unknown")
results: list[CriterionResult] = []
def _r(id: str, label: str, tier: str, status: CriterionStatus, detail: str = "") -> CriterionResult:
r = CriterionResult(id=id, label=label, tier=tier, status=status, detail=detail)
results.append(r)
return r
# ── Tier 1: Core ─────────────────────────────────────────────────────────
# C1: registered
_r("C1", "Registered in state-hub", "core", "pass", "Repo record exists")
# C2: domain assigned and active
domain_slug = repo.get("domain_slug") or ""
if not domain_slug:
_r("C2", "Domain assigned", "core", "fail", "No domain_slug on repo record")
else:
if prefetch and "domain_status" in prefetch:
dom_status = prefetch["domain_status"].get(domain_slug)
else:
d = await _get(api_base, f"/domains/{domain_slug}/")
dom_status = d.get("status") if d else None
if dom_status == "active":
_r("C2", "Domain assigned", "core", "pass", f"domain: {domain_slug}")
elif dom_status:
_r("C2", "Domain assigned", "core", "warn", f"Domain '{domain_slug}' status: {dom_status}")
else:
_r("C2", "Domain assigned", "core", "fail", f"Domain '{domain_slug}' not found")
# C3: local path resolves
repo_path = _resolve_path(repo)
if repo_path:
_r("C3", "Local path resolves", "core", "pass", repo_path)
else:
raw = repo.get("local_path") or "(none)"
_r("C3", "Local path resolves", "core", "fail", f"Path not accessible: {raw}")
# C4: remote URL set
remote = repo.get("remote_url") or ""
if remote.strip():
_r("C4", "Remote URL set", "core", "pass", remote)
else:
_r("C4", "Remote URL set", "core", "fail", "remote_url is empty")
# ── Tier 2: Standard ─────────────────────────────────────────────────────
# C5a/C5b/C5c: SCOPE.md structure and capability declarations
for issue in evaluate_scope_health(repo):
_r(issue["id"], issue["label"], "standard", issue["status"], issue["detail"])
# C6: CLAUDE.md
if not repo_path:
_r("C6", "CLAUDE.md present", "standard", "skip", "Local path unavailable")
elif (Path(repo_path) / "CLAUDE.md").exists():
_r("C6", "CLAUDE.md present", "standard", "pass")
else:
_r("C6", "CLAUDE.md present", "standard", "fail", "CLAUDE.md not found at repo root")
# C7: workplan convention — consistency check 0 FAIL
if skip_consistency:
_r("C7", "Workplan convention (0 FAIL)", "standard", "skip", "Not checked in summary mode — use /repos/{slug}/doi for full check")
else:
try:
fail, warn, _ = await _run_consistency(slug, api_base)
if fail == 0:
_r("C7", "Workplan convention (0 FAIL)", "standard", "pass", f"consistency: {fail} fail / {warn} warn")
else:
_r("C7", "Workplan convention (0 FAIL)", "standard", "fail", f"consistency: {fail} fail / {warn} warn")
except Exception as e:
_r("C7", "Workplan convention (0 FAIL)", "standard", "skip", f"Could not run consistency check: {e}")
# C8: SBOM ingested
last_sbom = repo.get("last_sbom_at")
if last_sbom:
_r("C8", "SBOM ingested", "standard", "pass", f"last ingested: {last_sbom[:10]}")
else:
_r("C8", "SBOM ingested", "standard", "fail", "last_sbom_at not set — run make ingest-sbom")
# C9: TPSC declared (tpsc.yaml present + snapshot exists)
tpsc_file_ok = repo_path and (Path(repo_path) / "tpsc.yaml").exists()
if prefetch and "tpsc_snap_counts" in prefetch:
has_snap = (prefetch["tpsc_snap_counts"].get(slug, 0) > 0)
snap_count = prefetch["tpsc_snap_counts"].get(slug, 0)
else:
tpsc_snaps = await _get(api_base, "/tpsc/snapshots/", {"repo_slug": slug}) or []
has_snap = len(tpsc_snaps) > 0
snap_count = len(tpsc_snaps)
if not repo_path:
_r("C9", "TPSC declared", "standard", "skip", "Local path unavailable")
elif tpsc_file_ok and has_snap:
_r("C9", "TPSC declared", "standard", "pass", f"{snap_count} snapshot(s)")
elif tpsc_file_ok and not has_snap:
_r("C9", "TPSC declared", "standard", "warn", "tpsc.yaml exists but not yet ingested — run make ingest-tpsc")
elif not tpsc_file_ok:
_r("C9", "TPSC declared", "standard", "fail", "tpsc.yaml missing at repo root")
# ── Tier 3: Full ─────────────────────────────────────────────────────────
# C10: active repo goal
if prefetch and "active_goal_counts" in prefetch:
active_goal_count = prefetch["active_goal_counts"].get(slug, 0)
else:
goals = await _get(api_base, "/repo-goals/", {"repo_slug": slug}) or []
active_goal_count = sum(1 for g in goals if g.get("status") == "active")
if active_goal_count > 0:
_r("C10", "Active repo goal", "full", "pass", f"{active_goal_count} active goal(s)")
else:
_r("C10", "Active repo goal", "full", "fail", "No active repo goal — create one with create_repo_goal()")
# C11: Provided Capabilities declared in SCOPE.md
if not repo_path:
_r("C11", "Provided Capabilities declared", "full", "skip", "Local path unavailable")
else:
scope = Path(repo_path) / "SCOPE.md"
if not scope.exists():
_r("C11", "Provided Capabilities declared", "full", "skip", "SCOPE.md absent")
else:
text = scope.read_text()
has_cap_block = "```capability" in text
has_none_explicit = "## Provided Capabilities" in text and (
"none" in text.lower().split("## provided capabilities")[-1][:200]
or "no capabilities" in text.lower().split("## provided capabilities")[-1][:200]
)
if has_cap_block:
_r("C11", "Provided Capabilities declared", "full", "pass", "capability block(s) found in SCOPE.md")
elif has_none_explicit:
_r("C11", "Provided Capabilities declared", "full", "pass", "Explicitly declared none in SCOPE.md")
elif "## Provided Capabilities" in text:
_r("C11", "Provided Capabilities declared", "full", "warn",
"Section present but no capability block or explicit none — add blocks or state 'none'")
else:
_r("C11", "Provided Capabilities declared", "full", "fail",
"No '## Provided Capabilities' section in SCOPE.md")
# C12: agents template applied (CLAUDE.md mentions kaizen)
if not repo_path:
_r("C12", "Agents template applied", "full", "skip", "Local path unavailable")
else:
claude_md = Path(repo_path) / "CLAUDE.md"
if not claude_md.exists():
_r("C12", "Agents template applied", "full", "skip", "CLAUDE.md absent")
else:
text = claude_md.read_text()
if "get_kaizen_agent" in text or "kaizen" in text.lower():
_r("C12", "Agents template applied", "full", "pass")
else:
_r("C12", "Agents template applied", "full", "fail",
"CLAUDE.md has no kaizen agent reference")
# C13: consistency check clean (0 FAIL, 0 WARN — C-12 exempt)
if skip_consistency:
_r("C13", "Consistency check clean (0 FAIL/WARN)", "full", "skip", "Not checked in summary mode — use /repos/{slug}/doi for full check")
else:
try:
fail, warn, _ = await _run_consistency(slug, api_base)
if fail == 0 and warn == 0:
_r("C13", "Consistency check clean (0 FAIL/WARN)", "full", "pass")
elif fail == 0 and warn > 0:
_r("C13", "Consistency check clean (0 FAIL/WARN)", "full", "warn",
f"{warn} warn(s) — C-12 legacy tasks may be exempt")
else:
_r("C13", "Consistency check clean (0 FAIL/WARN)", "full", "fail",
f"{fail} fail(s), {warn} warn(s)")
except Exception as e:
_r("C13", "Consistency check clean (0 FAIL/WARN)", "full", "skip", f"Could not run: {e}")
# C14: host paths registered
host_paths = repo.get("host_paths") or {}
if host_paths:
_r("C14", "Host paths registered", "full", "pass",
f"{len(host_paths)} host(s): {', '.join(host_paths.keys())}")
else:
_r("C14", "Host paths registered", "full", "fail",
"host_paths empty — run update_repo_path() for each active machine")
# ── Compute tier ─────────────────────────────────────────────────────────
by_id = {r.id: r for r in results}
def _tier_pass(ids: set[str]) -> bool:
return all(by_id[i].status in ("pass", "warn") for i in ids if i in by_id)
core_pass = _tier_pass(CORE_IDS)
standard_pass = core_pass and _tier_pass(STANDARD_IDS)
full_pass = standard_pass and _tier_pass(FULL_IDS)
if full_pass:
tier: Tier = "full"
elif standard_pass:
tier = "standard"
elif core_pass:
tier = "core"
else:
tier = "none"
return DoIReport(
repo_slug=slug,
tier=tier,
core_pass=core_pass,
standard_pass=standard_pass,
full_pass=full_pass,
criteria=results,
)

13
api/events/__init__.py Normal file
View File

@@ -0,0 +1,13 @@
from api.events.envelope import EventEnvelope
from api.events.nats_publisher import (
publish_event,
publish_event_sync,
shutdown_publisher,
)
__all__ = [
"EventEnvelope",
"publish_event",
"publish_event_sync",
"shutdown_publisher",
]

55
api/events/envelope.py Normal file
View File

@@ -0,0 +1,55 @@
"""EventEnvelope — schema for state-hub lifecycle events published to NATS.
Mirrors the EventEnvelope contract defined in activity-core
(`src/activity_core/models.py`). The state-hub publishes; activity-core
consumes and routes to ActivityDefinitions.
Subject naming convention (see docs/nats-event-subjects.md):
org.statehub.{noun}.{verb}
Examples:
org.statehub.repo.registered
org.statehub.workstream.completed
org.statehub.decision.resolved
org.statehub.domain.goal.activated
org.statehub.task.stale
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Any
from pydantic import BaseModel, Field
PUBLISHER = "the-custodian/state-hub"
class EventEnvelope(BaseModel):
"""Standard envelope shared with activity-core. Do not break compatibility.
All inbound events on activity-core's side are normalised into this shape.
"""
id: str = Field(description="UUID v4 — stable unique ID for deduplication.")
type: str = Field(description="Dot-namespaced event type, e.g. 'org.statehub.repo.registered'.")
version: str = Field(default="1.0", description="Schema version string.")
timestamp: datetime = Field(description="When the event occurred (UTC).")
publisher: str = Field(default=PUBLISHER, description="Originating service.")
attributes: dict[str, Any] = Field(
default_factory=dict,
description="Event-specific attributes; structure varies by event type.",
)
@classmethod
def new(cls, event_type: str, attributes: dict[str, Any] | None = None) -> "EventEnvelope":
"""Construct an envelope with a fresh UUID and current UTC timestamp."""
return cls(
id=str(uuid.uuid4()),
type=event_type,
timestamp=datetime.now(tz=timezone.utc),
publisher=PUBLISHER,
attributes=attributes or {},
)

View File

@@ -0,0 +1,139 @@
"""NATS JetStream publisher for state-hub lifecycle events.
Design:
- One process-wide publisher (`_Publisher` singleton).
- Connects lazily on first publish; reuses the connection thereafter.
- When ``NATS_URL`` is unset or empty, every publish is a logged no-op
so the state hub remains usable in environments without NATS.
- All publishes are fire-and-forget from the caller's perspective.
Failures are logged but never raise — losing a lifecycle event must
never break the API request that triggered it.
Stream + subject conventions live in ``docs/nats-event-subjects.md``.
Envelope schema lives in :mod:`api.events.envelope`.
"""
from __future__ import annotations
import asyncio
import logging
import os
from typing import TYPE_CHECKING
from api.events.envelope import EventEnvelope
if TYPE_CHECKING: # pragma: no cover — import-only for typing
from nats.aio.client import Client as NATSClient
from nats.js.client import JetStreamContext
logger = logging.getLogger("state_hub.events.nats")
_STREAM_NAME = "ACTIVITY_EVENTS"
_STREAM_SUBJECT_PATTERN = "org.>"
def _nats_url() -> str | None:
"""Resolve NATS_URL at call time so tests / configs can override it."""
url = os.environ.get("NATS_URL", "").strip()
return url or None
class _Publisher:
"""Singleton holding the live NATS connection + JetStream context."""
def __init__(self) -> None:
self._nc: "NATSClient | None" = None
self._js: "JetStreamContext | None" = None
self._connect_lock = asyncio.Lock()
self._ensured_stream = False
async def _connect(self, url: str) -> None:
# Imported inside the method so module import works without the dep.
import nats
import nats.js.api
async with self._connect_lock:
if self._nc is not None and self._nc.is_connected:
return
self._nc = await nats.connect(url, connect_timeout=2)
self._js = self._nc.jetstream()
logger.info("nats: connected to %s", url)
if not self._ensured_stream:
try:
await self._js.find_stream_name_by_subject("org.statehub.repo.registered")
self._ensured_stream = True
except Exception:
try:
await self._js.add_stream(
nats.js.api.StreamConfig(
name=_STREAM_NAME,
subjects=[_STREAM_SUBJECT_PATTERN],
)
)
logger.info("nats: created JetStream stream %r", _STREAM_NAME)
except Exception as exc: # pragma: no cover — defensive
logger.warning("nats: could not ensure stream %r: %s", _STREAM_NAME, exc)
self._ensured_stream = True
async def publish(self, subject: str, envelope: EventEnvelope) -> None:
url = _nats_url()
if url is None:
logger.debug("nats: NATS_URL unset — skipping publish %s (id=%s)", subject, envelope.id)
return
try:
if self._nc is None or not self._nc.is_connected:
await self._connect(url)
assert self._js is not None
payload = envelope.model_dump_json().encode()
ack = await self._js.publish(subject, payload)
logger.info(
"nats: published %s id=%s stream=%s seq=%s",
subject,
envelope.id,
getattr(ack, "stream", "?"),
getattr(ack, "seq", "?"),
)
except Exception as exc:
logger.warning("nats: publish failed %s id=%s err=%s", subject, envelope.id, exc)
async def shutdown(self) -> None:
if self._nc is not None:
try:
await self._nc.drain()
except Exception: # pragma: no cover — defensive
pass
self._nc = None
self._js = None
self._ensured_stream = False
_PUBLISHER = _Publisher()
async def publish_event(subject: str, envelope: EventEnvelope) -> None:
"""Publish ``envelope`` on ``subject``. Logs but never raises on failure.
No-op when ``NATS_URL`` is not configured.
"""
await _PUBLISHER.publish(subject, envelope)
def publish_event_sync(subject: str, envelope: EventEnvelope) -> None:
"""Fire-and-forget variant for sync callers (scripts, cron jobs).
Runs the publish in a short-lived event loop. Intended for one-shot CLI
callers that aren't already inside an async context. Server code should
prefer :func:`publish_event` with ``asyncio.create_task``.
"""
try:
asyncio.run(publish_event(subject, envelope))
except RuntimeError:
# Already inside a running loop — schedule and forget.
asyncio.get_event_loop().create_task(publish_event(subject, envelope))
async def shutdown_publisher() -> None:
"""Drain the NATS connection on app shutdown."""
await _PUBLISHER.shutdown()

85
api/flow_defs.py Normal file
View File

@@ -0,0 +1,85 @@
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from task_flow_engine import AssertionDef, AssertionResult, FlowDef, FlowEngine, FlowResult
FLOW_DIR = Path(__file__).resolve().parents[1] / "flows"
@lru_cache
def load_flow(entity_type: str) -> FlowDef:
path = FLOW_DIR / f"{entity_type}.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
return FlowDef.from_dict(data)
def evaluate_transition(
entity_type: str,
current_workstation: str,
target_workstation: str,
extra: dict[str, Any] | None = None,
) -> tuple[bool, list[AssertionResult], FlowResult]:
flow = load_flow(entity_type)
obj = {
"status": current_workstation,
"workstation": current_workstation,
"previous_workstation": current_workstation,
**(extra or {}),
}
engine = create_flow_engine()
result = engine.evaluate(obj, flow)
can_reach, failures = engine.can_reach(obj, flow, target_workstation)
return can_reach, failures, result
def create_flow_engine() -> FlowEngine:
return FlowEngine(
custom_ops={
"dependencies.any_incomplete": _dependencies_any_incomplete,
}
)
def _dependencies_any_incomplete(
assertion: AssertionDef,
obj: dict[str, Any],
values: list[Any],
) -> bool:
return bool(values) and any(value != assertion.value for value in values)
def assertion_result_to_dict(result: AssertionResult) -> dict[str, Any]:
return {
"id": result.id,
"passed": result.passed,
"target": result.target,
"op": result.op,
"expected": result.expected,
"actual": result.actual,
"description": result.description,
"reason": result.reason,
}
def flow_result_to_dict(result: FlowResult) -> dict[str, Any]:
return {
"current_workstation": result.current_workstation,
"exit_blocked": result.exit_blocked,
"blocking_assertions": [
assertion_result_to_dict(item) for item in result.blocking_assertions
],
"reachable": result.reachable,
"unreachable": [
{
"workstation": item.workstation,
"blocking": assertion_result_to_dict(item.blocking),
}
for item in result.unreachable
],
}

106
api/main.py Normal file
View File

@@ -0,0 +1,106 @@
import hashlib
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response as StarletteResponse
from api.database import engine
from api.events import shutdown_publisher
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, domain_goals, repo_goals, messages, capability_requests, tpsc
from api.routers import token_events
from api.routers import interface_changes
from api.routers import flows
class ETagMiddleware(BaseHTTPMiddleware):
"""Add ETag + conditional-GET (304) support to all JSON GET responses."""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
if request.method != "GET":
return response
if "application/json" not in response.headers.get("content-type", ""):
return response
body_parts = []
async for chunk in response.body_iterator:
body_parts.append(chunk)
body = b"".join(body_parts)
etag = '"' + hashlib.md5(body, usedforsecurity=False).hexdigest() + '"'
if request.headers.get("if-none-match") == etag:
return StarletteResponse(
status_code=304,
headers={"ETag": etag, "Cache-Control": "no-cache"},
)
headers = {k: v for k, v in response.headers.items() if k.lower() != "content-length"}
headers["ETag"] = etag
if not any(k.lower() == "cache-control" for k in headers):
headers["Cache-Control"] = "no-cache"
return StarletteResponse(
content=body,
status_code=response.status_code,
headers=headers,
media_type=response.media_type,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
await shutdown_publisher()
await engine.dispose()
app = FastAPI(
title="Custodian State Hub",
description="Local-first state API for the Custodian agent system.",
version="0.6.0",
lifespan=lifespan,
)
_cors_env = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000")
_cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()]
app.add_middleware(ETagMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_methods=["GET", "POST", "PATCH", "DELETE", "PUT"],
allow_headers=["Content-Type", "If-None-Match"],
expose_headers=["ETag"],
)
app.include_router(domains.router)
app.include_router(repos.router)
app.include_router(topics.router)
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(domain_goals.router)
app.include_router(repo_goals.router)
app.include_router(contributions.router)
app.include_router(sbom.router)
app.include_router(messages.router)
app.include_router(capability_requests.router)
app.include_router(tpsc.router)
app.include_router(token_events.router)
app.include_router(interface_changes.router)
app.include_router(flows.router)
app.include_router(state.router)
app.include_router(policy.router)
@app.get("/", include_in_schema=False)
async def root():
return {"service": "state-hub", "docs": "/docs"}

49
api/models/__init__.py Normal file
View File

@@ -0,0 +1,49 @@
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
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
from api.models.contribution import Contribution, ContributionType, ContributionStatus
from api.models.sbom_snapshot import SBOMSnapshot
from api.models.sbom_entry import SBOMEntry, Ecosystem
from api.models.agent_message import AgentMessage
from api.models.capability_catalog import CapabilityCatalog
from api.models.capability_request import CapabilityRequest
from api.models.tpsc import TPSCCatalog, TPSCSnapshot, TPSCEntry
from api.models.doi_cache import DOICache
from api.models.token_event import TokenEvent
from api.models.interface_change import InterfaceChange
__all__ = [
"Base",
"Domain",
"DomainGoal", "DomainGoalStatus",
"Topic", "TopicStatus",
"ManagedRepo",
"RepoGoal", "RepoGoalStatus",
"Workstream",
"WorkstreamDependency",
"Task", "TaskStatus", "TaskPriority",
"Decision", "DecisionType", "DecisionStatus",
"ProgressEvent",
"ExtensionPoint", "EPStatus",
"TechnicalDebt", "TDStatus",
"Contribution", "ContributionType", "ContributionStatus",
"SBOMSnapshot",
"SBOMEntry", "Ecosystem",
"AgentMessage",
"CapabilityCatalog",
"CapabilityRequest",
"TPSCCatalog", "TPSCSnapshot", "TPSCEntry",
"DOICache",
"TokenEvent",
"InterfaceChange",
]

View File

@@ -0,0 +1,44 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, new_uuid
class AgentMessage(Base):
__tablename__ = "agent_messages"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
from_agent: Mapped[str] = mapped_column(String(100), nullable=False)
to_agent: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
subject: Mapped[str] = mapped_column(String(500), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
thread_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agent_messages.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
read_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
archived_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=text("now()"),
nullable=False,
)
thread_root: Mapped["AgentMessage | None"] = relationship(
"AgentMessage",
remote_side="AgentMessage.id",
foreign_keys=[thread_id],
lazy="select",
)

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

@@ -0,0 +1,26 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
def new_uuid() -> uuid.UUID:
return uuid.uuid4()

View File

@@ -0,0 +1,50 @@
import uuid
from sqlalchemy import ARRAY, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid
class CapabilityCatalog(Base, TimestampMixin):
__tablename__ = "capability_catalog"
__table_args__ = (
UniqueConstraint("domain_id", "capability_type", "title", name="uq_catalog_domain_type_title"),
)
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,
)
repo_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("managed_repos.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
capability_type: Mapped[str] = mapped_column(String(50), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
keywords: Mapped[list[str]] = mapped_column(
ARRAY(String), nullable=False, server_default="{}"
)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="active", server_default="active"
)
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
@property
def domain_slug(self) -> str:
return self.domain.slug if self.domain is not None else ""
@property
def repo_slug(self) -> str | None:
return self.repo.slug if self.repo is not None else None

View File

@@ -0,0 +1,101 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, 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 CapabilityRequest(Base, TimestampMixin):
__tablename__ = "capability_requests"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
capability_type: Mapped[str] = mapped_column(String(50), nullable=False)
priority: Mapped[str] = mapped_column(
String(20), nullable=False, default="medium", server_default="medium"
)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="requested", server_default="requested"
)
# Requester side
requesting_domain_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("domains.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
requesting_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workstreams.id", ondelete="SET NULL"),
nullable=True,
)
requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False)
# Fulfiller side (populated on accept / auto-route)
fulfilling_domain_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("domains.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
fulfilling_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workstreams.id", ondelete="SET NULL"),
nullable=True,
)
fulfilling_agent: Mapped[str | None] = mapped_column(String(100), nullable=True)
# Links
blocking_task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="SET NULL"),
nullable=True,
)
catalog_entry_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("capability_catalog.id", ondelete="SET NULL"),
nullable=True,
)
resolution_note: Mapped[str | None] = mapped_column(Text, nullable=True)
routing_note: Mapped[str | None] = mapped_column(Text, nullable=True)
# Dispute fields (populated when status = routing_disputed)
dispute_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
disputed_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
dispute_suggested_domain: Mapped[str | None] = mapped_column(String(100), nullable=True)
disputed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
accepted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Relationships
requesting_domain: Mapped["Domain"] = relationship( # noqa: F821
"Domain", foreign_keys=[requesting_domain_id], lazy="selectin"
)
fulfilling_domain: Mapped["Domain | None"] = relationship( # noqa: F821
"Domain", foreign_keys=[fulfilling_domain_id], lazy="selectin"
)
blocking_task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821
catalog_entry: Mapped["CapabilityCatalog | None"] = relationship( # noqa: F821
"CapabilityCatalog", lazy="selectin"
)
@property
def requesting_domain_slug(self) -> str:
return self.requesting_domain.slug if self.requesting_domain else ""
@property
def fulfilling_domain_slug(self) -> str | None:
return self.fulfilling_domain.slug if self.fulfilling_domain else None

View File

@@ -0,0 +1,66 @@
import enum
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, 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 ContributionType(str, enum.Enum):
br = "br"
fr = "fr"
ep = "ep"
upr = "upr"
class ContributionStatus(str, enum.Enum):
draft = "draft"
submitted = "submitted"
acknowledged = "acknowledged"
accepted = "accepted"
rejected = "rejected"
merged = "merged"
withdrawn = "withdrawn"
class Contribution(Base, TimestampMixin):
__tablename__ = "contributions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
type: Mapped[ContributionType] = mapped_column(
Enum(ContributionType, name="contributiontype"), nullable=False
)
target_org: Mapped[str | None] = mapped_column(String(200), nullable=True)
target_repo: Mapped[str | None] = mapped_column(String(200), nullable=True)
slug: Mapped[str | None] = mapped_column(String(200), nullable=True)
title: Mapped[str] = mapped_column(String(500), nullable=False)
status: Mapped[ContributionStatus] = mapped_column(
Enum(ContributionStatus, name="contributionstatus"),
nullable=False, default=ContributionStatus.draft,
)
body_path: Mapped[str | None] = mapped_column(Text, nullable=True)
related_topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
)
related_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
)
repo_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True
)
submitted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
resolved_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821

63
api/models/decision.py Normal file
View File

@@ -0,0 +1,63 @@
import enum
import uuid
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, 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 DecisionType(str, enum.Enum):
made = "made"
pending = "pending"
class DecisionStatus(str, enum.Enum):
open = "open"
resolved = "resolved"
escalated = "escalated"
superseded = "superseded"
class Decision(Base, TimestampMixin):
__tablename__ = "decisions"
__table_args__ = (
CheckConstraint(
"topic_id IS NOT NULL OR workstream_id IS NOT NULL",
name="ck_decisions_topic_or_workstream",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
)
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
decision_type: Mapped[DecisionType] = mapped_column(
Enum(DecisionType), nullable=False, default=DecisionType.pending
)
status: Mapped[DecisionStatus] = mapped_column(
Enum(DecisionStatus), nullable=False, default=DecisionStatus.open
)
rationale: Mapped[str | None] = mapped_column(Text, nullable=True)
decided_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
deadline: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
escalation_note: Mapped[str | None] = mapped_column(Text, nullable=True)
superseded_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("decisions.id", ondelete="SET NULL"), nullable=True
)
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="decisions") # noqa: F821
workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="decisions") # noqa: F821
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
"ProgressEvent", back_populates="decision", lazy="selectin"
)

27
api/models/doi_cache.py Normal file
View File

@@ -0,0 +1,27 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import JSON, UUID
from sqlalchemy.orm import Mapped, mapped_column
from api.models.base import Base
class DOICache(Base):
__tablename__ = "doi_cache"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
repo_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("managed_repos.id", ondelete="CASCADE"),
nullable=False, unique=True, index=True,
)
tier: Mapped[str] = mapped_column(String(20), nullable=False)
core_pass: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
standard_pass: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
full_pass: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
criteria: Mapped[list | None] = mapped_column(JSON, nullable=True)
# Pipe-joined string of timestamps/mtimes used to detect staleness
fingerprint: Mapped[str] = mapped_column(Text, nullable=False)
checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

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

@@ -0,0 +1,29 @@
import uuid
from sqlalchemy import String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid
class Domain(Base, TimestampMixin):
__tablename__ = "domains"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
slug: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
topics: Mapped[list["Topic"]] = relationship( # noqa: F821
"Topic", back_populates="domain", lazy="selectin"
)
repos: Mapped[list["ManagedRepo"]] = relationship( # noqa: F821
"ManagedRepo", back_populates="domain", lazy="selectin"
)
goals: Mapped[list["DomainGoal"]] = relationship( # noqa: F821
"DomainGoal", back_populates="domain", lazy="selectin"
)

41
api/models/domain_goal.py Normal file
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

@@ -0,0 +1,57 @@
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_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(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
)
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
@property
def domain_slug(self) -> str:
return self.domain.slug if self.domain is not None else ""

View File

@@ -0,0 +1,53 @@
import uuid
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Index, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid
class InterfaceChange(Base, TimestampMixin):
__tablename__ = "interface_changes"
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="CASCADE"),
nullable=False, index=True,
)
interface_type: Mapped[str] = mapped_column(
String(40), nullable=False
) # rest_api | mcp_tool | cli | schema | capability
change_type: Mapped[str] = mapped_column(
String(40), nullable=False
) # breaking | additive | deprecation | removal
title: Mapped[str] = mapped_column(String(300), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
affected_paths: Mapped[list] = mapped_column(
JSONB, nullable=False, default=list, server_default="[]"
)
affected_repo_slugs: Mapped[list] = mapped_column(
JSONB, nullable=False, default=list, server_default="[]"
)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="draft", index=True
) # draft | published | resolved
planned_for: Mapped[date | None] = mapped_column(Date, nullable=True)
published_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
resolved_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
author: Mapped[str] = mapped_column(String(100), nullable=False, default="custodian")
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
"ManagedRepo", lazy="selectin"
)
__table_args__ = (
Index("ix_interface_changes_repo_status", "repo_id", "status"),
)

View File

@@ -0,0 +1,49 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid
class ManagedRepo(Base, TimestampMixin):
__tablename__ = "managed_repos"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
domain_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("domains.id", ondelete="RESTRICT"), nullable=False, index=True
)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
local_path: Mapped[str | None] = mapped_column(Text, nullable=True)
host_paths: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
remote_url: Mapped[str | None] = mapped_column(Text, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
)
git_fingerprint: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
sbom_source: Mapped[str | None] = mapped_column(Text, nullable=True)
last_sbom_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
last_state_synced_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
domain: Mapped["Domain"] = relationship( # noqa: F821
"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,43 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, new_uuid
class ProgressEvent(Base):
"""Append-only event log. No updated_at. No DELETE endpoint (constitution §5)."""
__tablename__ = "progress_events"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
)
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True
)
task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="RESTRICT"), nullable=True, index=True
)
decision_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("decisions.id", ondelete="RESTRICT"), nullable=True, index=True
)
event_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
summary: Mapped[str] = mapped_column(Text, nullable=False)
detail: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
author: Mapped[str | None] = mapped_column(String(100), nullable=True)
session_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
)
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="progress_events") # noqa: F821
workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="progress_events") # noqa: F821
task: Mapped["Task | None"] = relationship("Task", back_populates="progress_events") # noqa: F821
decision: Mapped["Decision | None"] = relationship("Decision", back_populates="progress_events") # noqa: F821

49
api/models/repo_goal.py Normal file
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 ""

59
api/models/sbom_entry.py Normal file
View File

@@ -0,0 +1,59 @@
import enum
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, new_uuid
class Ecosystem(str, enum.Enum):
python = "python"
node = "node"
rust = "rust"
go = "go"
java = "java"
terraform = "terraform"
ansible = "ansible"
tool = "tool"
other = "other"
class SBOMEntry(Base):
"""Snapshot-based SBOM entry — no updated_at; new ingest replaces old rows."""
__tablename__ = "sbom_entries"
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,
)
package_name: Mapped[str] = mapped_column(String(300), nullable=False)
package_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
ecosystem: Mapped[Ecosystem] = mapped_column(
Enum(Ecosystem, name="ecosystem"), nullable=False
)
license_spdx: Mapped[str | None] = mapped_column(String(100), nullable=True)
is_direct: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
is_dev: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
snapshot_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sbom_snapshots.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
snapshot_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
snapshot: Mapped["SBOMSnapshot"] = relationship( # noqa: F821
"SBOMSnapshot", lazy="selectin", back_populates="entries"
)

View File

@@ -0,0 +1,32 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, new_uuid
class SBOMSnapshot(Base):
"""Container entity for a point-in-time SBOM scan of a repository (GEMS Complex)."""
__tablename__ = "sbom_snapshots"
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,
)
snapshot_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
source: Mapped[str | None] = mapped_column(String(200), nullable=True)
entry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
entries: Mapped[list["SBOMEntry"]] = relationship( # noqa: F821
"SBOMEntry", lazy="select", back_populates="snapshot"
)

59
api/models/task.py Normal file
View File

@@ -0,0 +1,59 @@
import enum
import uuid
from datetime import date
from sqlalchemy import Boolean, Date, 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 TaskStatus(str, enum.Enum):
todo = "todo"
in_progress = "in_progress"
blocked = "blocked"
done = "done"
cancelled = "cancelled"
class TaskPriority(str, enum.Enum):
low = "low"
medium = "medium"
high = "high"
critical = "critical"
class Task(Base, TimestampMixin):
__tablename__ = "tasks"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
workstream_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[TaskStatus] = mapped_column(
Enum(TaskStatus), nullable=False, default=TaskStatus.todo
)
priority: Mapped[TaskPriority] = mapped_column(
Enum(TaskPriority), nullable=False, default=TaskPriority.medium
)
assignee: Mapped[str | None] = mapped_column(String(100), nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
blocking_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
needs_human: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
intervention_note: Mapped[str | None] = mapped_column(Text, nullable=True)
parent_task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
workstream: Mapped["Workstream"] = relationship("Workstream", back_populates="tasks") # noqa: F821
subtasks: Mapped[list["Task"]] = relationship(
"Task", foreign_keys=[parent_task_id], lazy="selectin"
)
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
"ProgressEvent", back_populates="task", lazy="selectin"
)

View File

@@ -0,0 +1,93 @@
import enum
import uuid
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from api.models.base import Base, TimestampMixin, new_uuid
class TDStatus(str, enum.Enum):
# Legacy general statuses
open = "open"
in_progress = "in_progress"
resolved = "resolved"
deferred = "deferred"
wont_fix = "wont_fix"
# Dashboard-improvement workflow steps
submitted = "submitted"
analyse = "analyse"
plan = "plan"
implement = "implement"
test = "test"
review = "review"
finished = "finished"
# Ordered workflow steps for dashboard-improvement suggestions
SUGGESTION_STEPS = ["submitted", "analyse", "plan", "implement", "test", "review", "finished"]
class TDNote(Base):
__tablename__ = "td_notes"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid)
td_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("technical_debt.id", ondelete="CASCADE"),
nullable=False, index=True,
)
step: Mapped[str] = mapped_column(String(30), nullable=False)
author: Mapped[str | None] = mapped_column(String(100), nullable=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[DateTime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
td: Mapped["TechnicalDebt"] = relationship("TechnicalDebt", back_populates="notes")
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_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(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
)
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
notes: Mapped[list["TDNote"]] = relationship(
"TDNote", back_populates="td", lazy="selectin",
order_by="TDNote.created_at",
)
@property
def domain_slug(self) -> str:
return self.domain.slug if self.domain is not None else ""

40
api/models/token_event.py Normal file
View File

@@ -0,0 +1,40 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, new_uuid
class TokenEvent(Base):
__tablename__ = "token_events"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True
)
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True, index=True
)
repo_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True
)
session_id: Mapped[str | None] = mapped_column(Text, nullable=True)
model: Mapped[str | None] = mapped_column(Text, nullable=True)
tokens_in: Mapped[int] = mapped_column(Integer, nullable=False)
tokens_out: Mapped[int] = mapped_column(Integer, nullable=False)
agent: Mapped[str | None] = mapped_column(Text, nullable=True)
ref_type: Mapped[str | None] = mapped_column(Text, nullable=True)
ref_id: Mapped[str | None] = mapped_column(Text, nullable=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
)
task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821
workstream: Mapped["Workstream | None"] = relationship("Workstream", lazy="selectin") # noqa: F821
repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821

54
api/models/topic.py Normal file
View File

@@ -0,0 +1,54 @@
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 TopicStatus(str, enum.Enum):
active = "active"
paused = "paused"
archived = "archived"
class Topic(Base, TimestampMixin):
__tablename__ = "topics"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
domain_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("domains.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
status: Mapped[TopicStatus] = mapped_column(
Enum(TopicStatus), nullable=False, default=TopicStatus.active
)
domain: Mapped["Domain"] = relationship( # noqa: F821
"Domain", back_populates="topics", lazy="selectin"
)
workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821
"Workstream", back_populates="topic", lazy="selectin"
)
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
"Decision", back_populates="topic", lazy="selectin"
)
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
"ProgressEvent", back_populates="topic", lazy="selectin"
)
@property
def domain_slug(self) -> str | None:
"""Returns the domain slug string for serialization."""
if self.domain is not None:
return self.domain.slug
return None

64
api/models/tpsc.py Normal file
View File

@@ -0,0 +1,64 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSON, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base
class TPSCCatalog(Base):
__tablename__ = "tpsc_catalog"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
provider: Mapped[str | None] = mapped_column(String(200), nullable=True)
category: Mapped[str | None] = mapped_column(String(100), nullable=True)
website_url: Mapped[str | None] = mapped_column(Text, nullable=True)
# Pricing: free | paid | freemium | usage_based | unknown
pricing_model: Mapped[str] = mapped_column(String(20), nullable=False, server_default="unknown")
# GDPR maturity (CNIL/IAPP CMMI-aligned):
# unknown | non_compliant | initial | developing | defined | managed | certified
gdpr_maturity: Mapped[str] = mapped_column(String(20), nullable=False, server_default="unknown", index=True)
gdpr_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
dpa_available: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
tos_url: Mapped[str | None] = mapped_column(Text, nullable=True)
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
data_processing_regions: Mapped[list | None] = mapped_column(JSON, nullable=True)
data_retention_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# status: active | deprecated
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="active")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
entries: Mapped[list["TPSCEntry"]] = relationship("TPSCEntry", back_populates="catalog_entry")
class TPSCSnapshot(Base):
__tablename__ = "tpsc_snapshots"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
repo_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True)
snapshot_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
source_file: Mapped[str | None] = mapped_column(String(200), nullable=True)
entry_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
entries: Mapped[list["TPSCEntry"]] = relationship("TPSCEntry", back_populates="snapshot", cascade="all, delete-orphan")
class TPSCEntry(Base):
__tablename__ = "tpsc_entries"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
snapshot_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tpsc_snapshots.id", ondelete="CASCADE"), nullable=False, index=True)
catalog_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("tpsc_catalog.id", ondelete="SET NULL"), nullable=True)
service_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
purpose: Mapped[str | None] = mapped_column(Text, nullable=True)
# auth_type: api_key | oauth | cli | none | unknown
auth_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
endpoint_override: Mapped[str | None] = mapped_column(Text, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
snapshot: Mapped["TPSCSnapshot"] = relationship("TPSCSnapshot", back_populates="entries")
catalog_entry: Mapped["TPSCCatalog | None"] = relationship("TPSCCatalog", back_populates="entries")

55
api/models/workstream.py Normal file
View File

@@ -0,0 +1,55 @@
import uuid
from datetime import date
from sqlalchemy import Date, 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 Workstream(Base, TimestampMixin):
__tablename__ = "workstreams"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
topic_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=False, index=True
)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="active", server_default="active"
)
owner: Mapped[str | None] = mapped_column(String(100), nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
planning_priority: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
planning_order: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
repo_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("managed_repos.id", ondelete="SET NULL"),
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"
)
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
"Decision", back_populates="workstream", lazy="selectin"
)
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
"ProgressEvent", back_populates="workstream", lazy="selectin"
)

View File

@@ -0,0 +1,75 @@
import uuid
from sqlalchemy import CheckConstraint, ForeignKey, Index, String, Text, 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 WorkstreamDependency(Base, TimestampMixin):
"""Directed dependency edge: `from_workstream` depends on a workstream or task.
Semantics: the target must reach a satisfactory state before `from_workstream`
can fully proceed. Hard deletes are intentional —
removing an edge removes a constraint, not information.
"""
__tablename__ = "workstream_dependencies"
__table_args__ = (
CheckConstraint(
"(to_workstream_id IS NOT NULL AND to_task_id IS NULL) "
"OR (to_workstream_id IS NULL AND to_task_id IS NOT NULL)",
name="ck_ws_dep_exactly_one_target",
),
Index(
"uq_ws_dep_workstream_target",
"from_workstream_id",
"to_workstream_id",
"relationship_type",
unique=True,
postgresql_where=text("to_workstream_id IS NOT NULL"),
),
Index(
"uq_ws_dep_task_target",
"from_workstream_id",
"to_task_id",
"relationship_type",
unique=True,
postgresql_where=text("to_task_id IS NOT NULL"),
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
from_workstream_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workstreams.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
to_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workstreams.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
to_task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
relationship_type: Mapped[str] = mapped_column(
String(40), nullable=False, default="blocks", server_default="blocks", index=True
)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
from_workstream: Mapped["Workstream"] = relationship( # noqa: F821
"Workstream", foreign_keys=[from_workstream_id]
)
to_workstream: Mapped["Workstream | None"] = relationship( # noqa: F821
"Workstream", foreign_keys=[to_workstream_id]
)
to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821

0
api/routers/__init__.py Normal file
View File

View File

@@ -0,0 +1,607 @@
import re
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.flow_defs import assertion_result_to_dict, evaluate_transition, flow_result_to_dict
from api.models.agent_message import AgentMessage
from api.models.capability_catalog import CapabilityCatalog
from api.models.capability_request import CapabilityRequest
from api.models.domain import Domain
from api.models.managed_repo import ManagedRepo
from api.models.task import Task
from api.schemas.capability_request import (
CatalogCreate,
CatalogPatch,
CatalogRead,
CapabilityRequestAccept,
CapabilityRequestCreate,
CapabilityRequestDispute,
CapabilityRequestPatch,
CapabilityRequestRead,
CapabilityRequestReroute,
CapabilityRequestStatusPatch,
)
router = APIRouter(tags=["capability-requests"])
# ---------------------------------------------------------------------------
# Capability Catalog endpoints
# ---------------------------------------------------------------------------
@router.post("/capability-catalog/", response_model=CatalogRead, status_code=status.HTTP_201_CREATED)
async def create_catalog_entry(
body: CatalogCreate,
session: AsyncSession = Depends(get_session),
) -> CapabilityCatalog:
domain = await _resolve_domain(body.domain, session)
repo_id = None
if body.repo_slug:
repo = await _resolve_repo(body.repo_slug, session)
repo_id = repo.id
entry = CapabilityCatalog(
domain_id=domain.id,
repo_id=repo_id,
capability_type=body.capability_type,
title=body.title,
description=body.description,
keywords=body.keywords,
)
session.add(entry)
try:
await session.commit()
except Exception:
await session.rollback()
raise HTTPException(
status_code=409,
detail=f"Catalog entry '{body.title}' for type '{body.capability_type}' already exists in domain '{body.domain}'",
)
await session.refresh(entry)
return entry
@router.patch("/capability-catalog/{entry_id}", response_model=CatalogRead)
async def patch_catalog_entry(
entry_id: uuid.UUID,
body: CatalogPatch,
session: AsyncSession = Depends(get_session),
) -> CapabilityCatalog:
entry = await session.get(CapabilityCatalog, entry_id)
if entry is None:
raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found")
if body.repo_slug is not None:
repo = await _resolve_repo(body.repo_slug, session)
entry.repo_id = repo.id
if body.description is not None:
entry.description = body.description
if body.keywords is not None:
entry.keywords = body.keywords
if body.status is not None:
entry.status = body.status
await session.commit()
await session.refresh(entry)
return entry
@router.get("/capability-catalog/", response_model=list[CatalogRead])
async def list_catalog(
domain: str | None = Query(None),
capability_type: str | None = Query(None),
status_filter: str | None = Query(None, alias="status"),
session: AsyncSession = Depends(get_session),
) -> list[CapabilityCatalog]:
q = select(CapabilityCatalog).order_by(CapabilityCatalog.created_at.desc())
if domain:
d = await _resolve_domain(domain, session)
q = q.where(CapabilityCatalog.domain_id == d.id)
if capability_type:
q = q.where(CapabilityCatalog.capability_type == capability_type)
if status_filter and status_filter != "all":
q = q.where(CapabilityCatalog.status == status_filter)
elif not status_filter:
q = q.where(CapabilityCatalog.status == "active")
result = await session.execute(q)
return list(result.scalars().all())
# ---------------------------------------------------------------------------
# Capability Request endpoints
# ---------------------------------------------------------------------------
@router.post("/capability-requests/", response_model=CapabilityRequestRead, status_code=status.HTTP_201_CREATED)
async def create_request(
body: CapabilityRequestCreate,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
req_domain = await _resolve_domain(body.requesting_domain, session)
# Route to provider
fulfilling_domain_id, catalog_entry_id, routing_note = await _route_capability(
session, body.capability_type, body.title, body.description or ""
)
req = CapabilityRequest(
title=body.title,
description=body.description,
capability_type=body.capability_type,
priority=body.priority,
requesting_domain_id=req_domain.id,
requesting_agent=body.requesting_agent,
requesting_workstream_id=body.requesting_workstream_id,
blocking_task_id=body.blocking_task_id,
fulfilling_domain_id=fulfilling_domain_id,
catalog_entry_id=catalog_entry_id,
routing_note=routing_note,
)
session.add(req)
await session.flush() # get req.id before creating notification
# Auto-notify
if fulfilling_domain_id:
ful_domain = await session.get(Domain, fulfilling_domain_id)
to_agent = ful_domain.slug if ful_domain else "broadcast"
else:
to_agent = "broadcast"
_add_notification(
session,
from_agent="system",
to_agent=to_agent,
subject=f"[capability-request] {body.title}",
body=(
f"New capability request from **{body.requesting_agent}** "
f"({body.requesting_domain}):\n\n"
f"**Type:** {body.capability_type}\n"
f"**Priority:** {body.priority}\n\n"
f"{body.description or '(no description)'}"
),
)
await session.commit()
await session.refresh(req)
return req
@router.get("/capability-requests/", response_model=list[CapabilityRequestRead])
async def list_requests(
domain: str | None = Query(None, description="Filter by requesting OR fulfilling domain slug"),
status_filter: str | None = Query(None, alias="status"),
capability_type: str | None = Query(None),
session: AsyncSession = Depends(get_session),
) -> list[CapabilityRequest]:
q = select(CapabilityRequest).order_by(CapabilityRequest.created_at.desc())
if domain:
d = await _resolve_domain(domain, session)
q = q.where(
(CapabilityRequest.requesting_domain_id == d.id)
| (CapabilityRequest.fulfilling_domain_id == d.id)
)
if status_filter:
q = q.where(CapabilityRequest.status == status_filter)
if capability_type:
q = q.where(CapabilityRequest.capability_type == capability_type)
result = await session.execute(q)
return list(result.scalars().all())
@router.get("/capability-requests/{request_id}", response_model=CapabilityRequestRead)
async def get_request(
request_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
return await _get_request_or_404(request_id, session)
@router.post("/capability-requests/{request_id}/accept", response_model=CapabilityRequestRead)
async def accept_request(
request_id: uuid.UUID,
body: CapabilityRequestAccept,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
req = await _get_request_or_404(request_id, session)
_check_transition(req.status, "accepted")
now = datetime.now(tz=timezone.utc)
req.status = "accepted"
req.fulfilling_agent = body.fulfilling_agent
req.fulfilling_workstream_id = body.fulfilling_workstream_id
req.accepted_at = now
# If no fulfilling domain was set by routing, infer from the accepting agent's context
# (The agent can also PATCH it later if needed)
_add_notification(
session,
from_agent=body.fulfilling_agent,
to_agent=req.requesting_agent,
subject=f"[capability-accepted] {req.title}",
body=f"Your capability request **{req.title}** has been accepted by **{body.fulfilling_agent}**.",
)
await session.commit()
await session.refresh(req)
return req
@router.patch("/capability-requests/{request_id}/status", response_model=CapabilityRequestRead)
async def patch_request_status(
request_id: uuid.UUID,
body: CapabilityRequestStatusPatch,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
req = await _get_request_or_404(request_id, session)
_check_transition(req.status, body.status)
req.status = body.status
if body.note:
req.resolution_note = body.note
now = datetime.now(tz=timezone.utc)
# Status-specific side effects
if body.status == "completed":
req.completed_at = now
# Auto-unblock the blocking task
if req.blocking_task_id:
task = await session.get(Task, req.blocking_task_id)
if task and task.status == "blocked":
task.status = "todo"
task.blocking_reason = None
_add_notification(
session,
from_agent="system",
to_agent=req.requesting_agent,
subject=f"[capability-completed] {req.title}",
body=(
f"Capability request **{req.title}** has been completed.\n\n"
f"{body.note or ''}"
),
)
elif body.status == "ready_for_review":
_add_notification(
session,
from_agent=req.fulfilling_agent or "system",
to_agent=req.requesting_agent,
subject=f"[capability-ready] {req.title} -- please review",
body=(
f"Capability **{req.title}** is ready for your review and optimization.\n\n"
f"{body.note or ''}"
),
)
elif body.status == "rejected":
_add_notification(
session,
from_agent=req.fulfilling_agent or "system",
to_agent=req.requesting_agent,
subject=f"[capability-rejected] {req.title}",
body=(
f"Capability request **{req.title}** has been rejected.\n\n"
f"**Reason:** {body.note or '(no reason given)'}"
),
)
elif body.status == "in_progress":
_add_notification(
session,
from_agent=req.fulfilling_agent or "system",
to_agent=req.requesting_agent,
subject=f"[capability-in-progress] {req.title}",
body=f"Work on capability **{req.title}** is now in progress.",
)
await session.commit()
await session.refresh(req)
return req
@router.patch("/capability-requests/{request_id}", response_model=CapabilityRequestRead)
async def patch_request(
request_id: uuid.UUID,
body: CapabilityRequestPatch,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
"""Correct mutable metadata: catalog_entry_id (re-derives fulfilling domain),
priority, blocking_task_id, fulfilling_workstream_id.
Only fields present in the request body (non-None) are updated.
"""
req = await _get_request_or_404(request_id, session)
corrections: list[str] = []
if body.catalog_entry_id is not None:
old_entry_id = req.catalog_entry_id
entry = await session.get(CapabilityCatalog, body.catalog_entry_id)
if entry is None:
raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found")
req.catalog_entry_id = entry.id
# Re-derive fulfilling domain from catalog entry
old_domain_id = req.fulfilling_domain_id
req.fulfilling_domain_id = entry.domain_id
corrections.append(
f"catalog_entry: {old_entry_id}{entry.id} ({entry.title}); "
f"fulfilling_domain re-derived → {entry.domain_id}"
)
if body.priority is not None:
req.priority = body.priority
corrections.append(f"priority → {body.priority}")
if body.blocking_task_id is not None:
req.blocking_task_id = body.blocking_task_id
corrections.append(f"blocking_task_id → {body.blocking_task_id}")
if body.fulfilling_workstream_id is not None:
req.fulfilling_workstream_id = body.fulfilling_workstream_id
corrections.append(f"fulfilling_workstream_id → {body.fulfilling_workstream_id}")
if not corrections:
return req # no-op
correction_note = "hub correction: " + "; ".join(corrections)
req.routing_note = (req.routing_note + "\n" + correction_note) if req.routing_note else correction_note
await session.commit()
await session.refresh(req)
return req
# ---------------------------------------------------------------------------
# Dispute endpoints
# ---------------------------------------------------------------------------
@router.post("/capability-requests/{request_id}/dispute", response_model=CapabilityRequestRead)
async def dispute_request(
request_id: uuid.UUID,
body: CapabilityRequestDispute,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
"""Flag a routing decision as incorrect. Transitions to routing_disputed."""
req = await _get_request_or_404(request_id, session)
_check_transition(req.status, "routing_disputed")
now = datetime.now(tz=timezone.utc)
req.status = "routing_disputed"
req.dispute_reason = body.reason
req.disputed_by = body.disputed_by
req.dispute_suggested_domain = body.suggested_domain
req.disputed_at = now
dispute_entry = (
f"disputed by {body.disputed_by}: {body.reason}"
+ (f" (suggested: {body.suggested_domain})" if body.suggested_domain else "")
)
req.routing_note = (req.routing_note + "\n" + dispute_entry) if req.routing_note else dispute_entry
# Notify custodian
_add_notification(
session,
from_agent=body.disputed_by,
to_agent="custodian",
subject=f"[routing-disputed] {req.title}",
body=(
f"**{body.disputed_by}** has disputed the routing of capability request "
f"**{req.title}**.\n\n"
f"**Reason:** {body.reason}\n"
+ (f"**Suggested domain:** {body.suggested_domain}\n" if body.suggested_domain else "")
+ f"\nCurrently routed to: {req.fulfilling_domain_slug or 'unrouted'}"
),
)
# Notify current fulfilling domain
if req.fulfilling_domain_slug:
_add_notification(
session,
from_agent=body.disputed_by,
to_agent=req.fulfilling_domain_slug,
subject=f"[routing-disputed] {req.title}",
body=(
f"The capability request **{req.title}** routed to your domain has been disputed "
f"by **{body.disputed_by}**.\n\n"
f"**Reason:** {body.reason}\n"
+ (f"**Suggested domain:** {body.suggested_domain}" if body.suggested_domain else "")
),
)
await session.commit()
await session.refresh(req)
return req
@router.post("/capability-requests/{request_id}/reroute", response_model=CapabilityRequestRead)
async def reroute_request(
request_id: uuid.UUID,
body: CapabilityRequestReroute,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
"""Re-route a disputed request to a new domain. Resets to requested."""
req = await _get_request_or_404(request_id, session)
if req.status != "routing_disputed":
raise HTTPException(
status_code=422,
detail=f"Cannot reroute from status '{req.status}'. Only 'routing_disputed' requests can be rerouted.",
)
if body.catalog_entry_id is None and body.domain is None:
raise HTTPException(status_code=422, detail="Either catalog_entry_id or domain must be provided.")
if body.catalog_entry_id is not None:
entry = await session.get(CapabilityCatalog, body.catalog_entry_id)
if entry is None:
raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found")
req.catalog_entry_id = entry.id
req.fulfilling_domain_id = entry.domain_id
new_domain_slug = (await session.get(Domain, entry.domain_id)).slug if entry.domain_id else "unknown"
else:
new_domain = await _resolve_domain(body.domain, session)
req.fulfilling_domain_id = new_domain.id
new_domain_slug = new_domain.slug
old_domain = req.dispute_suggested_domain or "unknown"
# Clear dispute fields
req.dispute_reason = None
req.disputed_by = None
req.dispute_suggested_domain = None
req.disputed_at = None
req.status = "requested"
reroute_entry = f"re-routed by {body.rerouted_by}{new_domain_slug}: {body.note}"
req.routing_note = (req.routing_note + "\n" + reroute_entry) if req.routing_note else reroute_entry
# Notify requester
_add_notification(
session,
from_agent=body.rerouted_by,
to_agent=req.requesting_agent,
subject=f"[re-routed] {req.title}",
body=(
f"Capability request **{req.title}** has been re-routed to **{new_domain_slug}**.\n\n"
f"**Note:** {body.note}"
),
)
# Notify new fulfilling domain
_add_notification(
session,
from_agent=body.rerouted_by,
to_agent=new_domain_slug,
subject=f"[capability-request] {req.title}",
body=(
f"Capability request **{req.title}** has been re-routed to your domain.\n\n"
f"**From:** {req.requesting_agent} ({req.requesting_domain_slug})\n"
f"**Type:** {req.capability_type}\n"
f"**Priority:** {req.priority}\n\n"
f"{req.description or '(no description)'}"
),
)
await session.commit()
await session.refresh(req)
return req
# ---------------------------------------------------------------------------
# Routing algorithm
# ---------------------------------------------------------------------------
async def _route_capability(
session: AsyncSession, capability_type: str, title: str, description: str
) -> tuple[uuid.UUID | None, uuid.UUID | None, str]:
"""Find the best-matching catalog entry for a capability request.
Returns (domain_id, catalog_entry_id, routing_note).
Uses word-boundary matching on (title + description) combined to avoid
false positives from substring matches (e.g. 'postgres' inside 'postgresql',
'ha' inside 'has').
"""
q = select(CapabilityCatalog).where(
CapabilityCatalog.capability_type == capability_type,
CapabilityCatalog.status == "active",
)
entries = list((await session.execute(q)).scalars().all())
if not entries:
return None, None, f"no active catalog entries for type '{capability_type}' — broadcast"
if len(entries) == 1:
e = entries[0]
return e.domain_id, e.id, f"single match: '{e.title}' (domain={e.domain_id})"
# Score by word-boundary keyword overlap against title + description combined
combined = f"{title} {description or ''}".lower()
scored: list[tuple[int, CapabilityCatalog]] = []
for entry in entries:
keywords = [kw for kw in (entry.keywords or []) if len(kw) >= 3]
score = sum(
1 for kw in keywords
if re.search(r'\b' + re.escape(kw.lower()) + r'\b', combined)
)
scored.append((score, entry))
scored.sort(key=lambda x: -x[0])
best_score, best = scored[0]
if best_score == 0:
return None, None, (
f"no keyword overlap for type '{capability_type}' among "
f"{len(entries)} entries — broadcast"
)
if len(scored) >= 2 and scored[1][0] == best_score:
return None, None, (
f"ambiguous routing: '{scored[0][1].title}' and '{scored[1][1].title}' "
f"both scored {best_score} — broadcast"
)
return best.domain_id, best.id, (
f"matched '{best.title}' (score={best_score}, "
f"keywords matched from: {title!r})"
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _add_notification(
session: AsyncSession,
from_agent: str,
to_agent: str,
subject: str,
body: str,
) -> None:
"""Create an AgentMessage notification in the current session (no commit)."""
msg = AgentMessage(
from_agent=from_agent,
to_agent=to_agent,
subject=subject,
body=body,
)
session.add(msg)
async def _resolve_domain(slug: str, session: AsyncSession) -> Domain:
result = await session.execute(select(Domain).where(Domain.slug == slug))
domain = result.scalar_one_or_none()
if domain is None:
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
return domain
async def _resolve_repo(slug: str, session: AsyncSession) -> ManagedRepo:
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
repo = result.scalar_one_or_none()
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
return repo
async def _get_request_or_404(request_id: uuid.UUID, session: AsyncSession) -> CapabilityRequest:
req = await session.get(CapabilityRequest, request_id)
if req is None:
raise HTTPException(status_code=404, detail=f"Capability request '{request_id}' not found")
return req
def _check_transition(current: str, target: str) -> None:
can_reach, failures, flow_result = evaluate_transition(
"capability_request",
current,
target,
)
if not can_reach:
raise HTTPException(
status_code=422,
detail={
"message": f"Cannot transition from '{current}' to '{target}'.",
"current_workstation": current,
"target_workstation": target,
"blocking_assertions": [
assertion_result_to_dict(item) for item in failures
],
"flow_result": flow_result_to_dict(flow_result),
},
)

View File

@@ -0,0 +1,137 @@
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.flow_defs import assertion_result_to_dict, evaluate_transition, flow_result_to_dict
from api.models.contribution import Contribution, ContributionStatus, ContributionType
from api.schemas.contribution import ContributionCreate, ContributionRead, ContributionStatusPatch
router = APIRouter(prefix="/contributions", tags=["contributions"])
@router.get("/", response_model=list[ContributionRead])
async def list_contributions(
type: ContributionType | None = Query(None),
status: ContributionStatus | None = Query(None),
target_repo: str | None = Query(None),
session: AsyncSession = Depends(get_session),
) -> list[Contribution]:
q = select(Contribution).order_by(Contribution.created_at.desc())
if type is not None:
q = q.where(Contribution.type == type)
if status is not None:
q = q.where(Contribution.status == status)
if target_repo:
q = q.where(Contribution.target_repo == target_repo)
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=ContributionRead, status_code=status.HTTP_201_CREATED)
async def create_contribution(
body: ContributionCreate,
session: AsyncSession = Depends(get_session),
) -> Contribution:
contrib = Contribution(
type=body.type,
target_org=body.target_org,
target_repo=body.target_repo,
slug=body.slug,
title=body.title,
body_path=body.body_path,
related_topic_id=body.related_topic_id,
related_workstream_id=body.related_workstream_id,
notes=body.notes,
status=ContributionStatus.draft,
)
session.add(contrib)
await session.commit()
await session.refresh(contrib)
return contrib
@router.get("/{contribution_id}", response_model=ContributionRead)
async def get_contribution(
contribution_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Contribution:
return await _get_or_404(contribution_id, session)
@router.patch("/{contribution_id}/status", response_model=ContributionRead)
async def patch_contribution_status(
contribution_id: uuid.UUID,
body: ContributionStatusPatch,
session: AsyncSession = Depends(get_session),
) -> Contribution:
contrib = await _get_or_404(contribution_id, session)
current = _status_value(contrib.status)
target = _status_value(body.status)
can_reach, failures, flow_result = evaluate_transition(
"contribution",
current,
target,
)
if not can_reach:
raise HTTPException(
status_code=422,
detail={
"message": f"Cannot transition from '{current}' to '{target}'.",
"current_workstation": current,
"target_workstation": target,
"blocking_assertions": [
assertion_result_to_dict(item) for item in failures
],
"flow_result": flow_result_to_dict(flow_result),
},
)
contrib.status = body.status
if body.notes:
contrib.notes = body.notes
now = datetime.now(tz=timezone.utc)
if body.status == ContributionStatus.submitted:
contrib.submitted_at = now
elif body.status in (
ContributionStatus.accepted, ContributionStatus.rejected,
ContributionStatus.merged, ContributionStatus.withdrawn,
):
contrib.resolved_at = now
await session.commit()
await session.refresh(contrib)
return contrib
@router.delete("/{contribution_id}", status_code=status.HTTP_204_NO_CONTENT)
async def withdraw_contribution(
contribution_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> None:
"""Soft-delete: sets status to 'withdrawn'."""
contrib = await _get_or_404(contribution_id, session)
if contrib.status == ContributionStatus.withdrawn:
return # idempotent
if contrib.status in (ContributionStatus.merged, ContributionStatus.rejected):
raise HTTPException(
status_code=409,
detail=f"Cannot withdraw a contribution with status '{contrib.status}'.",
)
contrib.status = ContributionStatus.withdrawn
contrib.resolved_at = datetime.now(tz=timezone.utc)
await session.commit()
async def _get_or_404(contribution_id: uuid.UUID, session: AsyncSession) -> Contribution:
result = await session.execute(
select(Contribution).where(Contribution.id == contribution_id)
)
contrib = result.scalar_one_or_none()
if contrib is None:
raise HTTPException(status_code=404, detail=f"Contribution '{contribution_id}' not found")
return contrib
def _status_value(status: ContributionStatus | str) -> str:
return status.value if isinstance(status, ContributionStatus) else str(status)

217
api/routers/decisions.py Normal file
View File

@@ -0,0 +1,217 @@
import asyncio
import logging
import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status
logger = logging.getLogger(__name__)
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.events import EventEnvelope, publish_event
from api.models.decision import Decision, DecisionStatus, DecisionType
from api.models.progress_event import ProgressEvent
from api.schemas.decision import DecisionCreate, DecisionRead, DecisionResolve, DecisionUpdate
router = APIRouter(prefix="/decisions", tags=["decisions"])
_FINANCIAL_LEGAL_KEYWORDS = (
"financ", "legal", "payment", "purchas", "contract", "commit",
"obligation", "external representation",
)
def _needs_escalation(body: DecisionCreate) -> str | None:
if body.decision_type != DecisionType.pending:
return None
text = f"{body.title} {body.description or ''}".lower()
for kw in _FINANCIAL_LEGAL_KEYWORDS:
if kw in text:
return (
"Auto-escalated per constitution §4: this pending decision touches "
"financial or legal territory and requires explicit human approval before action."
)
return None
@router.get("/", response_model=list[DecisionRead])
async def list_decisions(
topic_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None,
status: DecisionStatus | None = None,
decision_type: DecisionType | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Decision]:
q = select(Decision)
if topic_id:
q = q.where(Decision.topic_id == topic_id)
if workstream_id:
q = q.where(Decision.workstream_id == workstream_id)
if status:
q = q.where(Decision.status == status)
if decision_type:
q = q.where(Decision.decision_type == decision_type)
q = q.order_by(Decision.created_at)
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=DecisionRead, status_code=status.HTTP_201_CREATED)
async def create_decision(
body: DecisionCreate,
session: AsyncSession = Depends(get_session),
) -> Decision:
data = body.model_dump()
note = _needs_escalation(body)
if note:
data["escalation_note"] = note
data["status"] = DecisionStatus.escalated
decision = Decision(**data)
session.add(decision)
await session.commit()
await session.refresh(decision)
return decision
@router.get("/{decision_id}", response_model=DecisionRead)
async def get_decision(
decision_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Decision:
decision = await session.get(Decision, decision_id)
if decision is None:
raise HTTPException(status_code=404, detail="Decision not found")
return decision
@router.patch("/{decision_id}", response_model=DecisionRead)
async def update_decision(
decision_id: uuid.UUID,
body: DecisionUpdate,
session: AsyncSession = Depends(get_session),
) -> Decision:
decision = await session.get(Decision, decision_id)
if decision is None:
raise HTTPException(status_code=404, detail="Decision not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(decision, field, value)
await session.commit()
await session.refresh(decision)
return decision
@router.delete("/{decision_id}", response_model=DecisionRead)
async def supersede_decision(
decision_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Decision:
decision = await session.get(Decision, decision_id)
if decision is None:
raise HTTPException(status_code=404, detail="Decision not found")
decision.status = DecisionStatus.superseded
await session.commit()
await session.refresh(decision)
return decision
@router.post("/{decision_id}/resolve", response_model=DecisionRead)
async def resolve_decision_action(
decision_id: uuid.UUID,
body: DecisionResolve,
session: AsyncSession = Depends(get_session),
) -> Decision:
decision = await session.get(Decision, decision_id)
if decision is None:
raise HTTPException(status_code=404, detail="Decision not found")
if decision.status == DecisionStatus.resolved:
raise HTTPException(status_code=409, detail="Decision already resolved")
decision.status = DecisionStatus.resolved
decision.decision_type = DecisionType.made
decision.rationale = body.rationale
decision.decided_by = body.decided_by
decision.decided_at = datetime.now(tz=timezone.utc)
await session.commit()
await session.refresh(decision)
event = ProgressEvent(
topic_id=decision.topic_id,
workstream_id=decision.workstream_id,
decision_id=decision.id,
event_type="decision_resolved",
summary=f"Decision resolved: {decision.title}",
author=body.decided_by,
detail={"rationale": body.rationale},
)
session.add(event)
await session.commit()
if body.write_log:
await _write_project_log(decision, body.rationale, body.decided_by, session)
subject = "org.statehub.decision.resolved"
envelope = EventEnvelope.new(
subject,
attributes={
"decision_id": str(decision.id),
"title": decision.title,
"topic_id": str(decision.topic_id) if decision.topic_id else None,
"workstream_id": str(decision.workstream_id) if decision.workstream_id else None,
"decided_by": body.decided_by,
"rationale_snippet": (body.rationale or "")[:240],
},
)
asyncio.create_task(publish_event(subject, envelope))
return decision
async def _write_project_log(
decision: Decision, rationale: str, decided_by: str, session: AsyncSession
) -> None:
"""Append a DECISIONS.md entry to the registered project directory for this topic."""
if decision.topic_id is None:
return
rows = await session.execute(
select(ProgressEvent)
.where(ProgressEvent.topic_id == decision.topic_id)
.where(ProgressEvent.event_type == "milestone")
.order_by(ProgressEvent.created_at.desc())
)
project_path: str | None = None
for pe in rows.scalars():
if pe.summary and "Project registered with State Hub:" in pe.summary:
project_path = (pe.detail or {}).get("project_path")
if project_path:
break
if not project_path:
logger.warning("write_log requested but no project_path found for topic %s", decision.topic_id)
return
p = Path(project_path)
if not p.is_dir():
logger.warning("write_log requested but project_path does not exist: %s", project_path)
return
now = datetime.now(tz=timezone.utc)
entry = (
f"\n## {decision.title}\n\n"
f"**Date:** {now.strftime('%Y-%m-%d')} \n"
f"**Decided by:** {decided_by} \n\n"
f"{rationale}\n\n"
f"---\n"
)
log_file = p / "DECISIONS.md"
if log_file.exists():
log_file.write_text(log_file.read_text() + entry)
else:
log_file.write_text(
"# Decision Log\n\n"
"_Auto-generated by the Custodian State Hub._\n"
+ entry
)

135
api/routers/domain_goals.py Normal file
View File

@@ -0,0 +1,135 @@
import asyncio
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.events import EventEnvelope, publish_event
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")
was_active = goal.status == DomainGoalStatus.active
# 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,
)
)
superseded_ids: list[str] = []
for old in existing.scalars().all():
old.status = DomainGoalStatus.superseded
superseded_ids.append(str(old.id))
goal.status = DomainGoalStatus.active
await session.commit()
await session.refresh(goal)
if not was_active:
domain = await session.get(Domain, goal.domain_id)
subject = "org.statehub.domain.goal.activated"
envelope = EventEnvelope.new(
subject,
attributes={
"goal_id": str(goal.id),
"domain_id": str(goal.domain_id),
"domain_slug": domain.slug if domain else None,
"title": goal.title,
"superseded_goal_ids": superseded_ids,
},
)
asyncio.create_task(publish_event(subject, envelope))
return goal

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

@@ -0,0 +1,172 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import func, select
from sqlalchemy.orm import noload
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.domain import Domain
from api.models.extension_point import ExtensionPoint
from api.models.managed_repo import ManagedRepo
from api.models.technical_debt import TechnicalDebt
from api.models.topic import Topic
from api.models.workstream import Workstream
from api.schemas.domain import DomainCreate, DomainDetail, DomainRead, DomainRename, DomainUpdate, RepoStub
router = APIRouter(prefix="/domains", tags=["domains"])
@router.get("/", response_model=list[DomainRead])
async def list_domains(
response: Response,
status: str | None = Query(None, description="active | archived | all"),
session: AsyncSession = Depends(get_session),
) -> list[Domain]:
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
q = select(Domain).options(
noload(Domain.topics),
noload(Domain.repos),
noload(Domain.goals),
).order_by(Domain.name)
if status and status != "all":
q = q.where(Domain.status == status)
elif status is None:
q = q.where(Domain.status == "active")
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=DomainRead, status_code=status.HTTP_201_CREATED)
async def create_domain(
body: DomainCreate,
session: AsyncSession = Depends(get_session),
) -> Domain:
existing = await session.execute(select(Domain).where(Domain.slug == body.slug))
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Domain slug '{body.slug}' already exists")
domain = Domain(slug=body.slug, name=body.name, description=body.description)
session.add(domain)
await session.commit()
await session.refresh(domain)
return domain
@router.get("/{slug}", response_model=DomainDetail)
async def get_domain(
slug: str,
session: AsyncSession = Depends(get_session),
) -> DomainDetail:
domain = await _get_domain_by_slug(slug, session)
# Count topics
topic_count_row = await session.execute(
select(func.count()).select_from(Topic).where(Topic.domain_id == domain.id)
)
topic_count = topic_count_row.scalar_one()
# Count active workstreams (via topics)
topic_ids_row = await session.execute(
select(Topic.id).where(Topic.domain_id == domain.id)
)
topic_ids = [r[0] for r in topic_ids_row.all()]
ws_count = 0
if topic_ids:
ws_count_row = await session.execute(
select(func.count()).select_from(Workstream)
.where(Workstream.topic_id.in_(topic_ids))
.where(Workstream.status == "active")
)
ws_count = ws_count_row.scalar_one()
# Count EPs and TDs
ep_count_row = await session.execute(
select(func.count()).select_from(ExtensionPoint)
.where(ExtensionPoint.domain_id == domain.id)
)
ep_count = ep_count_row.scalar_one()
td_count_row = await session.execute(
select(func.count()).select_from(TechnicalDebt)
.where(TechnicalDebt.domain_id == domain.id)
)
td_count = td_count_row.scalar_one()
# Repos
repos_row = await session.execute(
select(ManagedRepo).where(ManagedRepo.domain_id == domain.id)
.where(ManagedRepo.status == "active")
.order_by(ManagedRepo.name)
)
repos = list(repos_row.scalars().all())
return DomainDetail(
id=domain.id,
slug=domain.slug,
name=domain.name,
description=domain.description,
status=domain.status,
created_at=domain.created_at,
updated_at=domain.updated_at,
topic_count=topic_count,
workstream_count=ws_count,
ep_count=ep_count,
td_count=td_count,
repos=[RepoStub.model_validate(r) for r in repos],
)
@router.patch("/{slug}/rename", response_model=DomainRead)
async def rename_domain(
slug: str,
body: DomainRename,
session: AsyncSession = Depends(get_session),
) -> Domain:
domain = await _get_domain_by_slug(slug, session)
if body.new_slug != slug:
conflict = await session.execute(select(Domain).where(Domain.slug == body.new_slug))
if conflict.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Slug '{body.new_slug}' already taken")
old_slug = domain.slug
domain.slug = body.new_slug
domain.name = body.new_name
await session.commit()
await session.refresh(domain)
return domain
@router.patch("/{slug}/archive", response_model=DomainRead)
async def archive_domain(
slug: str,
session: AsyncSession = Depends(get_session),
) -> Domain:
domain = await _get_domain_by_slug(slug, session)
# Reject if any active topics exist for this domain
active_topics = await session.execute(
select(func.count()).select_from(Topic)
.where(Topic.domain_id == domain.id)
.where(Topic.status == "active")
)
if active_topics.scalar_one() > 0:
raise HTTPException(
status_code=409,
detail="Cannot archive domain with active topics. Archive or reassign topics first.",
)
domain.status = "archived"
await session.commit()
await session.refresh(domain)
return domain
async def _get_domain_by_slug(slug: str, session: AsyncSession) -> Domain:
result = await session.execute(select(Domain).where(Domain.slug == slug))
domain = result.scalar_one_or_none()
if domain is None:
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
return domain

View File

@@ -0,0 +1,105 @@
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.extension_point import EPStatus, ExtensionPoint
from api.schemas.extension_point import EPCreate, EPRead, EPUpdate
router = APIRouter(prefix="/extension-points", tags=["extension-points"])
async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID:
"""Resolve a domain slug to its UUID, raising 422 if unknown."""
row = await session.execute(
select(Domain.id).where(Domain.slug == slug, Domain.status == "active")
)
domain_id = row.scalar_one_or_none()
if domain_id is None:
valid = [r[0] for r in (await session.execute(
select(Domain.slug).where(Domain.status == "active")
)).all()]
raise HTTPException(
status_code=422,
detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}",
)
return domain_id
@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:
domain_id = await _resolve_domain_id(domain, session)
q = q.where(ExtensionPoint.domain_id == domain_id)
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:
domain_id = await _resolve_domain_id(body.domain, session)
data = body.model_dump(exclude={"domain"})
data["domain_id"] = domain_id
ep = ExtensionPoint(**data)
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

167
api/routers/flows.py Normal file
View File

@@ -0,0 +1,167 @@
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.flow_defs import (
assertion_result_to_dict,
create_flow_engine,
flow_result_to_dict,
load_flow,
)
from api.models.capability_request import CapabilityRequest
from api.models.contribution import Contribution
from api.models.task import Task
from api.models.workstream import Workstream
from api.models.workstream_dependency import WorkstreamDependency
router = APIRouter(prefix="/flows", tags=["flows"])
@router.get("/definitions")
async def list_flow_definitions() -> list[dict[str, Any]]:
flows = [
load_flow(entity_type)
for entity_type in (
"workstream",
"task",
"contribution",
"capability_request",
)
]
return [
{
"id": flow.id,
"entity_type": flow.entity_type,
"workstations": [
{
"name": workstation.name,
"description": workstation.description,
"entry_assertion_count": len(workstation.entry_assertions),
"exit_assertion_count": len(workstation.exit_assertions),
}
for workstation in flow.workstations
],
}
for flow in flows
]
@router.get("/{entity_type}/{entity_id}")
async def get_flow_state(
entity_type: str,
entity_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
obj = await _flow_object(entity_type, entity_id, session)
flow = load_flow(entity_type)
result = create_flow_engine().evaluate(obj, flow)
return flow_result_to_dict(result)
@router.post("/{entity_type}/{entity_id}/advance/{target_workstation}")
async def advance_workstation(
entity_type: str,
entity_id: uuid.UUID,
target_workstation: str,
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
obj = await _flow_object(entity_type, entity_id, session)
flow = load_flow(entity_type)
engine = create_flow_engine()
can_reach, failures = engine.can_reach(obj, flow, target_workstation)
if not can_reach:
raise HTTPException(
status_code=409,
detail={
"message": (
f"Cannot advance {entity_type} '{entity_id}' "
f"to '{target_workstation}'."
),
"blocking_assertions": [
assertion_result_to_dict(item) for item in failures
],
"flow_result": flow_result_to_dict(engine.evaluate(obj, flow)),
},
)
entity = await _entity(entity_type, entity_id, session)
entity.status = target_workstation
await session.commit()
await session.refresh(entity)
return await get_flow_state(entity_type, entity_id, session)
async def _flow_object(
entity_type: str,
entity_id: uuid.UUID,
session: AsyncSession,
) -> dict[str, Any]:
entity = await _entity(entity_type, entity_id, session)
status = _value(entity.status)
obj: dict[str, Any] = {
"id": str(entity.id),
"status": status,
"workstation": status,
"previous_workstation": status,
}
if entity_type == "workstream":
tasks = list((await session.execute(
select(Task).where(Task.workstream_id == entity_id)
)).scalars().all())
deps = list((await session.execute(
select(WorkstreamDependency).where(
WorkstreamDependency.from_workstream_id == entity_id
)
)).scalars().all())
dependency_ids = [dep.to_workstream_id for dep in deps]
dependency_workstations: list[dict[str, Any]] = []
if dependency_ids:
dep_ws = list((await session.execute(
select(Workstream).where(Workstream.id.in_(dependency_ids))
)).scalars().all())
dependency_workstations = [
{"id": str(ws.id), "workstation": ws.status}
for ws in dep_ws
]
obj.update({
"tasks": [{"id": str(task.id), "status": _value(task.status)} for task in tasks],
"dependencies": dependency_workstations,
})
elif entity_type == "task":
obj.update({
"needs_human": entity.needs_human,
"blocking_reason": entity.blocking_reason,
})
return obj
async def _entity(
entity_type: str,
entity_id: uuid.UUID,
session: AsyncSession,
):
model_by_type = {
"workstream": Workstream,
"task": Task,
"contribution": Contribution,
"capability_request": CapabilityRequest,
}
model = model_by_type.get(entity_type)
if model is None:
raise HTTPException(status_code=404, detail=f"Unknown flow entity type '{entity_type}'")
entity = await session.get(model, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail=f"{entity_type} '{entity_id}' not found")
return entity
def _value(item):
return item.value if hasattr(item, "value") else item

View File

@@ -0,0 +1,192 @@
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.agent_message import AgentMessage
from api.models.interface_change import InterfaceChange
from api.models.managed_repo import ManagedRepo
from api.models.progress_event import ProgressEvent
from api.schemas.interface_change import (
InterfaceChangeCreate,
InterfaceChangePatch,
InterfaceChangeRead,
)
router = APIRouter(prefix="/interface-changes", tags=["interface-changes"])
_VALID_INTERFACE_TYPES = {"rest_api", "mcp_tool", "cli", "schema", "capability"}
_VALID_CHANGE_TYPES = {"breaking", "additive", "deprecation", "removal"}
@router.post("/", response_model=InterfaceChangeRead, status_code=status.HTTP_201_CREATED)
async def create_interface_change(
body: InterfaceChangeCreate,
session: AsyncSession = Depends(get_session),
) -> InterfaceChangeRead:
if body.interface_type not in _VALID_INTERFACE_TYPES:
raise HTTPException(status_code=422, detail=f"interface_type must be one of {sorted(_VALID_INTERFACE_TYPES)}")
if body.change_type not in _VALID_CHANGE_TYPES:
raise HTTPException(status_code=422, detail=f"change_type must be one of {sorted(_VALID_CHANGE_TYPES)}")
repo = await _repo_by_slug(body.repo_slug, session)
change = InterfaceChange(
repo_id=repo.id,
interface_type=body.interface_type,
change_type=body.change_type,
title=body.title,
description=body.description,
affected_paths=body.affected_paths,
affected_repo_slugs=body.affected_repo_slugs,
planned_for=body.planned_for,
author=body.author,
status="draft",
)
session.add(change)
await session.commit()
await session.refresh(change)
return InterfaceChangeRead.from_orm_with_slug(change)
@router.get("/", response_model=list[InterfaceChangeRead])
async def list_interface_changes(
repo_slug: str | None = Query(None),
status: str | None = Query(None),
change_type: str | None = Query(None),
affected_repo: str | None = Query(None, description="Return changes that affect this repo slug"),
session: AsyncSession = Depends(get_session),
) -> list[InterfaceChangeRead]:
q = select(InterfaceChange).order_by(InterfaceChange.created_at.desc())
if repo_slug:
repo = await _repo_by_slug(repo_slug, session)
q = q.where(InterfaceChange.repo_id == repo.id)
if status:
q = q.where(InterfaceChange.status == status)
if change_type:
q = q.where(InterfaceChange.change_type == change_type)
if affected_repo:
q = q.where(InterfaceChange.affected_repo_slugs.contains([affected_repo]))
result = await session.execute(q)
return [InterfaceChangeRead.from_orm_with_slug(c) for c in result.scalars().all()]
@router.get("/{change_id}", response_model=InterfaceChangeRead)
async def get_interface_change(
change_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> InterfaceChangeRead:
change = await _get_or_404(change_id, session)
return InterfaceChangeRead.from_orm_with_slug(change)
@router.patch("/{change_id}", response_model=InterfaceChangeRead)
async def patch_interface_change(
change_id: uuid.UUID,
body: InterfaceChangePatch,
session: AsyncSession = Depends(get_session),
) -> InterfaceChangeRead:
change = await _get_or_404(change_id, session)
if change.status != "draft":
raise HTTPException(
status_code=409,
detail=f"Cannot edit a change with status '{change.status}'. Only draft records are mutable.",
)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(change, field, value)
await session.commit()
await session.refresh(change)
return InterfaceChangeRead.from_orm_with_slug(change)
@router.post("/{change_id}/publish", response_model=InterfaceChangeRead)
async def publish_interface_change(
change_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> InterfaceChangeRead:
change = await _get_or_404(change_id, session)
if change.status != "draft":
raise HTTPException(
status_code=409,
detail=f"Cannot publish a change with status '{change.status}'. Must be 'draft'.",
)
now = datetime.now(tz=timezone.utc)
change.status = "published"
change.published_at = now
# Send inbox notifications to agents of affected repos
affected = change.affected_repo_slugs or []
for slug in affected:
paths_summary = ", ".join(change.affected_paths[:5]) if change.affected_paths else "see description"
if len(change.affected_paths) > 5:
paths_summary += f" (+{len(change.affected_paths) - 5} more)"
msg = AgentMessage(
from_agent=change.repo.slug,
to_agent=slug,
subject=f"[{change.change_type.upper()}] {change.title}",
body=(
f"**Interface change published by `{change.repo.slug}`**\n\n"
f"- Type: `{change.interface_type}` / `{change.change_type}`\n"
f"- Affected paths: {paths_summary}\n\n"
f"{change.description}\n\n"
f"Change ID: `{change.id}` — resolve with "
f"`POST /interface-changes/{change.id}/resolve` once adapted."
),
)
session.add(msg)
# Progress event on the originating repo
session.add(ProgressEvent(
event_type="milestone",
summary=f"Interface change published: {change.title}",
detail={
"change_id": str(change.id),
"change_type": change.change_type,
"interface_type": change.interface_type,
"affected_repos": affected,
"notifications_sent": len(affected),
},
author=change.author,
))
await session.commit()
await session.refresh(change)
return InterfaceChangeRead.from_orm_with_slug(change)
@router.post("/{change_id}/resolve", response_model=InterfaceChangeRead)
async def resolve_interface_change(
change_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> InterfaceChangeRead:
change = await _get_or_404(change_id, session)
if change.status != "published":
raise HTTPException(
status_code=409,
detail=f"Cannot resolve a change with status '{change.status}'. Must be 'published'.",
)
change.status = "resolved"
change.resolved_at = datetime.now(tz=timezone.utc)
await session.commit()
await session.refresh(change)
return InterfaceChangeRead.from_orm_with_slug(change)
async def _repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
repo = result.scalar_one_or_none()
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
return repo
async def _get_or_404(change_id: uuid.UUID, session: AsyncSession) -> InterfaceChange:
result = await session.execute(
select(InterfaceChange).where(InterfaceChange.id == change_id)
)
change = result.scalar_one_or_none()
if change is None:
raise HTTPException(status_code=404, detail=f"InterfaceChange '{change_id}' not found")
return change

138
api/routers/messages.py Normal file
View File

@@ -0,0 +1,138 @@
import uuid
from datetime import datetime, timezone
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.agent_message import AgentMessage
from api.schemas.agent_message import MessageCreate, MessageRead, MessageReply
router = APIRouter(prefix="/messages", tags=["messages"])
@router.post("/", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
async def send_message(
body: MessageCreate,
session: AsyncSession = Depends(get_session),
) -> AgentMessage:
"""Send a message from one agent to another (or 'broadcast')."""
if body.thread_id:
root = await session.get(AgentMessage, body.thread_id)
if root is None:
raise HTTPException(status_code=404, detail=f"Thread root {body.thread_id} not found")
msg = AgentMessage(
from_agent=body.from_agent,
to_agent=body.to_agent,
subject=body.subject,
body=body.body,
thread_id=body.thread_id,
)
session.add(msg)
await session.commit()
await session.refresh(msg)
return msg
@router.get("/", response_model=list[MessageRead])
async def list_messages(
to_agent: str | None = None,
from_agent: str | None = None,
unread_only: bool = False,
limit: int = 50,
session: AsyncSession = Depends(get_session),
) -> list[AgentMessage]:
"""List messages. Filter by recipient, sender, or unread status."""
q = select(AgentMessage).where(AgentMessage.archived_at.is_(None))
if to_agent:
q = q.where(
(AgentMessage.to_agent == to_agent) | (AgentMessage.to_agent == "broadcast")
)
if from_agent:
q = q.where(AgentMessage.from_agent == from_agent)
if unread_only:
q = q.where(AgentMessage.read_at.is_(None))
q = q.order_by(AgentMessage.created_at.desc()).limit(limit)
result = await session.execute(q)
return list(result.scalars().all())
@router.get("/thread/{thread_id}", response_model=list[MessageRead])
async def get_thread(
thread_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> list[AgentMessage]:
"""Get all messages in a thread (root + replies), oldest first."""
# Include the root message itself
q = select(AgentMessage).where(
(AgentMessage.id == thread_id) | (AgentMessage.thread_id == thread_id)
).order_by(AgentMessage.created_at)
result = await session.execute(q)
return list(result.scalars().all())
@router.patch("/{message_id}/read", response_model=MessageRead)
async def mark_read(
message_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> AgentMessage:
"""Mark a message as read."""
msg = await _get_message(message_id, session)
if msg.read_at is None:
msg.read_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(msg)
return msg
@router.patch("/{message_id}/archive", response_model=MessageRead)
async def archive_message(
message_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> AgentMessage:
"""Archive a message (soft-delete)."""
msg = await _get_message(message_id, session)
msg.archived_at = datetime.now(timezone.utc)
if msg.read_at is None:
msg.read_at = msg.archived_at
await session.commit()
await session.refresh(msg)
return msg
@router.post("/{message_id}/reply", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
async def reply_to_message(
message_id: uuid.UUID,
body: MessageReply,
session: AsyncSession = Depends(get_session),
) -> AgentMessage:
"""Reply to a message. Marks the original as read and creates a reply in the same thread."""
original = await _get_message(message_id, session)
# Mark original as read
if original.read_at is None:
original.read_at = datetime.now(timezone.utc)
# Thread root is either the original's thread_id or the original itself
thread_root = original.thread_id or original.id
reply = AgentMessage(
from_agent=body.from_agent,
to_agent=original.from_agent,
subject=f"Re: {original.subject}",
body=body.body,
thread_id=thread_root,
)
session.add(reply)
await session.commit()
await session.refresh(reply)
return reply
async def _get_message(message_id: uuid.UUID, session: AsyncSession) -> AgentMessage:
msg = await session.get(AgentMessage, message_id)
if msg is None:
raise HTTPException(status_code=404, detail=f"Message {message_id} not found")
return msg

41
api/routers/policy.py Normal file
View File

@@ -0,0 +1,41 @@
import re
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
POLICY_DIR = Path(__file__).parent.parent.parent / "policies"
_VALID_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
router = APIRouter(prefix="/policy", tags=["policy"])
class PolicyRead(BaseModel):
name: str
content: str
class PolicyUpdate(BaseModel):
content: str
def _policy_path(name: str) -> Path:
if not _VALID_NAME.match(name):
raise HTTPException(status_code=400, detail="Invalid policy name")
path = POLICY_DIR / f"{name}.md"
if not path.exists():
raise HTTPException(status_code=404, detail=f"Policy '{name}' not found")
return path
@router.get("/{name}", response_model=PolicyRead)
def get_policy(name: str) -> PolicyRead:
path = _policy_path(name)
return PolicyRead(name=name, content=path.read_text())
@router.put("/{name}", response_model=PolicyRead)
def update_policy(name: str, body: PolicyUpdate) -> PolicyRead:
path = _policy_path(name)
path.write_text(body.content)
return PolicyRead(name=name, content=body.content)

51
api/routers/progress.py Normal file
View File

@@ -0,0 +1,51 @@
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.progress_event import ProgressEvent
from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead
router = APIRouter(prefix="/progress", tags=["progress"])
@router.get("/", response_model=list[ProgressEventRead])
async def list_progress(
topic_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None,
task_id: uuid.UUID | None = None,
event_type: str | None = None,
since: datetime | None = None,
limit: int = Query(100, le=1000),
offset: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session),
) -> list[ProgressEvent]:
q = select(ProgressEvent)
if topic_id:
q = q.where(ProgressEvent.topic_id == topic_id)
if workstream_id:
q = q.where(ProgressEvent.workstream_id == workstream_id)
if task_id:
q = q.where(ProgressEvent.task_id == task_id)
if event_type:
q = q.where(ProgressEvent.event_type == event_type)
if since:
q = q.where(ProgressEvent.created_at >= since)
q = q.order_by(ProgressEvent.created_at.desc()).offset(offset).limit(limit)
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=ProgressEventRead, status_code=status.HTTP_201_CREATED)
async def append_progress(
body: ProgressEventCreate,
session: AsyncSession = Depends(get_session),
) -> ProgressEvent:
event = ProgressEvent(**body.model_dump())
session.add(event)
await session.commit()
await session.refresh(event)
return event

79
api/routers/repo_goals.py Normal file
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

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

@@ -0,0 +1,728 @@
import asyncio
import json
import os
import re
import socket
import subprocess
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import case, func, select
from sqlalchemy.orm import noload
from sqlalchemy.ext.asyncio import AsyncSession
from api.config import settings
from api.database import get_session
from api.events import EventEnvelope, publish_event
from api.doi_engine import (
compute_fingerprint,
evaluate as _doi_evaluate,
evaluate_scope_health,
resolve_repo_path,
)
from api.models.doi_cache import DOICache
from api.models.domain import Domain
from api.models.interface_change import InterfaceChange
from api.models.managed_repo import ManagedRepo
from api.models.repo_goal import RepoGoal
from api.models.tpsc import TPSCSnapshot
from api.models.task import Task
from api.models.workstream import Workstream
from api.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry
from api.schemas.managed_repo import (
DispatchTask,
DispatchWorkstream,
PendingInterfaceChange,
RepoCreate,
RepoDispatch,
RepoOnboardRequest,
RepoOnboardResult,
RepoPathRegister,
RepoRead,
RepoScopeHealth,
RepoUpdate,
ScopeIssueDetail,
)
router = APIRouter(prefix="/repos", tags=["repos"])
@router.get("/", response_model=list[RepoRead])
async def list_repos(
response: Response,
domain: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[ManagedRepo]:
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
q = select(ManagedRepo).options(noload(ManagedRepo.goals)).order_by(ManagedRepo.name)
if domain:
domain_row = await session.execute(select(Domain).where(Domain.slug == domain))
domain_obj = domain_row.scalar_one_or_none()
if domain_obj is None:
raise HTTPException(status_code=404, detail=f"Domain '{domain}' not found")
q = q.where(ManagedRepo.domain_id == domain_obj.id)
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=RepoRead, status_code=status.HTTP_201_CREATED)
async def register_repo(
body: RepoCreate,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
domain_row = await session.execute(select(Domain).where(Domain.slug == body.domain_slug))
domain_obj = domain_row.scalar_one_or_none()
if domain_obj is None:
raise HTTPException(status_code=404, detail=f"Domain '{body.domain_slug}' not found")
existing = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == body.slug))
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Repo slug '{body.slug}' already exists")
repo = ManagedRepo(
domain_id=domain_obj.id,
slug=body.slug,
name=body.name,
local_path=body.local_path,
remote_url=body.remote_url,
git_fingerprint=body.git_fingerprint,
description=body.description,
topic_id=body.topic_id,
)
session.add(repo)
await session.commit()
await session.refresh(repo)
subject = "org.statehub.repo.registered"
envelope = EventEnvelope.new(
subject,
attributes={
"repo_id": str(repo.id),
"repo_slug": repo.slug,
"domain_slug": body.domain_slug,
"remote_url": repo.remote_url,
"local_path": repo.local_path,
},
)
asyncio.create_task(publish_event(subject, envelope))
return repo
@router.post("/onboard", response_model=RepoOnboardResult)
async def onboard_repo(body: RepoOnboardRequest) -> RepoOnboardResult:
"""Run the local repo onboarding script for an accessible working copy.
The dashboard uses this for the "Add Repo" action. The path must be visible
from the State Hub host, either as a local checkout or through an ops-bridge
mounted/exposed working copy. Keep the API agent-profile based so future
native coding agents can gain their own profiles without changing callers.
"""
project_path = Path(body.project_path).expanduser()
if not project_path.exists() or not project_path.is_dir():
raise HTTPException(
status_code=400,
detail=f"project_path is not an accessible directory: {body.project_path}",
)
if not (project_path / ".git").exists():
raise HTTPException(
status_code=400,
detail=f"project_path does not look like a git working copy: {body.project_path}",
)
script = Path(__file__).parent.parent.parent / "scripts" / "register_project.sh"
cmd = ["bash", str(script), body.domain_slug, str(project_path)]
if body.agent_profile == "codex":
cmd.append("--codex")
if body.additional:
cmd.append("--additional")
env = {
**os.environ,
"API_BASE": settings.api_base,
"CUSTODIAN_SKIP_SBOM_PROMPT": "true",
}
result = await asyncio.to_thread(
subprocess.run,
cmd,
cwd=str(script.parent.parent),
env=env,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=180,
)
stdout = result.stdout or ""
stderr = result.stderr or ""
if result.returncode != 0:
raise HTTPException(
status_code=500,
detail={
"message": "Repo onboarding failed.",
"command": cmd,
"stdout": stdout,
"stderr": stderr,
},
)
repo_slug = None
match = re.search(r"Repo slug:\s+([a-z0-9][a-z0-9-]*)", stdout)
if match:
repo_slug = match.group(1)
return RepoOnboardResult(
ok=True,
repo_slug=repo_slug,
agent_profile=body.agent_profile,
command=cmd,
stdout=stdout,
stderr=stderr,
)
@router.get("/by-fingerprint", response_model=list[RepoRead])
async def get_repo_by_fingerprint(
hash: str,
remote_url: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[ManagedRepo]:
"""Look up repos by git root-commit SHA-1 fingerprint.
The fingerprint is the output of ``git rev-list --max-parents=0 HEAD`` and
is identical across every clone of the same repository. Repos that share
git history (forks, monorepo splits) will have the same fingerprint.
Pass ``remote_url`` to narrow results to a specific remote — useful when
multiple repos share the same ancestor commit.
Returns an empty list if no match is found.
"""
q = select(ManagedRepo).where(ManagedRepo.git_fingerprint == hash)
if remote_url:
q = q.where(ManagedRepo.remote_url == remote_url)
result = await session.execute(q)
return list(result.scalars().all())
@router.get("/by-remote", response_model=RepoRead)
async def get_repo_by_remote_url(
url: str,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
"""Look up a repo by its git remote URL (fallback; prefer /by-fingerprint)."""
result = await session.execute(select(ManagedRepo).where(ManagedRepo.remote_url == url))
repo = result.scalar_one_or_none()
if repo is None:
raise HTTPException(status_code=404, detail=f"No repo with remote_url '{url}' found")
return repo
@router.get("/doi/summary", response_model=list[DoISummaryEntry])
async def doi_summary(session: AsyncSession = Depends(get_session)) -> list[DoISummaryEntry]:
"""Return DoI tier for all active repos, worst tier first.
Results are cached in doi_cache. A repo is only re-evaluated when its
fingerprint changes (repo record updated, new TPSC snapshot, goal change,
or a key file mtime changes on disk).
"""
repos_result = await session.execute(
select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.name)
)
repos = list(repos_result.scalars().all())
repo_ids = [r.id for r in repos]
id_to_slug = {r.id: r.slug for r in repos}
# ── Bulk DB queries for fingerprint inputs ────────────────────────────────
domains_result = await session.execute(select(Domain))
domain_obj_map = {d.id: d for d in domains_result.scalars().all()}
domain_map = {d.id: d.slug for d in domain_obj_map.values()}
domain_status = {d.slug: d.status for d in domain_obj_map.values()}
# Latest TPSC snapshot timestamp per repo (for fingerprint + C9 count)
tpsc_result = await session.execute(
select(TPSCSnapshot.repo_id,
func.count().label("cnt"),
func.max(TPSCSnapshot.snapshot_at).label("latest"))
.where(TPSCSnapshot.repo_id.in_(repo_ids))
.group_by(TPSCSnapshot.repo_id)
)
tpsc_by_id = {row.repo_id: row for row in tpsc_result}
tpsc_snap_counts = {id_to_slug[rid]: row.cnt for rid, row in tpsc_by_id.items() if rid in id_to_slug}
tpsc_snap_latest = {id_to_slug[rid]: str(row.latest) for rid, row in tpsc_by_id.items() if rid in id_to_slug}
# Latest goal updated_at + active count per repo (for fingerprint + C10)
goals_result = await session.execute(
select(RepoGoal.repo_id,
func.count().label("total"),
func.sum(case((RepoGoal.status == "active", 1), else_=0)).label("active_cnt"),
func.max(RepoGoal.updated_at).label("latest"))
.where(RepoGoal.repo_id.in_(repo_ids))
.group_by(RepoGoal.repo_id)
)
goals_by_id = {row.repo_id: row for row in goals_result}
active_goal_counts = {id_to_slug[rid]: int(row.active_cnt or 0) for rid, row in goals_by_id.items() if rid in id_to_slug}
goals_latest = {id_to_slug[rid]: str(row.latest) for rid, row in goals_by_id.items() if rid in id_to_slug}
# Load existing cache rows
cache_result = await session.execute(
select(DOICache).where(DOICache.repo_id.in_(repo_ids))
)
cache_by_repo_id = {row.repo_id: row for row in cache_result.scalars().all()}
# ─────────────────────────────────────────────────────────────────────────
prefetch = {
"domain_status": domain_status,
"tpsc_snap_counts": tpsc_snap_counts,
"active_goal_counts": active_goal_counts,
}
async def _get_or_refresh(repo: ManagedRepo) -> DoISummaryEntry:
slug = repo.slug
repo_dict = {
"slug": slug,
"domain_slug": domain_map.get(repo.domain_id),
"local_path": repo.local_path,
"remote_url": repo.remote_url,
"host_paths": repo.host_paths or {},
"last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None,
"updated_at": str(repo.updated_at) if repo.updated_at else "",
}
fp = compute_fingerprint(
repo_dict,
tpsc_snap_latest.get(slug),
goals_latest.get(slug),
)
cached = cache_by_repo_id.get(repo.id)
if cached and cached.fingerprint == fp:
# Cache hit — return stored result
return DoISummaryEntry(
repo_slug=slug,
domain_slug=domain_map.get(repo.domain_id),
tier=cached.tier,
core_pass=cached.core_pass,
standard_pass=cached.standard_pass,
full_pass=cached.full_pass,
checked_at=cached.checked_at.isoformat(),
)
# Cache miss — evaluate and store
report = await _doi_evaluate(repo_dict, skip_consistency=True, prefetch=prefetch)
now = datetime.now(tz=timezone.utc)
if cached:
cached.tier = report.tier
cached.core_pass = report.core_pass
cached.standard_pass = report.standard_pass
cached.full_pass = report.full_pass
cached.criteria = [{"id": c.id, "label": c.label, "tier": c.tier,
"status": c.status, "detail": c.detail}
for c in report.criteria]
cached.fingerprint = fp
cached.checked_at = now
cached.updated_at = now
else:
session.add(DOICache(
repo_id=repo.id,
tier=report.tier,
core_pass=report.core_pass,
standard_pass=report.standard_pass,
full_pass=report.full_pass,
criteria=[{"id": c.id, "label": c.label, "tier": c.tier,
"status": c.status, "detail": c.detail}
for c in report.criteria],
fingerprint=fp,
checked_at=now,
updated_at=now,
))
return DoISummaryEntry(
repo_slug=slug,
domain_slug=domain_map.get(repo.domain_id),
tier=report.tier,
core_pass=report.core_pass,
standard_pass=report.standard_pass,
full_pass=report.full_pass,
checked_at=now.isoformat(),
)
entries: list[DoISummaryEntry] = list(await asyncio.gather(*[_get_or_refresh(r) for r in repos]))
await session.commit()
tier_order = {"none": 0, "core": 1, "standard": 2, "full": 3}
entries.sort(key=lambda e: tier_order.get(e.tier, 0))
return entries
@router.get("/{slug}/doi", response_model=DoIReport)
async def get_repo_doi(
slug: str,
force_refresh: bool = False,
session: AsyncSession = Depends(get_session),
) -> DoIReport:
"""Evaluate the 14 DoI criteria for a single repo (full check including C7/C13).
Results are cached by fingerprint. Pass ?force_refresh=true to bypass the cache.
"""
repo = await _get_repo_by_slug(slug, session)
domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id))
domain_obj = domain_result.scalar_one_or_none()
# Fingerprint inputs for this single repo
tpsc_row = (await session.execute(
select(func.count().label("cnt"), func.max(TPSCSnapshot.snapshot_at).label("latest"))
.where(TPSCSnapshot.repo_id == repo.id)
)).one()
goal_row = (await session.execute(
select(func.max(RepoGoal.updated_at).label("latest"))
.where(RepoGoal.repo_id == repo.id)
)).one()
repo_dict = {
"slug": repo.slug,
"domain_slug": domain_obj.slug if domain_obj else None,
"local_path": repo.local_path,
"remote_url": repo.remote_url,
"host_paths": repo.host_paths or {},
"last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None,
"updated_at": str(repo.updated_at) if repo.updated_at else "",
}
fp = compute_fingerprint(repo_dict, str(tpsc_row.latest) if tpsc_row.latest else None,
str(goal_row.latest) if goal_row.latest else None)
# Check cache (unless force_refresh)
cached = (await session.execute(
select(DOICache).where(DOICache.repo_id == repo.id)
)).scalar_one_or_none()
if not force_refresh and cached and cached.fingerprint == fp and cached.criteria:
return DoIReport(
repo_slug=slug,
tier=cached.tier,
core_pass=cached.core_pass,
standard_pass=cached.standard_pass,
full_pass=cached.full_pass,
checked_at=cached.checked_at.isoformat(),
criteria=[DoICriterion(**c) for c in cached.criteria],
)
# Full evaluation (includes C7/C13 consistency subprocesses)
report = await _doi_evaluate(repo_dict)
now = datetime.now(tz=timezone.utc)
criteria_json = [{"id": c.id, "label": c.label, "tier": c.tier,
"status": c.status, "detail": c.detail} for c in report.criteria]
if cached:
cached.tier = report.tier; cached.core_pass = report.core_pass
cached.standard_pass = report.standard_pass; cached.full_pass = report.full_pass
cached.criteria = criteria_json; cached.fingerprint = fp
cached.checked_at = now; cached.updated_at = now
else:
session.add(DOICache(repo_id=repo.id, tier=report.tier,
core_pass=report.core_pass, standard_pass=report.standard_pass,
full_pass=report.full_pass, criteria=criteria_json,
fingerprint=fp, checked_at=now, updated_at=now))
await session.commit()
return DoIReport(
repo_slug=report.repo_slug, tier=report.tier,
core_pass=report.core_pass, standard_pass=report.standard_pass,
full_pass=report.full_pass, checked_at=report.checked_at,
criteria=[DoICriterion(id=c.id, label=c.label, tier=c.tier,
status=c.status, detail=c.detail) for c in report.criteria],
)
@router.get("/by-id/{repo_id}", response_model=RepoRead)
async def get_repo_by_id(
repo_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
repo = await session.get(ManagedRepo, repo_id)
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{repo_id}' not found")
return repo
@router.get("/scope-health", response_model=list[RepoScopeHealth])
async def list_repo_scope_health(
needs_review: bool | None = None,
reachable_only: bool = False,
session: AsyncSession = Depends(get_session),
) -> list[RepoScopeHealth]:
"""Return machine-readable SCOPE.md health for active repos.
Repo-scoping uses this to refresh only repos and SCOPE.md sections that
need attention, without guessing from free-text DoI output.
"""
result = await session.execute(
select(ManagedRepo, Domain.slug)
.join(Domain, Domain.id == ManagedRepo.domain_id)
.where(ManagedRepo.status == "active")
.order_by(ManagedRepo.slug)
)
entries: list[RepoScopeHealth] = []
for repo, domain_slug in result.all():
repo_dict = _repo_doi_dict(repo, domain_slug)
resolved_path = resolve_repo_path(repo_dict)
scope_issue_details = [
ScopeIssueDetail(**issue)
for issue in evaluate_scope_health(repo_dict)
]
scope_needs_review = any(
issue.id in {"C5a", "C5b", "C5c"} and issue.status in {"fail", "warn"}
for issue in scope_issue_details
)
entry = RepoScopeHealth(
repo_slug=repo.slug,
domain_slug=domain_slug,
local_path=resolved_path or repo.local_path,
path_available=bool(resolved_path),
scope_needs_review=scope_needs_review,
scope_issue_details=scope_issue_details,
)
if needs_review is not None and entry.scope_needs_review != needs_review:
continue
if reachable_only and not entry.path_available:
continue
entries.append(entry)
return entries
@router.get("/{slug}", response_model=RepoRead)
async def get_repo(
slug: str,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
return await _get_repo_by_slug(slug, session)
@router.patch("/{slug}", response_model=RepoRead)
async def update_repo(
slug: str,
body: RepoUpdate,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
repo = await _get_repo_by_slug(slug, session)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(repo, field, value)
await session.commit()
await session.refresh(repo)
return repo
@router.post("/{slug}/paths", response_model=RepoRead)
async def register_host_path(
slug: str,
body: RepoPathRegister,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
"""Register or update the local path for a specific host.
Merges {"host": path} into host_paths without overwriting other entries.
Use this when a repo lives at a different absolute path on different machines.
"""
repo = await _get_repo_by_slug(slug, session)
updated = dict(repo.host_paths or {})
updated[body.host] = body.path
repo.host_paths = updated
await session.commit()
await session.refresh(repo)
return repo
@router.patch("/{slug}/archive", response_model=RepoRead)
async def archive_repo(
slug: str,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
repo = await _get_repo_by_slug(slug, session)
repo.status = "archived"
await session.commit()
await session.refresh(repo)
return repo
@router.get("/{slug}/dispatch", response_model=RepoDispatch)
async def get_repo_dispatch(
slug: str,
session: AsyncSession = Depends(get_session),
) -> RepoDispatch:
"""Return active workstreams, pending tasks, and goal for a repo.
This endpoint is the foundation for autonomous agent sessions: an agent can
call it at session start to discover what work is pending without needing to
read state-hub summary or scan workplan files manually.
"""
repo = await _get_repo_by_slug(slug, session)
# Active goal
goal_result = await session.execute(
select(RepoGoal)
.where(RepoGoal.repo_id == repo.id, RepoGoal.status == "active")
.order_by(RepoGoal.priority)
.limit(1)
)
goal_obj = goal_result.scalar_one_or_none()
active_goal = None
if goal_obj:
active_goal = {
"id": str(goal_obj.id),
"title": goal_obj.title,
"description": goal_obj.description,
"priority": goal_obj.priority,
}
# Active workstreams
ws_result = await session.execute(
select(Workstream)
.where(Workstream.repo_id == repo.id, Workstream.status == "active")
.order_by(Workstream.created_at)
)
workstreams = list(ws_result.scalars().all())
dispatch_workstreams: list[DispatchWorkstream] = []
all_interventions: list[DispatchTask] = []
for ws in workstreams:
task_result = await session.execute(
select(Task)
.where(Task.workstream_id == ws.id, Task.status.in_(["todo", "in_progress"]))
.order_by(Task.created_at)
)
tasks = list(task_result.scalars().all())
pending = [
DispatchTask(
id=t.id,
title=t.title,
priority=t.priority,
status=t.status,
needs_human=t.needs_human,
)
for t in tasks
]
interventions = [t for t in pending if t.needs_human]
all_interventions.extend(interventions)
dispatch_workstreams.append(
DispatchWorkstream(
id=ws.id,
title=ws.title,
status=ws.status,
pending_tasks=pending,
)
)
# Published interface changes that affect this repo and are not yet resolved
ic_result = await session.execute(
select(InterfaceChange).where(
InterfaceChange.status == "published",
InterfaceChange.affected_repo_slugs.contains([slug]),
).order_by(InterfaceChange.published_at.desc())
)
pending_changes = [
PendingInterfaceChange(
id=ic.id,
title=ic.title,
change_type=ic.change_type,
interface_type=ic.interface_type,
origin_repo_slug=ic.repo.slug,
affected_paths=ic.affected_paths or [],
planned_for=ic.planned_for,
published_at=ic.published_at,
)
for ic in ic_result.scalars().all()
]
domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id))
domain_obj = domain_result.scalar_one_or_none()
scope_issue_details = [
ScopeIssueDetail(**issue)
for issue in evaluate_scope_health(_repo_doi_dict(repo, domain_obj.slug if domain_obj else None))
]
scope_needs_review = any(
issue.id in {"C5a", "C5b", "C5c"} and issue.status in {"fail", "warn"}
for issue in scope_issue_details
)
return RepoDispatch(
repo_slug=slug,
active_goal=active_goal,
active_workstreams=dispatch_workstreams,
human_interventions=all_interventions,
pending_interface_changes=pending_changes,
scope_needs_review=scope_needs_review,
scope_issue_details=scope_issue_details,
last_state_synced_at=repo.last_state_synced_at,
)
@router.post("/{slug}/sync")
async def sync_repo_consistency(
slug: str,
fix: bool = True,
session: AsyncSession = Depends(get_session),
) -> dict:
"""Run ADR-001 consistency check (and optional --fix) for a repo via HTTP.
Intended for non-Claude-Code agents (e.g. Codex) that cannot use MCP tools
but need to sync workplan file state to the state-hub DB after making changes.
Returns the raw JSON output from consistency_check.py.
Query param ?fix=false to run check-only without writing.
"""
repo = await _get_repo_by_slug(slug, session)
hostname = socket.gethostname()
host_paths = repo.host_paths or {}
repo_path = host_paths.get(hostname)
if not repo_path or not Path(repo_path).exists():
raise HTTPException(
status_code=503,
detail=(
f"No accessible path for repo '{slug}' on host '{hostname}'. "
f"Register with: POST /repos/{slug}/paths/"
),
)
script = Path(__file__).parent.parent.parent / "scripts" / "consistency_check.py"
cmd = [sys.executable, str(script), "--repo", slug, "--json",
"--api-base", settings.api_base]
if fix:
cmd.append("--fix")
result = await asyncio.to_thread(
subprocess.run, cmd, capture_output=True, text=True
)
try:
return json.loads(result.stdout)
except Exception:
raise HTTPException(
status_code=500,
detail=f"Consistency check failed: {result.stderr or result.stdout or '(no output)'}",
)
async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
repo = result.scalar_one_or_none()
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
return repo
def _repo_doi_dict(repo: ManagedRepo, domain_slug: str | None) -> dict:
return {
"slug": repo.slug,
"domain_slug": domain_slug,
"local_path": repo.local_path,
"remote_url": repo.remote_url,
"host_paths": repo.host_paths or {},
"last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None,
"updated_at": str(repo.updated_at) if repo.updated_at else "",
}

245
api/routers/sbom.py Normal file
View File

@@ -0,0 +1,245 @@
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.managed_repo import ManagedRepo
from api.models.sbom_entry import Ecosystem, SBOMEntry
from api.models.sbom_snapshot import SBOMSnapshot
from api.schemas.sbom import (
LicenceGroup,
LicenceReport,
SBOMEntryRead,
SBOMIngest,
SBOMRepoView,
SBOMSnapshotDetail,
SBOMSnapshotRead,
)
router = APIRouter(prefix="/sbom", tags=["sbom"])
_COPYLEFT_PATTERNS = {"GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"}
def _is_copyleft(spdx: str | None) -> bool:
if not spdx:
return False
upper = spdx.upper()
return any(pat in upper for pat in _COPYLEFT_PATTERNS)
def _latest_snapshot_ids_subquery():
"""Subquery returning the latest SBOMSnapshot.id per repo."""
max_at_sq = (
select(SBOMSnapshot.repo_id, func.max(SBOMSnapshot.snapshot_at).label("max_at"))
.group_by(SBOMSnapshot.repo_id)
.subquery("max_snap_at")
)
return (
select(SBOMSnapshot.id)
.join(
max_at_sq,
and_(
SBOMSnapshot.repo_id == max_at_sq.c.repo_id,
SBOMSnapshot.snapshot_at == max_at_sq.c.max_at,
),
)
.subquery("latest_snap_ids")
)
@router.post("/ingest/")
async def ingest_sbom(
body: SBOMIngest,
session: AsyncSession = Depends(get_session),
) -> dict:
"""Create a new SBOM snapshot for a repo. Previous snapshots are retained."""
repo = await _get_repo_by_slug(body.repo_slug, session)
now = datetime.now(tz=timezone.utc)
snap = SBOMSnapshot(
repo_id=repo.id,
snapshot_at=now,
source="manual",
entry_count=len(body.entries),
created_at=now,
)
session.add(snap)
await session.flush() # materialise snap.id before creating entries
for entry in body.entries:
sbom = SBOMEntry(
repo_id=repo.id,
snapshot_id=snap.id,
package_name=entry.package_name,
package_version=entry.package_version,
ecosystem=entry.ecosystem,
license_spdx=entry.license_spdx,
is_direct=entry.is_direct,
is_dev=entry.is_dev,
snapshot_at=now,
created_at=now,
)
session.add(sbom)
repo.last_sbom_at = now
if not repo.sbom_source:
repo.sbom_source = "manual"
await session.commit()
return {
"repo_slug": body.repo_slug,
"snapshot_id": str(snap.id),
"ingested": len(body.entries),
"snapshot_at": now.isoformat(),
}
@router.get("/snapshots/", response_model=list[SBOMSnapshotRead])
async def list_snapshots(
repo_slug: str | None = Query(None),
session: AsyncSession = Depends(get_session),
) -> list[SBOMSnapshotRead]:
"""List SBOM snapshots, newest first. Optionally filter by repo."""
q = select(SBOMSnapshot).order_by(SBOMSnapshot.snapshot_at.desc())
if repo_slug:
repo = await _get_repo_by_slug(repo_slug, session)
q = q.where(SBOMSnapshot.repo_id == repo.id)
result = await session.execute(q)
return [SBOMSnapshotRead.model_validate(s) for s in result.scalars().all()]
@router.get("/snapshots/{snapshot_id}", response_model=SBOMSnapshotDetail)
async def get_snapshot(
snapshot_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> SBOMSnapshotDetail:
"""Get a snapshot with its full entry list."""
snap = await session.get(SBOMSnapshot, snapshot_id)
if snap is None:
raise HTTPException(status_code=404, detail=f"Snapshot '{snapshot_id}' not found")
result = await session.execute(
select(SBOMEntry)
.where(SBOMEntry.snapshot_id == snapshot_id)
.order_by(SBOMEntry.package_name)
)
entries = list(result.scalars().all())
return SBOMSnapshotDetail(
id=snap.id,
repo_id=snap.repo_id,
snapshot_at=snap.snapshot_at,
source=snap.source,
entry_count=snap.entry_count,
created_at=snap.created_at,
entries=[SBOMEntryRead.model_validate(e) for e in entries],
)
@router.get("/")
async def list_sbom_entries(
repo_slug: str | None = Query(None),
ecosystem: Ecosystem | None = Query(None),
license_spdx: str | None = Query(None),
is_direct: bool | None = Query(None),
is_dev: bool | None = Query(None),
session: AsyncSession = Depends(get_session),
) -> list[SBOMEntryRead]:
"""Return entries from the latest snapshot per repo (default) or filter by repo."""
if repo_slug:
repo = await _get_repo_by_slug(repo_slug, session)
latest_snap_id_sq = (
select(SBOMSnapshot.id)
.where(SBOMSnapshot.repo_id == repo.id)
.order_by(SBOMSnapshot.snapshot_at.desc())
.limit(1)
.scalar_subquery()
)
q = select(SBOMEntry).where(SBOMEntry.snapshot_id == latest_snap_id_sq)
else:
latest_ids_sq = _latest_snapshot_ids_subquery()
q = select(SBOMEntry).where(SBOMEntry.snapshot_id.in_(select(latest_ids_sq.c.id)))
if ecosystem is not None:
q = q.where(SBOMEntry.ecosystem == ecosystem)
if license_spdx:
q = q.where(SBOMEntry.license_spdx == license_spdx)
if is_direct is not None:
q = q.where(SBOMEntry.is_direct == is_direct)
if is_dev is not None:
q = q.where(SBOMEntry.is_dev == is_dev)
q = q.order_by(SBOMEntry.package_name)
result = await session.execute(q)
return [SBOMEntryRead.model_validate(e) for e in result.scalars().all()]
@router.get("/report/licences/", response_model=LicenceReport)
async def licence_report(
session: AsyncSession = Depends(get_session),
) -> LicenceReport:
"""Group latest-snapshot SBOM entries by SPDX licence identifier, flag copyleft."""
latest_ids_sq = _latest_snapshot_ids_subquery()
rows = await session.execute(
select(SBOMEntry, ManagedRepo.slug)
.join(ManagedRepo, ManagedRepo.id == SBOMEntry.repo_id)
.where(SBOMEntry.snapshot_id.in_(select(latest_ids_sq.c.id)))
)
groups: dict[str | None, dict] = {}
copyleft_direct_count = 0
for entry, repo_slug in rows.all():
key = entry.license_spdx
if key not in groups:
groups[key] = {"count": 0, "repos": set()}
groups[key]["count"] += 1
groups[key]["repos"].add(repo_slug)
if _is_copyleft(key) and entry.is_direct and not entry.is_dev:
copyleft_direct_count += 1
licence_groups = [
LicenceGroup(
license_spdx=lic,
count=info["count"],
repos=sorted(info["repos"]),
is_copyleft=_is_copyleft(lic),
)
for lic, info in sorted(groups.items(), key=lambda x: -x[1]["count"])
]
return LicenceReport(groups=licence_groups, copyleft_direct_count=copyleft_direct_count)
@router.get("/{repo_slug}", response_model=SBOMRepoView)
async def get_repo_sbom(
repo_slug: str,
session: AsyncSession = Depends(get_session),
) -> SBOMRepoView:
"""Return the latest snapshot entries for a specific repo."""
repo = await _get_repo_by_slug(repo_slug, session)
latest_snap_id_sq = (
select(SBOMSnapshot.id)
.where(SBOMSnapshot.repo_id == repo.id)
.order_by(SBOMSnapshot.snapshot_at.desc())
.limit(1)
.scalar_subquery()
)
rows = await session.execute(
select(SBOMEntry)
.where(SBOMEntry.snapshot_id == latest_snap_id_sq)
.order_by(SBOMEntry.package_name)
)
entries = list(rows.scalars().all())
return SBOMRepoView(
repo_slug=repo_slug,
last_sbom_at=repo.last_sbom_at,
entry_count=len(entries),
entries=[SBOMEntryRead.model_validate(e) for e in entries],
)
async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
repo = result.scalar_one_or_none()
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
return repo

668
api/routers/state.py Normal file
View File

@@ -0,0 +1,668 @@
import time
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload, selectinload
from api.database import get_session, engine
from api.flow_defs import assertion_result_to_dict, load_flow
from api.models.capability_request import CapabilityRequest
from api.models.contribution import Contribution, ContributionStatus, ContributionType
from api.models.decision import Decision, DecisionStatus, DecisionType
from api.models.domain import Domain
from api.models.extension_point import ExtensionPoint
from api.models.managed_repo import ManagedRepo
from api.models.progress_event import ProgressEvent
from api.models.sbom_entry import SBOMEntry
from api.models.task import Task, TaskPriority, TaskStatus
from api.models.technical_debt import TechnicalDebt
from api.models.topic import Topic, TopicStatus
from api.models.workstream import Workstream
from api.models.workstream_dependency import WorkstreamDependency
from api.schemas.decision import DecisionRead
from api.schemas.domain import DomainSummary
from api.schemas.progress_event import ProgressEventRead
from api.schemas.state import (
DecisionTotals,
NextStep,
StateSummary,
TaskTotals,
Totals,
TopicTotals,
WorkstreamTotals,
)
from api.schemas.task import TaskRead
from api.schemas.topic import TopicRead, TopicWithWorkstreams
from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps
from api.schemas.workstream_dependency import WorkstreamDepStub
from task_flow_engine import FlowEngine
router = APIRouter(prefix="/state", tags=["state"])
_SUMMARY_CACHE: StateSummary | None = None
_SUMMARY_CACHE_AT: float = 0.0
_SUMMARY_TTL = 15.0
@router.get("/summary", response_model=StateSummary)
async def get_summary(
request: Request,
session: AsyncSession = Depends(get_session),
) -> StateSummary:
global _SUMMARY_CACHE, _SUMMARY_CACHE_AT
no_cache = "no-cache" in request.headers.get("cache-control", "")
if not no_cache and _SUMMARY_CACHE is not None and (time.monotonic() - _SUMMARY_CACHE_AT) < _SUMMARY_TTL:
return _SUMMARY_CACHE
# Run all queries sequentially on one session.
# AsyncSession does not support concurrent operations (no gather on same session).
topics_rows = await session.execute(
select(Topic)
.options(
selectinload(Topic.domain),
noload(Topic.workstreams),
noload(Topic.decisions),
noload(Topic.progress_events),
)
.where(Topic.status != TopicStatus.archived)
.order_by(Topic.created_at)
)
topics = list(topics_rows.scalars().all())
topic_ids = [t.id for t in topics]
topic_workstreams: dict = {t.id: [] for t in topics}
if topic_ids:
topic_ws_rows = await session.execute(
select(
Workstream.topic_id,
Workstream.id,
Workstream.slug,
Workstream.title,
Workstream.status,
Workstream.owner,
Workstream.due_date,
)
.where(Workstream.topic_id.in_(topic_ids))
.order_by(Workstream.created_at)
)
for topic_id, ws_id, slug, title, status, owner, due_date in topic_ws_rows:
topic_workstreams.setdefault(topic_id, []).append({
"id": ws_id,
"slug": slug,
"title": title,
"status": status,
"owner": owner,
"due_date": due_date,
})
blocking_rows = await session.execute(
select(Decision)
.where(Decision.decision_type == DecisionType.pending)
.where(Decision.status.in_([DecisionStatus.open, DecisionStatus.escalated]))
.order_by(Decision.deadline.asc().nullslast(), Decision.created_at)
)
blocking = list(blocking_rows.scalars().all())
blocked_rows = await session.execute(
select(Task).options(noload("*")).where(Task.status == TaskStatus.blocked).order_by(Task.created_at)
)
blocked = list(blocked_rows.scalars().all())
recent_rows = await session.execute(
select(ProgressEvent).options(noload("*")).order_by(ProgressEvent.created_at.desc()).limit(20)
)
recent = list(recent_rows.scalars().all())
open_ws_rows = await session.execute(
select(Workstream)
.options(noload("*"))
.where(Workstream.status.in_(["active", "blocked"]))
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
)
open_ws = list(open_ws_rows.scalars().all())
# Task counts per workstream (used to enrich open_workstreams)
task_per_ws: dict = {}
task_statuses_per_ws: dict = {}
for ws_id, tstat, cnt in await session.execute(
select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
):
task_per_ws.setdefault(ws_id, {})[tstat] = cnt
task_statuses_per_ws.setdefault(ws_id, []).extend([_value(tstat)] * cnt)
# Dependency graph for open workstreams
open_ws_ids = [w.id for w in open_ws]
dep_rows = []
if open_ws_ids:
dep_result = await session.execute(
select(WorkstreamDependency).where(
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
)
)
dep_rows = list(dep_result.scalars().all())
# Build a slug+title lookup for all workstreams referenced in deps
dep_ws_ids = set()
dep_task_ids = set()
for d in dep_rows:
dep_ws_ids.add(d.from_workstream_id)
if d.to_workstream_id:
dep_ws_ids.add(d.to_workstream_id)
if d.to_task_id:
dep_task_ids.add(d.to_task_id)
ws_lookup: dict = {w.id: w for w in open_ws}
extra_ids = dep_ws_ids - set(ws_lookup.keys())
if extra_ids:
extra_rows = await session.execute(
select(Workstream).options(noload("*")).where(Workstream.id.in_(extra_ids))
)
for w in extra_rows.scalars():
ws_lookup[w.id] = w
task_lookup: dict = {}
if dep_task_ids:
task_rows = await session.execute(select(Task).where(Task.id.in_(dep_task_ids)))
task_lookup = {t.id: t for t in task_rows.scalars().all()}
# Index: workstream_id → (depends_on stubs, blocks stubs)
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
for d in dep_rows:
from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id
if from_id in dep_index and to_id and to_id in ws_lookup:
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
dep_id=d.id,
target_type="workstream",
relationship_type=d.relationship_type,
workstream_id=to_id,
workstream_slug=ws_lookup[to_id].slug,
workstream_title=ws_lookup[to_id].title,
description=d.description,
))
if from_id in dep_index and task_id and task_id in task_lookup:
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
dep_id=d.id,
target_type="task",
relationship_type=d.relationship_type,
task_id=task_id,
task_title=task_lookup[task_id].title,
description=d.description,
))
if to_id and to_id in dep_index and from_id in ws_lookup:
dep_index[to_id]["blocks"].append(WorkstreamDepStub(
dep_id=d.id,
target_type="workstream",
relationship_type=d.relationship_type,
workstream_id=from_id,
workstream_slug=ws_lookup[from_id].slug,
workstream_title=ws_lookup[from_id].title,
description=d.description,
))
workstream_flow = load_flow("workstream")
flow_engine = FlowEngine()
effective_status: dict = {}
blocked_reasons: dict = {}
for w in open_ws:
flow_obj = {
"status": w.status,
"workstation": w.status,
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
"dependencies": [
{"workstation": ws_lookup[d.to_workstream_id].status}
for d in dep_rows
if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup
],
}
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
effective_status[w.id] = "blocked" if flow_result.exit_blocked else w.status
blocked_reasons[w.id] = [
assertion_result_to_dict(item) for item in flow_result.blocking_assertions
]
# Totals — one GROUP BY per table
topic_counts = {r[0]: r[1] for r in await session.execute(
select(Topic.status, func.count()).group_by(Topic.status)
)}
ws_counts = {r[0]: r[1] for r in await session.execute(
select(Workstream.status, func.count()).group_by(Workstream.status)
)}
task_counts = {r[0]: r[1] for r in await session.execute(
select(Task.status, func.count()).group_by(Task.status)
)}
dec_counts = {r[0]: r[1] for r in await session.execute(
select(Decision.status, func.count()).group_by(Decision.status)
)}
totals = Totals(
topics=TopicTotals(
active=topic_counts.get(TopicStatus.active, 0),
paused=topic_counts.get(TopicStatus.paused, 0),
archived=topic_counts.get(TopicStatus.archived, 0),
total=sum(topic_counts.values()),
),
workstreams=WorkstreamTotals(
active=sum(1 for status in effective_status.values() if status == "active"),
blocked=sum(1 for status in effective_status.values() if status == "blocked"),
completed=ws_counts.get("completed", 0),
archived=ws_counts.get("archived", 0),
total=sum(ws_counts.values()),
),
tasks=TaskTotals(
todo=task_counts.get(TaskStatus.todo, 0),
in_progress=task_counts.get(TaskStatus.in_progress, 0),
blocked=task_counts.get(TaskStatus.blocked, 0),
done=task_counts.get(TaskStatus.done, 0),
cancelled=task_counts.get(TaskStatus.cancelled, 0),
total=sum(task_counts.values()),
),
decisions=DecisionTotals(
open=dec_counts.get(DecisionStatus.open, 0),
resolved=dec_counts.get(DecisionStatus.resolved, 0),
escalated=dec_counts.get(DecisionStatus.escalated, 0),
superseded=dec_counts.get(DecisionStatus.superseded, 0),
total=sum(dec_counts.values()),
),
)
next_steps = await _derive_next_steps(session)
# Domain summary stats
domain_summaries = await _build_domain_summaries(session)
# Contribution counts (by type and status)
contrib_type_counts = {r[0].value: r[1] for r in await session.execute(
select(Contribution.type, func.count()).group_by(Contribution.type)
)}
contrib_status_counts = {r[0].value: r[1] for r in await session.execute(
select(Contribution.status, func.count()).group_by(Contribution.status)
)}
contribution_counts = {**contrib_type_counts, **contrib_status_counts}
# Licence risk: copyleft packages in direct prod deps
_COPYLEFT_PATS = ("GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL")
copyleft_risk_rows = await session.execute(
select(func.count()).select_from(SBOMEntry)
.where(SBOMEntry.is_direct.is_(True))
.where(SBOMEntry.is_dev.is_(False))
)
# Filter in Python since ILIKE across multiple patterns is verbose in SQLAlchemy
all_direct_prod_rows = await session.execute(
select(SBOMEntry.license_spdx)
.where(SBOMEntry.is_direct.is_(True))
.where(SBOMEntry.is_dev.is_(False))
)
licence_risk_count = sum(
1 for (lic,) in all_direct_prod_rows.all()
if lic and any(pat in lic.upper() for pat in _COPYLEFT_PATS)
)
# Open capability requests (non-terminal statuses)
open_cap_req_count = (await session.execute(
select(func.count()).select_from(CapabilityRequest).where(
CapabilityRequest.status.in_(["requested", "accepted", "in_progress", "ready_for_review"])
)
)).scalar() or 0
result = StateSummary(
generated_at=datetime.now(tz=timezone.utc),
totals=totals,
topics=[
TopicWithWorkstreams(
**TopicRead.model_validate(t).model_dump(),
workstreams=topic_workstreams.get(t.id, []),
)
for t in topics
],
blocking_decisions=[DecisionRead.model_validate(d) for d in blocking],
blocked_tasks=[TaskRead.model_validate(t) for t in blocked],
recent_progress=[ProgressEventRead.model_validate(e) for e in recent],
next_steps=next_steps,
domains=domain_summaries,
contribution_counts=contribution_counts,
licence_risk_count=licence_risk_count,
open_capability_requests=open_cap_req_count,
open_workstreams=[
WorkstreamWithDeps(
**{
**WorkstreamRead.model_validate(w).model_dump(),
"status": effective_status.get(w.id, w.status),
},
tasks_total=sum(task_per_ws.get(w.id, {}).values()),
tasks_todo=task_per_ws.get(w.id, {}).get(TaskStatus.todo, 0),
tasks_in_progress=task_per_ws.get(w.id, {}).get(TaskStatus.in_progress, 0),
tasks_blocked=task_per_ws.get(w.id, {}).get(TaskStatus.blocked, 0),
tasks_done=task_per_ws.get(w.id, {}).get(TaskStatus.done, 0),
depends_on=dep_index.get(w.id, {}).get("depends_on", []),
blocks=dep_index.get(w.id, {}).get("blocks", []),
blocked_reasons=blocked_reasons.get(w.id, []),
)
for w in open_ws
],
)
_SUMMARY_CACHE = result
_SUMMARY_CACHE_AT = time.monotonic()
return result
async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
"""Compute per-domain stats for the state summary."""
domains_rows = await session.execute(
select(Domain).options(noload("*")).where(Domain.status == "active").order_by(Domain.name)
)
domains = list(domains_rows.scalars().all())
# Repo counts per domain
repo_counts = {r[0]: r[1] for r in await session.execute(
select(ManagedRepo.domain_id, func.count())
.where(ManagedRepo.status == "active")
.group_by(ManagedRepo.domain_id)
)}
# Active workstream counts per domain (join through topics)
ws_per_domain = {}
for domain_id, cnt in await session.execute(
select(Topic.domain_id, func.count(Workstream.id))
.join(Workstream, Workstream.topic_id == Topic.id)
.where(Workstream.status == "active")
.group_by(Topic.domain_id)
):
ws_per_domain[domain_id] = cnt
# EP counts per domain id (via FK)
ep_counts = {r[0]: r[1] for r in await session.execute(
select(ExtensionPoint.domain_id, func.count()).group_by(ExtensionPoint.domain_id)
)}
# TD counts per domain id (via FK)
td_counts = {r[0]: r[1] for r in await session.execute(
select(TechnicalDebt.domain_id, func.count()).group_by(TechnicalDebt.domain_id)
)}
return [
DomainSummary(
slug=d.slug,
name=d.name,
repo_count=repo_counts.get(d.id, 0),
active_workstream_count=ws_per_domain.get(d.id, 0),
ep_count=ep_counts.get(d.id, 0),
td_count=td_counts.get(d.id, 0),
)
for d in domains
]
@router.get("/deps", response_model=list[WorkstreamWithDeps])
async def get_deps(session: AsyncSession = Depends(get_session)) -> list[WorkstreamWithDeps]:
"""Lightweight dep-graph endpoint: open workstreams with their dependency edges only.
Returns the same structure as open_workstreams in /state/summary but skips
the 10-table full-summary computation. Task counts are omitted (all zero).
Used by workstreams.md and dependencies.md which only need dep edges.
"""
open_ws_rows = await session.execute(
select(Workstream)
.options(noload("*"))
.where(Workstream.status.in_(["active", "blocked"]))
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
)
open_ws = list(open_ws_rows.scalars().all())
open_ws_ids = [w.id for w in open_ws]
dep_rows = []
if open_ws_ids:
dep_result = await session.execute(
select(WorkstreamDependency).where(
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
)
)
dep_rows = list(dep_result.scalars().all())
dep_ws_ids: set = set()
dep_task_ids: set = set()
for d in dep_rows:
dep_ws_ids.add(d.from_workstream_id)
if d.to_workstream_id:
dep_ws_ids.add(d.to_workstream_id)
if d.to_task_id:
dep_task_ids.add(d.to_task_id)
ws_lookup: dict = {w.id: w for w in open_ws}
extra_ids = dep_ws_ids - set(ws_lookup.keys())
if extra_ids:
extra_rows = await session.execute(
select(Workstream).options(noload("*")).where(Workstream.id.in_(extra_ids))
)
for w in extra_rows.scalars():
ws_lookup[w.id] = w
task_lookup: dict = {}
if dep_task_ids:
task_rows = await session.execute(select(Task).options(noload("*")).where(Task.id.in_(dep_task_ids)))
task_lookup = {t.id: t for t in task_rows.scalars().all()}
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
for d in dep_rows:
from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id
if from_id in dep_index and to_id and to_id in ws_lookup:
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type,
workstream_id=to_id, workstream_slug=ws_lookup[to_id].slug,
workstream_title=ws_lookup[to_id].title, description=d.description,
))
if from_id in dep_index and task_id and task_id in task_lookup:
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
dep_id=d.id, target_type="task", relationship_type=d.relationship_type,
task_id=task_id, task_title=task_lookup[task_id].title, description=d.description,
))
if to_id and to_id in dep_index and from_id in ws_lookup:
dep_index[to_id]["blocks"].append(WorkstreamDepStub(
dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type,
workstream_id=from_id, workstream_slug=ws_lookup[from_id].slug,
workstream_title=ws_lookup[from_id].title, description=d.description,
))
return [
WorkstreamWithDeps(
**WorkstreamRead.model_validate(w).model_dump(),
depends_on=dep_index[w.id]["depends_on"],
blocks=dep_index[w.id]["blocks"],
)
for w in open_ws
]
_PRIORITY_RANK = {
TaskPriority.critical: 0,
TaskPriority.high: 1,
TaskPriority.medium: 2,
TaskPriority.low: 3,
}
async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
"""Derive contextual next-action suggestions from current hub state.
Two signal sources:
1. Recently resolved decisions (last 7 days) → first open task in same workstream
2. Workstreams whose every dependency is now completed → first todo task in that workstream
"""
steps: list[NextStep] = []
seen_task_ids: set = set()
# ── Signal 1: recently resolved decisions ────────────────────────────────
cutoff = datetime.now(tz=timezone.utc) - timedelta(days=7)
resolved_rows = await session.execute(
select(Decision)
.options(noload("*"))
.where(Decision.status == DecisionStatus.resolved)
.where(Decision.decided_at >= cutoff)
.where(Decision.workstream_id.isnot(None))
.order_by(Decision.decided_at.desc())
.limit(20)
)
for decision in resolved_rows.scalars().all():
open_tasks_rows = await session.execute(
select(Task)
.options(noload("*"))
.where(Task.workstream_id == decision.workstream_id)
.where(Task.status.in_([TaskStatus.todo, TaskStatus.in_progress]))
)
open_tasks = list(open_tasks_rows.scalars().all())
if not open_tasks:
continue
task = min(open_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
if task.id in seen_task_ids:
continue
ws = await session.get(Workstream, decision.workstream_id, options=[noload("*")])
domain_slug = await _get_domain_slug_for_workstream(ws, session)
steps.append(NextStep(
type="resolved_decision",
domain=domain_slug,
workstream_id=ws.id if ws else None,
workstream_title=ws.title if ws else None,
workstream_slug=ws.slug if ws else None,
task_id=task.id,
task_title=task.title,
message=(
f"Decision '{decision.title}' was resolved → "
f"'{task.title}' is the next open task in '{ws.title if ws else '?'}'"
),
))
seen_task_ids.add(task.id)
# ── Signal 2: cleared dependencies ──────────────────────────────────────
all_dep_rows = await session.execute(
select(
WorkstreamDependency.from_workstream_id,
WorkstreamDependency.to_workstream_id,
).where(WorkstreamDependency.to_workstream_id.isnot(None))
)
all_deps = all_dep_rows.all()
# Group from_workstream_id → set of to_workstream_ids
dep_map: dict = {}
dep_ws_ids = set()
for from_ws_id, to_ws_id in all_deps:
dep_map.setdefault(from_ws_id, set()).add(to_ws_id)
dep_ws_ids.add(from_ws_id)
dep_ws_ids.add(to_ws_id)
ws_info = {}
if dep_ws_ids:
ws_rows = await session.execute(
select(
Workstream.id,
Workstream.status,
Workstream.title,
Workstream.slug,
Workstream.topic_id,
).where(Workstream.id.in_(dep_ws_ids))
)
ws_info = {
ws_id: {
"status": status,
"title": title,
"slug": slug,
"topic_id": topic_id,
}
for ws_id, status, title, slug, topic_id in ws_rows
}
ready_from_ws_ids = [
from_ws_id
for from_ws_id, to_ws_ids in dep_map.items()
if ws_info.get(from_ws_id, {}).get("status") in ("active", "blocked")
and all(ws_info.get(to_id, {}).get("status") == "completed" for to_id in to_ws_ids)
]
todo_by_ws: dict = {}
if ready_from_ws_ids:
todo_rows = await session.execute(
select(Task)
.options(noload("*"))
.where(Task.workstream_id.in_(ready_from_ws_ids))
.where(Task.status == TaskStatus.todo)
)
for task in todo_rows.scalars().all():
todo_by_ws.setdefault(task.workstream_id, []).append(task)
for from_ws_id in ready_from_ws_ids:
from_ws = ws_info.get(from_ws_id, {})
todo_tasks = todo_by_ws.get(from_ws_id, [])
if not todo_tasks:
continue
task = min(todo_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
if task.id in seen_task_ids:
continue
domain_slug = await _get_domain_slug_for_topic(from_ws.get("topic_id"), session)
_blocker_slugs = []
for tid in dep_map[from_ws_id]:
if tid in ws_info:
_blocker_slugs.append(ws_info[tid]["slug"])
blocker_slugs = ", ".join(_blocker_slugs)
steps.append(NextStep(
type="dependency_cleared",
domain=domain_slug,
workstream_id=from_ws_id,
workstream_title=from_ws["title"],
workstream_slug=from_ws["slug"],
task_id=task.id,
task_title=task.title,
message=(
f"All dependencies of '{from_ws['title']}' are completed ({blocker_slugs}) → "
f"'{task.title}' is ready to start"
),
))
seen_task_ids.add(task.id)
return steps
async def _get_domain_slug_for_workstream(ws: Workstream | None, session: AsyncSession) -> str | None:
"""Get the domain slug for a workstream via its topic."""
if ws is None or ws.topic_id is None:
return None
return await _get_domain_slug_for_topic(ws.topic_id, session)
async def _get_domain_slug_for_topic(topic_id, session: AsyncSession) -> str | None:
"""Get the domain slug for a topic id."""
if topic_id is None:
return None
topic = await session.get(Topic, topic_id, options=[noload("*")])
if topic is None or topic.domain_id is None:
return None
domain = await session.get(Domain, topic.domain_id, options=[noload("*")])
return domain.slug if domain else None
def _value(item):
return item.value if hasattr(item, "value") else item
@router.get("/next_steps", response_model=list[NextStep])
async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[NextStep]:
"""Derive contextual next-action suggestions from current hub state.
Returns suggestions based on:
- Recently resolved decisions → first open task in the same workstream
- Workstreams whose every dependency workstream is now completed → first todo task
"""
return await _derive_next_steps(session)
@router.get("/health")
async def health_check() -> dict:
try:
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
return {"status": "ok", "db": "connected"}
except Exception as exc:
return JSONResponse(
status_code=503,
content={"status": "error", "db": str(exc)},
)

142
api/routers/tasks.py Normal file
View File

@@ -0,0 +1,142 @@
import uuid
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.task import Task, TaskStatus
from api.models.token_event import TokenEvent
from api.models.workstream import Workstream
from api.schemas.task import TaskCreate, TaskRead, TaskUpdate
router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.get("/", response_model=list[TaskRead])
async def list_tasks(
workstream_id: uuid.UUID | None = None,
status: TaskStatus | None = None,
assignee: str | None = None,
needs_human: bool | None = Query(None),
priority: str | None = None,
due_date_before: date | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Task]:
q = select(Task)
if workstream_id:
q = q.where(Task.workstream_id == workstream_id)
if status:
q = q.where(Task.status == status)
if assignee:
q = q.where(Task.assignee == assignee)
if needs_human is not None:
q = q.where(Task.needs_human == needs_human)
if priority:
q = q.where(Task.priority == priority)
if due_date_before is not None:
q = q.where(Task.due_date <= due_date_before)
q = q.order_by(Task.created_at)
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=TaskRead, status_code=status.HTTP_201_CREATED)
async def create_task(
body: TaskCreate,
session: AsyncSession = Depends(get_session),
) -> Task:
task = Task(**body.model_dump())
session.add(task)
await session.commit()
await session.refresh(task)
return task
@router.get("/{task_id}", response_model=TaskRead)
async def get_task(
task_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Task:
task = await session.get(Task, task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.patch("/{task_id}", response_model=TaskRead)
async def update_task(
task_id: uuid.UUID,
body: TaskUpdate,
session: AsyncSession = Depends(get_session),
) -> Task:
task = await session.get(Task, task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
# Separate token fields from task fields
token_field_names = {"tokens_in", "tokens_out", "workplan_tokens_in", "workplan_tokens_out", "token_note", "model", "agent", "session_id"}
update_data = body.model_dump(exclude_unset=True)
token_data = {k: update_data.pop(k) for k in list(update_data.keys()) if k in token_field_names}
for field, value in update_data.items():
setattr(task, field, value)
await session.commit()
await session.refresh(task)
# Token event — three-tier logic, only when marking done
if update_data.get("status") == "done":
if "tokens_in" in token_data and "tokens_out" in token_data:
# Tier 1: exact counts — default note "measured"; caller may override with token_note
tin = token_data["tokens_in"]
tout = token_data["tokens_out"]
tnote = token_data.get("token_note") or "measured"
elif "workplan_tokens_in" in token_data and "workplan_tokens_out" in token_data:
# Tier 2: prorate workplan total across task count
count_result = await session.execute(
select(func.count(Task.id)).where(Task.workstream_id == task.workstream_id)
)
task_count = max(count_result.scalar() or 1, 1)
tin = token_data["workplan_tokens_in"] // task_count
tout = token_data["workplan_tokens_out"] // task_count
tnote = "workplan"
else:
# Tier 3: heuristic fallback
tin, tout, tnote = 1000, 500, "heuristic"
# Resolve repo_id via workstream
ws = await session.get(Workstream, task.workstream_id)
repo_id = ws.repo_id if ws else None
event = TokenEvent(
task_id=task_id,
workstream_id=task.workstream_id,
repo_id=repo_id,
tokens_in=tin,
tokens_out=tout,
model=token_data.get("model"),
agent=token_data.get("agent"),
session_id=token_data.get("session_id"),
ref_type="task",
ref_id=str(task_id),
note=tnote,
)
session.add(event)
await session.commit()
return task
@router.delete("/{task_id}", response_model=TaskRead)
async def cancel_task(
task_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Task:
task = await session.get(Task, task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
task.status = TaskStatus.cancelled
await session.commit()
await session.refresh(task)
return task

View File

@@ -0,0 +1,140 @@
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.technical_debt import TDNote, TDStatus, TechnicalDebt
from api.schemas.technical_debt import TDCreate, TDNoteCreate, TDNoteRead, TDRead, TDUpdate
router = APIRouter(prefix="/technical-debt", tags=["technical-debt"])
async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID:
"""Resolve a domain slug to its UUID, raising 422 if unknown."""
row = await session.execute(
select(Domain.id).where(Domain.slug == slug, Domain.status == "active")
)
domain_id = row.scalar_one_or_none()
if domain_id is None:
valid = [r[0] for r in (await session.execute(
select(Domain.slug).where(Domain.status == "active")
)).all()]
raise HTTPException(
status_code=422,
detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}",
)
return domain_id
@router.get("/", response_model=list[TDRead])
async def list_td(
domain: str | None = None,
status: str | None = None, # str to accept both legacy and workflow values
debt_type: str | None = None,
severity: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[TechnicalDebt]:
q = select(TechnicalDebt)
if domain:
domain_id = await _resolve_domain_id(domain, session)
q = q.where(TechnicalDebt.domain_id == domain_id)
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:
domain_id = await _resolve_domain_id(body.domain, session)
data = body.model_dump(exclude={"domain"})
data["domain_id"] = domain_id
td = TechnicalDebt(**data)
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
# ── Notes ─────────────────────────────────────────────────────────────────────
@router.get("/{td_id}/notes", response_model=list[TDNoteRead])
async def list_notes(
td_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> list[TDNote]:
td = await session.get(TechnicalDebt, td_id)
if td is None:
raise HTTPException(status_code=404, detail="Technical debt item not found")
result = await session.execute(
select(TDNote).where(TDNote.td_id == td_id).order_by(TDNote.created_at)
)
return list(result.scalars().all())
@router.post("/{td_id}/notes", response_model=TDNoteRead, status_code=status.HTTP_201_CREATED)
async def add_note(
td_id: uuid.UUID,
body: TDNoteCreate,
session: AsyncSession = Depends(get_session),
) -> TDNote:
td = await session.get(TechnicalDebt, td_id)
if td is None:
raise HTTPException(status_code=404, detail="Technical debt item not found")
note = TDNote(td_id=td_id, **body.model_dump())
session.add(note)
await session.commit()
await session.refresh(note)
return note

228
api/routers/token_events.py Normal file
View File

@@ -0,0 +1,228 @@
import uuid
from collections import defaultdict
from fastapi import APIRouter, Depends, HTTPException, Query, 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.task import Task
from api.models.token_event import TokenEvent
from api.models.workstream import Workstream
from api.schemas.token_event import RepoTokenSummary, TokenEventCreate, TokenEventPatch, TokenEventRead, TokenSummary
router = APIRouter(prefix="/token-events", tags=["token-events"])
@router.post("/", response_model=TokenEventRead, status_code=status.HTTP_201_CREATED)
async def create_token_event(
body: TokenEventCreate,
session: AsyncSession = Depends(get_session),
) -> TokenEvent:
data = body.model_dump()
# Auto-populate workstream_id from task if not provided
if data.get("task_id") and not data.get("workstream_id"):
task = await session.get(Task, data["task_id"])
if task:
data["workstream_id"] = task.workstream_id
# Auto-populate repo_id from workstream if not provided
if data.get("workstream_id") and not data.get("repo_id"):
ws = await session.get(Workstream, data["workstream_id"])
if ws and ws.repo_id:
data["repo_id"] = ws.repo_id
event = TokenEvent(**data)
session.add(event)
await session.commit()
await session.refresh(event)
return event
@router.get("/summary/", response_model=TokenSummary)
async def get_token_summary(
scope: str = Query(..., description="task|workstream|repo|commit|release|session"),
id: str = Query(..., description="FK value or ref_id depending on scope"),
session: AsyncSession = Depends(get_session),
) -> TokenSummary:
q = select(TokenEvent)
if scope == "task":
try:
uid = uuid.UUID(id)
except ValueError:
raise HTTPException(status_code=422, detail="id must be a valid UUID for scope=task")
q = q.where(TokenEvent.task_id == uid)
elif scope == "workstream":
try:
uid = uuid.UUID(id)
except ValueError:
raise HTTPException(status_code=422, detail="id must be a valid UUID for scope=workstream")
q = q.where(TokenEvent.workstream_id == uid)
elif scope == "repo":
try:
uid = uuid.UUID(id)
except ValueError:
raise HTTPException(status_code=422, detail="id must be a valid UUID for scope=repo")
q = q.where(TokenEvent.repo_id == uid)
elif scope in ("commit", "release", "session"):
q = q.where(TokenEvent.ref_type == scope, TokenEvent.ref_id == id)
else:
raise HTTPException(status_code=422, detail=f"Unknown scope: {scope!r}")
result = await session.execute(q)
events = list(result.scalars().all())
tokens_in = sum(e.tokens_in for e in events)
tokens_out = sum(e.tokens_out for e in events)
by_model: dict[str, int] = defaultdict(int)
by_agent: dict[str, int] = defaultdict(int)
for e in events:
if e.model:
by_model[e.model] += e.tokens_in + e.tokens_out
if e.agent:
by_agent[e.agent] += e.tokens_in + e.tokens_out
return TokenSummary(
scope=scope,
scope_id=id,
tokens_in=tokens_in,
tokens_out=tokens_out,
tokens_total=tokens_in + tokens_out,
event_count=len(events),
by_model=dict(by_model),
by_agent=dict(by_agent),
)
@router.get("/by-repo/", response_model=list[RepoTokenSummary])
async def get_tokens_by_repo(
session: AsyncSession = Depends(get_session),
) -> list[RepoTokenSummary]:
"""Aggregate token consumption per repo, resolving via the full graph.
Resolution order for each event:
1. token_events.repo_id (direct)
2. → workstreams.repo_id (via workstream_id)
3. → task.workstream_id → workstreams.repo_id (via task_id)
Only events that resolve to a repo are included.
"""
# Fetch all events, workstreams, repos in three queries (avoids N+1)
events_result = await session.execute(select(TokenEvent))
events = list(events_result.scalars().all())
ws_result = await session.execute(select(Workstream))
ws_map: dict[uuid.UUID, Workstream] = {w.id: w for w in ws_result.scalars().all()}
task_result = await session.execute(select(Task))
task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()}
repo_result = await session.execute(select(ManagedRepo))
repo_map: dict[uuid.UUID, ManagedRepo] = {r.id: r for r in repo_result.scalars().all()}
def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None:
if e.repo_id:
return e.repo_id
ws_id = e.workstream_id
if not ws_id and e.task_id and e.task_id in task_map:
ws_id = task_map[e.task_id].workstream_id
if ws_id and ws_id in ws_map:
return ws_map[ws_id].repo_id
return None
groups: dict[uuid.UUID, dict] = {}
for e in events:
rid = resolve_repo_id(e)
if not rid or rid not in repo_map:
continue
if rid not in groups:
groups[rid] = {
"repo_id": rid,
"repo_slug": repo_map[rid].slug,
"tokens_in": 0,
"tokens_out": 0,
"event_count": 0,
"by_model": defaultdict(int),
"by_note": defaultdict(int),
}
g = groups[rid]
g["tokens_in"] += e.tokens_in
g["tokens_out"] += e.tokens_out
g["event_count"] += 1
if e.model:
g["by_model"][e.model] += e.tokens_in + e.tokens_out
g["by_note"][e.note or "unknown"] += e.tokens_in + e.tokens_out
return [
RepoTokenSummary(
**{k: (dict(v) if isinstance(v, defaultdict) else v) for k, v in g.items()},
tokens_total=g["tokens_in"] + g["tokens_out"],
)
for g in sorted(groups.values(), key=lambda x: -(x["tokens_in"] + x["tokens_out"]))
]
@router.patch("/{event_id}", response_model=TokenEventRead)
async def patch_token_event(
event_id: uuid.UUID,
body: TokenEventPatch,
session: AsyncSession = Depends(get_session),
) -> TokenEvent:
event = await session.get(TokenEvent, event_id)
if event is None:
raise HTTPException(status_code=404, detail="Token event not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(event, field, value)
await session.commit()
await session.refresh(event)
return event
@router.get("/{event_id}", response_model=TokenEventRead)
async def get_token_event(
event_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> TokenEvent:
event = await session.get(TokenEvent, event_id)
if event is None:
raise HTTPException(status_code=404, detail="Token event not found")
return event
@router.get("/", response_model=list[TokenEventRead])
async def list_token_events(
task_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None,
repo_id: uuid.UUID | None = None,
ref_type: str | None = None,
ref_id: str | None = None,
model: str | None = None,
agent: str | None = None,
note: str | None = None,
limit: int = Query(100, le=1000),
session: AsyncSession = Depends(get_session),
) -> list[TokenEvent]:
q = select(TokenEvent)
if task_id:
q = q.where(TokenEvent.task_id == task_id)
if workstream_id:
q = q.where(TokenEvent.workstream_id == workstream_id)
if repo_id:
q = q.where(TokenEvent.repo_id == repo_id)
if ref_type:
q = q.where(TokenEvent.ref_type == ref_type)
if ref_id:
q = q.where(TokenEvent.ref_id == ref_id)
if model:
q = q.where(TokenEvent.model == model)
if agent:
q = q.where(TokenEvent.agent == agent)
if note:
q = q.where(TokenEvent.note == note)
q = q.order_by(TokenEvent.created_at.desc()).limit(limit)
result = await session.execute(q)
return list(result.scalars().all())

107
api/routers/topics.py Normal file
View File

@@ -0,0 +1,107 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy.orm import noload
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.domain import Domain
from api.models.topic import Topic, TopicStatus
from api.schemas.topic import TopicCreate, TopicRead, TopicUpdate, TopicWithWorkstreams
router = APIRouter(prefix="/topics", tags=["topics"])
async def _resolve_domain_id(domain_slug: str, session: AsyncSession) -> uuid.UUID:
"""Resolve a domain slug to its UUID. Raises 404 if not found."""
result = await session.execute(select(Domain).where(Domain.slug == domain_slug))
domain = result.scalar_one_or_none()
if domain is None:
raise HTTPException(status_code=404, detail=f"Domain '{domain_slug}' not found")
return domain.id
@router.get("/", response_model=list[TopicRead])
async def list_topics(
response: Response,
status: TopicStatus | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Topic]:
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
q = select(Topic).options(
noload(Topic.workstreams),
noload(Topic.decisions),
noload(Topic.progress_events),
)
if status:
q = q.where(Topic.status == status)
q = q.order_by(Topic.created_at)
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=TopicRead, status_code=status.HTTP_201_CREATED)
async def create_topic(
body: TopicCreate,
session: AsyncSession = Depends(get_session),
) -> Topic:
domain_id = await _resolve_domain_id(body.domain, session)
existing = await session.execute(select(Topic).where(Topic.slug == body.slug))
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Topic slug '{body.slug}' already exists")
topic = Topic(
slug=body.slug,
title=body.title,
description=body.description,
domain_id=domain_id,
status=body.status,
)
session.add(topic)
await session.commit()
await session.refresh(topic)
return topic
@router.get("/{topic_id}", response_model=TopicWithWorkstreams)
async def get_topic(
topic_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Topic:
topic = await session.get(Topic, topic_id)
if topic is None:
raise HTTPException(status_code=404, detail="Topic not found")
return topic
@router.patch("/{topic_id}", response_model=TopicRead)
async def update_topic(
topic_id: uuid.UUID,
body: TopicUpdate,
session: AsyncSession = Depends(get_session),
) -> Topic:
topic = await session.get(Topic, topic_id)
if topic is None:
raise HTTPException(status_code=404, detail="Topic not found")
updates = body.model_dump(exclude_unset=True)
if "domain" in updates:
topic.domain_id = await _resolve_domain_id(updates.pop("domain"), session)
for field, value in updates.items():
setattr(topic, field, value)
await session.commit()
await session.refresh(topic)
return topic
@router.delete("/{topic_id}", response_model=TopicRead)
async def archive_topic(
topic_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Topic:
topic = await session.get(Topic, topic_id)
if topic is None:
raise HTTPException(status_code=404, detail="Topic not found")
topic.status = TopicStatus.archived
await session.commit()
await session.refresh(topic)
return topic

240
api/routers/tpsc.py Normal file
View File

@@ -0,0 +1,240 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from api.database import get_session
from api.models.managed_repo import ManagedRepo
from api.models.tpsc import TPSCCatalog, TPSCSnapshot, TPSCEntry
from api.schemas.tpsc import (
TPSCCatalogCreate, TPSCCatalogRead,
TPSCEntryRead, TPSCIngestRequest, TPSCSnapshotRead,
TPSCGDPRReport, TPSCGDPRWarning, GDPR_WARNING_LEVELS,
)
router = APIRouter(prefix="/tpsc", tags=["tpsc"])
# ---------------------------------------------------------------------------
# Catalog
# ---------------------------------------------------------------------------
@router.get("/catalog/", response_model=list[TPSCCatalogRead])
async def list_catalog(
gdpr_maturity: str | None = None,
category: str | None = None,
pricing_model: str | None = None,
session: AsyncSession = Depends(get_session),
):
q = select(TPSCCatalog).where(TPSCCatalog.status != "deprecated")
if gdpr_maturity:
q = q.where(TPSCCatalog.gdpr_maturity == gdpr_maturity)
if category:
q = q.where(TPSCCatalog.category == category)
if pricing_model:
q = q.where(TPSCCatalog.pricing_model == pricing_model)
q = q.order_by(TPSCCatalog.name)
rows = (await session.execute(q)).scalars().all()
return rows
@router.get("/catalog/{slug}", response_model=TPSCCatalogRead)
async def get_catalog_entry(slug: str, session: AsyncSession = Depends(get_session)):
row = (await session.execute(select(TPSCCatalog).where(TPSCCatalog.slug == slug))).scalar_one_or_none()
if not row:
raise HTTPException(404, f"Service '{slug}' not found in catalog")
return row
@router.post("/catalog/", response_model=TPSCCatalogRead, status_code=201)
async def register_service(body: TPSCCatalogCreate, session: AsyncSession = Depends(get_session)):
"""Register a new service or upsert an existing one by slug."""
existing = (await session.execute(select(TPSCCatalog).where(TPSCCatalog.slug == body.slug))).scalar_one_or_none()
if existing:
for k, v in body.model_dump(exclude_unset=True).items():
setattr(existing, k, v)
existing.updated_at = datetime.now(tz=timezone.utc)
await session.commit()
await session.refresh(existing)
return existing
entry = TPSCCatalog(**body.model_dump())
session.add(entry)
await session.commit()
await session.refresh(entry)
return entry
# ---------------------------------------------------------------------------
# Ingest
# ---------------------------------------------------------------------------
@router.post("/ingest/", response_model=TPSCSnapshotRead, status_code=201)
async def ingest_tpsc(body: TPSCIngestRequest, session: AsyncSession = Depends(get_session)):
"""Accept a tpsc.yaml snapshot for a repo."""
# Resolve repo_id
repo = (await session.execute(select(ManagedRepo).where(ManagedRepo.slug == body.repo_slug))).scalar_one_or_none()
repo_id = repo.id if repo else None
# Build catalog lookup by slug
slugs = {e.service_slug for e in body.entries}
catalog_rows = (await session.execute(select(TPSCCatalog).where(TPSCCatalog.slug.in_(slugs)))).scalars().all()
catalog_map = {r.slug: r for r in catalog_rows}
snapshot = TPSCSnapshot(
repo_id=repo_id,
source_file=body.source_file,
entry_count=len(body.entries),
)
session.add(snapshot)
await session.flush()
entries_with_cats = []
for e in body.entries:
cat = catalog_map.get(e.service_slug)
entry = TPSCEntry(
snapshot_id=snapshot.id,
catalog_id=cat.id if cat else None,
service_slug=e.service_slug,
purpose=e.purpose,
auth_type=e.auth_type,
endpoint_override=e.endpoint_override,
notes=e.notes,
)
session.add(entry)
entries_with_cats.append((entry, cat))
await session.flush() # assign UUIDs to all entries
await session.commit()
await session.refresh(snapshot)
entry_reads = [
TPSCEntryRead(
id=entry.id,
snapshot_id=snapshot.id,
catalog_id=cat.id if cat else None,
service_slug=entry.service_slug,
purpose=entry.purpose,
auth_type=entry.auth_type,
endpoint_override=entry.endpoint_override,
notes=entry.notes,
gdpr_maturity=cat.gdpr_maturity if cat else None,
gdpr_warning=(cat.gdpr_maturity in GDPR_WARNING_LEVELS) if cat else True,
pricing_model=cat.pricing_model if cat else None,
)
for entry, cat in entries_with_cats
]
return TPSCSnapshotRead(
id=snapshot.id,
repo_id=snapshot.repo_id,
snapshot_at=snapshot.snapshot_at,
source_file=snapshot.source_file,
entry_count=snapshot.entry_count,
entries=entry_reads,
)
# ---------------------------------------------------------------------------
# Snapshots
# ---------------------------------------------------------------------------
@router.get("/snapshots/", response_model=list[TPSCSnapshotRead])
async def list_snapshots(
repo_slug: str | None = None,
session: AsyncSession = Depends(get_session),
):
q = select(TPSCSnapshot).options(
selectinload(TPSCSnapshot.entries).selectinload(TPSCEntry.catalog_entry)
)
if repo_slug:
repo = (await session.execute(select(ManagedRepo).where(ManagedRepo.slug == repo_slug))).scalar_one_or_none()
if not repo:
raise HTTPException(404, f"Repo '{repo_slug}' not found")
q = q.where(TPSCSnapshot.repo_id == repo.id)
q = q.order_by(TPSCSnapshot.snapshot_at.desc())
rows = (await session.execute(q)).scalars().all()
result = []
for snap in rows:
entry_reads = []
for e in snap.entries:
cat = e.catalog_entry
entry_reads.append(TPSCEntryRead(
id=e.id,
snapshot_id=e.snapshot_id,
catalog_id=e.catalog_id,
service_slug=e.service_slug,
purpose=e.purpose,
auth_type=e.auth_type,
endpoint_override=e.endpoint_override,
notes=e.notes,
gdpr_maturity=cat.gdpr_maturity if cat else None,
gdpr_warning=(cat.gdpr_maturity in GDPR_WARNING_LEVELS) if cat else True,
pricing_model=cat.pricing_model if cat else None,
))
result.append(TPSCSnapshotRead(
id=snap.id,
repo_id=snap.repo_id,
snapshot_at=snap.snapshot_at,
source_file=snap.source_file,
entry_count=snap.entry_count,
entries=entry_reads,
))
return result
# ---------------------------------------------------------------------------
# GDPR report
# ---------------------------------------------------------------------------
@router.get("/report/gdpr", response_model=TPSCGDPRReport)
async def gdpr_report(session: AsyncSession = Depends(get_session)):
"""Aggregated GDPR warnings across all latest repo snapshots."""
# Latest snapshot per repo
latest_sub = (
select(TPSCSnapshot.repo_id, func.max(TPSCSnapshot.snapshot_at).label("max_at"))
.group_by(TPSCSnapshot.repo_id)
.subquery()
)
latest_snaps = (await session.execute(
select(TPSCSnapshot)
.join(latest_sub, (TPSCSnapshot.repo_id == latest_sub.c.repo_id) & (TPSCSnapshot.snapshot_at == latest_sub.c.max_at))
.options(selectinload(TPSCSnapshot.entries).selectinload(TPSCEntry.catalog_entry))
)).scalars().all()
# Repo slug lookup
all_repos = (await session.execute(select(ManagedRepo))).scalars().all()
repo_map = {r.id: r.slug for r in all_repos}
all_services = (await session.execute(select(TPSCCatalog))).scalars().all()
by_maturity: dict[str, int] = {}
for s in all_services:
by_maturity[s.gdpr_maturity] = by_maturity.get(s.gdpr_maturity, 0) + 1
warnings = []
seen = set()
for snap in latest_snaps:
repo_slug = repo_map.get(snap.repo_id) if snap.repo_id else None
for entry in snap.entries:
cat = entry.catalog_entry
maturity = cat.gdpr_maturity if cat else "unknown"
if maturity in GDPR_WARNING_LEVELS:
key = (repo_slug, entry.service_slug)
if key not in seen:
seen.add(key)
warnings.append(TPSCGDPRWarning(
repo_slug=repo_slug,
service_slug=entry.service_slug,
gdpr_maturity=maturity,
purpose=entry.purpose,
pricing_model=cat.pricing_model if cat else None,
))
return TPSCGDPRReport(
generated_at=datetime.now(tz=timezone.utc),
total_services=len(all_services),
warning_count=len(warnings),
warnings=warnings,
by_maturity=by_maturity,
)

View File

@@ -0,0 +1,91 @@
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.task import Task
from api.models.workstream import Workstream
from api.models.workstream_dependency import WorkstreamDependency
from api.schemas.workstream_dependency import WorkstreamDependencyCreate, WorkstreamDependencyRead
router = APIRouter(prefix="/workstreams", tags=["dependencies"])
@router.post(
"/{workstream_id}/dependencies/",
response_model=WorkstreamDependencyRead,
status_code=status.HTTP_201_CREATED,
)
async def create_dependency(
workstream_id: uuid.UUID,
body: WorkstreamDependencyCreate,
session: AsyncSession = Depends(get_session),
) -> WorkstreamDependency:
"""Record that workstream_id depends on another workstream or a task."""
if await session.get(Workstream, workstream_id) is None:
raise HTTPException(status_code=404, detail="from workstream not found")
has_workstream_target = body.to_workstream_id is not None
has_task_target = body.to_task_id is not None
if has_workstream_target == has_task_target:
raise HTTPException(status_code=422, detail="provide exactly one dependency target")
if body.to_workstream_id and await session.get(Workstream, body.to_workstream_id) is None:
raise HTTPException(status_code=404, detail="target workstream not found")
if body.to_task_id and await session.get(Task, body.to_task_id) is None:
raise HTTPException(status_code=404, detail="target task not found")
if workstream_id == body.to_workstream_id:
raise HTTPException(status_code=422, detail="a workstream cannot depend on itself")
dep = WorkstreamDependency(
from_workstream_id=workstream_id,
to_workstream_id=body.to_workstream_id,
to_task_id=body.to_task_id,
relationship_type=body.relationship_type,
description=body.description,
)
session.add(dep)
await session.commit()
await session.refresh(dep)
return dep
@router.get(
"/{workstream_id}/dependencies/",
response_model=list[WorkstreamDependencyRead],
)
async def list_dependencies(
workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> list[WorkstreamDependency]:
"""Return all dependency edges touching this workstream (both directions)."""
if await session.get(Workstream, workstream_id) is None:
raise HTTPException(status_code=404, detail="workstream not found")
rows = await session.execute(
select(WorkstreamDependency).where(
(WorkstreamDependency.from_workstream_id == workstream_id)
| (WorkstreamDependency.to_workstream_id == workstream_id)
)
)
return list(rows.scalars().all())
@router.delete(
"/{workstream_id}/dependencies/{dep_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_dependency(
workstream_id: uuid.UUID,
dep_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> None:
"""Hard-delete a dependency edge. Removing a constraint is safe — no information is lost."""
dep = await session.get(WorkstreamDependency, dep_id)
if dep is None:
raise HTTPException(status_code=404, detail="dependency not found")
if dep.from_workstream_id != workstream_id:
raise HTTPException(status_code=403, detail="dependency does not belong to this workstream")
await session.delete(dep)
await session.commit()

208
api/routers/workstreams.py Normal file
View File

@@ -0,0 +1,208 @@
import asyncio
import uuid
import socket
import time
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.events import EventEnvelope, publish_event
from api.models.managed_repo import ManagedRepo
from api.models.workstream import Workstream
from api.schemas.workstream import (
WorkstreamCreate,
WorkstreamRead,
WorkstreamStatus,
WorkstreamUpdate,
)
router = APIRouter(prefix="/workstreams", tags=["workstreams"])
_INDEX_CACHE: dict[str, Any] | None = None
_INDEX_CACHE_AT: float = 0.0
_INDEX_TTL = 30.0
def _repo_path(repo: ManagedRepo) -> Path | None:
hostname = socket.gethostname()
candidates = []
host_paths = repo.host_paths or {}
if host_paths.get(hostname):
candidates.append(host_paths[hostname])
if repo.local_path:
candidates.append(repo.local_path)
for raw in candidates:
path = Path(raw).expanduser()
if path.is_dir():
return path
return None
def _frontmatter(path: Path) -> dict[str, Any]:
try:
text = path.read_text(encoding="utf-8")
except OSError:
return {}
if not text.startswith("---\n"):
return {}
end = text.find("\n---", 4)
if end == -1:
return {}
data: dict[str, Any] = {}
for raw_line in text[4:end].splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or ":" not in line:
continue
key, value = line.split(":", 1)
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
data[key.strip()] = value
return data
@router.get("/", response_model=list[WorkstreamRead])
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,
owner: str | None = None,
slug: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Workstream]:
q = select(Workstream)
if topic_id:
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)
if owner:
q = q.where(Workstream.owner == owner)
if slug:
q = q.where(Workstream.slug == slug)
q = q.order_by(
Workstream.planning_priority.asc().nullslast(),
Workstream.planning_order.asc().nullslast(),
Workstream.updated_at.desc(),
)
result = await session.execute(q)
return list(result.scalars().all())
@router.get("/workplan-index")
async def workplan_index(
refresh: bool = Query(False, description="Force cache invalidation"),
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
"""Map file-backed workstream ids to their local workplan filenames."""
global _INDEX_CACHE, _INDEX_CACHE_AT
if not refresh and _INDEX_CACHE is not None and (time.monotonic() - _INDEX_CACHE_AT) < _INDEX_TTL:
return _INDEX_CACHE
result = await session.execute(
select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.slug)
)
index: dict[str, Any] = {}
for repo in result.scalars().all():
root = _repo_path(repo)
if root is None:
continue
for directory, archived in (
(root / "workplans", False),
(root / "workplans" / "archived", True),
):
if not directory.is_dir():
continue
for path in sorted(directory.glob("*.md")):
data = _frontmatter(path)
workstream_id = data.get("state_hub_workstream_id")
if not workstream_id:
continue
index[str(workstream_id)] = {
"filename": path.name,
"relative_path": str(path.relative_to(root)),
"repo_slug": repo.slug,
"archived": archived,
}
_INDEX_CACHE = {"workstreams": index}
_INDEX_CACHE_AT = time.monotonic()
return _INDEX_CACHE
@router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)
async def create_workstream(
body: WorkstreamCreate,
session: AsyncSession = Depends(get_session),
) -> Workstream:
ws = Workstream(**body.model_dump())
session.add(ws)
await session.commit()
await session.refresh(ws)
return ws
@router.get("/{workstream_id}", response_model=WorkstreamRead)
async def get_workstream(
workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Workstream:
ws = await session.get(Workstream, workstream_id)
if ws is None:
raise HTTPException(status_code=404, detail="Workstream not found")
return ws
@router.patch("/{workstream_id}", response_model=WorkstreamRead)
async def update_workstream(
workstream_id: uuid.UUID,
body: WorkstreamUpdate,
session: AsyncSession = Depends(get_session),
) -> Workstream:
ws = await session.get(Workstream, workstream_id)
if ws is None:
raise HTTPException(status_code=404, detail="Workstream not found")
prev_status = ws.status
for field, value in body.model_dump(exclude_unset=True).items():
setattr(ws, field, value)
await session.commit()
await session.refresh(ws)
if prev_status != "completed" and ws.status == "completed":
subject = "org.statehub.workstream.completed"
envelope = EventEnvelope.new(
subject,
attributes={
"workstream_id": str(ws.id),
"slug": ws.slug,
"title": ws.title,
"topic_id": str(ws.topic_id),
"repo_id": str(ws.repo_id) if ws.repo_id else None,
"repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None,
},
)
asyncio.create_task(publish_event(subject, envelope))
return ws
@router.delete("/{workstream_id}", response_model=WorkstreamRead)
async def archive_workstream(
workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Workstream:
ws = await session.get(Workstream, workstream_id)
if ws is None:
raise HTTPException(status_code=404, detail="Workstream not found")
ws.status = "archived"
await session.commit()
await session.refresh(ws)
return ws

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

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

View File

@@ -0,0 +1,30 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class MessageCreate(BaseModel):
from_agent: str
to_agent: str
subject: str
body: str
thread_id: uuid.UUID | None = None
class MessageReply(BaseModel):
from_agent: str
body: str
class MessageRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
from_agent: str
to_agent: str
subject: str
body: str
thread_id: uuid.UUID | None = None
read_at: datetime | None = None
archived_at: datetime | None = None
created_at: datetime

View File

@@ -0,0 +1,114 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
# ---------------------------------------------------------------------------
# Capability Catalog schemas
# ---------------------------------------------------------------------------
class CatalogCreate(BaseModel):
domain: str # slug, resolved to domain_id in router
capability_type: str
title: str
description: str | None = None
keywords: list[str] = []
repo_slug: str | None = None # optional repo attribution
class CatalogPatch(BaseModel):
repo_slug: str | None = None
description: str | None = None
keywords: list[str] | None = None
status: str | None = None
class CatalogRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
domain_slug: str
repo_id: uuid.UUID | None = None
repo_slug: str | None = None
capability_type: str
title: str
description: str | None = None
keywords: list[str] = []
status: str
created_at: datetime
updated_at: datetime
# ---------------------------------------------------------------------------
# Capability Request schemas
# ---------------------------------------------------------------------------
class CapabilityRequestCreate(BaseModel):
title: str
description: str | None = None
capability_type: str
priority: str = "medium"
requesting_domain: str # slug, resolved to domain_id in router
requesting_agent: str
requesting_workstream_id: uuid.UUID | None = None
blocking_task_id: uuid.UUID | None = None
class CapabilityRequestAccept(BaseModel):
fulfilling_agent: str
fulfilling_workstream_id: uuid.UUID | None = None
class CapabilityRequestStatusPatch(BaseModel):
status: str # in_progress | ready_for_review | completed | rejected | withdrawn
note: str | None = None
class CapabilityRequestPatch(BaseModel):
catalog_entry_id: uuid.UUID | None = None
priority: str | None = None
blocking_task_id: uuid.UUID | None = None
fulfilling_workstream_id: uuid.UUID | None = None
class CapabilityRequestDispute(BaseModel):
reason: str
disputed_by: str
suggested_domain: str | None = None
class CapabilityRequestReroute(BaseModel):
note: str
rerouted_by: str
domain: str | None = None # slug — used if catalog_entry_id not given
catalog_entry_id: uuid.UUID | None = None # preferred: re-derives domain
class CapabilityRequestRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
title: str
description: str | None = None
capability_type: str
priority: str
status: str
requesting_domain_slug: str
requesting_agent: str
requesting_workstream_id: uuid.UUID | None = None
fulfilling_domain_slug: str | None = None
fulfilling_agent: str | None = None
fulfilling_workstream_id: uuid.UUID | None = None
blocking_task_id: uuid.UUID | None = None
catalog_entry_id: uuid.UUID | None = None
resolution_note: str | None = None
routing_note: str | None = None
dispute_reason: str | None = None
disputed_by: str | None = None
dispute_suggested_domain: str | None = None
disputed_at: datetime | None = None
accepted_at: datetime | None = None
completed_at: datetime | None = None
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,45 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from api.models.contribution import ContributionStatus, ContributionType
class ContributionCreate(BaseModel):
type: ContributionType
target_org: str | None = None
target_repo: str | None = None
slug: str | None = None
title: str
body_path: str | None = None
related_topic_id: uuid.UUID | None = None
related_workstream_id: uuid.UUID | None = None
repo_id: uuid.UUID | None = None
notes: str | None = None
class ContributionStatusPatch(BaseModel):
status: ContributionStatus
notes: str | None = None
class ContributionRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
type: ContributionType
target_org: str | None = None
target_repo: str | None = None
slug: str | None = None
title: str
status: ContributionStatus
body_path: str | None = None
related_topic_id: uuid.UUID | None = None
related_workstream_id: uuid.UUID | None = None
repo_id: uuid.UUID | None = None
submitted_at: datetime | None = None
resolved_at: datetime | None = None
notes: str | None = None
created_at: datetime
updated_at: datetime

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

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

29
api/schemas/doi.py Normal file
View File

@@ -0,0 +1,29 @@
from pydantic import BaseModel
class DoICriterion(BaseModel):
id: str
label: str
tier: str
status: str # pass | fail | warn | skip
detail: str = ""
class DoIReport(BaseModel):
repo_slug: str
tier: str # none | core | standard | full
core_pass: bool
standard_pass: bool
full_pass: bool
criteria: list[DoICriterion] = []
checked_at: str
class DoISummaryEntry(BaseModel):
repo_slug: str
domain_slug: str | None
tier: str
core_pass: bool
standard_pass: bool
full_pass: bool
checked_at: str

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

@@ -0,0 +1,61 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class DomainCreate(BaseModel):
slug: str
name: str
description: str | None = None
class DomainUpdate(BaseModel):
name: str | None = None
description: str | None = None
status: str | None = None
class DomainRename(BaseModel):
new_slug: str
new_name: str
class RepoStub(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
slug: str
name: str
local_path: str | None = None
remote_url: str | None = None
status: str
class DomainRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
slug: str
name: str
description: str | None = None
status: str
created_at: datetime
updated_at: datetime
class DomainDetail(DomainRead):
"""Domain with entity counts and repo list."""
topic_count: int = 0
workstream_count: int = 0
ep_count: int = 0
td_count: int = 0
repos: list[RepoStub] = []
class DomainSummary(BaseModel):
"""Lightweight domain stats for the state summary."""
slug: str
name: str
repo_count: int = 0
active_workstream_count: int = 0
ep_count: int = 0
td_count: int = 0

View File

@@ -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,50 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from api.models.extension_point import EPStatus
VALID_PRIORITIES = {"low", "medium", "high", "critical"}
class EPCreate(BaseModel):
ep_id: str | None = None
domain: str # slug; router resolves to domain_id FK
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):
ep_id: str | None = None
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_slug: str # derived from domain relationship
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

View File

@@ -0,0 +1,66 @@
import uuid
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict
class InterfaceChangeCreate(BaseModel):
repo_slug: str
interface_type: str # rest_api | mcp_tool | cli | schema | capability
change_type: str # breaking | additive | deprecation | removal
title: str
description: str
affected_paths: list[str] = []
affected_repo_slugs: list[str] = []
planned_for: date | None = None
author: str = "custodian"
class InterfaceChangePatch(BaseModel):
title: str | None = None
description: str | None = None
affected_paths: list[str] | None = None
affected_repo_slugs: list[str] | None = None
planned_for: date | None = None
class InterfaceChangeRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
repo_id: uuid.UUID
repo_slug: str
interface_type: str
change_type: str
title: str
description: str
affected_paths: list[str]
affected_repo_slugs: list[str]
status: str
planned_for: date | None
published_at: datetime | None
resolved_at: datetime | None
author: str
created_at: datetime
updated_at: datetime
@classmethod
def from_orm_with_slug(cls, obj) -> "InterfaceChangeRead":
return cls(
id=obj.id,
repo_id=obj.repo_id,
repo_slug=obj.repo.slug,
interface_type=obj.interface_type,
change_type=obj.change_type,
title=obj.title,
description=obj.description,
affected_paths=obj.affected_paths or [],
affected_repo_slugs=obj.affected_repo_slugs or [],
status=obj.status,
planned_for=obj.planned_for,
published_at=obj.published_at,
resolved_at=obj.resolved_at,
author=obj.author,
created_at=obj.created_at,
updated_at=obj.updated_at,
)

126
api/schemas/managed_repo.py Normal file
View File

@@ -0,0 +1,126 @@
import uuid
from datetime import date, datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class RepoCreate(BaseModel):
domain_slug: str
slug: str
name: str
local_path: str | None = None
remote_url: str | None = None
git_fingerprint: str | None = None
description: str | None = None
topic_id: uuid.UUID | None = None
class RepoUpdate(BaseModel):
name: str | None = None
local_path: str | None = None
remote_url: str | None = None
git_fingerprint: str | None = None
description: str | None = None
topic_id: uuid.UUID | None = None
last_state_synced_at: datetime | None = None
class RepoPathRegister(BaseModel):
"""Register a machine-local path for a repo on a specific host."""
host: str
path: str
class RepoOnboardRequest(BaseModel):
"""Start scripted onboarding for a working copy that is visible to State Hub."""
domain_slug: str
project_path: str
agent_profile: Literal["claude-code", "codex"] = "codex"
additional: bool = False
class RepoOnboardResult(BaseModel):
ok: bool
repo_slug: str | None = None
agent_profile: str
command: list[str]
stdout: str = ""
stderr: str = ""
class RepoRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
domain_id: uuid.UUID
domain_slug: str # derived from domain relationship
slug: str
name: str
local_path: str | None = None
host_paths: dict = {}
remote_url: str | None = None
git_fingerprint: str | None = None
description: str | None = None
status: str
topic_id: uuid.UUID | None = None
sbom_source: str | None = None
last_sbom_at: datetime | None = None
last_state_synced_at: datetime | None = None
created_at: datetime
updated_at: datetime
class DispatchTask(BaseModel):
id: uuid.UUID
title: str
priority: str
status: str
needs_human: bool
class DispatchWorkstream(BaseModel):
id: uuid.UUID
title: str
status: str
pending_tasks: list[DispatchTask]
class PendingInterfaceChange(BaseModel):
id: uuid.UUID
title: str
change_type: str
interface_type: str
origin_repo_slug: str
affected_paths: list[str]
planned_for: date | None
published_at: datetime | None
class ScopeIssueDetail(BaseModel):
id: str
label: str
status: str
detail: str
missing_sections: list[str] = Field(default_factory=list)
invalid_capability_blocks: list[dict[str, Any]] = Field(default_factory=list)
needs_refresh_sections: list[str] = Field(default_factory=list)
class RepoDispatch(BaseModel):
repo_slug: str
active_goal: dict[str, Any] | None
active_workstreams: list[DispatchWorkstream]
human_interventions: list[DispatchTask]
pending_interface_changes: list[PendingInterfaceChange]
scope_needs_review: bool
scope_issue_details: list[ScopeIssueDetail]
last_state_synced_at: datetime | None
class RepoScopeHealth(BaseModel):
repo_slug: str
domain_slug: str | None = None
local_path: str | None = None
path_available: bool
scope_needs_review: bool
scope_issue_details: list[ScopeIssueDetail]

View File

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

37
api/schemas/repo_goal.py Normal file
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

78
api/schemas/sbom.py Normal file
View File

@@ -0,0 +1,78 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from api.models.sbom_entry import Ecosystem
class SBOMEntryCreate(BaseModel):
package_name: str
package_version: str | None = None
ecosystem: Ecosystem
license_spdx: str | None = None
is_direct: bool = True
is_dev: bool = False
class SBOMIngest(BaseModel):
repo_slug: str
entries: list[SBOMEntryCreate]
class SBOMEntryRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
repo_id: uuid.UUID
snapshot_id: uuid.UUID
package_name: str
package_version: str | None = None
ecosystem: Ecosystem
license_spdx: str | None = None
is_direct: bool
is_dev: bool
snapshot_at: datetime
created_at: datetime
class SBOMSnapshotRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
repo_id: uuid.UUID
snapshot_at: datetime
source: str | None = None
entry_count: int
created_at: datetime
class SBOMSnapshotDetail(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
repo_id: uuid.UUID
snapshot_at: datetime
source: str | None = None
entry_count: int
created_at: datetime
entries: list[SBOMEntryRead] = []
class LicenceGroup(BaseModel):
license_spdx: str | None
count: int
repos: list[str]
is_copyleft: bool
class LicenceReport(BaseModel):
groups: list[LicenceGroup]
copyleft_direct_count: int
class SBOMRepoView(BaseModel):
repo_slug: str
last_sbom_at: datetime | None = None
entry_count: int
entries: list[SBOMEntryRead]

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

@@ -0,0 +1,82 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
from api.schemas.decision import DecisionRead
from api.schemas.domain import DomainSummary
from api.schemas.progress_event import ProgressEventRead
from api.schemas.task import TaskRead
from api.schemas.topic import TopicWithWorkstreams
from api.schemas.workstream import WorkstreamWithDeps
class TopicTotals(BaseModel):
active: int = 0
paused: int = 0
archived: int = 0
total: int = 0
class WorkstreamTotals(BaseModel):
active: int = 0
blocked: int = 0
completed: int = 0
archived: int = 0
total: int = 0
class TaskTotals(BaseModel):
todo: int = 0
in_progress: int = 0
blocked: int = 0
done: int = 0
cancelled: int = 0
total: int = 0
class DecisionTotals(BaseModel):
open: int = 0
resolved: int = 0
escalated: int = 0
superseded: int = 0
total: int = 0
class Totals(BaseModel):
topics: TopicTotals
workstreams: WorkstreamTotals
tasks: TaskTotals
decisions: DecisionTotals
class NextStep(BaseModel):
"""A derived suggestion pointing to where work should happen next.
Suggestions are never persisted — they are computed on demand from
current hub state: recently resolved decisions, newly unblocked tasks,
cleared dependencies.
"""
type: str # unblocked_task | resolved_decision | dependency_cleared
domain: str | None = None
workstream_id: uuid.UUID | None = None
workstream_title: str | None = None
workstream_slug: str | None = None
task_id: uuid.UUID | None = None
task_title: str | None = None
message: str # plain-language explanation
class StateSummary(BaseModel):
generated_at: datetime
totals: Totals
topics: list[TopicWithWorkstreams]
blocking_decisions: list[DecisionRead]
blocked_tasks: list[TaskRead]
recent_progress: list[ProgressEventRead]
open_workstreams: list[WorkstreamWithDeps]
next_steps: list[NextStep] = []
domains: list[DomainSummary] = []
contribution_counts: dict[str, int] = {}
licence_risk_count: int = 0
open_capability_requests: int = 0

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

@@ -0,0 +1,83 @@
import uuid
from datetime import date, datetime
from typing import Self
from pydantic import BaseModel, ConfigDict, model_validator
from api.models.task import TaskPriority, TaskStatus
class TaskCreate(BaseModel):
workstream_id: uuid.UUID
title: str
description: str | None = None
status: TaskStatus = TaskStatus.todo
priority: TaskPriority = TaskPriority.medium
assignee: str | None = None
due_date: date | None = None
blocking_reason: str | None = None
needs_human: bool = False
intervention_note: str | None = None
parent_task_id: uuid.UUID | None = None
@model_validator(mode="after")
def intervention_note_required_when_flagged(self) -> Self:
if self.needs_human and not self.intervention_note:
raise ValueError("intervention_note is required when needs_human is True")
return self
class TaskUpdate(BaseModel):
title: str | None = None
description: str | None = None
status: TaskStatus | None = None
priority: TaskPriority | None = None
assignee: str | None = None
due_date: date | None = None
blocking_reason: str | None = None
needs_human: bool | None = None
intervention_note: str | None = None
parent_task_id: uuid.UUID | None = None
# Token passthrough — three tiers (highest precision wins):
# 1. tokens_in + tokens_out → exact counts; note defaults to "measured"
# 2. workplan_tokens_in + workplan_tokens_out → prorated across task count (note="workplan")
# 3. neither provided, status=done → heuristic 1000/500 (note="heuristic")
# token_note overrides the auto-assigned note for Tier 1 only (e.g. "userbased")
tokens_in: int | None = None
tokens_out: int | None = None
workplan_tokens_in: int | None = None
workplan_tokens_out: int | None = None
token_note: str | None = None
model: str | None = None
agent: str | None = None
session_id: str | None = None
@model_validator(mode="after")
def blocking_reason_required_when_blocked(self) -> Self:
if self.status == TaskStatus.blocked and not self.blocking_reason:
raise ValueError("blocking_reason is required when status is blocked")
return self
@model_validator(mode="after")
def intervention_note_required_when_flagged(self) -> Self:
if self.needs_human and not self.intervention_note:
raise ValueError("intervention_note is required when needs_human is True")
return self
class TaskRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
workstream_id: uuid.UUID
title: str
description: str | None = None
status: TaskStatus
priority: TaskPriority
assignee: str | None = None
due_date: date | None = None
blocking_reason: str | None = None
needs_human: bool
intervention_note: str | None = None
parent_task_id: uuid.UUID | None = None
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,67 @@
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 TDNoteCreate(BaseModel):
step: str
author: str | None = None
content: str
class TDNoteRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
td_id: uuid.UUID
step: str
author: str | None = None
content: str
created_at: datetime
class TDCreate(BaseModel):
td_id: str | None = None
domain: str # slug; router resolves to domain_id FK
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_slug: str # derived from domain relationship
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
notes: list[TDNoteRead] = []

View File

@@ -0,0 +1,71 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, computed_field
class TokenEventCreate(BaseModel):
tokens_in: int
tokens_out: int
task_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
repo_id: uuid.UUID | None = None
session_id: str | None = None
model: str | None = None
agent: str | None = None
ref_type: str | None = None
ref_id: str | None = None
note: str | None = None
class TokenEventRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tokens_in: int
tokens_out: int
task_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
repo_id: uuid.UUID | None = None
session_id: str | None = None
model: str | None = None
agent: str | None = None
ref_type: str | None = None
ref_id: str | None = None
note: str | None = None
created_at: datetime
@computed_field
@property
def tokens_total(self) -> int:
return self.tokens_in + self.tokens_out
class TokenSummary(BaseModel):
scope: str
scope_id: str
tokens_in: int
tokens_out: int
tokens_total: int
event_count: int
by_model: dict[str, int]
by_agent: dict[str, int]
class TokenEventPatch(BaseModel):
tokens_in: int | None = None
tokens_out: int | None = None
note: str | None = None
model: str | None = None
agent: str | None = None
class RepoTokenSummary(BaseModel):
repo_id: uuid.UUID
repo_slug: str
tokens_in: int
tokens_out: int
tokens_total: int
event_count: int
by_model: dict[str, int]
by_note: dict[str, int]

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

@@ -0,0 +1,47 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from api.models.topic import TopicStatus
class TopicCreate(BaseModel):
slug: str
title: str
description: str | None = None
domain: str # domain slug — resolved to domain_id in the router
status: TopicStatus = TopicStatus.active
class TopicUpdate(BaseModel):
title: str | None = None
description: str | None = None
status: TopicStatus | None = None
domain: str | None = None # domain slug — resolved to domain_id in the router
class WorkstreamStub(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
slug: str
title: str
status: str
owner: str | None = None
due_date: datetime | None = None
class TopicRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
slug: str
title: str
description: str | None = None
domain_slug: str | None = None # resolved from FK relationship via @property
status: TopicStatus
created_at: datetime
updated_at: datetime
class TopicWithWorkstreams(TopicRead):
workstreams: list[WorkstreamStub] = []

115
api/schemas/tpsc.py Normal file
View File

@@ -0,0 +1,115 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, computed_field
# GDPR maturity scale (CNIL/IAPP CMMI-aligned, adapted for third-party assessment)
GDPRMaturity = Literal["unknown", "non_compliant", "initial", "developing", "defined", "managed", "certified"]
# Services at these levels trigger a GDPR warning
GDPR_WARNING_LEVELS = {"unknown", "non_compliant", "initial"}
PricingModel = Literal["free", "paid", "freemium", "usage_based", "unknown"]
AuthType = Literal["api_key", "oauth", "cli", "none", "unknown"]
class TPSCCatalogCreate(BaseModel):
slug: str
name: str
provider: str | None = None
category: str | None = None
website_url: str | None = None
pricing_model: PricingModel = "unknown"
gdpr_maturity: GDPRMaturity = "unknown"
gdpr_notes: str | None = None
dpa_available: bool = False
tos_url: str | None = None
privacy_policy_url: str | None = None
data_processing_regions: list[str] | None = None
data_retention_notes: str | None = None
status: str = "active"
class TPSCCatalogRead(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
slug: str
name: str
provider: str | None
category: str | None
website_url: str | None
pricing_model: str
gdpr_maturity: str
gdpr_notes: str | None
dpa_available: bool
tos_url: str | None
privacy_policy_url: str | None
data_processing_regions: list[str] | None
data_retention_notes: str | None
status: str
created_at: datetime
updated_at: datetime
@computed_field
@property
def gdpr_warning(self) -> bool:
return self.gdpr_maturity in GDPR_WARNING_LEVELS
class TPSCEntryCreate(BaseModel):
service_slug: str
purpose: str | None = None
auth_type: str | None = None
endpoint_override: str | None = None
notes: str | None = None
class TPSCEntryRead(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
snapshot_id: uuid.UUID
catalog_id: uuid.UUID | None
service_slug: str
purpose: str | None
auth_type: str | None
endpoint_override: str | None
notes: str | None
# Denormalised from catalog for convenience
gdpr_maturity: str | None = None
gdpr_warning: bool = False
pricing_model: str | None = None
class TPSCIngestRequest(BaseModel):
repo_slug: str
source_file: str = "tpsc.yaml"
entries: list[TPSCEntryCreate]
class TPSCSnapshotRead(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
repo_id: uuid.UUID | None
snapshot_at: datetime
source_file: str | None
entry_count: int
entries: list[TPSCEntryRead] = []
class TPSCGDPRWarning(BaseModel):
repo_slug: str | None
service_slug: str
gdpr_maturity: str
purpose: str | None
pricing_model: str | None
class TPSCGDPRReport(BaseModel):
generated_at: datetime
total_services: int
warning_count: int
warnings: list[TPSCGDPRWarning]
by_maturity: dict[str, int]

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

@@ -0,0 +1,68 @@
import uuid
from datetime import date, datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict
from api.schemas.workstream_dependency import WorkstreamDepStub
WorkstreamStatus = Literal["todo", "active", "blocked", "completed", "archived"]
class WorkstreamCreate(BaseModel):
topic_id: uuid.UUID
slug: str
title: str
description: str | None = None
status: WorkstreamStatus = "active"
owner: str | None = None
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
repo_id: uuid.UUID | None = None # GEMS primary: the owning repository
repo_goal_id: uuid.UUID | None = None
class WorkstreamUpdate(BaseModel):
title: str | None = None
description: str | None = None
status: WorkstreamStatus | None = None
owner: str | None = None
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
repo_id: uuid.UUID | None = None
repo_goal_id: uuid.UUID | None = None
class WorkstreamRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
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
status: WorkstreamStatus
owner: str | None = None
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
created_at: datetime
updated_at: datetime
class WorkstreamWithTaskCounts(WorkstreamRead):
tasks_total: int = 0
tasks_todo: int = 0
tasks_in_progress: int = 0
tasks_blocked: int = 0
tasks_done: int = 0
class WorkstreamWithDeps(WorkstreamWithTaskCounts):
"""WorkstreamWithTaskCounts enriched with dependency graph edges."""
depends_on: list[WorkstreamDepStub] = []
blocks: list[WorkstreamDepStub] = []
blocked_reasons: list[dict] = []

View File

@@ -0,0 +1,36 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class WorkstreamDependencyCreate(BaseModel):
to_workstream_id: uuid.UUID | None = None
to_task_id: uuid.UUID | None = None
relationship_type: str = "blocks"
description: str | None = None
class WorkstreamDependencyRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
from_workstream_id: uuid.UUID
to_workstream_id: uuid.UUID | None = None
to_task_id: uuid.UUID | None = None
relationship_type: str
description: str | None = None
created_at: datetime
updated_at: datetime
class WorkstreamDepStub(BaseModel):
"""Minimal projection of the other end of a dependency edge."""
dep_id: uuid.UUID
target_type: str = "workstream"
relationship_type: str = "blocks"
workstream_id: uuid.UUID | None = None
workstream_slug: str | None = None
workstream_title: str | None = None
task_id: uuid.UUID | None = None
task_title: str | None = None
description: str | None = None

529
custodian_cli.py Normal file
View File

@@ -0,0 +1,529 @@
#!/usr/bin/env python3
"""
custodian — CLI for the Custodian State Hub.
Usage:
custodian register-project [--domain DOMAIN] [--path PATH]
Run from inside the project directory you want to connect.
--domain defaults to auto-detection from the project charter.
--path defaults to current working directory.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
STATE_HUB_DIR = Path(__file__).resolve().parent
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000")
TEMPLATE = STATE_HUB_DIR / "scripts" / "project_claude_md.template"
PATCH_CWD = STATE_HUB_DIR / "scripts" / "patch_mcp_cwd.py"
_SUGGESTION_PREAMBLE = """\
<!--
CUSTODIAN SUGGESTION — generated by `custodian register-project`
This file was written by the Custodian State Hub registration workflow.
It contains the standard State Hub integration block for this repository.
A Claude Code agent in this repo should:
1. Read this file and the existing CLAUDE.md side by side
2. Integrate the sections below into CLAUDE.md — preserve all existing
project-specific conventions; add or update the hub integration block
3. Delete this file once integration is complete and commit the result
Do NOT add this file to .gitignore. It is a temporary artefact that signals
pending integration work to the repo agent.
-->
"""
_ONBOARDING_TASKS = [
(
"Integrate CLAUDE.custodian.md → CLAUDE.md",
"high",
"A CLAUDE.custodian.md suggestion file was written by the custodian registration workflow. "
"Read both files, merge the hub integration block into the existing CLAUDE.md "
"(preserve all project-specific conventions), then delete CLAUDE.custodian.md and commit.",
),
(
"Write first workplan and initialise workplans/",
"high",
"Create a workplans/ directory and write the first workplan file following ADR-001 "
"(~/the-custodian/canon/architecture/adr-001-workplans-as-repo-artefacts.md). "
"Cover the repo's primary near-term work strand. Register the workstream in the state hub via MCP.",
),
(
"Ingest SBOM",
"medium",
# path substituted at call time
"",
),
(
"Register known EPs and TDs",
"low",
"Catalogue any known extension points (future enhancement hooks) and technical debt items "
"using the register_extension_point() and register_technical_debt() MCP tools.",
),
]
# ── Helpers ────────────────────────────────────────────────────────────────────
def _api_get(path: str) -> object:
url = API_BASE.rstrip("/") + path
try:
with urllib.request.urlopen(url, timeout=10) as r:
return json.loads(r.read())
except urllib.error.URLError as e:
print(f"ERROR: Cannot reach API at {API_BASE}: {e}")
print(f" Start it: cd {STATE_HUB_DIR} && make api")
sys.exit(1)
def _api_post(path: str, body: dict) -> object:
url = API_BASE.rstrip("/") + path
data = json.dumps({k: v for k, v in body.items() if v is not None}).encode()
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read())
def _api_patch(path: str, body: dict) -> object:
url = API_BASE.rstrip("/") + path
data = json.dumps({k: v for k, v in body.items() if v is not None}).encode()
req = urllib.request.Request(
url,
data=data,
headers={"Content-Type": "application/json"},
method="PATCH",
)
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read())
def _find_repo_by_slug(repo_slug: str) -> dict | None:
repos = _api_get("/repos/")
return next((r for r in repos if r.get("slug") == repo_slug), None)
def _detect_domain(project_path: Path) -> str | None:
"""Try to read domain from project charter frontmatter."""
for charter in project_path.rglob("project_charter_v*.md"):
text = charter.read_text()
m = re.search(r"^domain:\s*(\S+)", text, re.MULTILINE)
if m:
return m.group(1).strip('"\'')
return None
def _check_mcp() -> bool:
claude_json = Path.home() / ".claude.json"
if not claude_json.exists():
return False
config = json.loads(claude_json.read_text())
return "state-hub" in config.get("mcpServers", {})
# ── Subcommands ────────────────────────────────────────────────────────────────
def cmd_register(args: argparse.Namespace) -> None:
"""Register a project/repo with the State Hub and generate onboarding tasks."""
project_path = Path(args.path).resolve()
if not project_path.is_dir():
print(f"ERROR: {project_path} is not a directory.")
sys.exit(1)
project_name = project_path.name
repo_slug = re.sub(r"-+", "-", re.sub(r"[^a-z0-9]", "-", project_name.lower())).strip("-")
# ── Step 1: API health ─────────────────────────────────────────────────────
print(f"==> Checking API at {API_BASE} ...")
_api_get("/state/health")
print(" API OK")
# ── Step 2: Domain ─────────────────────────────────────────────────────────
domain = args.domain
valid_domains = [d["slug"] for d in _api_get("/domains/?status=active")]
if not domain:
print("==> Auto-detecting domain from project charter ...")
domain = _detect_domain(project_path)
if domain:
print(f" Detected: {domain}")
else:
print("ERROR: Could not auto-detect domain. Pass --domain explicitly.")
print(f" Valid: {', '.join(valid_domains)}")
sys.exit(1)
if domain not in valid_domains:
print(f"ERROR: Unknown domain '{domain}'. Valid: {', '.join(valid_domains)}")
sys.exit(1)
# ── Step 3: Topic ID lookup (auto-create if new domain) ───────────────────
print(f"==> Looking up topic for domain '{domain}' ...")
topics = _api_get("/topics/?status=active")
match = next((t for t in topics if t.get("domain_slug") == domain), None)
if not match:
print(f" No topic found — creating one for domain '{domain}' ...")
t_slug = re.sub(r"[^a-z0-9]+", "-", domain.lower()).strip("-")
try:
match = _api_post("/topics/", {
"slug": t_slug,
"title": project_name,
"domain": domain,
"status": "active",
})
print(f" Topic created: {match['title']} ({match['id']})")
except Exception as e:
print(f"ERROR: Could not create topic for domain '{domain}': {e}")
sys.exit(1)
topic_id = match["id"]
print(f" topic_id: {topic_id}")
# ── Step 4: MCP check ──────────────────────────────────────────────────────
print("==> Checking MCP server registration ...")
if _check_mcp():
print(" MCP OK")
else:
print("WARNING: 'state-hub' not in ~/.claude.json.")
print(" See ~/.claude/CLAUDE.md → MCP Server Registration section.")
# ── Step 5: Write CLAUDE.custodian.md ─────────────────────────────────────
suggestion_file = project_path / "CLAUDE.custodian.md"
print(f"==> Writing custodian suggestion to {suggestion_file} ...")
content = (
_SUGGESTION_PREAMBLE
+ TEMPLATE.read_text()
.replace("{PROJECT_NAME}", project_name)
.replace("{DOMAIN}", domain)
.replace("{TOPIC_ID}", topic_id)
.replace("{REPO_SLUG}", repo_slug)
)
suggestion_file.write_text(content)
print(" Written. The repo agent integrates it into CLAUDE.md then deletes it.")
# ── Step 6: Register repo ─────────────────────────────────────────────────
print(f"==> Registering repo '{repo_slug}' under domain '{domain}' ...")
repo = None
try:
repo = _api_post("/repos/", {
"domain_slug": domain,
"slug": repo_slug,
"name": project_name,
"local_path": str(project_path),
})
print(" Registered.")
except urllib.error.HTTPError as e:
if e.code != 409:
print(f" NOTE: {e} — repo registration failed, continuing.")
else:
print(" Repo already registered, reusing existing record.")
repo = _find_repo_by_slug(repo_slug)
except Exception as e:
print(f" NOTE: {e} — repo may already be registered, continuing.")
repo = _find_repo_by_slug(repo_slug)
repo_id = repo.get("id") if isinstance(repo, dict) else None
if repo_id:
print(f" repo_id: {repo_id}")
else:
print(" WARNING: Could not resolve repo_id; onboarding workstream will remain domain-level.")
# ── Step 7: Onboarding workstream + tasks ─────────────────────────────────
ws_slug = f"repo-integration-{repo_slug}"
print(f"==> Creating onboarding workstream '{ws_slug}' ...")
# Check if it already exists
existing_ws = next(
(w for w in _api_get("/workstreams/") if w.get("slug") == ws_slug and w.get("status") == "active"),
None,
)
if existing_ws:
print(" Onboarding workstream already exists — skipping task creation.")
if repo_id and not existing_ws.get("repo_id"):
existing_owner = existing_ws.get("owner")
_api_patch(f"/workstreams/{existing_ws['id']}/", {
"repo_id": repo_id,
"owner": repo_slug if existing_owner in (None, domain) else existing_owner,
})
print(" Attached existing onboarding workstream to repo.")
elif repo_id and existing_ws.get("repo_id") != repo_id:
print(
" WARNING: Existing onboarding workstream is attached to a different repo_id; "
"leaving it unchanged."
)
else:
try:
ws = _api_post("/workstreams/", {
"topic_id": topic_id,
"title": f"Repo Integration: {repo_slug}",
"slug": ws_slug,
"description": (
f"Bootstrapping workstream created by the custodian during registration of "
f"'{repo_slug}'. Contains onboarding tasks for the repo agent to execute. "
f"ADR-001 exception: this workstream is DB-first because the repo has no "
f"workplans/ directory yet. Task T2 produces the first workplan file."
),
"owner": repo_slug,
"status": "active",
"repo_id": repo_id,
})
ws_id = ws["id"]
sbom_desc = (
f"Capture the repo's dependency snapshot. From state-hub dir: "
f"make ingest-sbom REPO={repo_slug} SCAN=1 REPO_PATH={project_path}"
)
tasks = [
(_ONBOARDING_TASKS[0][0], _ONBOARDING_TASKS[0][1], _ONBOARDING_TASKS[0][2]),
(_ONBOARDING_TASKS[1][0], _ONBOARDING_TASKS[1][1], _ONBOARDING_TASKS[1][2]),
(_ONBOARDING_TASKS[2][0], _ONBOARDING_TASKS[2][1], sbom_desc),
(_ONBOARDING_TASKS[3][0], _ONBOARDING_TASKS[3][1], _ONBOARDING_TASKS[3][2]),
]
for title, priority, description in tasks:
_api_post("/tasks/", {
"workstream_id": ws_id,
"title": title,
"priority": priority,
"description": description,
})
print(f" Created with {len(tasks)} onboarding tasks.")
print(f" The {domain} repo agent will see these at next session start.")
except Exception as e:
print(f" WARNING: Could not create onboarding tasks: {e}")
ws_id = None
# ── Step 8: Progress event ─────────────────────────────────────────────────
print("==> Recording registration event ...")
try:
_api_post("/progress/", {
"topic_id": topic_id,
"event_type": "milestone",
"summary": f"Repo registered: {project_name} ({domain}) — onboarding tasks created",
"author": "custodian",
"detail": {
"project_path": str(project_path),
"suggestion_file": str(suggestion_file),
"repo_slug": repo_slug,
"domain": domain,
"onboarding_workstream_slug": ws_slug,
},
})
print(" Event recorded.")
except Exception as e:
print(f" WARNING: Could not record progress event: {e}")
print()
print("Registration complete!")
print(f" Project: {project_name}")
print(f" Domain: {domain}")
print(f" Repo slug: {repo_slug}")
print(f" Topic ID: {topic_id}")
print(f" Suggestion: {suggestion_file}")
print()
print("Next: open the repo in Claude Code.")
print(" The repo agent will pick up 4 onboarding tasks and integrate autonomously.")
def cmd_ingest_sbom(args: argparse.Namespace) -> None:
"""Ingest SBOM for the current (or specified) repo. Auto-detects slug from registration."""
project_path = Path(args.path).resolve()
_api_get("/state/health")
# Resolve repo slug: explicit override, or look up by local_path
repo_slug = args.slug
if not repo_slug:
repos = _api_get("/repos/")
repo = next((r for r in repos if r.get("local_path") == str(project_path)), None)
if not repo:
print(f"ERROR: No registered repo found for path '{project_path}'.")
print(" Register first: custodian register-project --domain <slug>")
print(" Or pass --slug explicitly.")
sys.exit(1)
repo_slug = repo["slug"]
print(f"==> Ingesting SBOM for '{repo_slug}' from {project_path} ...")
python = STATE_HUB_DIR / ".venv" / "bin" / "python"
ingest_script = STATE_HUB_DIR / "scripts" / "ingest_sbom.py"
if not python.exists():
print(f"ERROR: .venv not found at {STATE_HUB_DIR}. Run 'make install' in the state-hub directory.")
sys.exit(1)
cmd = [str(python), str(ingest_script), "--repo", repo_slug, "--scan", "--repo-path", str(project_path)]
if args.dry_run:
cmd.append("--dry-run")
result = subprocess.run(cmd)
sys.exit(result.returncode)
def cmd_create_workstream(args: argparse.Namespace) -> None:
"""Create a workstream under a domain's topic."""
_api_get("/state/health")
# Resolve topic_id from domain
topics = _api_get("/topics/?status=active")
match = next((t for t in topics if t.get("domain_slug") == args.domain), None)
if not match:
print(f"ERROR: No active topic for domain '{args.domain}'.")
sys.exit(1)
topic_id = match["id"]
slug = args.slug or re.sub(r"[^a-z0-9]+", "-", args.title.lower()).strip("-")
ws = _api_post("/workstreams/", {
"topic_id": topic_id,
"title": args.title,
"slug": slug,
"description": args.description,
"owner": args.owner,
"status": "active",
})
_api_post("/progress/", {
"topic_id": topic_id,
"workstream_id": ws["id"],
"event_type": "workstream_created",
"summary": f"Workstream created: {args.title}",
"author": "custodian",
"detail": {"owner": args.owner, "slug": slug},
})
print(f"Created workstream: {ws['title']}")
print(f" id: {ws['id']}")
print(f" slug: {ws['slug']}")
print(f" domain: {args.domain}")
print(f" owner: {ws.get('owner') or ''}")
def cmd_create_task(args: argparse.Namespace) -> None:
"""Create a task under a workstream (by ID or slug)."""
_api_get("/state/health")
# Resolve workstream: accept UUID or slug
workstream_id = args.workstream
if not _is_uuid(workstream_id):
wss = _api_get("/workstreams/")
match = next((w for w in wss if w.get("slug") == workstream_id), None)
if not match:
print(f"ERROR: No workstream found with slug '{workstream_id}'.")
print(" Use 'custodian status' or check the dashboard for valid slugs.")
sys.exit(1)
workstream_id = match["id"]
task = _api_post("/tasks/", {
"workstream_id": workstream_id,
"title": args.title,
"priority": args.priority,
"description": args.description,
"assignee": args.assignee,
})
_api_post("/progress/", {
"workstream_id": workstream_id,
"task_id": task["id"],
"event_type": "task_created",
"summary": f"Task created: {args.title}",
"author": "custodian",
"detail": {"priority": args.priority},
})
print(f"Created task: {task['title']}")
print(f" id: {task['id']}")
print(f" priority: {task['priority']}")
print(f" status: {task['status']}")
def _is_uuid(s: str) -> bool:
import uuid as _uuid
try:
_uuid.UUID(s)
return True
except ValueError:
return False
def cmd_status(_args: argparse.Namespace) -> None:
"""Quick status: API health + summary totals."""
health = _api_get("/state/health")
print(f"API: {health.get('status', '?')} DB: {health.get('db', '?')}")
summary = _api_get("/state/summary")
t = summary["totals"]
print(f"Topics: {t['topics']['active']} active")
print(f"Workstreams: {t['workstreams']['active']} active, {t['workstreams']['blocked']} blocked")
print(f"Tasks: {t['tasks']['in_progress']} in-progress, {t['tasks']['todo']} todo, {t['tasks']['blocked']} blocked")
print(f"Decisions: {t['decisions']['open']} open, {t['decisions']['escalated']} escalated")
blocking = summary.get("blocking_decisions", [])
if blocking:
print(f"\nBlocking decisions ({len(blocking)}):")
for d in blocking:
deadline = d.get("deadline") or "no deadline"
print(f" [{deadline}] {d['title']}")
# ── Entry point ────────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
prog="custodian",
description="Custodian State Hub CLI",
)
sub = parser.add_subparsers(dest="command", required=True)
# register-project
reg = sub.add_parser("register-project", help="Register a project with the State Hub")
reg.add_argument(
"--domain",
default=None,
help="Project domain slug (auto-detected from charter if omitted)",
)
reg.add_argument(
"--path",
default=os.getcwd(),
help="Project directory (defaults to current directory)",
)
# ingest-sbom
ing = sub.add_parser("ingest-sbom", help="Ingest SBOM for the repo at the current directory")
ing.add_argument("--path", default=os.getcwd(), help="Repo directory (defaults to cwd)")
ing.add_argument("--slug", default=None, help="Repo slug (auto-detected from path if omitted)")
ing.add_argument("--dry-run", action="store_true", help="Parse lockfiles but do not submit to API")
# create-workstream
cws = sub.add_parser("create-workstream", help="Create a workstream under a domain topic")
cws.add_argument("--domain", required=True, help="Domain slug to create the workstream under")
cws.add_argument("--title", required=True, help="Workstream title")
cws.add_argument("--slug", default=None, help="URL slug (auto-generated from title if omitted)")
cws.add_argument("--owner", default=None, help="Owner name")
cws.add_argument("--description", default=None, help="Optional description")
# create-task
ctask = sub.add_parser("create-task", help="Create a task under a workstream")
ctask.add_argument("--workstream", required=True, metavar="ID_OR_SLUG", help="Workstream UUID or slug")
ctask.add_argument("--title", required=True, help="Task title")
ctask.add_argument("--priority", choices=["low", "medium", "high", "critical"], default="medium")
ctask.add_argument("--assignee", default=None)
ctask.add_argument("--description", default=None)
# status
sub.add_parser("status", help="Show State Hub health and summary totals")
args = parser.parse_args()
if args.command == "register-project":
cmd_register(args)
elif args.command == "ingest-sbom":
cmd_ingest_sbom(args)
elif args.command == "create-workstream":
cmd_create_workstream(args)
elif args.command == "create-task":
cmd_create_task(args)
elif args.command == "status":
cmd_status(args)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,119 @@
import {readFileSync} from "node:fs";
import {fileURLToPath} from "node:url";
import {dirname, join} from "node:path";
// Read improvement-modal.js at config load time and inject as a plain <script>.
// Observable Framework proxies all src/*.js files through its own module
// bundler — they cannot be imported via a raw <script type="module"> in <head>.
// Reading the file here and stripping the ES module export is the reliable path.
const _configDir = dirname(fileURLToPath(import.meta.url));
const _modalScript = readFileSync(
join(_configDir, "src/components/improvement-modal.js"), "utf-8"
)
.replace(/^export function /gm, "function ") // strip ES module export
+ "\ninitImprovementModal();\n"; // auto-initialise
export default {
root: "src",
title: "Custodian State Hub",
pages: [
// ── Pages (Overview first, then alphabetical) ────────────────────────────
{ name: "Overview", path: "/" },
{ name: "Capabilities", path: "/capability-requests" },
{ name: "Contributions", path: "/contributions" },
{ name: "Domains", path: "/domains" },
{ name: "Goals", path: "/goals" },
{ name: "Inbox", path: "/inbox" },
{ name: "Progress", path: "/progress" },
{ name: "Token Cost", path: "/token-cost" },
{ name: "Services (TPSC)", path: "/tpsc" },
{ name: "Todo", path: "/todo" },
{ name: "Tools & Apps", path: "/tools" },
// ── Sections (alphabetical) ───────────────────────────────────────────────
{
name: "Policies",
collapsible: true,
open: false,
pages: [
{ name: "Repository DoI", path: "/policy/repo-doi" },
{ name: "Workstream DoD", path: "/policy/workstream-dod" },
],
},
{
name: "Repositories",
path: "/repos",
collapsible: true,
open: false,
pages: [
{ name: "Debt", path: "/techdept" },
{ name: "Repo Sync", path: "/repo-sync" },
{ name: "SBOM", path: "/sbom" },
],
},
{
name: "Workstreams",
path: "/workstreams",
collapsible: true,
open: false,
pages: [
{ name: "Decisions", path: "/decisions" },
{ name: "Dependencies", path: "/dependencies" },
{ name: "Extends", path: "/extensions" },
{ name: "Interface Changes", path: "/interface-changes" },
{ name: "Interventions", path: "/interventions" },
{ name: "Tasks", path: "/tasks" },
{ name: "UI Feedback", path: "/ui-feedback" },
],
},
// ── Reference (always last) ───────────────────────────────────────────────
{
name: "Reference",
path: "/reference",
collapsible: true,
open: false,
pages: [
{ name: "Capabilities", path: "/docs/capabilities" },
{ name: "Connecting to the Hub", path: "/docs/connecting" },
{ name: "Dashboard", path: "/docs/dashboard" },
{ name: "Contributions", path: "/docs/contributions" },
{ name: "Decision Health", path: "/docs/decisions-kpi" },
{ 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" },
{ name: "Live Data", path: "/docs/live-data" },
{ name: "Overview", path: "/docs/overview" },
{ name: "Progress Log", path: "/docs/progress-log" },
{ name: "Ralph Workplan", path: "/docs/ralph-workplan" },
{ name: "Reference & Context Help", path: "/docs/reference" },
{ name: "Repo Integration", path: "/docs/repo-integration" },
{ name: "State Hub", path: "/docs/state-hub" },
{ name: "Repos", path: "/docs/repos" },
{ name: "SBOM", path: "/docs/sbom" },
{ name: "SCOPE.md", path: "/docs/scope" },
{ name: "Tasks", path: "/docs/tasks" },
{ name: "TPSC", path: "/docs/tpsc" },
{ name: "TPSC — GDPR Maturity", path: "/docs/gdpr-maturity" },
{ name: "Technical Debt", path: "/docs/debt" },
{ name: "Todo", path: "/docs/todo" },
{ name: "Workstream Health", path: "/docs/workstream-health-index" },
{ name: "Workstream Lifecycle", path: "/docs/workstream-lifecycle" },
{ name: "Workstreams", path: "/docs/workstreams" },
],
},
],
theme: ["air", "near-midnight"],
head: `<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🗄️</text></svg>">
<script>${_modalScript}</script>
<style>
.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; padding-right: 1.6rem; }
.filter-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
.filter-text-input { display: flex; align-items: center; }
.filter-text-input input { height: 30px; font-size: 0.85rem; padding: 0.25rem 0.5rem; border-radius: 6px; border: 1px solid var(--theme-foreground-faint, #ccc); background: var(--theme-background, #fff); font-family: inherit; color: inherit; }
</style>`,
footer: "Custodian State Hub — local-first, append-only, sovereignty-preserving.",
};

4184
dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

14
dashboard/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "custodian-state-hub-dashboard",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "observable preview",
"build": "observable build",
"clean": "rm -rf dist"
},
"dependencies": {
"@observablehq/framework": "^1.13.3"
}
}

View File

@@ -0,0 +1,283 @@
---
title: Capability Requests
---
```js
import {API, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
const POLL = 30_000;
```
```js
// Live poll for capability requests
const reqState = (async function*() {
let failures = 0;
while (true) {
let data = [], ok = false;
try {
const r = await apiFetch("/capability-requests/");
ok = r.ok;
data = ok ? await r.json() : [];
} catch {}
failures = ok ? 0 : failures + 1;
yield {data, ok, ts: new Date()};
await waitForVisible(pollDelay({ok, base: POLL, failures}));
}
})();
```
```js
const requests = reqState.data ?? [];
const _ok = reqState.ok ?? false;
const _ts = reqState.ts;
```
# Capability Requests
```js
import {injectTocTop} from "./components/toc-sidebar.js";
import {withDocHelp} from "./components/doc-overlay.js";
const _liveEl = html`<div class="live-indicator">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok ? `Live · ${_ts?.toLocaleTimeString()}` : html`<span style="color:red">API offline</span>`}
</div>`;
withDocHelp(_liveEl, "/docs/live-data");
injectTocTop("live-indicator", _liveEl);
const _h1 = document.querySelector("#observablehq-main h1");
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/capabilities"); }
```
```js
// KPI sidebar
const open = requests.filter(r => ["requested","routing_disputed","accepted","in_progress","ready_for_review"].includes(r.status));
const completed = requests.filter(r => r.status === "completed");
const avgFulfill = completed.length > 0
? (completed.reduce((s, r) => s + (new Date(r.completed_at) - new Date(r.created_at)), 0) / completed.length / 86400000).toFixed(1)
: "—";
const critical = open.filter(r => r.priority === "critical" || r.priority === "high").length;
const kpiEl = html`<div class="kpi-infobox">
<div class="kpi-infobox-title">Capability Requests</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.4rem 0.8rem;font-size:0.82rem">
<span>Open</span><strong>${open.length}</strong>
<span>Avg fulfill</span><strong>${avgFulfill}d</strong>
<span>High/Critical</span><strong style="color:${critical > 0 ? 'orange' : 'inherit'}">${critical}</strong>
<span>Total</span><strong>${requests.length}</strong>
</div>
</div>`;
injectTocTop("cap-req-kpi", kpiEl);
```
```js
// Filters
const typeFilter = Inputs.select(
["all", ...new Set(requests.map(r => r.capability_type))],
{label: "Type", value: "all"}
);
const statFilter = Inputs.select(
["all", "routing_disputed", "requested", "accepted", "in_progress", "ready_for_review", "completed", "rejected", "withdrawn"],
{label: "Status", value: "all"}
);
const domFilter = Inputs.select(
["all", ...new Set([...requests.map(r => r.requesting_domain_slug), ...requests.map(r => r.fulfilling_domain_slug).filter(Boolean)])],
{label: "Domain", value: "all"}
);
display(html`<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem">
${typeFilter}${statFilter}${domFilter}
</div>`);
```
```js
const tf = typeFilter.value;
const sf = statFilter.value;
const df = domFilter.value;
const filtered = requests.filter(r =>
(tf === "all" || r.capability_type === tf) &&
(sf === "all" || r.status === sf) &&
(df === "all" || r.requesting_domain_slug === df || r.fulfilling_domain_slug === df)
);
```
## Summary
```js
const priorityColors = {critical: "#e53935", high: "orange", medium: "steelblue", low: "#aaa"};
const disputed = requests.filter(r => r.status === "routing_disputed");
// Disputed banner — shown at top when any exist
if (disputed.length > 0) {
display(html`<div class="disputed-banner">
<div class="disputed-banner-title">⚠ Routing Disputed (${disputed.length})</div>
${disputed.map(r => html`
<div class="disputed-card">
<div class="disputed-card-header">
<span class="cap-title">${r.title}</span>
<span class="cap-domains">${r.requesting_domain_slug} → <strong>${r.fulfilling_domain_slug ?? "unassigned"}</strong></span>
</div>
<div class="disputed-reason"><strong>Dispute:</strong> ${r.dispute_reason ?? "(no reason given)"}</div>
${r.dispute_suggested_domain ? html`<div class="disputed-suggestion">Suggested domain: <strong>${r.dispute_suggested_domain}</strong></div>` : ""}
${r.disputed_by ? html`<div class="disputed-meta">Raised by <em>${r.disputed_by}</em> · ${new Date(r.disputed_at).toLocaleString()}</div>` : ""}
</div>
`)}
</div>`);
}
display(html`<div class="grid grid-cols-4" style="gap:1rem;margin-bottom:1.5rem">
<div class="card"><h3>Requested</h3><p class="big-num">${requests.filter(r => r.status === "requested").length}</p></div>
<div class="card"><h3>In Progress</h3><p class="big-num">${requests.filter(r => ["accepted","in_progress"].includes(r.status)).length}</p></div>
<div class="card"><h3>Ready for Review</h3><p class="big-num">${requests.filter(r => r.status === "ready_for_review").length}</p></div>
<div class="card"><h3>Completed</h3><p class="big-num">${completed.length}</p></div>
</div>`);
```
## Status Kanban
```js
const statusCols = [
{key: "routing_disputed", label: "⚠ Routing Disputed", color: "#f59e0b"},
{key: "requested", label: "Requested", color: "steelblue"},
{key: "accepted", label: "Accepted", color: "#f0a500"},
{key: "in_progress", label: "In Progress", color: "#2196f3"},
{key: "ready_for_review", label: "Ready for Review", color: "#4caf50"},
{key: "completed", label: "Completed", color: "#2e7d32"},
{key: "rejected", label: "Rejected", color: "#e53935"},
{key: "withdrawn", label: "Withdrawn", color: "#bbb"},
];
const colMap = {};
for (const r of filtered) {
(colMap[r.status] = colMap[r.status] ?? []).push(r);
}
const activeCols = statusCols.filter(s => colMap[s.key]?.length);
if (activeCols.length === 0) {
display(html`<p style="color:gray">No capability requests match the current filters.</p>`);
} else {
const ageDays = (r) => ((Date.now() - new Date(r.created_at)) / 86400000).toFixed(0);
display(html`<div class="kanban">
${activeCols.map(s => html`
<div class="kanban-col">
<div class="kanban-header" style="border-bottom:2px solid ${s.color}">${s.label} <span class="kanban-count">${colMap[s.key].length}</span></div>
${colMap[s.key].map(r => html`
<div class="cap-card">
<div class="cap-type-badge" style="background:${priorityColors[r.priority] ?? '#aaa'}20;color:${priorityColors[r.priority] ?? '#aaa'}">${r.capability_type}</div>
<div class="cap-priority-badge" style="color:${priorityColors[r.priority] ?? '#888'}">${r.priority}</div>
<div class="cap-title">${r.title}</div>
<div class="cap-domains">
<span>${r.requesting_domain_slug}</span>
${r.fulfilling_domain_slug ? html` → <strong>${r.fulfilling_domain_slug}</strong>` : html` → <em>unassigned</em>`}
</div>
<div class="cap-age">${ageDays(r)}d old</div>
</div>
`)}
</div>
`)}
</div>`);
}
```
## All Requests
```js
display(Inputs.table(filtered.map(r => ({
Type: r.capability_type,
Title: r.title,
Priority: r.priority,
Status: r.status,
Requester: r.requesting_domain_slug,
Provider: r.fulfilling_domain_slug ?? "—",
Agent: r.requesting_agent,
Created: new Date(r.created_at).toLocaleDateString(),
})), {maxWidth: 1000}));
```
---
## Capability Catalog
```js
// Live poll for catalog entries
const catalogState = (async function*() {
let failures = 0;
while (true) {
let data = [], ok = false;
try {
const r = await apiFetch("/capability-catalog/?status=all");
ok = r.ok;
if (r.ok) data = await r.json();
} catch {}
failures = ok ? 0 : failures + 1;
yield data;
await waitForVisible(pollDelay({ok, base: POLL, failures}));
}
})();
```
```js
const catalog = catalogState ?? [];
```
```js
if (catalog.length === 0) {
display(html`<p style="color:gray">No capabilities registered yet. Add <code>&#96;&#96;&#96;capability</code> blocks to SCOPE.md files and run <code>make ingest-capabilities-all</code>.</p>`);
} else {
// Group by domain
const byDomain = {};
for (const c of catalog) {
(byDomain[c.domain_slug] = byDomain[c.domain_slug] ?? []).push(c);
}
const typeColors = {
infrastructure: "#e65100", api: "#1565c0", data: "#2e7d32",
security: "#c62828", documentation: "#6a1b9a", other: "#888"
};
display(html`<div class="catalog-grid">
${Object.entries(byDomain).sort((a, b) => a[0].localeCompare(b[0])).map(([domain, caps]) => html`
<div class="catalog-domain">
<div class="catalog-domain-header">${domain} <span class="kanban-count">${caps.length}</span></div>
${caps.map(c => html`
<div class="catalog-entry ${c.status === 'deprecated' ? 'catalog-deprecated' : ''}">
<div class="cap-type-badge" style="background:${(typeColors[c.capability_type] ?? '#888')}18;color:${typeColors[c.capability_type] ?? '#888'}">${c.capability_type}</div>
<div class="catalog-title">${c.title}</div>
${c.description ? html`<div class="catalog-desc">${c.description}</div>` : ""}
${c.keywords?.length ? html`<div class="catalog-kw">${c.keywords.map(k => html`<span class="kw-tag">${k}</span>`)}</div>` : ""}
</div>
`)}
</div>
`)}
</div>`);
}
```
<style>
.live-indicator { font-size: 0.8rem; color: gray; padding: 0.55rem 0.7rem; margin-bottom: 0.75rem; }
.card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; }
.big-num { font-size: 2.2rem; font-weight: bold; margin: 0.25rem 0; }
.kanban { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.kanban-col { background: var(--theme-background-alt); border-radius: 8px; padding: 0.75rem; }
.kanban-header { font-weight: 600; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.04em; padding-bottom: 0.5rem; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; }
.kanban-count { font-size: 0.75rem; background: var(--theme-background); border-radius: 10px; padding: 0.1rem 0.4rem; font-weight: 500; }
.cap-card { background: var(--theme-background); border-radius: 6px; padding: 0.6rem 0.75rem; margin-bottom: 0.5rem; }
.cap-type-badge { display: inline-block; font-size: 0.65rem; font-weight: 700; border-radius: 3px; padding: 0.1rem 0.35rem; margin-bottom: 0.15rem; }
.cap-priority-badge { display: inline-block; font-size: 0.6rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-left: 0.3rem; }
.cap-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 0.2rem; line-height: 1.3; }
.cap-domains { font-size: 0.75rem; color: steelblue; font-family: monospace; }
.cap-age { font-size: 0.7rem; color: gray; margin-top: 0.3rem; }
.catalog-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.catalog-domain { background: var(--theme-background-alt); border-radius: 8px; padding: 0.75rem; }
.catalog-domain-header { font-weight: 600; font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.04em; padding-bottom: 0.5rem; margin-bottom: 0.5rem; border-bottom: 2px solid var(--theme-foreground-faint, #ddd); display: flex; align-items: center; gap: 0.4rem; }
.catalog-entry { background: var(--theme-background); border-radius: 6px; padding: 0.6rem 0.75rem; margin-bottom: 0.5rem; }
.catalog-deprecated { opacity: 0.5; }
.catalog-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 0.15rem; }
.catalog-desc { font-size: 0.75rem; color: var(--theme-foreground-muted, #666); line-height: 1.35; margin-bottom: 0.3rem; }
.catalog-kw { display: flex; flex-wrap: wrap; gap: 0.25rem; }
.kw-tag { font-size: 0.6rem; background: var(--theme-background-alt, #f0f0f0); border: 1px solid var(--theme-foreground-faint, #ddd); border-radius: 3px; padding: 0.05rem 0.3rem; font-family: monospace; color: var(--theme-foreground-muted, #666); }
.disputed-banner { background: #fff8e1; border: 1.5px solid #f59e0b; border-radius: 8px; padding: 0.85rem 1rem; margin-bottom: 1.25rem; }
.disputed-banner-title { font-weight: 700; font-size: 0.9rem; color: #b45309; margin-bottom: 0.6rem; }
.disputed-card { background: #fffbf0; border: 1px solid #fcd34d; border-radius: 6px; padding: 0.6rem 0.75rem; margin-bottom: 0.5rem; }
.disputed-card-header { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; margin-bottom: 0.3rem; flex-wrap: wrap; }
.disputed-reason { font-size: 0.8rem; color: #92400e; margin-bottom: 0.2rem; }
.disputed-suggestion { font-size: 0.78rem; color: #1d4ed8; margin-bottom: 0.15rem; }
.disputed-meta { font-size: 0.72rem; color: gray; margin-top: 0.2rem; }
</style>

View File

@@ -0,0 +1,270 @@
/**
* action-confirm — modal dialog that requires a non-empty comment before
* confirming an action (e.g. resolving a decision, marking an intervention done).
*
* Lives on document.body so it survives live-poll re-renders of the page content.
*
* Usage:
* import {openActionConfirm} from "./components/action-confirm.js";
*
* openActionConfirm({
* title: "Mark as Done", // modal header
* entityTitle: task.title, // shown as context below the header
* label: "Resolution comment", // textarea label
* placeholder: "What was done?", // textarea placeholder
* confirmLabel: "Mark Done", // confirm button text
* onConfirm: async (comment) => { ... }, // called with trimmed comment
* // onConfirm should throw (or return a rejected promise) on API error
* });
*/
const _STYLE_ID = "action-confirm-styles";
function _ensureStyles() {
if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return;
const s = document.createElement("style");
s.id = _STYLE_ID;
s.textContent = `
/* ── Backdrop ────────────────────────────────────────────────────────────── */
.ac-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
z-index: 9200; display: flex; align-items: center; justify-content: center;
animation: _ac-fade 0.15s ease;
}
@keyframes _ac-fade { from { opacity: 0 } to { opacity: 1 } }
/* ── Box ──────────────────────────────────────────────────────────────────── */
.ac-box {
width: min(480px, 92vw);
background: var(--theme-background, #fff);
border-radius: 12px;
box-shadow: 0 16px 56px rgba(0,0,0,0.28);
display: flex; flex-direction: column; overflow: hidden;
animation: _ac-rise 0.15s ease;
}
@keyframes _ac-rise {
from { transform: translateY(12px); opacity: 0 }
to { transform: translateY(0); opacity: 1 }
}
/* ── Header ───────────────────────────────────────────────────────────────── */
.ac-header {
display: flex; align-items: flex-start; gap: 0.75rem;
padding: 0.85rem 1rem 0.75rem;
border-bottom: 1px solid var(--theme-foreground-faint, #e8e8e8);
background: var(--theme-background-alt, #f7f7f7);
}
.ac-title {
flex: 1; font-size: 0.95rem; font-weight: 700; line-height: 1.3;
color: var(--theme-foreground, #111);
}
.ac-close {
background: none; border: 1px solid transparent; cursor: pointer;
font-size: 0.9rem; color: var(--theme-foreground-muted, #888);
padding: 0.15rem 0.45rem; border-radius: 6px; flex-shrink: 0;
font-family: inherit; line-height: 1.3;
}
.ac-close:hover {
border-color: var(--theme-foreground-faint, #ccc);
background: var(--theme-background, #fff); color: var(--theme-foreground, #111);
}
/* ── Body ─────────────────────────────────────────────────────────────────── */
.ac-body { padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
.ac-entity-title {
font-size: 0.85rem; color: var(--theme-foreground-muted, #555);
background: var(--theme-background-alt, #f9f9f9);
border: 1px solid var(--theme-foreground-faint, #eee);
border-radius: 6px; padding: 0.45rem 0.65rem; line-height: 1.45;
word-break: break-word;
}
.ac-label {
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--theme-foreground-muted, #666);
margin-bottom: 0.25rem; display: block;
}
.ac-textarea {
width: 100%; box-sizing: border-box;
min-height: 80px; resize: vertical;
font-size: 0.87rem; line-height: 1.5; font-family: inherit;
padding: 0.5rem 0.65rem;
border: 1px solid var(--theme-foreground-faint, #d1d5db);
border-radius: 6px;
background: var(--theme-background, #fff);
color: var(--theme-foreground, #111);
transition: border-color 0.1s, box-shadow 0.1s;
}
.ac-textarea:focus { outline: none; border-color: steelblue; box-shadow: 0 0 0 2px #bfdbfe; }
.ac-textarea.ac-invalid { border-color: #dc2626; box-shadow: 0 0 0 2px #fecaca; }
.ac-error {
font-size: 0.8rem; color: #dc2626;
background: #fef2f2; border: 1px solid #fecaca;
border-radius: 6px; padding: 0.35rem 0.6rem;
}
/* ── Footer ───────────────────────────────────────────────────────────────── */
.ac-footer {
display: flex; justify-content: flex-end; gap: 0.5rem;
padding: 0.65rem 1rem 0.9rem;
border-top: 1px solid var(--theme-foreground-faint, #e8e8e8);
}
.ac-btn-cancel {
padding: 0.3rem 0.85rem; border-radius: 6px;
border: 1px solid var(--theme-foreground-faint, #d1d5db);
background: var(--theme-background, #fff);
color: var(--theme-foreground-muted, #555);
font-size: 0.85rem; font-family: inherit; cursor: pointer;
}
.ac-btn-cancel:hover { border-color: #9ca3af; color: var(--theme-foreground, #111); }
.ac-btn-confirm {
padding: 0.3rem 0.85rem; border-radius: 6px;
border: 1px solid #22c55e; background: #f0fdf4; color: #166534;
font-size: 0.85rem; font-weight: 600; font-family: inherit; cursor: pointer;
transition: background 0.1s;
}
.ac-btn-confirm:hover:not(:disabled) { background: #dcfce7; }
.ac-btn-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
`;
document.head.append(s);
}
/**
* @param {object} opts
* @param {string} opts.title Modal header text
* @param {string} [opts.entityTitle] Optional context shown below the header
* @param {string} opts.label Textarea label
* @param {string} [opts.placeholder] Textarea placeholder
* @param {string} [opts.confirmLabel] Confirm button label (default: "Confirm")
* @param {Function} opts.onConfirm async (comment: string) => void — throw to show error
*/
export function openActionConfirm({
title,
entityTitle,
label,
placeholder = "Add a comment…",
confirmLabel = "Confirm",
onConfirm,
}) {
_ensureStyles();
document.getElementById("_ac-root")?.remove();
const root = document.createElement("div");
root.id = "_ac-root";
root.className = "ac-backdrop";
root.setAttribute("role", "dialog");
root.setAttribute("aria-modal", "true");
root.setAttribute("aria-label", title);
// ── Header ────────────────────────────────────────────────────────────────
const header = document.createElement("div");
header.className = "ac-header";
const titleEl = document.createElement("div");
titleEl.className = "ac-title";
titleEl.textContent = title;
const closeBtn = document.createElement("button");
closeBtn.className = "ac-close";
closeBtn.textContent = "✕";
closeBtn.setAttribute("aria-label", "Cancel");
header.append(titleEl, closeBtn);
// ── Body ──────────────────────────────────────────────────────────────────
const body = document.createElement("div");
body.className = "ac-body";
if (entityTitle) {
const ctx = document.createElement("div");
ctx.className = "ac-entity-title";
ctx.textContent = entityTitle;
body.append(ctx);
}
const labelEl = document.createElement("label");
labelEl.className = "ac-label";
labelEl.textContent = label;
const textarea = document.createElement("textarea");
textarea.className = "ac-textarea";
textarea.placeholder = placeholder;
textarea.rows = 3;
labelEl.append(textarea); // make label clickable
const fieldWrap = document.createElement("div");
fieldWrap.append(labelEl);
body.append(fieldWrap);
const errorEl = document.createElement("div");
errorEl.className = "ac-error";
errorEl.style.display = "none";
body.append(errorEl);
// ── Footer ────────────────────────────────────────────────────────────────
const footer = document.createElement("div");
footer.className = "ac-footer";
const cancelBtn = document.createElement("button");
cancelBtn.className = "ac-btn-cancel";
cancelBtn.textContent = "Cancel";
const confirmBtn = document.createElement("button");
confirmBtn.className = "ac-btn-confirm";
confirmBtn.textContent = confirmLabel;
footer.append(cancelBtn, confirmBtn);
// ── Assemble ──────────────────────────────────────────────────────────────
const box = document.createElement("div");
box.className = "ac-box";
box.append(header, body, footer);
root.append(box);
document.body.append(root);
// Focus the textarea after animation
setTimeout(() => textarea.focus(), 50);
// ── Behaviour ─────────────────────────────────────────────────────────────
const close = () => root.remove();
cancelBtn.addEventListener("click", close);
closeBtn.addEventListener("click", close);
root.addEventListener("click", e => { if (e.target === root) close(); });
const onKey = e => {
if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); }
};
document.addEventListener("keydown", onKey);
textarea.addEventListener("input", () => {
textarea.classList.remove("ac-invalid");
errorEl.style.display = "none";
});
confirmBtn.addEventListener("click", async () => {
const comment = textarea.value.trim();
if (!comment) {
textarea.classList.add("ac-invalid");
textarea.focus();
return;
}
confirmBtn.disabled = true;
cancelBtn.disabled = true;
confirmBtn.textContent = "…";
errorEl.style.display = "none";
try {
await onConfirm(comment);
close();
} catch (err) {
errorEl.textContent = err?.message ?? "Request failed — check that the API is running.";
errorEl.style.display = "";
confirmBtn.disabled = false;
cancelBtn.disabled = false;
confirmBtn.textContent = confirmLabel;
}
});
}

View File

@@ -0,0 +1,38 @@
export const API = "http://127.0.0.1:8000";
export const POLL = 15_000;
export const POLL_HEAVY = 60_000;
export const FETCH_TIMEOUT = 12_000;
export function pollDelay({ok = true, base = POLL, failures = 0} = {}) {
return ok ? base : Math.min(base * 2 ** Math.min(failures, 4), 300_000);
}
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Waits `ms` if the tab is visible; pauses until the tab becomes visible if hidden,
// then returns immediately so the next poll fires as soon as the user returns.
export async function waitForVisible(ms) {
if (typeof document === "undefined") return sleep(ms);
if (document.visibilityState === "visible") return sleep(ms);
return new Promise(resolve => {
const handler = () => {
document.removeEventListener("visibilitychange", handler);
resolve();
};
document.addEventListener("visibilitychange", handler);
});
}
export async function apiFetch(path, options = {}) {
const url = path.startsWith("http") ? path : `${API}${path}`;
const timeout = options.timeout ?? FETCH_TIMEOUT;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeout);
try {
return await fetch(url, {...options, signal: ctrl.signal});
} finally {
clearTimeout(timer);
}
}

View File

@@ -0,0 +1,222 @@
/**
* doc-overlay — hoverable ? button that opens a documentation page in an overlay.
*
* Usage:
* import {withDocHelp} from "./components/doc-overlay.js";
*
* const el = html`<div class="my-card">...</div>`;
* withDocHelp(el, "/docs/my-page"); // mutates el in place, returns it
* display(el);
*
* The element must have position:relative (or set it via inline style before calling).
* The ? button is invisible until the user hovers over the element.
*/
const _STYLE_ID = "doc-overlay-styles";
const _titleCache = new Map();
async function _fetchDocTitle(docPath) {
if (_titleCache.has(docPath)) return _titleCache.get(docPath);
try {
const res = await fetch(docPath);
if (!res.ok) return null;
const parser = new DOMParser();
const doc = parser.parseFromString(await res.text(), "text/html");
const title = doc.querySelector("h1")?.textContent?.trim() ?? null;
if (title) _titleCache.set(docPath, title);
return title;
} catch { return null; }
}
function _ensureStyles() {
if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return;
const s = document.createElement("style");
s.id = _STYLE_ID;
s.textContent = `
/* ── ? help button ─────────────────────────────────────────────────────────── */
.doc-help-btn {
position: absolute;
top: 0.45rem;
right: 0.45rem;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
border: 1px solid var(--theme-foreground-faint, #ccc);
background: var(--theme-background, #fff);
color: var(--theme-foreground-muted, #999);
font-size: 0.65rem;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s, background 0.15s, border-color 0.15s;
z-index: 10;
padding: 0;
line-height: 1;
font-family: var(--sans-serif, system-ui, sans-serif);
}
.doc-help-wrap:hover .doc-help-btn,
.doc-help-btn:focus-visible {
opacity: 1;
}
.doc-help-btn:hover {
background: var(--theme-background-alt, #f0f0f0);
border-color: steelblue;
color: steelblue;
}
/* ── overlay backdrop ───────────────────────────────────────────────────────── */
.doc-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
animation: _doc-fade-in 0.15s ease;
}
@keyframes _doc-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* ── overlay box ────────────────────────────────────────────────────────────── */
.doc-overlay-box {
width: min(780px, 92vw);
height: 82vh;
background: var(--theme-background, #fff);
border-radius: 12px;
box-shadow: 0 16px 56px rgba(0, 0, 0, 0.28);
overflow: hidden;
display: flex;
flex-direction: column;
animation: _doc-rise 0.15s ease;
}
@keyframes _doc-rise {
from { transform: translateY(14px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* ── overlay header bar ─────────────────────────────────────────────────────── */
.doc-overlay-header {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0.45rem 0.75rem;
border-bottom: 1px solid var(--theme-foreground-faint, #e8e8e8);
background: var(--theme-background-alt, #f7f7f7);
flex-shrink: 0;
gap: 0.5rem;
}
.doc-overlay-hint {
font-size: 0.75rem;
color: var(--theme-foreground-faint, #aaa);
margin-right: auto;
}
.doc-overlay-close {
background: none;
border: 1px solid transparent;
cursor: pointer;
font-size: 0.82rem;
color: var(--theme-foreground-muted, #888);
padding: 0.2rem 0.55rem;
border-radius: 6px;
line-height: 1.2;
font-family: inherit;
}
.doc-overlay-close:hover {
border-color: var(--theme-foreground-faint, #ccc);
background: var(--theme-background, #fff);
color: var(--theme-foreground, #111);
}
/* ── iframe ─────────────────────────────────────────────────────────────────── */
.doc-overlay-frame {
flex: 1;
border: none;
width: 100%;
}
`;
document.head.append(s);
}
function _openOverlay(docPath) {
// Remove any existing overlay
document.getElementById("_doc-overlay-root")?.remove();
const root = document.createElement("div");
root.id = "_doc-overlay-root";
root.className = "doc-overlay";
root.setAttribute("role", "dialog");
root.setAttribute("aria-modal", "true");
const box = document.createElement("div");
box.className = "doc-overlay-box";
const header = document.createElement("div");
header.className = "doc-overlay-header";
const hint = document.createElement("span");
hint.className = "doc-overlay-hint";
hint.textContent = "Press Esc or click outside to close";
const closeBtn = document.createElement("button");
closeBtn.className = "doc-overlay-close";
closeBtn.textContent = "✕ close";
closeBtn.setAttribute("aria-label", "Close documentation");
header.append(hint, closeBtn);
const frame = document.createElement("iframe");
frame.className = "doc-overlay-frame";
frame.src = docPath;
frame.setAttribute("loading", "lazy");
frame.title = "Documentation";
box.append(header, frame);
root.append(box);
document.body.append(root);
const close = () => root.remove();
closeBtn.addEventListener("click", close);
root.addEventListener("click", e => { if (e.target === root) close(); });
const onKey = e => {
if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); }
};
document.addEventListener("keydown", onKey);
}
/**
* Adds a hoverable ? button to an element that opens a documentation overlay.
*
* @param {HTMLElement} element - Element to annotate. Must have position:relative.
* @param {string} docPath - Root-relative URL, e.g. "/docs/decisions-kpi"
* @returns {HTMLElement} The element (mutated in place).
*/
export function withDocHelp(element, docPath) {
_ensureStyles();
element.classList.add("doc-help-wrap");
const btn = document.createElement("button");
btn.className = "doc-help-btn";
btn.textContent = "?";
btn.setAttribute("aria-label", "Open documentation");
btn.addEventListener("click", e => { e.stopPropagation(); _openOverlay(docPath); });
// Lazy-load the h1 of the target doc page as a native tooltip (bubblehelp)
btn.addEventListener("mouseenter", async () => {
if (btn.dataset.titleFetched) return;
btn.dataset.titleFetched = "1";
const title = await _fetchDocTitle(docPath);
if (title) btn.title = title;
}, {once: true});
element.append(btn);
return element;
}

View File

@@ -0,0 +1,415 @@
/**
* entity-modal — click any entity row or card to open a full-detail overlay.
*
* Usage:
* import {openEntityModal} from "./components/entity-modal.js";
* row.addEventListener("click", () => openEntityModal(entity, "workstream"));
*
* Supported types: "workstream" | "task" | "ep" | "td"
*/
const _STYLE_ID = "entity-modal-styles";
function _ensureStyles() {
if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return;
const s = document.createElement("style");
s.id = _STYLE_ID;
s.textContent = `
/* ── Modal backdrop ──────────────────────────────────────────────────────── */
.entity-modal {
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
z-index: 9100; display: flex; align-items: center; justify-content: center;
animation: _em-fade 0.15s ease;
}
@keyframes _em-fade { from { opacity:0 } to { opacity:1 } }
/* ── Modal box ────────────────────────────────────────────────────────────── */
.entity-modal-box {
width: min(700px, 92vw); max-height: 88vh;
background: var(--theme-background, #fff); border-radius: 12px;
box-shadow: 0 16px 56px rgba(0,0,0,0.28);
display: flex; flex-direction: column;
animation: _em-rise 0.15s ease; overflow: hidden;
}
@keyframes _em-rise {
from { transform: translateY(14px); opacity: 0 }
to { transform: translateY(0); opacity: 1 }
}
/* ── Header ───────────────────────────────────────────────────────────────── */
.entity-modal-header {
display: flex; align-items: flex-start; gap: 0.75rem;
padding: 0.85rem 1rem 0.75rem;
border-bottom: 1px solid var(--theme-foreground-faint, #e8e8e8);
background: var(--theme-background-alt, #f7f7f7); flex-shrink: 0;
}
.entity-modal-title {
flex: 1; font-size: 1rem; font-weight: 700; line-height: 1.35;
color: var(--theme-foreground, #111); word-break: break-word;
}
.entity-modal-close {
background: none; border: 1px solid transparent; cursor: pointer;
font-size: 0.9rem; color: var(--theme-foreground-muted, #888);
padding: 0.15rem 0.45rem; border-radius: 6px; flex-shrink: 0;
font-family: inherit; line-height: 1.3;
}
.entity-modal-close:hover {
border-color: var(--theme-foreground-faint, #ccc);
background: var(--theme-background, #fff); color: var(--theme-foreground, #111);
}
/* ── Body ─────────────────────────────────────────────────────────────────── */
.entity-modal-body {
overflow-y: auto; padding: 0.85rem 1rem;
display: flex; flex-direction: column; gap: 0.4rem;
}
/* ── Field rows ───────────────────────────────────────────────────────────── */
.em-field {
display: grid; grid-template-columns: 130px 1fr;
gap: 0.15rem 0.65rem; font-size: 0.85rem; align-items: baseline;
}
.em-label {
font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--theme-foreground-faint, #aaa);
padding-top: 0.14rem; white-space: nowrap;
}
.em-value { color: var(--theme-foreground, #222); line-height: 1.5; word-break: break-word; }
/* ── Description block ────────────────────────────────────────────────────── */
.em-desc {
font-size: 0.83rem; color: var(--theme-foreground-muted, #555);
line-height: 1.55; white-space: pre-wrap; word-break: break-word;
background: var(--theme-background-alt, #f9f9f9);
border-radius: 6px; padding: 0.55rem 0.75rem;
border: 1px solid var(--theme-foreground-faint, #eee);
max-width: 100%;
}
/* ── Divider ──────────────────────────────────────────────────────────────── */
.em-divider { border: none; border-top: 1px solid var(--theme-foreground-faint, #eee); margin: 0.2rem 0; }
/* ── Badge (reused from page styles, self-contained) ─────────────────────── */
.em-badge {
display: inline-block; padding: 0.12rem 0.5rem; border-radius: 10px;
font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
}
/* ── Deps list ────────────────────────────────────────────────────────────── */
.em-deps-list { display: flex; flex-direction: column; gap: 0.12rem; }
.em-dep-item { font-size: 0.82rem; color: var(--theme-foreground-muted, #666); }
/* ── Entity table (shared across all list pages) ──────────────────────────── */
.entity-table-wrap { overflow-x: auto; max-width: 100%; }
.entity-table {
width: 100%; border-collapse: collapse; font-size: 0.87rem;
table-layout: fixed; /* honour column widths; never spill outside container */
}
.entity-table thead th {
text-align: left; padding: 0.4rem 0.65rem;
border-bottom: 2px solid var(--theme-foreground-faint, #ddd);
font-size: 0.73rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--theme-foreground-muted, #777);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.entity-table tbody tr { border-bottom: 1px solid var(--theme-foreground-faint, #eee); }
.entity-table tbody tr:last-child { border-bottom: none; }
.entity-table tbody tr:hover { background: var(--theme-background-alt, #f5f5f5); }
.entity-table td {
padding: 0.4rem 0.65rem; vertical-align: middle;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.entity-row { cursor: pointer; }
/* Proportional column widths — other cols share the remainder equally */
.et-title-col { width: 32%; }
.et-ws-col { width: 14%; }
.et-title-cell { font-weight: 500; }
.et-ws-cell { font-style: italic; }
`;
document.head.append(s);
}
/* ── Style maps ──────────────────────────────────────────────────────────── */
const _STATUS_STYLE = {
active: "background:#d4edda;color:#155724",
blocked: "background:#f8d7da;color:#721c24",
completed: "background:#cce5ff;color:#004085",
archived: "background:#e2e3e5;color:#383d41",
open: "background:#dbeafe;color:#1e40af",
in_progress: "background:#fef3c7;color:#92400e",
addressed: "background:#dcfce7;color:#166534",
deferred: "background:#f1f5f9;color:#64748b",
wont_fix: "background:#f3f4f6;color:#9ca3af",
todo: "background:#f1f5f9;color:#475569",
done: "background:#dcfce7;color:#166534",
cancelled: "background:#f3f4f6;color:#9ca3af",
resolved: "background:#dcfce7;color:#166534",
superseded: "background:#e2e3e5;color:#383d41",
};
const _PRIORITY_STYLE = {
critical: "background:#fee2e2;color:#991b1b",
high: "background:#ffedd5;color:#9a3412",
medium: "background:#dbeafe;color:#1e40af",
low: "background:#f1f5f9;color:#475569",
};
/* ── DOM helpers ─────────────────────────────────────────────────────────── */
function _badge(text, styleMap) {
const el = document.createElement("span");
el.className = "em-badge";
el.style.cssText = styleMap[text] ?? "background:#f1f5f9;color:#555";
el.textContent = (text ?? "").replace(/_/g, " ");
return el;
}
function _field(label, valueEl) {
const row = document.createElement("div");
row.className = "em-field";
const l = document.createElement("span");
l.className = "em-label";
l.textContent = label;
row.append(l, valueEl);
return row;
}
function _textVal(text) {
const v = document.createElement("span");
v.className = "em-value";
v.textContent = text ?? "—";
return v;
}
function _descVal(text) {
const v = document.createElement("div");
v.className = "em-desc";
v.textContent = text;
return v;
}
function _divider() {
const hr = document.createElement("hr");
hr.className = "em-divider";
return hr;
}
function _fmtDate(iso) {
if (!iso) return "—";
try { return new Date(iso).toLocaleString(); } catch { return iso; }
}
/* ── Body builders per entity type ─────────────────────────────────────── */
function _buildBody(entity, type) {
const els = [];
const tf = (label, text) => _field(label, _textVal(text));
const bf = (label, val, styleMap) => {
const v = document.createElement("span");
v.className = "em-value";
v.append(_badge(val, styleMap));
return _field(label, v);
};
if (type === "workstream") {
els.push(
bf("Status", entity.status, _STATUS_STYLE),
tf("Domain", entity.domain ?? entity.topic_title ?? "—"),
tf("Owner", entity.owner ?? "—"),
tf("Due", entity.due_date ?? "—"),
);
if (entity.description) {
els.push(_divider(), _field("Description", _descVal(entity.description)));
}
if (entity.tasks_total !== undefined) {
els.push(_divider(),
tf("Tasks", `${entity.tasks_done ?? 0} done / ${entity.tasks_total} total` +
(entity.tasks_in_progress > 0 ? ` · ${entity.tasks_in_progress} in progress` : "") +
(entity.tasks_blocked > 0 ? ` · ${entity.tasks_blocked} blocked` : ""))
);
}
if (entity.depends_on?.length) {
const list = document.createElement("div"); list.className = "em-deps-list";
for (const d of entity.depends_on) {
const span = document.createElement("span"); span.className = "em-dep-item";
span.textContent = `${d.workstream_title}`;
list.append(span);
}
els.push(_field("Depends on", list));
}
if (entity.blocks?.length) {
const list = document.createElement("div"); list.className = "em-deps-list";
for (const d of entity.blocks) {
const span = document.createElement("span"); span.className = "em-dep-item";
span.textContent = `${d.workstream_title}`;
list.append(span);
}
els.push(_field("Blocks", list));
}
els.push(_divider(), tf("Updated", _fmtDate(entity.updated_at)));
if (entity.slug) els.push(tf("Slug", entity.slug));
els.push(tf("ID", entity.id));
}
else if (type === "task") {
els.push(
bf("Status", entity.status, _STATUS_STYLE),
bf("Priority", entity.priority, _PRIORITY_STYLE),
tf("Domain", entity.domain ?? "—"),
tf("Workstream", entity.workstream_title ?? "—"),
tf("Assignee", entity.assignee ?? "—"),
tf("Due", entity.due_date ?? "—"),
);
if (entity.description) {
els.push(_divider(), _field("Description", _descVal(entity.description)));
}
if (entity.blocking_reason) {
const v = document.createElement("span");
v.className = "em-value"; v.style.color = "#b45309";
v.textContent = entity.blocking_reason;
els.push(_divider(), _field("Blocking reason", v));
}
els.push(_divider(), tf("Updated", _fmtDate(entity.updated_at)));
els.push(tf("ID", entity.id));
}
else if (type === "ep") {
if (entity.ep_id) els.push(tf("EP ID", entity.ep_id));
els.push(
bf("Status", entity.status, _STATUS_STYLE),
bf("Priority", entity.priority, _PRIORITY_STYLE),
tf("Type", entity.ep_type ?? "—"),
tf("Domain", entity.domain ?? "—"),
tf("Workstream", entity.workstream_title ?? "—"),
tf("Location", entity.location ?? "—"),
);
if (entity.description) {
els.push(_divider(), _field("Description", _descVal(entity.description)));
}
els.push(_divider(), tf("Recorded", _fmtDate(entity.created_at)));
els.push(tf("UUID", entity.id));
}
else if (type === "td") {
if (entity.td_id) els.push(tf("TD ID", entity.td_id));
els.push(
bf("Severity", entity.severity, _PRIORITY_STYLE),
bf("Status", entity.status, _STATUS_STYLE),
tf("Type", entity.debt_type ?? "—"),
tf("Domain", entity.domain ?? "—"),
tf("Workstream", entity.workstream_title ?? "—"),
tf("Location", entity.location ?? "—"),
);
if (entity.description) {
els.push(_divider(), _field("Description", _descVal(entity.description)));
}
els.push(_divider(), tf("Recorded", _fmtDate(entity.created_at)));
els.push(tf("UUID", entity.id));
}
return els;
}
/* ── Public API ──────────────────────────────────────────────────────────── */
/**
* Open a detail modal for the given entity.
* @param {object} entity - The entity data object (workstream, task, ep, or td)
* @param {string} type - One of: "workstream" | "task" | "ep" | "td"
*/
export function openEntityModal(entity, type) {
_ensureStyles();
document.getElementById("_entity-modal-root")?.remove();
const title = entity.title ?? "(no title)";
const root = document.createElement("div");
root.id = "_entity-modal-root";
root.className = "entity-modal";
root.setAttribute("role", "dialog");
root.setAttribute("aria-modal", "true");
root.setAttribute("aria-label", title);
const box = document.createElement("div");
box.className = "entity-modal-box";
// Header
const header = document.createElement("div");
header.className = "entity-modal-header";
const titleEl = document.createElement("div");
titleEl.className = "entity-modal-title";
titleEl.textContent = title;
const closeBtn = document.createElement("button");
closeBtn.className = "entity-modal-close";
closeBtn.textContent = "✕ close";
closeBtn.setAttribute("aria-label", "Close detail panel");
header.append(titleEl, closeBtn);
// Body
const body = document.createElement("div");
body.className = "entity-modal-body";
for (const el of _buildBody(entity, type)) body.append(el);
box.append(header, body);
root.append(box);
document.body.append(root);
const close = () => root.remove();
closeBtn.addEventListener("click", close);
root.addEventListener("click", e => { if (e.target === root) close(); });
const onKey = e => {
if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); }
};
document.addEventListener("keydown", onKey);
}
/**
* Build an interactive entity table element.
*
* @param {Array} rows - Array of entity objects to display
* @param {Array} columns - [{label, key, cls?}] — columns in order
* @param {Function} onRowClick - Called with the full entity when a row is clicked
* @returns {HTMLTableElement}
*/
export function buildEntityTable(rows, columns, onRowClick) {
_ensureStyles();
const table = document.createElement("table");
table.className = "entity-table";
// Header
const thead = document.createElement("thead");
const htr = document.createElement("tr");
for (const col of columns) {
const th = document.createElement("th");
th.textContent = col.label;
if (col.cls) th.className = col.cls;
htr.append(th);
}
thead.append(htr);
table.append(thead);
// Body
const tbody = document.createElement("tbody");
for (const row of rows) {
const tr = document.createElement("tr");
tr.className = "entity-row";
tr.addEventListener("click", () => onRowClick(row));
for (const col of columns) {
const td = document.createElement("td");
const raw = col.key ? row[col.key] : null;
const text = col.render ? col.render(row) : (raw ?? "—");
const textStr = String(text ?? "—");
td.textContent = textStr;
if (col.cls) td.className = col.cls;
// Native tooltip so full value shows on hover (skip placeholder "—")
if (textStr && textStr !== "—") td.title = textStr;
tr.append(td);
}
tbody.append(tr);
}
table.append(tbody);
const wrap = document.createElement("div");
wrap.className = "entity-table-wrap";
wrap.append(table);
return wrap;
}

View File

@@ -0,0 +1,263 @@
// Field-level help registry for dashboard entity detail pages.
//
// FIELD_HELP maps a field key to { label, description, doc? }.
// label — human-readable field name (used as bold heading in help-tip)
// description — one sentence explaining the field
// doc — optional anchor into /docs/ pages for "Learn more"
//
// fieldRow(key, value) → <tr> element with a help-tip-decorated key cell and
// a value cell. Falls back to a plain key cell when key is not in FIELD_HELP.
//
// Usage:
// import {fieldRow} from "./components/field-help.js";
// const tbody = html`<tbody>${fields.map(([k,v]) => fieldRow(k,v))}</tbody>`;
import {HelpTip} from "./help-tip.js"; // ensures custom element is registered
import {API} from "./config.js";
void HelpTip; // silence unused-import linters
// ── Entity link registry ────────────────────────────────────────────────────
// Maps FK field names to fetch/URL/title resolution rules.
// getUrl receives (id, data) so slug-routed entities (repos) can use data.slug.
const FIELD_LINKS = {
task_id: {
apiUrl: id => `${API}/tasks/${id}`,
getUrl: (id, _d) => `/tasks/${id}`,
getTitle: d => d.title,
},
workstream_id: {
apiUrl: id => `${API}/workstreams/${id}`,
getUrl: (id, _d) => `/workstreams/${id}`,
getTitle: d => d.title || d.slug,
},
repo_id: {
apiUrl: id => `${API}/repos/by-id/${id}`,
getUrl: (_id, d) => `/repos/${d.slug}`,
getTitle: d => d.name || d.slug,
},
};
/**
* Render an entity-reference value as a link with an async help-tip showing
* the entity title. Falls back gracefully if the fetch fails.
*/
function _linkCell(key, id) {
const rule = FIELD_LINKS[key];
const shortId = String(id).slice(0, 8) + "…";
const a = document.createElement("a");
a.textContent = shortId;
a.href = "#";
a.style.fontFamily = "var(--monospace, monospace)";
a.title = String(id); // full UUID as native tooltip while async loads
fetch(rule.apiUrl(id))
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data) return;
const title = rule.getTitle(data);
const url = rule.getUrl(id, data);
if (url) a.href = url;
if (title) {
const tip = document.createElement("help-tip");
tip.setAttribute("label", title);
tip.setAttribute("description", `${key.replace(/_id$/, "")} · ${id}`);
a.replaceWith(tip);
tip.appendChild(a);
}
})
.catch(() => { /* leave plain link */ });
return a;
}
export const FIELD_HELP = {
// ── TokenEvent ─────────────────────────────────────────────────────────────
id: {
label: "ID",
description: "Unique identifier for this token event (UUID v4).",
doc: "/docs/reference#token-events",
},
tokens_in: {
label: "Tokens In",
description: "Number of input (prompt) tokens consumed in this event.",
doc: "/docs/reference#token-events",
},
tokens_out: {
label: "Tokens Out",
description: "Number of output (completion) tokens generated in this event.",
doc: "/docs/reference#token-events",
},
tokens_total: {
label: "Tokens Total",
description: "Sum of input and output tokens — total cost proxy for this event.",
doc: "/docs/reference#token-events",
},
task_id: {
label: "Task ID",
description: "The task this token event was recorded against (if any).",
doc: "/docs/tasks",
},
workstream_id: {
label: "Workstream ID",
description: "The workstream this event belongs to; auto-resolved from task if not set directly.",
doc: "/docs/workstreams",
},
repo_id: {
label: "Repo ID",
description: "The managed repo this event is attributed to; auto-resolved from workstream.",
doc: "/docs/repos",
},
session_id: {
label: "Session ID",
description: "Opaque identifier for the Claude Code session that produced this event.",
},
model: {
label: "Model",
description: "The Claude model used (e.g. claude-sonnet-4-6).",
doc: "/docs/reference#models",
},
agent: {
label: "Agent",
description: "The agent persona that produced this event (e.g. custodian, ralph).",
doc: "/docs/reference#agents",
},
ref_type: {
label: "Ref Type",
description: "What kind of entity the ref_id points to: task, commit, release, or session.",
doc: "/docs/reference#token-events",
},
ref_id: {
label: "Ref ID",
description: "Free-form reference ID; interpretation depends on ref_type.",
doc: "/docs/reference#token-events",
},
note: {
label: "Note",
description: "Quality tag: 'measured' = exact from status bar, 'userbased' = human-provided, 'workplan' = prorated, 'heuristic' = server fallback.",
doc: "/docs/reference#token-note-taxonomy",
},
created_at: {
label: "Created At",
description: "Timestamp when this token event was recorded (UTC).",
},
// ── Workstream ──────────────────────────────────────────────────────────────
slug: {
label: "Slug",
description: "URL-safe short identifier for this entity.",
},
title: {
label: "Title",
description: "Human-readable name for this workstream or task.",
},
status: {
label: "Status",
description: "Current lifecycle state: todo, in_progress, blocked, done, or cancelled.",
doc: "/docs/workstream-lifecycle",
},
topic_id: {
label: "Topic ID",
description: "The topic this workstream is grouped under.",
doc: "/docs/reference#topics",
},
repo_goal_id: {
label: "Repo Goal ID",
description: "Optional link to a repo-level strategic goal this workstream advances.",
doc: "/docs/goals",
},
// ── Task ───────────────────────────────────────────────────────────────────
assignee: {
label: "Assignee",
description: "Who is responsible for completing this task (agent name or human).",
},
priority: {
label: "Priority",
description: "Relative urgency: high, medium, or low.",
},
due_date: {
label: "Due Date",
description: "Target completion date (ISO 8601).",
},
needs_human: {
label: "Needs Human",
description: "True if the task is blocked waiting for human input or approval.",
doc: "/interventions",
},
intervention_note: {
label: "Intervention Note",
description: "Why human intervention is required for this task.",
},
// ── Repo ───────────────────────────────────────────────────────────────────
repo_slug: {
label: "Repo Slug",
description: "Short identifier for the repository (matches the git remote slug).",
doc: "/docs/repos",
},
event_count: {
label: "Event Count",
description: "Total number of token events attributed to this entity.",
},
by_model: {
label: "By Model",
description: "Token totals broken down by Claude model.",
},
by_note: {
label: "By Note",
description: "Token totals broken down by quality tier (measured / workplan / heuristic).",
doc: "/docs/reference#token-note-taxonomy",
},
};
/**
* Render a single key-value row for an entity detail table.
* @param {string} key — field name
* @param {*} value — field value (stringified automatically)
* @returns {HTMLTableRowElement}
*/
export function fieldRow(key, value) {
const tr = document.createElement("tr");
// Key cell
const tdKey = document.createElement("td");
tdKey.style.cssText = "padding:0.3rem 0.8rem 0.3rem 0; white-space:nowrap; vertical-align:top; color:var(--theme-foreground-muted,#666); font-size:0.82rem;";
const help = FIELD_HELP[key];
if (help) {
const tip = document.createElement("help-tip");
tip.setAttribute("label", help.label);
tip.setAttribute("description", help.description);
if (help.doc) tip.setAttribute("doc", help.doc);
tip.textContent = help.label;
tdKey.appendChild(tip);
} else {
tdKey.textContent = key;
}
tr.appendChild(tdKey);
// Value cell
const tdVal = document.createElement("td");
tdVal.style.cssText = "padding:0.3rem 0; font-size:0.82rem; word-break:break-all; vertical-align:top;";
let display;
if (value === null || value === undefined) {
display = document.createElement("span");
display.style.color = "var(--theme-foreground-faint,#aaa)";
display.textContent = "—";
} else if (key in FIELD_LINKS) {
display = _linkCell(key, value);
} else if (typeof value === "object") {
display = document.createElement("pre");
display.style.cssText = "margin:0; font-size:0.75rem; white-space:pre-wrap;";
display.textContent = JSON.stringify(value, null, 2);
} else {
display = document.createElement("span");
display.textContent = String(value);
}
tdVal.appendChild(display);
tr.appendChild(tdVal);
return tr;
}

View File

@@ -0,0 +1,167 @@
// <help-tip label="Full Name" description="One sentence." doc="/path">ABBR</help-tip>
//
// A custom element that shows a floating card on hover/focus.
// Attributes:
// label — bold title line in the card
// description — body text
// doc — optional URL; adds a "Learn more →" link
//
// The card is appended to document.body (position:fixed) so it escapes
// any overflow:hidden or clipping ancestors (e.g. the TOC sidebar).
const _STYLE_ID = "helptip-global-style";
if (!document.getElementById(_STYLE_ID)) {
const s = document.createElement("style");
s.id = _STYLE_ID;
s.textContent = `
help-tip {
cursor: help;
display: inline;
}
.helptip-card {
position: fixed;
z-index: 9999;
background: var(--theme-background, #fff);
border: 1px solid var(--theme-foreground-faint, #ddd);
border-radius: 9px;
padding: 0.6rem 0.85rem;
max-width: 270px;
min-width: 130px;
box-shadow: 0 6px 22px rgba(0,0,0,0.13), 0 1px 4px rgba(0,0,0,0.07);
font-size: 0.78rem;
line-height: 1.5;
color: var(--theme-foreground, #333);
opacity: 0;
transition: opacity 0.12s ease;
pointer-events: auto;
}
.helptip-card.helptip-visible { opacity: 1; }
.helptip-card-label {
font-weight: 700;
font-size: 0.8rem;
margin-bottom: 0.3rem;
color: var(--theme-foreground, #222);
}
.helptip-card-desc {
color: var(--theme-foreground-muted, #555);
}
.helptip-card-link {
display: inline-block;
margin-top: 0.45rem;
font-size: 0.72rem;
color: var(--theme-foreground-focus, #3b82f6);
text-decoration: none;
}
.helptip-card-link:hover { text-decoration: underline; }
`;
document.head.appendChild(s);
}
class HelpTip extends HTMLElement {
#card = null;
#showTimer = null;
#hideTimer = null;
connectedCallback() {
this.addEventListener("mouseenter", this.#onEnter);
this.addEventListener("mouseleave", this.#onLeave);
this.addEventListener("focusin", this.#onEnter);
this.addEventListener("focusout", this.#onLeave);
}
disconnectedCallback() {
clearTimeout(this.#showTimer);
clearTimeout(this.#hideTimer);
this.#clearCard();
this.removeEventListener("mouseenter", this.#onEnter);
this.removeEventListener("mouseleave", this.#onLeave);
this.removeEventListener("focusin", this.#onEnter);
this.removeEventListener("focusout", this.#onLeave);
}
#onEnter = () => {
clearTimeout(this.#hideTimer);
this.#showTimer = setTimeout(() => this.#showCard(), 80);
};
#onLeave = () => {
clearTimeout(this.#showTimer);
this.#hideTimer = setTimeout(() => this.#clearCard(), 200);
};
#showCard() {
if (this.#card) return;
const label = this.getAttribute("label") || "";
const desc = this.getAttribute("description") || "";
const doc = this.getAttribute("doc") || "";
const card = document.createElement("div");
card.className = "helptip-card";
if (label) {
const h = document.createElement("div");
h.className = "helptip-card-label";
h.textContent = label;
card.appendChild(h);
}
if (desc) {
const d = document.createElement("div");
d.className = "helptip-card-desc";
d.textContent = desc;
card.appendChild(d);
}
if (doc) {
const a = document.createElement("a");
a.className = "helptip-card-link";
a.textContent = "Learn more →";
a.href = doc;
card.appendChild(a);
}
// Keep card alive while mouse is over it
card.addEventListener("mouseenter", () => clearTimeout(this.#hideTimer));
card.addEventListener("mouseleave", this.#onLeave);
document.body.appendChild(card);
this.#card = card;
this.#position(card);
requestAnimationFrame(() => card.classList.add("helptip-visible"));
}
#position(card) {
const rect = this.getBoundingClientRect();
const cw = card.offsetWidth || 230;
const ch = card.offsetHeight || 80;
const gap = 8;
const vw = window.innerWidth;
const vh = window.innerHeight;
// Horizontal: align to left of trigger, clamp inside viewport
let left = rect.left;
if (left + cw + gap > vw) left = vw - cw - gap;
if (left < gap) left = gap;
// Vertical: prefer above; fall back to below
const top = (rect.top - ch - gap >= 0)
? rect.top - ch - gap
: Math.min(rect.bottom + gap, vh - ch - gap);
card.style.left = `${left}px`;
card.style.top = `${top}px`;
}
#clearCard() {
if (this.#card) {
this.#card.remove();
this.#card = null;
}
}
}
if (!customElements.get("help-tip")) {
customElements.define("help-tip", HelpTip);
}
export { HelpTip };

View File

@@ -0,0 +1,412 @@
/**
* improvement-modal — Shift+click any dashboard widget to suggest an improvement.
*
* Usage (once per page, usually via _footer.md):
* import {initImprovementModal} from "./components/improvement-modal.js";
* initImprovementModal({apiBase: "http://127.0.0.1:8000"});
*
* Widget names can be declared explicitly via data attribute:
* <div data-widget-name="Workstreams by Domain">…</div>
*
* Otherwise the component walks the DOM to infer the nearest section heading.
* Submissions are stored as technical-debt items with debt_type="dashboard-improvement".
*
* Interaction:
* - Hold Shift → cursor changes to crosshair across the entire page
* - Shift+click any element (except form controls) → opens suggestion modal
*/
const _STYLE_ID = "improvement-modal-styles";
function _ensureStyles() {
if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return;
const s = document.createElement("style");
s.id = _STYLE_ID;
s.textContent = `
/* ── Backdrop ──────────────────────────────────────────────────────────── */
.impr-modal {
position: fixed; inset: 0; background: rgba(0,0,0,0.42);
z-index: 9200; display: flex; align-items: center; justify-content: center;
animation: _im-fade 0.15s ease;
}
@keyframes _im-fade { from { opacity:0 } to { opacity:1 } }
/* ── Box ────────────────────────────────────────────────────────────────── */
.impr-modal-box {
width: min(480px, 92vw); max-height: 90vh;
background: var(--theme-background, #fff); border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.30);
display: flex; flex-direction: column;
animation: _im-rise 0.15s ease; overflow: hidden;
}
@keyframes _im-rise {
from { transform: translateY(12px); opacity: 0 }
to { transform: translateY(0); opacity: 1 }
}
/* ── Header ─────────────────────────────────────────────────────────────── */
.impr-header {
display: flex; align-items: center; gap: 0.55rem;
padding: 0.8rem 1rem 0.75rem;
border-bottom: 1px solid var(--theme-foreground-faint, #e4e4e4);
background: var(--theme-background-alt, #f7f7f7); flex-shrink: 0;
}
.impr-header-icon { font-size: 1.1rem; flex-shrink: 0; line-height: 1; }
.impr-header-title {
flex: 1; font-size: 0.95rem; font-weight: 700;
color: var(--theme-foreground, #111); margin: 0;
}
.impr-header-close {
background: none; border: 1px solid transparent; cursor: pointer;
font-size: 0.82rem; color: var(--theme-foreground-muted, #999);
padding: 0.15rem 0.42rem; border-radius: 6px; flex-shrink: 0;
font-family: inherit; line-height: 1.3;
transition: background 0.1s, border-color 0.1s;
}
.impr-header-close:hover {
border-color: var(--theme-foreground-faint, #ccc);
background: var(--theme-background, #fff);
color: var(--theme-foreground, #111);
}
/* ── Body ───────────────────────────────────────────────────────────────── */
.impr-body {
padding: 0.85rem 1rem 0.25rem; overflow-y: auto;
display: flex; flex-direction: column; gap: 0.6rem; flex: 1;
}
.impr-field-label {
font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--theme-foreground-faint, #aaa);
margin-bottom: 0.18rem;
}
.impr-context-chip {
font-size: 0.82rem; color: var(--theme-foreground-muted, #555);
background: var(--theme-background-alt, #f4f4f4);
border: 1px solid var(--theme-foreground-faint, #e0e0e0);
border-radius: 6px; padding: 0.32rem 0.65rem;
word-break: break-word; line-height: 1.45;
}
.impr-textarea {
width: 100%; box-sizing: border-box;
min-height: 106px; resize: vertical;
font-size: 0.87rem; font-family: inherit; line-height: 1.55;
color: var(--theme-foreground, #111);
background: var(--theme-background, #fff);
border: 1px solid var(--theme-foreground-faint, #ccc);
border-radius: 7px; padding: 0.5rem 0.7rem; outline: none;
transition: border-color 0.12s, box-shadow 0.12s;
}
.impr-textarea:focus {
border-color: #6366f1;
box-shadow: 0 0 0 2px rgba(99,102,241,0.18);
}
.impr-textarea.impr-error { border-color: #e53e3e; }
.impr-hint {
font-size: 0.71rem; color: var(--theme-foreground-faint, #bbb);
margin-top: 0.15rem;
}
/* ── Footer ─────────────────────────────────────────────────────────────── */
.impr-footer {
display: flex; justify-content: flex-end; gap: 0.45rem;
padding: 0.7rem 1rem 0.8rem; flex-shrink: 0;
border-top: 1px solid var(--theme-foreground-faint, #e4e4e4);
}
.impr-btn {
padding: 0.38rem 1rem; border-radius: 7px;
font-size: 0.83rem; cursor: pointer; font-family: inherit;
font-weight: 600; border: 1px solid transparent;
transition: background 0.12s, opacity 0.12s;
}
.impr-btn-cancel {
background: var(--theme-background-alt, #f1f1f1);
border-color: var(--theme-foreground-faint, #ddd);
color: var(--theme-foreground-muted, #666);
}
.impr-btn-cancel:hover { background: var(--theme-foreground-faint, #e6e6e6); }
.impr-btn-submit { background: #6366f1; color: #fff; }
.impr-btn-submit:hover:not(:disabled) { background: #4f46e5; }
.impr-btn-submit:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Toast ──────────────────────────────────────────────────────────────── */
.impr-toast {
position: fixed; bottom: 1.4rem; left: 50%; transform: translateX(-50%);
background: #1e1b4b; color: #e0e7ff; border-radius: 8px;
padding: 0.5rem 1.15rem; font-size: 0.82rem; font-weight: 500;
z-index: 9300; box-shadow: 0 4px 24px rgba(0,0,0,0.28);
white-space: nowrap; pointer-events: none;
animation: _im-tin 0.18s ease, _im-tout 0.28s ease 1.7s forwards;
}
@keyframes _im-tin { from { opacity:0; transform:translateX(-50%) translateY(6px) } to { opacity:1; transform:translateX(-50%) translateY(0) } }
@keyframes _im-tout { from { opacity:1 } to { opacity:0 } }
/* ── Shift-held mode: cursor + element highlighting ─────────────────────── */
.impr-mode-shift,
.impr-mode-shift * { cursor: copy !important; }
/* Highlight annotatable elements in main content, left nav, and right TOC */
.impr-mode-shift #observablehq-main figure,
.impr-mode-shift #observablehq-main h2,
.impr-mode-shift #observablehq-main h3,
.impr-mode-shift #observablehq-main h4,
.impr-mode-shift #observablehq-main [data-widget-name],
.impr-mode-shift #observablehq-sidebar a,
.impr-mode-shift #observablehq-sidebar summary,
.impr-mode-shift #observablehq-toc a,
.impr-mode-shift #observablehq-toc .kpi-infobox,
.impr-mode-shift #observablehq-toc [id] {
outline: 1px dashed rgba(99, 102, 241, 0.45);
background: rgba(99, 102, 241, 0.055) !important;
border-radius: 4px;
transition: background 0.1s, outline 0.1s;
}
`;
document.head.append(s);
}
/* ── Widget name inference ─────────────────────────────────────────────── */
function _inferWidgetName(target) {
// 0a. Right-margin TOC: link text, KPI box title, or nearest labelled container
if (target.closest("#observablehq-toc")) {
const link = target.closest("a");
if (link) return (link.textContent.trim() || "TOC link") + " (TOC)";
const kpiTitle = target.closest(".kpi-infobox")
?.querySelector(".kpi-infobox-title");
if (kpiTitle) return kpiTitle.textContent.trim() + " (sidebar widget)";
const labelled = target.closest("[id]");
if (labelled) return labelled.id.replace(/-/g, " ") + " (TOC)";
return "Right margin";
}
// 0b. Left sidebar navigation: nav link text or section heading
if (target.closest("#observablehq-sidebar")) {
const link = target.closest("a");
if (link) return link.textContent.trim() || "Nav link";
const summary = target.closest("summary");
if (summary) return summary.textContent.trim() || "Nav section";
return target.textContent.trim() || "Navigation";
}
// 1. Explicit data-widget-name on self or ancestor
let el = target;
while (el && el !== document.body) {
if (el.dataset?.widgetName) return el.dataset.widgetName;
el = el.parentElement;
}
// 2. Direct child heading inside a container (chart cards etc.)
el = target;
const main = document.querySelector("#observablehq-main") ?? document.body;
while (el && el !== main) {
const h = el.querySelector(":scope > h2, :scope > h3, :scope > h4");
if (h) return h.textContent.trim();
el = el.parentElement;
}
// 3. Nearest preceding sibling or ancestor heading in the main flow
el = target;
while (el && el !== main) {
let sib = el.previousElementSibling;
while (sib) {
if (sib.matches("h2, h3, h4")) return sib.textContent.trim();
const inner = sib.querySelector("h2, h3, h4");
if (inner) return inner.textContent.trim();
sib = sib.previousElementSibling;
}
el = el.parentElement;
}
// 4. Page h1 as final fallback
return document.querySelector("#observablehq-main h1, h1")?.textContent?.trim()
?? "Dashboard page";
}
/* ── Toast helper ──────────────────────────────────────────────────────── */
function _toast(msg) {
document.querySelector(".impr-toast")?.remove();
const t = document.createElement("div");
t.className = "impr-toast";
t.textContent = msg;
document.body.append(t);
setTimeout(() => t.remove(), 2100);
}
/* ── Module-level guard — one listener per page load ──────────────────── */
let _initialized = false;
/**
* Wire Shift+click → improvement modal on the current page.
* Safe to call multiple times — only the first call takes effect.
*
* @param {object} opts
* @param {string} opts.apiBase State Hub API base URL (default: "http://127.0.0.1:8000")
* @param {string} opts.domain Domain slug for the TD record (default: "custodian")
*/
export function initImprovementModal({ apiBase = "http://127.0.0.1:8000", domain = "custodian" } = {}) {
if (_initialized) return;
_initialized = true;
_ensureStyles();
// Track modifier state. Highlighting is delayed 1 s so normal Shift+typing
// doesn't trigger the visual mode change; releasing Shift cancels immediately.
let _shiftTimer = null;
function _updateMode(e) {
if (e.shiftKey) {
if (!_shiftTimer && !document.body.classList.contains("impr-mode-shift")) {
_shiftTimer = setTimeout(() => {
document.body.classList.add("impr-mode-shift");
_shiftTimer = null;
}, 1000);
}
} else {
clearTimeout(_shiftTimer);
_shiftTimer = null;
document.body.classList.remove("impr-mode-shift");
}
}
window.addEventListener("keydown", _updateMode);
window.addEventListener("keyup", _updateMode);
window.addEventListener("mousemove", _updateMode);
// Clear on blur in case Shift is held when the window loses focus
window.addEventListener("blur", () => {
clearTimeout(_shiftTimer);
_shiftTimer = null;
document.body.classList.remove("impr-mode-shift");
});
document.addEventListener("click", (e) => {
if (!e.shiftKey) return;
const inSidebar = !!e.target.closest("#observablehq-sidebar");
const inToc = !!e.target.closest("#observablehq-toc");
// Block shift-clicks on form controls; allow nav/toc links (preventDefault stops navigation)
if (!inSidebar && !inToc && e.target.matches("input, textarea, select, a, button")) return;
if ((inSidebar || inToc) && e.target.matches("input, textarea, select")) return;
e.preventDefault();
const widgetName = _inferWidgetName(e.target);
const currentPage = document.title
? document.title.replace(" Custodian State Hub", "").trim()
: (location.pathname.replace(/^\//, "") || "Overview");
const pageName = inSidebar ? "Navigation" : currentPage;
// Remove any open modal
document.getElementById("_impr-root")?.remove();
/* ── DOM construction ────────────────────────────────────────────── */
const root = document.createElement("div");
root.id = "_impr-root";
root.className = "impr-modal";
root.setAttribute("role", "dialog");
root.setAttribute("aria-modal", "true");
root.setAttribute("aria-label", "Request Improvement");
const box = document.createElement("div");
box.className = "impr-modal-box";
// Header
const header = document.createElement("div");
header.className = "impr-header";
const icon = Object.assign(document.createElement("span"), { className: "impr-header-icon", textContent: "💡" });
const title = Object.assign(document.createElement("div"), { className: "impr-header-title", textContent: "Request Improvement" });
const closeBtn = Object.assign(document.createElement("button"), { className: "impr-header-close", textContent: "✕ close" });
header.append(icon, title, closeBtn);
// Body
const body = document.createElement("div");
body.className = "impr-body";
const ctxLabel = Object.assign(document.createElement("div"), { className: "impr-field-label", textContent: "Widget / Section" });
const chip = Object.assign(document.createElement("div"), {
className: "impr-context-chip",
textContent: `${pageName} ${widgetName}`,
});
const sugLabel = Object.assign(document.createElement("div"), { className: "impr-field-label", textContent: "Your suggestion" });
const textarea = document.createElement("textarea");
textarea.className = "impr-textarea";
textarea.placeholder = "Describe what you'd like to improve or change…";
textarea.rows = 5;
const hint = Object.assign(document.createElement("div"), {
className: "impr-hint",
textContent: "Ctrl + Enter to submit · Escape to cancel",
});
body.append(ctxLabel, chip, sugLabel, textarea, hint);
// Footer
const footer = document.createElement("div");
footer.className = "impr-footer";
const cancelBtn = Object.assign(document.createElement("button"), { className: "impr-btn impr-btn-cancel", textContent: "Cancel" });
const submitBtn = Object.assign(document.createElement("button"), { className: "impr-btn impr-btn-submit", textContent: "Submit suggestion" });
footer.append(cancelBtn, submitBtn);
box.append(header, body, footer);
root.append(box);
document.body.append(root);
// Focus textarea after animation settles
setTimeout(() => textarea.focus(), 80);
/* ── Close behaviour ─────────────────────────────────────────────── */
const close = () => {
root.remove();
document.removeEventListener("keydown", onKey);
};
closeBtn.addEventListener("click", close);
cancelBtn.addEventListener("click", close);
root.addEventListener("click", e => { if (e.target === root) close(); });
const onKey = e => {
if (e.key === "Escape") close();
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") submitBtn.click();
};
document.addEventListener("keydown", onKey);
/* ── Submit ──────────────────────────────────────────────────────── */
submitBtn.addEventListener("click", async () => {
const suggestion = textarea.value.trim();
if (!suggestion) {
textarea.classList.add("impr-error");
textarea.focus();
setTimeout(() => textarea.classList.remove("impr-error"), 1200);
return;
}
submitBtn.disabled = true;
submitBtn.textContent = "Submitting…";
const location = `${pageName} ${widgetName}`;
const payload = {
domain: domain,
title: `UI: ${widgetName}`,
description: suggestion,
debt_type: "dashboard-improvement",
severity: "low",
status: "submitted",
location,
};
try {
const r = await fetch(`${apiBase}/technical-debt/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (r.ok) {
close();
_toast("✓ Suggestion saved — check UI Feedback in the nav");
} else {
submitBtn.disabled = false;
submitBtn.textContent = "Submit suggestion";
_toast(`⚠ Submission failed (HTTP ${r.status})`);
}
} catch {
submitBtn.disabled = false;
submitBtn.textContent = "Submit suggestion";
_toast("⚠ API unreachable — submission failed");
}
});
});
}

View File

@@ -0,0 +1,257 @@
/**
* MultiSelect — compact dropdown multi-select filter component.
*
* Observable-compatible: exposes `.value` (string[]) and dispatches bubbling
* `input` events on change, so it works with `view()`, `Inputs.form`, and
* `Generators.input`.
*
* Usage:
* const el = MultiSelect(["a", "b", "c"], {label: "Domain"});
* const selected = view(el); // string[] — empty means "all / no filter"
*/
const STYLE_ID = "ms-component-styles";
function ensureStyles() {
if (typeof document === "undefined" || document.getElementById(STYLE_ID)) return;
const s = document.createElement("style");
s.id = STYLE_ID;
s.textContent = `
.ms-wrap {
position: relative;
display: inline-block;
font-family: var(--sans-serif, system-ui, sans-serif);
font-size: 0.85rem;
}
.ms-trigger {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.28rem 0.6rem;
border-radius: 6px;
border: 1px solid var(--theme-foreground-faint, #ccc);
background: var(--theme-background, #fff);
cursor: pointer;
font: inherit;
font-size: 0.85rem;
white-space: nowrap;
transition: border-color 0.15s, background 0.15s;
user-select: none;
color: var(--theme-foreground, #111);
}
.ms-trigger:hover {
border-color: var(--theme-foreground-muted, #888);
}
.ms-trigger.ms-has-selection {
border-color: steelblue;
background: color-mix(in srgb, steelblue 8%, var(--theme-background, #fff));
}
.ms-trigger-label {
color: var(--theme-foreground-muted, #666);
}
.ms-trigger-value {
font-weight: 500;
color: var(--theme-foreground, #111);
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
}
.ms-trigger-value.ms-placeholder {
font-weight: 400;
color: var(--theme-foreground-muted, #888);
}
.ms-chevron {
font-size: 0.65rem;
color: var(--theme-foreground-muted, #888);
transition: transform 0.15s;
line-height: 1;
}
.ms-wrap.ms-open .ms-chevron {
transform: rotate(180deg);
}
.ms-dropdown {
display: none;
position: absolute;
top: calc(100% + 5px);
left: 0;
z-index: 1000;
min-width: max(100%, 160px);
background: var(--theme-background, #fff);
border: 1px solid var(--theme-foreground-faint, #ddd);
border-radius: 8px;
box-shadow: 0 4px 18px rgba(0,0,0,0.12);
padding: 0.35rem 0;
}
.ms-wrap.ms-open .ms-dropdown {
display: block;
}
.ms-clear {
display: block;
width: 100%;
padding: 0.2rem 0.75rem 0.35rem;
font: inherit;
font-size: 0.75rem;
color: steelblue;
background: none;
border: none;
text-align: left;
cursor: pointer;
border-bottom: 1px solid var(--theme-foreground-faint, #eee);
margin-bottom: 0.2rem;
}
.ms-clear:hover { text-decoration: underline; }
.ms-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.28rem 0.75rem;
cursor: pointer;
border-radius: 0;
}
.ms-option:hover {
background: var(--theme-background-alt, #f5f5f5);
}
.ms-option input[type=checkbox] {
margin: 0;
cursor: pointer;
accent-color: steelblue;
flex-shrink: 0;
}
`;
document.head.append(s);
}
/**
* @param {string[] | {value: string, label: string}[]} options
* @param {{ label?: string, value?: string[], placeholder?: string }} opts
* @returns {HTMLElement}
*/
export function MultiSelect(options, { label = "", value = [], placeholder = "All" } = {}) {
ensureStyles();
// Normalise options to {value, label} pairs
const opts = options.map(o => typeof o === "string" ? { value: o, label: o } : o);
let selected = new Set(value);
// ── Build DOM ──────────────────────────────────────────────────────────────
const wrap = document.createElement("div");
wrap.className = "ms-wrap";
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "ms-trigger";
const labelSpan = document.createElement("span");
labelSpan.className = "ms-trigger-label";
if (label) labelSpan.textContent = label + ":";
const valueSpan = document.createElement("span");
const chevron = document.createElement("span");
chevron.className = "ms-chevron";
chevron.textContent = "▾";
if (label) trigger.append(labelSpan, "\u00a0"); // non-breaking space between label and value
trigger.append(valueSpan, chevron);
// Dropdown
const dropdown = document.createElement("div");
dropdown.className = "ms-dropdown";
const clearBtn = document.createElement("button");
clearBtn.type = "button";
clearBtn.className = "ms-clear";
clearBtn.textContent = "Clear selection";
dropdown.append(clearBtn);
const checkboxes = opts.map(opt => {
const row = document.createElement("label");
row.className = "ms-option";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.value = opt.value;
cb.checked = selected.has(opt.value);
row.append(cb, opt.label);
dropdown.append(row);
return cb;
});
wrap.append(trigger, dropdown);
// ── State helpers ──────────────────────────────────────────────────────────
function syncUI() {
const n = selected.size;
if (n === 0) {
valueSpan.textContent = placeholder;
valueSpan.className = "ms-trigger-value ms-placeholder";
trigger.classList.remove("ms-has-selection");
clearBtn.style.display = "none";
} else {
const names = [...selected].map(v => opts.find(o => o.value === v)?.label ?? v);
valueSpan.textContent = n <= 2 ? names.join(", ") : `${n} of ${opts.length}`;
valueSpan.className = "ms-trigger-value";
trigger.classList.add("ms-has-selection");
clearBtn.style.display = "block";
}
}
function emit() {
syncUI();
wrap.dispatchEvent(new Event("input", { bubbles: true }));
}
// ── Open / close ───────────────────────────────────────────────────────────
function open() {
// Close any other open dropdowns on the page
document.querySelectorAll(".ms-wrap.ms-open").forEach(w => {
if (w !== wrap) w.classList.remove("ms-open");
});
wrap.classList.add("ms-open");
}
function close() {
wrap.classList.remove("ms-open");
}
// ── Events ─────────────────────────────────────────────────────────────────
trigger.addEventListener("click", e => {
e.stopPropagation();
wrap.classList.contains("ms-open") ? close() : open();
});
checkboxes.forEach((cb, i) => {
cb.addEventListener("change", () => {
if (cb.checked) selected.add(opts[i].value);
else selected.delete(opts[i].value);
emit();
});
});
clearBtn.addEventListener("click", e => {
e.stopPropagation();
selected.clear();
checkboxes.forEach(cb => (cb.checked = false));
emit();
});
// Prevent dropdown clicks from bubbling to the document closer
dropdown.addEventListener("click", e => e.stopPropagation());
document.addEventListener("click", close);
document.addEventListener("keydown", e => { if (e.key === "Escape") close(); });
// ── Observable compatibility ───────────────────────────────────────────────
// Empty array = "no filter" (show all). Semantics: any checked item = restrict to those.
Object.defineProperty(wrap, "value", {
get: () => [...selected],
enumerable: true,
});
syncUI();
return wrap;
}

View File

@@ -0,0 +1,101 @@
// refCell(index, recordType, id) → HTMLElement
//
// Renders a 1-based row number in a table cell.
// Single click — copies deep-link to clipboard and flashes "Copied!".
// Double click — opens deep-link in a new tab.
//
// Deep-link format: <origin>/data/<recordType>/<id>
//
// Usage:
// import {refCell} from "./components/ref-cell.js";
// // in an Inputs.table format callback:
// format: { id: (_, i) => refCell(i + 1, "token-events", row.id) }
const _STYLE_ID = "refcell-global-style";
if (!document.getElementById(_STYLE_ID)) {
const s = document.createElement("style");
s.id = _STYLE_ID;
s.textContent = `
.ref-cell {
display: inline-block;
font-family: var(--monospace, monospace);
font-size: 0.78rem;
color: var(--theme-foreground-focus, #3b82f6);
cursor: pointer;
user-select: none;
padding: 0 2px;
border-radius: 3px;
transition: background 0.1s;
}
.ref-cell:hover {
background: var(--theme-foreground-faint, #e8f0fe);
}
.ref-cell-toast {
position: fixed;
z-index: 10000;
background: var(--theme-background, #fff);
border: 1px solid var(--theme-foreground-faint, #ddd);
border-radius: 6px;
padding: 0.3rem 0.65rem;
font-size: 0.75rem;
color: var(--theme-foreground, #333);
box-shadow: 0 4px 14px rgba(0,0,0,0.12);
opacity: 0;
transition: opacity 0.1s ease;
pointer-events: none;
}
.ref-cell-toast.ref-cell-toast-visible { opacity: 1; }
`;
document.head.appendChild(s);
}
function _showToast(anchorEl, text) {
const toast = document.createElement("div");
toast.className = "ref-cell-toast";
toast.textContent = text;
document.body.appendChild(toast);
const rect = anchorEl.getBoundingClientRect();
const gap = 6;
toast.style.left = `${rect.left}px`;
toast.style.top = `${rect.top - toast.offsetHeight - gap}px`;
requestAnimationFrame(() => toast.classList.add("ref-cell-toast-visible"));
setTimeout(() => {
toast.classList.remove("ref-cell-toast-visible");
toast.addEventListener("transitionend", () => toast.remove(), {once: true});
}, 1200);
}
export function refCell(index, recordType, id) {
const deepLink = `${location.origin}/${recordType}/${id}`;
const el = document.createElement("span");
el.className = "ref-cell";
el.title = `Click to copy link · Double-click to open\n${deepLink}`;
el.textContent = String(index);
let clickTimer = null;
el.addEventListener("click", (e) => {
e.stopPropagation();
// Use a short delay so a double-click cancels the single-click handler.
clickTimer = setTimeout(async () => {
try {
await navigator.clipboard.writeText(deepLink);
_showToast(el, "Copied!");
} catch {
// Fallback for environments where clipboard API is blocked.
_showToast(el, deepLink);
}
}, 180);
});
el.addEventListener("dblclick", (e) => {
e.stopPropagation();
clearTimeout(clickTimer);
window.open(deepLink, "_blank", "noopener,noreferrer");
});
return el;
}

Some files were not shown because too many files have changed in this diff Show More