diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md
index 48a540c..011b71a 100644
--- a/docs/webui-local-dev.md
+++ b/docs/webui-local-dev.md
@@ -36,7 +36,9 @@ Optional environment variables:
|------|-------------|
| `/` | Home / operator overview |
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) |
-| `/projects` | Stub — registry (#427) |
+| `/projects` | Project registry list (#427) |
+| `/projects/{id}` | Project detail + onboarding checklist |
+| `/api/projects` | JSON registry export |
| `/prompts` | Stub — prompt library (#428) |
| `/runtime` | Stub — MCP runtime health (#430) |
| `/audit` | Stub — report audit paste (#431) |
@@ -46,8 +48,20 @@ Optional environment variables:
All routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
`read-only-mvp`.
+## Project registry (#427)
+
+Versioned registry file: `webui/data/projects.registry.json` (schema version `1`).
+
+Override path with `WEBUI_PROJECT_REGISTRY` when operators keep a machine-local
+copy outside git. The registry stores repo identity, remotes, profile names,
+workflow/schema path references, and onboarding checklist steps — never tokens
+or credentials.
+
+Seed entry: **Gitea-Tools** on `https://gitea.prgs.cc` with `prgs-author`,
+`prgs-reviewer`, and `prgs-reconciler` profiles.
+
## Tests
```bash
-pytest tests/test_webui_skeleton.py -q
+pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py -q
```
\ No newline at end of file
diff --git a/tests/test_webui_project_registry.py b/tests/test_webui_project_registry.py
new file mode 100644
index 0000000..f16774a
--- /dev/null
+++ b/tests/test_webui_project_registry.py
@@ -0,0 +1,111 @@
+"""Tests for web UI project registry (#427)."""
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from starlette.testclient import TestClient
+
+from webui.app import create_app
+from webui.project_registry import (
+ default_registry_path,
+ load_registry,
+ project_to_dict,
+)
+
+
+class TestProjectRegistryLoader(unittest.TestCase):
+ def test_default_registry_loads_gitea_tools(self):
+ registry = load_registry()
+ self.assertEqual(registry.version, 1)
+ self.assertEqual(len(registry.projects), 1)
+ project = registry.projects[0]
+ self.assertEqual(project.id, "gitea-tools")
+ self.assertEqual(project.repo_name, "Gitea-Tools")
+ self.assertEqual(project.gitea_owner, "Scaled-Tech-Consulting")
+ self.assertEqual(project.remote_host, "https://gitea.prgs.cc")
+ self.assertEqual(project.profiles["author"], "prgs-author")
+ self.assertEqual(project.profiles["reviewer"], "prgs-reviewer")
+ self.assertEqual(project.profiles["reconciler"], "prgs-reconciler")
+ self.assertIn("skill", project.workflow_paths)
+ self.assertGreaterEqual(len(project.onboarding_checklist), 4)
+
+ def test_registry_rejects_credential_keys(self):
+ payload = {
+ "version": 1,
+ "projects": [
+ {
+ "id": "bad",
+ "repo_name": "Bad",
+ "gitea_owner": "Org",
+ "remote_host": "https://gitea.example.invalid",
+ "default_branch": "main",
+ "local_checkout_path": ".",
+ "profiles": {
+ "author": "a",
+ "reviewer": "r",
+ "reconciler": "c",
+ },
+ "workflow_paths": {"skill": "skills/x.md"},
+ "api_token": "secret",
+ }
+ ],
+ }
+ with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
+ json.dump(payload, handle)
+ path = Path(handle.name)
+ try:
+ with self.assertRaises(ValueError):
+ load_registry(path)
+ finally:
+ path.unlink(missing_ok=True)
+
+ def test_default_registry_path_points_at_packaged_data(self):
+ path = default_registry_path()
+ self.assertTrue(path.name == "projects.registry.json")
+ self.assertTrue(path.parent.name == "data")
+
+
+class TestProjectRegistryRoutes(unittest.TestCase):
+ def setUp(self):
+ self.client = TestClient(create_app())
+
+ def test_projects_page_lists_gitea_tools(self):
+ response = self.client.get("/projects")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Gitea-Tools", response.text)
+ self.assertIn("Scaled-Tech-Consulting", response.text)
+ self.assertIn("prgs-author", response.text)
+ self.assertNotIn("child issue", response.text.lower())
+
+ def test_project_detail_renders_checklist(self):
+ response = self.client.get("/projects/gitea-tools")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Onboarding checklist", response.text)
+ self.assertIn("Configure execution profiles", response.text)
+ self.assertIn("branches/", response.text)
+
+ def test_project_detail_404(self):
+ response = self.client.get("/projects/unknown-repo")
+ self.assertEqual(response.status_code, 404)
+
+ def test_api_projects_json(self):
+ response = self.client.get("/api/projects")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["version"], 1)
+ self.assertEqual(len(data["projects"]), 1)
+ self.assertEqual(data["projects"][0]["id"], "gitea-tools")
+ self.assertIn("onboarding_checklist", data["projects"][0])
+
+ def test_project_to_dict_is_json_safe(self):
+ registry = load_registry()
+ encoded = json.dumps(project_to_dict(registry.projects[0]))
+ self.assertIn("gitea-tools", encoded)
+
+
+if __name__ == "__main__":
+ unittest.main()
\ No newline at end of file
diff --git a/tests/test_webui_skeleton.py b/tests/test_webui_skeleton.py
index 89471af..7e139c6 100644
--- a/tests/test_webui_skeleton.py
+++ b/tests/test_webui_skeleton.py
@@ -30,12 +30,17 @@ class TestWebuiSkeleton(unittest.TestCase):
self.assertIn("Read-only MVP", response.text)
def test_route_stubs_render(self):
- for path in ("/projects", "/prompts", "/runtime", "/audit"):
+ for path in ("/prompts", "/runtime", "/audit"):
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
self.assertIn("child issue", response.text.lower())
+ def test_projects_is_implemented(self):
+ response = self.client.get("/projects")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Gitea-Tools", response.text)
+
def test_extra_stub_routes(self):
for path in ("/worktrees", "/leases"):
with self.subTest(path=path):
diff --git a/webui/app.py b/webui/app.py
index aa0c10f..4f1cba9 100644
--- a/webui/app.py
+++ b/webui/app.py
@@ -10,6 +10,8 @@ from starlette.responses import HTMLResponse, JSONResponse, Response
from starlette.routing import Route
from webui.layout import render_page
+from webui.project_registry import find_project, load_registry, registry_to_dict
+from webui.project_views import render_project_detail, render_projects_list
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
@@ -49,10 +51,32 @@ async def health(_request: Request) -> JSONResponse:
async def projects(_request: Request) -> HTMLResponse:
- return _stub_page(
- "Projects",
- "Project registry and onboarding model will list configured repos and profiles.",
- )
+ registry = load_registry()
+ return HTMLResponse(render_projects_list(registry))
+
+
+async def project_detail(request: Request) -> HTMLResponse:
+ project_id = request.path_params["project_id"]
+ registry = load_registry()
+ project = find_project(registry, project_id)
+ if project is None:
+ return HTMLResponse(
+ render_page(
+ title="Project not found",
+ body_html=(
+ "
Project not found
"
+ f"No registry entry for {project_id}.
"
+ '← All projects
'
+ ),
+ ),
+ status_code=404,
+ )
+ return HTMLResponse(render_project_detail(project))
+
+
+async def api_projects(_request: Request) -> JSONResponse:
+ registry = load_registry()
+ return JSONResponse(registry_to_dict(registry))
async def prompts(_request: Request) -> HTMLResponse:
@@ -107,6 +131,8 @@ def create_app() -> Starlette:
Route("/", home, methods=["GET"]),
Route("/health", health, methods=["GET"]),
Route("/projects", projects, methods=["GET"]),
+ Route("/projects/{project_id}", project_detail, methods=["GET"]),
+ Route("/api/projects", api_projects, methods=["GET"]),
Route("/prompts", prompts, methods=["GET"]),
Route("/runtime", runtime, methods=["GET"]),
Route("/audit", audit, methods=["GET"]),
diff --git a/webui/data/projects.registry.json b/webui/data/projects.registry.json
new file mode 100644
index 0000000..cd6c852
--- /dev/null
+++ b/webui/data/projects.registry.json
@@ -0,0 +1,49 @@
+{
+ "version": 1,
+ "projects": [
+ {
+ "id": "gitea-tools",
+ "repo_name": "Gitea-Tools",
+ "gitea_owner": "Scaled-Tech-Consulting",
+ "remote_host": "https://gitea.prgs.cc",
+ "default_branch": "master",
+ "local_checkout_path": ".",
+ "profiles": {
+ "author": "prgs-author",
+ "reviewer": "prgs-reviewer",
+ "reconciler": "prgs-reconciler"
+ },
+ "workflow_paths": {
+ "skill": "skills/llm-project-workflow/SKILL.md",
+ "work_issue": "skills/llm-project-workflow/workflows/work-issue.md",
+ "review_merge": "skills/llm-project-workflow/workflows/review-merge-pr.md"
+ },
+ "schema_paths": {
+ "mcp_config_v2": "gitea-mcp.v2-contexts.example.json",
+ "mcp_config_v1": "gitea-mcp.example.json"
+ },
+ "onboarding_checklist": [
+ {
+ "id": "profiles",
+ "title": "Configure execution profiles",
+ "description": "Install author, reviewer, and reconciler MCP profiles (prgs-author, prgs-reviewer, prgs-reconciler) in separate namespaces. Tokens stay in keychain — never in this registry."
+ },
+ {
+ "id": "mcp_config",
+ "title": "Wire MCP v2 contexts",
+ "description": "Copy and customize gitea-mcp.v2-contexts.example.json for your machine. Map this repo path under projects with default_owner Scaled-Tech-Consulting and default_repo Gitea-Tools."
+ },
+ {
+ "id": "wiki_gate",
+ "title": "Wiki publication readiness",
+ "description": "For wiki-tracked work, satisfy the live Gitea Wiki proof gate (#224) before closing issues. See docs/wiki/Safety-and-Gates.md."
+ },
+ {
+ "id": "branches_layout",
+ "title": "Isolate work under branches/",
+ "description": "All LLM task edits happen in worktrees under branches/. Main checkout stays clean; use skills/llm-project-workflow templates for start-issue and review flows."
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/webui/layout.py b/webui/layout.py
index 0aa2a87..fc75efb 100644
--- a/webui/layout.py
+++ b/webui/layout.py
@@ -87,6 +87,34 @@ def render_page(*, title: str, body_html: str) -> str:
padding-left: 0.85rem;
margin: 1rem 0;
}}
+ .meta {{ font-size: 0.85rem; }}
+ table.registry, table.detail {{
+ width: 100%;
+ border-collapse: collapse;
+ margin: 1rem 0;
+ font-size: 0.9rem;
+ }}
+ table.registry th, table.registry td,
+ table.detail th, table.detail td {{
+ text-align: left;
+ padding: 0.45rem 0.6rem;
+ border-bottom: 1px solid var(--border);
+ }}
+ table.registry th, table.detail th {{
+ color: var(--muted);
+ font-weight: 500;
+ }}
+ code {{
+ font-family: ui-monospace, monospace;
+ font-size: 0.85em;
+ color: var(--text);
+ }}
+ ol.checklist {{
+ padding-left: 1.25rem;
+ margin: 0.5rem 0 1.5rem;
+ }}
+ ol.checklist li {{ margin-bottom: 0.85rem; }}
+ ol.checklist p {{ margin: 0.25rem 0 0; font-size: 0.9rem; }}
diff --git a/webui/project_registry.py b/webui/project_registry.py
new file mode 100644
index 0000000..2c5e889
--- /dev/null
+++ b/webui/project_registry.py
@@ -0,0 +1,196 @@
+"""Load and validate the web UI project registry (#427)."""
+
+from __future__ import annotations
+
+import json
+import os
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+_FORBIDDEN_EXACT_KEYS = frozenset({
+ "token",
+ "password",
+ "secret",
+ "credential",
+ "auth",
+ "api_key",
+ "api-key",
+})
+_FORBIDDEN_KEY_PREFIXES = ("auth_", "api_key_", "api-key_")
+_FORBIDDEN_KEY_SUFFIXES = ("_token", "_secret", "_password", "_credential", "_auth")
+
+
+def _is_forbidden_key(key: str) -> bool:
+ lowered = key.lower()
+ if lowered in _FORBIDDEN_EXACT_KEYS:
+ return True
+ return (
+ lowered.startswith(_FORBIDDEN_KEY_PREFIXES)
+ or lowered.endswith(_FORBIDDEN_KEY_SUFFIXES)
+ )
+
+_REQUIRED_PROJECT_FIELDS = (
+ "id",
+ "repo_name",
+ "gitea_owner",
+ "remote_host",
+ "default_branch",
+ "local_checkout_path",
+ "profiles",
+ "workflow_paths",
+)
+
+_REQUIRED_PROFILE_ROLES = ("author", "reviewer", "reconciler")
+
+
+@dataclass(frozen=True)
+class OnboardingStep:
+ id: str
+ title: str
+ description: str
+
+
+@dataclass(frozen=True)
+class ProjectRecord:
+ id: str
+ repo_name: str
+ gitea_owner: str
+ remote_host: str
+ default_branch: str
+ local_checkout_path: str
+ profiles: dict[str, str]
+ workflow_paths: dict[str, str]
+ schema_paths: dict[str, str]
+ onboarding_checklist: tuple[OnboardingStep, ...]
+
+
+@dataclass(frozen=True)
+class ProjectRegistry:
+ version: int
+ projects: tuple[ProjectRecord, ...]
+ source_path: Path
+
+
+def default_registry_path() -> Path:
+ override = os.environ.get("WEBUI_PROJECT_REGISTRY", "").strip()
+ if override:
+ return Path(override).expanduser().resolve()
+ return (Path(__file__).resolve().parent / "data" / "projects.registry.json").resolve()
+
+
+def _reject_credential_keys(obj: Any, *, path: str = "") -> None:
+ if isinstance(obj, dict):
+ for key, value in obj.items():
+ key_path = f"{path}.{key}" if path else key
+ if _is_forbidden_key(key):
+ raise ValueError(f"registry must not store credentials ({key_path})")
+ _reject_credential_keys(value, path=key_path)
+ elif isinstance(obj, list):
+ for index, item in enumerate(obj):
+ _reject_credential_keys(item, path=f"{path}[{index}]")
+
+
+def _parse_onboarding(raw: list[dict[str, Any]] | None) -> tuple[OnboardingStep, ...]:
+ if not raw:
+ return ()
+ steps: list[OnboardingStep] = []
+ for item in raw:
+ steps.append(
+ OnboardingStep(
+ id=str(item["id"]),
+ title=str(item["title"]),
+ description=str(item["description"]),
+ )
+ )
+ return tuple(steps)
+
+
+def _parse_project(raw: dict[str, Any]) -> ProjectRecord:
+ missing = [field for field in _REQUIRED_PROJECT_FIELDS if field not in raw]
+ if missing:
+ raise ValueError(f"project missing required fields: {', '.join(missing)}")
+
+ profiles = raw["profiles"]
+ if not isinstance(profiles, dict):
+ raise ValueError("profiles must be an object")
+ for role in _REQUIRED_PROFILE_ROLES:
+ if role not in profiles or not profiles[role]:
+ raise ValueError(f"profiles.{role} is required")
+
+ workflow_paths = raw["workflow_paths"]
+ if not isinstance(workflow_paths, dict) or not workflow_paths:
+ raise ValueError("workflow_paths must be a non-empty object")
+
+ schema_paths = raw.get("schema_paths") or {}
+ if not isinstance(schema_paths, dict):
+ raise ValueError("schema_paths must be an object when present")
+
+ return ProjectRecord(
+ id=str(raw["id"]),
+ repo_name=str(raw["repo_name"]),
+ gitea_owner=str(raw["gitea_owner"]),
+ remote_host=str(raw["remote_host"]),
+ default_branch=str(raw["default_branch"]),
+ local_checkout_path=str(raw["local_checkout_path"]),
+ profiles={role: str(profiles[role]) for role in _REQUIRED_PROFILE_ROLES},
+ workflow_paths={key: str(value) for key, value in workflow_paths.items()},
+ schema_paths={key: str(value) for key, value in schema_paths.items()},
+ onboarding_checklist=_parse_onboarding(raw.get("onboarding_checklist")),
+ )
+
+
+def load_registry(path: Path | None = None) -> ProjectRegistry:
+ """Load the versioned project registry from disk."""
+ source = (path or default_registry_path()).resolve()
+ raw_text = source.read_text(encoding="utf-8")
+ payload = json.loads(raw_text)
+ if not isinstance(payload, dict):
+ raise ValueError("registry root must be an object")
+
+ version = payload.get("version")
+ if version != 1:
+ raise ValueError(f"unsupported registry version: {version!r}")
+
+ _reject_credential_keys(payload)
+
+ projects_raw = payload.get("projects")
+ if not isinstance(projects_raw, list) or not projects_raw:
+ raise ValueError("projects must be a non-empty array")
+
+ projects = tuple(_parse_project(item) for item in projects_raw)
+ return ProjectRegistry(version=version, projects=projects, source_path=source)
+
+
+def project_to_dict(project: ProjectRecord) -> dict[str, Any]:
+ """Serialize a project for JSON API responses."""
+ return {
+ "id": project.id,
+ "repo_name": project.repo_name,
+ "gitea_owner": project.gitea_owner,
+ "remote_host": project.remote_host,
+ "default_branch": project.default_branch,
+ "local_checkout_path": project.local_checkout_path,
+ "profiles": dict(project.profiles),
+ "workflow_paths": dict(project.workflow_paths),
+ "schema_paths": dict(project.schema_paths),
+ "onboarding_checklist": [
+ {"id": step.id, "title": step.title, "description": step.description}
+ for step in project.onboarding_checklist
+ ],
+ }
+
+
+def registry_to_dict(registry: ProjectRegistry) -> dict[str, Any]:
+ return {
+ "version": registry.version,
+ "source_path": str(registry.source_path),
+ "projects": [project_to_dict(project) for project in registry.projects],
+ }
+
+
+def find_project(registry: ProjectRegistry, project_id: str) -> ProjectRecord | None:
+ for project in registry.projects:
+ if project.id == project_id:
+ return project
+ return None
\ No newline at end of file
diff --git a/webui/project_views.py b/webui/project_views.py
new file mode 100644
index 0000000..c5076ef
--- /dev/null
+++ b/webui/project_views.py
@@ -0,0 +1,93 @@
+"""HTML views for project registry pages (#427)."""
+
+from __future__ import annotations
+
+import html
+
+from webui.layout import render_page
+from webui.project_registry import ProjectRecord, ProjectRegistry
+
+
+def _escape(text: str) -> str:
+ return html.escape(text, quote=True)
+
+
+def render_projects_list(registry: ProjectRegistry) -> str:
+ rows = []
+ for project in registry.projects:
+ rows.append(
+ ""
+ f"| {_escape(project.repo_name)} | "
+ f"{_escape(project.gitea_owner)} | "
+ f"{_escape(project.remote_host)} | "
+ f"{_escape(project.default_branch)} | "
+ f"{_escape(project.profiles['author'])} | "
+ "
"
+ )
+ table = (
+ ""
+ ""
+ "| Repository | Owner | Remote | "
+ "Branch | Author profile | "
+ "
"
+ f"{''.join(rows)}
"
+ )
+ body = (
+ "Projects
"
+ "Configured repositories managed by the MCP Control Plane.
"
+ f"Registry: {_escape(str(registry.source_path))} "
+ f"(version {registry.version})
"
+ f"{table}"
+ "JSON API
"
+ )
+ return render_page(title="Projects", body_html=body)
+
+
+def render_project_detail(project: ProjectRecord) -> str:
+ profile_rows = "".join(
+ f"| {_escape(role)} | {_escape(name)} |
"
+ for role, name in project.profiles.items()
+ )
+ workflow_rows = "".join(
+ f"| {_escape(key)} | {_escape(path)} |
"
+ for key, path in project.workflow_paths.items()
+ )
+ schema_rows = "".join(
+ f"| {_escape(key)} | {_escape(path)} |
"
+ for key, path in project.schema_paths.items()
+ )
+ checklist_items = []
+ for index, step in enumerate(project.onboarding_checklist, start=1):
+ checklist_items.append(
+ ""
+ f"{index}. {_escape(step.title)}"
+ f"{_escape(step.description)}
"
+ ""
+ )
+ checklist_html = (
+ "" + "".join(checklist_items) + "
"
+ if checklist_items
+ else "No onboarding steps defined.
"
+ )
+ body = (
+ f"{_escape(project.repo_name)}
"
+ "← All projects
"
+ "Identity
"
+ ""
+ f"| Registry id | {_escape(project.id)} |
"
+ f"| Gitea owner | {_escape(project.gitea_owner)} |
"
+ f"| Remote host | {_escape(project.remote_host)} |
"
+ f"| Default branch | {_escape(project.default_branch)} |
"
+ f"| Local checkout | {_escape(project.local_checkout_path)} |
"
+ "
"
+ "Profiles
"
+ f""
+ "Workflow paths
"
+ f""
+ "Schema paths
"
+ f""
+ "Onboarding checklist
"
+ "Read-only MVP — complete these steps outside the UI.
"
+ f"{checklist_html}"
+ )
+ return render_page(title=project.repo_name, body_html=body)
\ No newline at end of file