feat: add canonical workflow prompt library to web UI (Closes #428)

Expose short copy/paste operator prompts on /prompts derived from canonical
workflow files with workflow path and SHA-256 hash. Adds JSON export at
/api/prompts, copy buttons, and tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-07 14:53:16 -04:00
co-authored by Claude Opus 4.8
parent 1529b9ff6b
commit 6670c72e65
7 changed files with 410 additions and 8 deletions
+11 -2
View File
@@ -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
```
+81
View File
@@ -0,0 +1,81 @@
"""Tests for web UI prompt library (#428)."""
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 load_prompt_library, prompt_to_dict
class TestWebuiPromptLibrary(unittest.TestCase):
def setUp(self):
self.client = TestClient(create_app())
def test_library_has_required_prompts(self):
entries = load_prompt_library()
slugs = {entry.slug for entry in entries}
self.assertEqual(
slugs,
{
"review-pr",
"work-issue",
"create-issue",
"comment-issue",
"cleanup",
"audit",
"onboarding",
},
)
def test_workflow_entries_include_hash_and_task_mode(self):
review = next(e for e in load_prompt_library() if e.slug == "review-pr")
self.assertEqual(
review.workflow_path,
"skills/llm-project-workflow/workflows/review-merge-pr.md",
)
self.assertEqual(review.task_mode, "review-merge-pr")
self.assertIsNotNone(review.workflow_hash)
self.assertGreater(len(review.workflow_hash), 12)
self.assertIn("eligible open PR", review.prompt_text)
def test_comment_issue_defers_to_workflow(self):
comment = next(e for e in load_prompt_library() if e.slug == "comment-issue")
self.assertIn("comment_issue", comment.prompt_text)
self.assertIn("create-issue", comment.workflow_path)
self.assertIn("§16", comment.source_note)
def test_prompts_page_renders_cards_and_copy_buttons(self):
response = self.client.get("/prompts")
self.assertEqual(response.status_code, 200)
text = response.text
self.assertIn("Prompt library", text)
self.assertIn("review-merge-pr.md", text)
self.assertIn("Copy prompt", text)
self.assertIn('data-copy-target="prompt-review-pr"', text)
self.assertNotIn("child issue", text.lower())
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)
self.assertEqual(len(data["prompts"]), 7)
first = data["prompts"][0]
self.assertIn("slug", first)
self.assertIn("prompt_text", first)
self.assertIn("workflow_path", first)
self.assertIn("workflow_hash", first)
def test_prompt_to_dict_round_trip(self):
entry = load_prompt_library()[0]
payload = prompt_to_dict(entry)
self.assertEqual(payload["slug"], entry.slug)
self.assertEqual(payload["prompt_text"], entry.prompt_text)
if __name__ == "__main__":
unittest.main()
+6 -1
View File
@@ -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)
+8 -4
View File
@@ -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 library_to_dict
from webui.prompt_views import render_prompts_page
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
@@ -80,10 +82,11 @@ 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 api_prompts(_request: Request) -> JSONResponse:
return JSONResponse(library_to_dict())
async def runtime(_request: Request) -> HTMLResponse:
@@ -134,6 +137,7 @@ 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("/api/prompts", api_prompts, methods=["GET"]),
Route("/runtime", runtime, methods=["GET"]),
Route("/audit", audit, methods=["GET"]),
Route("/worktrees", worktrees, methods=["GET"]),
+32 -1
View File
@@ -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'<a href="{href}">{label}</a>' 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); }}
</style>
{extra_head}
</head>
<body>
<header>
+192
View File
@@ -0,0 +1,192 @@
"""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 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="PR queue cleanup",
workflow_path=str(_WORKFLOW_ROOT / "pr-queue-cleanup.md"),
prompt_text=(
"Run PR-only queue cleanup: build a complete open-PR inventory "
"with pagination proof, select exactly one eligible PR, dispatch "
"one canonical review for that PR, then stop after any terminal "
"review mutation. Load pr-queue-cleanup.md and review-merge-pr.md "
"before any mutation."
),
source_note="Derived from pr-queue-cleanup.md; composes with review-merge-pr.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="docs/webui-local-dev.md",
prompt_text=(
"Start a new MCP Control Plane session: call mcp_get_control_plane_guide, "
"prove identity and task capability, then complete the onboarding checklist "
"for this project at /projects. Keep Gitea, MCP tools, and canonical "
"workflows as the source of truth."
),
source_note="Operator onboarding; checklist details live in the project registry UI.",
),
)
return entries
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],
}
+80
View File
@@ -0,0 +1,80 @@
"""HTML views for the prompt library (#428)."""
from __future__ import annotations
import html
from webui.prompt_library import PromptEntry, load_prompt_library
def _escape(text: str) -> str:
return html.escape(text, quote=True)
def render_prompts_page() -> str:
entries = load_prompt_library()
cards: list[str] = []
for entry in entries:
cards.append(_render_prompt_card(entry))
body = (
"<h2>Prompt library</h2>"
"<p>Short copy/paste task prompts derived from canonical workflows. "
"Full policy remains in the cited workflow files — not duplicated here.</p>"
f"{''.join(cards)}"
f"{PROMPT_PAGE_SCRIPT}"
)
from webui.layout import render_page
return render_page(title="Prompts", body_html=body)
def _render_prompt_card(entry: PromptEntry) -> str:
hash_short = (
f"<code>{_escape(entry.workflow_hash[:12])}</code>"
if entry.workflow_hash
else "<span class=\"muted\">n/a</span>"
)
task_mode = (
f"<code>{_escape(entry.task_mode)}</code>"
if entry.task_mode
else "<span class=\"muted\">n/a</span>"
)
note = (
f'<p class="meta">{_escape(entry.source_note)}</p>'
if entry.source_note
else ""
)
prompt_id = f"prompt-{entry.slug}"
return (
f'<section class="prompt-card" id="{_escape(entry.slug)}">'
f"<h3>{_escape(entry.label)}</h3>"
f'<p class="meta">Workflow: <code>{_escape(entry.workflow_path)}</code> · '
f"task_mode: {task_mode} · sha256: {hash_short}</p>"
f'<pre class="prompt-text" id="{prompt_id}">{_escape(entry.prompt_text)}</pre>'
f'<button type="button" class="copy-btn" data-copy-target="{prompt_id}">'
"Copy prompt</button>"
f"{note}"
"</section>"
)
PROMPT_PAGE_SCRIPT = """
<script>
document.querySelectorAll('.copy-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const targetId = btn.getAttribute('data-copy-target');
const node = document.getElementById(targetId);
if (!node) return;
const text = node.textContent || '';
try {
await navigator.clipboard.writeText(text);
const prior = btn.textContent;
btn.textContent = 'Copied';
setTimeout(() => { btn.textContent = prior; }, 1200);
} catch (_err) {
btn.textContent = 'Copy failed';
}
});
});
</script>
"""