diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md
index 011b71a..4b94aed 100644
--- a/docs/webui-local-dev.md
+++ b/docs/webui-local-dev.md
@@ -39,7 +39,8 @@ Optional environment variables:
| `/projects` | Project registry list (#427) |
| `/projects/{id}` | Project detail + onboarding checklist |
| `/api/projects` | JSON registry export |
-| `/prompts` | Stub — prompt library (#428) |
+| `/prompts` | Prompt library with per-prompt copy buttons (#428) |
+| `/api/prompts` | JSON prompt export with workflow hashes |
| `/runtime` | Stub — MCP runtime health (#430) |
| `/audit` | Stub — report audit paste (#431) |
| `/worktrees` | Stub — hygiene dashboard (#432) |
@@ -60,8 +61,16 @@ or credentials.
Seed entry: **Gitea-Tools** on `https://gitea.prgs.cc` with `prgs-author`,
`prgs-reviewer`, and `prgs-reconciler` profiles.
+## Prompt library (#428)
+
+Prompts are generated at load time from canonical workflow files under
+`skills/llm-project-workflow/workflows/`. SHA-256 hashes are computed from
+`WEBUI_REPO_ROOT` (defaults to the repository root). Prompt bodies are short
+copy/paste starters; canonical workflow files remain the only full policy
+source.
+
## Tests
```bash
-pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py -q
+pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py -q
```
\ No newline at end of file
diff --git a/tests/test_webui_prompt_library.py b/tests/test_webui_prompt_library.py
new file mode 100644
index 0000000..3538c2d
--- /dev/null
+++ b/tests/test_webui_prompt_library.py
@@ -0,0 +1,90 @@
+"""Tests for web UI prompt library (#428)."""
+import json
+import sys
+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.prompt_library import find_prompt, library_to_dict, load_prompt_library, prompt_to_dict
+
+REQUIRED_PROMPT_SLUGS = frozenset({
+ "review-pr",
+ "work-issue",
+ "create-issue",
+ "comment-issue",
+ "cleanup",
+ "audit",
+ "onboarding",
+})
+
+
+class TestPromptLibraryLoader(unittest.TestCase):
+ def test_library_loads_required_prompts(self):
+ entries = load_prompt_library()
+ slugs = {entry.slug for entry in entries}
+ self.assertEqual(slugs, REQUIRED_PROMPT_SLUGS)
+
+ def test_workflow_hashes_present(self):
+ review = find_prompt("review-pr")
+ self.assertIsNotNone(review)
+ assert review is not None
+ self.assertTrue(review.workflow_hash)
+ self.assertEqual(len(review.workflow_hash), 64)
+
+ def test_prompt_text_is_short(self):
+ for entry in load_prompt_library():
+ self.assertLess(len(entry.prompt_text), 400)
+ self.assertNotIn("Do not improvise around the gates", entry.prompt_text)
+
+
+class TestPromptLibraryRoutes(unittest.TestCase):
+ def setUp(self):
+ self.client = TestClient(create_app())
+
+ def test_prompts_page_lists_all_entries(self):
+ response = self.client.get("/prompts")
+ self.assertEqual(response.status_code, 200)
+ for label in (
+ "Review PR",
+ "Work issue",
+ "Create issue",
+ "Comment on issue",
+ "Post-merge cleanup",
+ "Reconciliation audit",
+ "Project onboarding",
+ ):
+ self.assertIn(label, response.text)
+ self.assertIn("Copy prompt", response.text)
+ self.assertIn("sha256:", response.text)
+ self.assertNotIn("child issue", response.text.lower())
+
+ def test_prompt_detail_route(self):
+ response = self.client.get("/prompts/work-issue")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("work-issue.md", response.text)
+ self.assertIn("Copy prompt", response.text)
+
+ def test_prompt_detail_404(self):
+ self.assertEqual(self.client.get("/prompts/missing").status_code, 404)
+
+ def test_api_prompts_json(self):
+ response = self.client.get("/api/prompts")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["count"], 7)
+ review = next(item for item in data["prompts"] if item["slug"] == "review-pr")
+ self.assertIn("workflow_hash", review)
+ self.assertIn("review-merge-pr.md", review["workflow_path"])
+
+ def test_prompt_to_dict_roundtrip(self):
+ entry = load_prompt_library()[0]
+ encoded = json.dumps(prompt_to_dict(entry))
+ self.assertIn("workflow_path", 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 7e139c6..937b095 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 ("/prompts", "/runtime", "/audit"):
+ for path in ("/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_prompts_is_implemented(self):
+ response = self.client.get("/prompts")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Prompt library", response.text)
+
def test_projects_is_implemented(self):
response = self.client.get("/projects")
self.assertEqual(response.status_code, 200)
diff --git a/webui/app.py b/webui/app.py
index 4f1cba9..d1e1c38 100644
--- a/webui/app.py
+++ b/webui/app.py
@@ -12,6 +12,8 @@ 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
+from webui.prompt_library import find_prompt, library_to_dict
+from webui.prompt_views import render_prompt_detail, render_prompts_page
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
@@ -80,10 +82,29 @@ async def api_projects(_request: Request) -> JSONResponse:
async def prompts(_request: Request) -> HTMLResponse:
- return _stub_page(
- "Prompts",
- "Prompt library will surface canonical workflows from skills/llm-project-workflow/.",
- )
+ return HTMLResponse(render_prompts_page())
+
+
+async def prompt_detail(request: Request) -> HTMLResponse:
+ prompt_id = request.path_params["prompt_id"]
+ prompt = find_prompt(prompt_id)
+ if prompt is None:
+ return HTMLResponse(
+ render_page(
+ title="Prompt not found",
+ body_html=(
+ "
Prompt not found
"
+ f"No library entry for {prompt_id}.
"
+ '← All prompts
'
+ ),
+ ),
+ status_code=404,
+ )
+ return HTMLResponse(render_prompt_detail(prompt))
+
+
+async def api_prompts(_request: Request) -> JSONResponse:
+ return JSONResponse(library_to_dict())
async def runtime(_request: Request) -> HTMLResponse:
@@ -134,6 +155,8 @@ def create_app() -> Starlette:
Route("/projects/{project_id}", project_detail, methods=["GET"]),
Route("/api/projects", api_projects, methods=["GET"]),
Route("/prompts", prompts, methods=["GET"]),
+ Route("/prompts/{prompt_id}", prompt_detail, methods=["GET"]),
+ Route("/api/prompts", api_prompts, methods=["GET"]),
Route("/runtime", runtime, methods=["GET"]),
Route("/audit", audit, methods=["GET"]),
Route("/worktrees", worktrees, methods=["GET"]),
diff --git a/webui/layout.py b/webui/layout.py
index fc75efb..d948204 100644
--- a/webui/layout.py
+++ b/webui/layout.py
@@ -18,7 +18,7 @@ MVP_NOTICE = (
)
-def render_page(*, title: str, body_html: str) -> str:
+def render_page(*, title: str, body_html: str, extra_head: str = "") -> str:
nav_links = "".join(
f'{label}' for href, label in NAV_ITEMS
)
@@ -115,7 +115,38 @@ def render_page(*, title: str, body_html: str) -> str:
}}
ol.checklist li {{ margin-bottom: 0.85rem; }}
ol.checklist p {{ margin: 0.25rem 0 0; font-size: 0.9rem; }}
+ .prompt-card {{
+ margin: 1.25rem 0 1.75rem;
+ padding: 1rem 1.1rem;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--surface);
+ }}
+ .prompt-card h3 {{ margin: 0 0 0.5rem; font-size: 1.05rem; }}
+ pre.prompt-text {{
+ white-space: pre-wrap;
+ word-break: break-word;
+ margin: 0.75rem 0;
+ padding: 0.75rem 0.85rem;
+ border-radius: 6px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ font-size: 0.9rem;
+ color: var(--text);
+ }}
+ .copy-btn {{
+ background: var(--accent);
+ color: #0b1219;
+ border: none;
+ border-radius: 6px;
+ padding: 0.4rem 0.85rem;
+ font-size: 0.85rem;
+ cursor: pointer;
+ }}
+ .copy-btn:hover {{ filter: brightness(1.08); }}
+ .muted {{ color: var(--muted); }}
+ {extra_head}
diff --git a/webui/prompt_library.py b/webui/prompt_library.py
new file mode 100644
index 0000000..e5ec1d7
--- /dev/null
+++ b/webui/prompt_library.py
@@ -0,0 +1,197 @@
+"""Canonical workflow prompt library for the internal web UI (#428)."""
+
+from __future__ import annotations
+
+import hashlib
+import os
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+_WORKFLOW_ROOT = Path("skills/llm-project-workflow/workflows")
+
+_DEFAULT_PROMPT_RE = re.compile(
+ r"\*\*Default task prompt:\*\*\s*\n+>\s*(.+?)(?=\n\n|\nDo not improvise)",
+ re.DOTALL,
+)
+_FRONTMATTER_TASK_MODE_RE = re.compile(r"^task_mode:\s*(\S+)", re.MULTILINE)
+
+
+@dataclass(frozen=True)
+class PromptEntry:
+ slug: str
+ label: str
+ prompt_text: str
+ workflow_path: str
+ task_mode: str | None
+ workflow_hash: str | None
+ source_note: str
+
+
+def _repo_root() -> Path:
+ override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip()
+ if override:
+ return Path(override).resolve()
+ return Path(__file__).resolve().parent.parent
+
+
+def _workflow_file(path: str) -> Path:
+ return _repo_root() / path
+
+
+def _sha256_hex(content: str) -> str:
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
+
+
+def _read_workflow(path: str) -> tuple[str, str]:
+ file_path = _workflow_file(path)
+ text = file_path.read_text(encoding="utf-8")
+ return text, _sha256_hex(text)
+
+
+def _extract_default_prompt(markdown: str) -> str | None:
+ match = _DEFAULT_PROMPT_RE.search(markdown)
+ if not match:
+ return None
+ lines = [line.strip() for line in match.group(1).splitlines()]
+ return " ".join(line for line in lines if line)
+
+
+def _extract_task_mode(markdown: str) -> str | None:
+ match = _FRONTMATTER_TASK_MODE_RE.search(markdown)
+ return match.group(1) if match else None
+
+
+def _entry_from_workflow(
+ *,
+ slug: str,
+ label: str,
+ workflow_path: str,
+ prompt_override: str | None = None,
+ source_note: str = "",
+) -> PromptEntry:
+ markdown, digest = _read_workflow(workflow_path)
+ prompt_text = prompt_override or _extract_default_prompt(markdown)
+ if not prompt_text:
+ raise ValueError(f"No default task prompt found in {workflow_path}")
+ return PromptEntry(
+ slug=slug,
+ label=label,
+ prompt_text=prompt_text,
+ workflow_path=workflow_path,
+ task_mode=_extract_task_mode(markdown),
+ workflow_hash=digest,
+ source_note=source_note,
+ )
+
+
+def _static_entry(
+ *,
+ slug: str,
+ label: str,
+ prompt_text: str,
+ workflow_path: str,
+ source_note: str,
+) -> PromptEntry:
+ path = _workflow_file(workflow_path)
+ digest = _sha256_hex(path.read_text(encoding="utf-8")) if path.is_file() else None
+ markdown = path.read_text(encoding="utf-8") if path.is_file() else ""
+ return PromptEntry(
+ slug=slug,
+ label=label,
+ prompt_text=prompt_text,
+ workflow_path=workflow_path,
+ task_mode=_extract_task_mode(markdown) if markdown else None,
+ workflow_hash=digest,
+ source_note=source_note,
+ )
+
+
+def load_prompt_library() -> tuple[PromptEntry, ...]:
+ """Load operator prompts derived from canonical workflows."""
+ entries = (
+ _entry_from_workflow(
+ slug="review-pr",
+ label="Review PR",
+ workflow_path=str(_WORKFLOW_ROOT / "review-merge-pr.md"),
+ ),
+ _entry_from_workflow(
+ slug="work-issue",
+ label="Work issue",
+ workflow_path=str(_WORKFLOW_ROOT / "work-issue.md"),
+ ),
+ _entry_from_workflow(
+ slug="create-issue",
+ label="Create issue",
+ workflow_path=str(_WORKFLOW_ROOT / "create-issue.md"),
+ ),
+ _static_entry(
+ slug="comment-issue",
+ label="Comment on issue",
+ workflow_path=str(_WORKFLOW_ROOT / "create-issue.md"),
+ prompt_text=(
+ "Comment on the target Gitea issue only if exact comment_issue "
+ "capability is proven. Load the canonical create-issue workflow "
+ "first and follow §16 (comment-on-existing issue rule). Include "
+ "specific evidence; do not duplicate existing comments."
+ ),
+ source_note="Derived from create-issue.md §16; full policy remains in the workflow file.",
+ ),
+ _static_entry(
+ slug="cleanup",
+ label="Post-merge cleanup",
+ workflow_path="skills/llm-project-workflow/templates/worktree-cleanup.md",
+ prompt_text=(
+ "Task: clean up branch/worktree for PR # / issue # after merge. "
+ "Confirm the merge on remote master before any deletion; never "
+ "force-remove a dirty worktree."
+ ),
+ source_note="Full cleanup steps live in templates/worktree-cleanup.md.",
+ ),
+ _entry_from_workflow(
+ slug="audit",
+ label="Reconciliation audit",
+ workflow_path=str(_WORKFLOW_ROOT / "reconcile-landed-pr.md"),
+ ),
+ _static_entry(
+ slug="onboarding",
+ label="Project onboarding",
+ workflow_path="skills/llm-project-workflow/SKILL.md",
+ prompt_text=(
+ "Onboard this repository into the MCP Control Plane: prove identity "
+ "and task capability, configure author/reviewer/reconciler profiles "
+ "in separate namespaces, then complete the checklist at /projects. "
+ "Canonical router: skills/llm-project-workflow/SKILL.md."
+ ),
+ source_note="Checklist details live in webui/data/projects.registry.json and /projects.",
+ ),
+ )
+ return entries
+
+
+def find_prompt(slug: str) -> PromptEntry | None:
+ for entry in load_prompt_library():
+ if entry.slug == slug:
+ return entry
+ return None
+
+
+def prompt_to_dict(entry: PromptEntry) -> dict[str, Any]:
+ return {
+ "slug": entry.slug,
+ "label": entry.label,
+ "prompt_text": entry.prompt_text,
+ "workflow_path": entry.workflow_path,
+ "task_mode": entry.task_mode,
+ "workflow_hash": entry.workflow_hash,
+ "source_note": entry.source_note,
+ }
+
+
+def library_to_dict() -> dict[str, Any]:
+ entries = load_prompt_library()
+ return {
+ "count": len(entries),
+ "prompts": [prompt_to_dict(entry) for entry in entries],
+ }
\ No newline at end of file
diff --git a/webui/prompt_views.py b/webui/prompt_views.py
new file mode 100644
index 0000000..fe6d687
--- /dev/null
+++ b/webui/prompt_views.py
@@ -0,0 +1,87 @@
+"""HTML views for the prompt library (#428)."""
+
+from __future__ import annotations
+
+import html
+
+from webui.layout import render_page
+from webui.prompt_library import PromptEntry, load_prompt_library
+
+
+def _escape(text: str) -> str:
+ return html.escape(text, quote=True)
+
+
+def _render_prompt_card(entry: PromptEntry) -> str:
+ hash_short = (
+ f"{_escape(entry.workflow_hash[:12])}"
+ if entry.workflow_hash
+ else "n/a"
+ )
+ task_mode = (
+ f"{_escape(entry.task_mode)}"
+ if entry.task_mode
+ else "n/a"
+ )
+ note = (
+ f'{_escape(entry.source_note)}
'
+ if entry.source_note
+ else ""
+ )
+ prompt_id = f"prompt-{entry.slug}"
+ return (
+ f''
+ f"{_escape(entry.label)}
"
+ f'Workflow: {_escape(entry.workflow_path)} · '
+ f"task_mode: {task_mode} · sha256: {hash_short}
"
+ f'{_escape(entry.prompt_text)}'
+ f'"
+ f"{note}"
+ ""
+ )
+
+
+PROMPT_PAGE_SCRIPT = """
+
+"""
+
+
+def render_prompts_page() -> str:
+ entries = load_prompt_library()
+ cards = "".join(_render_prompt_card(entry) for entry in entries)
+ body = (
+ "Prompt library
"
+ "Short copy/paste task prompts derived from canonical workflows. "
+ "Full policy remains in the cited workflow files — not duplicated here.
"
+ f"{cards}"
+ "JSON API
"
+ f"{PROMPT_PAGE_SCRIPT}"
+ )
+ return render_page(title="Prompts", body_html=body)
+
+
+def render_prompt_detail(entry: PromptEntry) -> str:
+ body = (
+ f"← All prompts
"
+ f"{_render_prompt_card(entry)}"
+ f"{PROMPT_PAGE_SCRIPT}"
+ )
+ return render_page(title=entry.label, body_html=body)
\ No newline at end of file