From 0e701d578a5b5953edc3faba6a9a35a3daaa7e94 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Tue, 7 Jul 2026 15:20:47 -0400 Subject: [PATCH 1/8] feat: add final-report audit paste tool to web UI (Closes #431) Expose /audit and /api/audit for local validator previews using final_report_validator and review schema checks. Operators can paste reports, auto-detect task kind, and get structured findings with suggested next prompts and issue-comment drafts. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/webui-local-dev.md | 18 +++- tests/test_webui_audit.py | 94 +++++++++++++++++++ tests/test_webui_skeleton.py | 13 ++- webui/app.py | 55 ++++++++++- webui/audit_validator.py | 176 +++++++++++++++++++++++++++++++++++ webui/audit_views.py | 139 +++++++++++++++++++++++++++ 6 files changed, 481 insertions(+), 14 deletions(-) create mode 100644 tests/test_webui_audit.py create mode 100644 webui/audit_validator.py create mode 100644 webui/audit_views.py diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index 0a5b935..6ef5750 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -44,12 +44,22 @@ Optional environment variables: | `/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) | +| `/audit` | Report audit paste + validator preview (#431) | +| `/api/audit` | JSON validator preview (POST `report_text`, optional `task_kind`) | | `/worktrees` | Stub — hygiene dashboard (#432) | | `/leases` | Stub — lease visibility (#433) | -All routes are GET-only. POST/PUT/PATCH/DELETE return `405` with -`read-only-mvp`. +Most routes are GET-only. POST/PUT/PATCH/DELETE return `405` with +`read-only-mvp`, except `/audit` and `/api/audit` which accept POST for +local validator preview only (no Gitea mutations, no server-side storage). + +## Report audit (#431) + +Paste an LLM final report at `/audit` or POST JSON to `/api/audit`. The UI +reuses `final_report_validator` and review schema checks to surface missing +proof fields, wrong validation vocabulary, mutation contradictions, and a +suggested next prompt or issue-comment draft. Task kind can be auto-detected +or selected explicitly. ## Project registry (#427) @@ -85,5 +95,5 @@ instead of an empty queue (fail closed). ## Tests ```bash -pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py -q +pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_audit.py -q ``` \ No newline at end of file diff --git a/tests/test_webui_audit.py b/tests/test_webui_audit.py new file mode 100644 index 0000000..51f8cd9 --- /dev/null +++ b/tests/test_webui_audit.py @@ -0,0 +1,94 @@ +"""Tests for web UI report audit paste (#431).""" +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.audit_validator import audit_report, infer_task_kind + + +_MINIMAL_REVIEW_REPORT = """## Controller Handoff + +- Task: review PR #1 +- Repo: Scaled-Tech-Consulting/Gitea-Tools +- Role: reviewer +- Identity: sysadmin / prgs-reviewer +- Review decision: approve +""" + + +class TestAuditValidator(unittest.TestCase): + def test_infer_task_kind_from_review_report(self): + self.assertEqual(infer_task_kind(_MINIMAL_REVIEW_REPORT), "review_pr") + + def test_infer_task_kind_explicit_override(self): + self.assertEqual( + infer_task_kind(_MINIMAL_REVIEW_REPORT, explicit="work_issue"), + "work_issue", + ) + + def test_empty_report_blocked(self): + result = audit_report("") + self.assertTrue(result["blocked"]) + self.assertIn("empty", result["reasons"][0].lower()) + + def test_minimal_report_produces_findings(self): + result = audit_report(_MINIMAL_REVIEW_REPORT) + self.assertIn(result["grade"], {"blocked", "downgraded", "warning", "A"}) + self.assertTrue(result["findings"] or result["blocked"] or result["downgraded"]) + + +class TestAuditRoutes(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_audit_page_renders_form(self): + response = self.client.get("/audit") + self.assertEqual(response.status_code, 200) + self.assertIn("Report audit", response.text) + self.assertIn('name="report_text"', response.text) + self.assertIn("Run validator preview", response.text) + self.assertNotIn("child issue", response.text.lower()) + + def test_audit_post_runs_validator(self): + response = self.client.post( + "/audit", + data={"report_text": _MINIMAL_REVIEW_REPORT, "task_kind": "review_pr"}, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("Validator preview", response.text) + self.assertIn("Safe next action", response.text) + + def test_api_audit_post_json(self): + response = self.client.post( + "/api/audit", + json={"report_text": _MINIMAL_REVIEW_REPORT, "task_kind": "review_pr"}, + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["task_kind"], "review_pr") + self.assertIn("grade", data) + self.assertIn("findings", data) + self.assertIn("suggested_next_prompt", data) + + def test_api_audit_rejects_empty_body(self): + response = self.client.post("/api/audit", json={"report_text": ""}) + self.assertEqual(response.status_code, 400) + + def test_audit_post_allowed_other_post_still_blocked(self): + self.assertEqual(self.client.post("/health").status_code, 405) + self.assertEqual( + self.client.post( + "/audit", + data={"report_text": _MINIMAL_REVIEW_REPORT}, + ).status_code, + 200, + ) + + +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 5dd23a1..08e361b 100644 --- a/tests/test_webui_skeleton.py +++ b/tests/test_webui_skeleton.py @@ -30,11 +30,14 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertIn("Read-only MVP", response.text) def test_route_stubs_render(self): - 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()) + response = self.client.get("/runtime") + self.assertEqual(response.status_code, 200) + self.assertIn("child issue", response.text.lower()) + + def test_audit_is_implemented(self): + response = self.client.get("/audit") + self.assertEqual(response.status_code, 200) + self.assertIn("Report audit", response.text) def test_prompts_is_implemented(self): response = self.client.get("/prompts") diff --git a/webui/app.py b/webui/app.py index a97d1ad..1bc72b4 100644 --- a/webui/app.py +++ b/webui/app.py @@ -14,10 +14,15 @@ 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 +from final_report_validator import FINAL_REPORT_TASK_KINDS + +from webui.audit_validator import audit_report, audit_to_dict +from webui.audit_views import render_audit_page from webui.queue_loader import load_queue_snapshot, snapshot_to_dict from webui.queue_views import render_queue_page _READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) +_AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"}) def _stub_page(title: str, description: str) -> HTMLResponse: @@ -126,13 +131,49 @@ async def runtime(_request: Request) -> HTMLResponse: ) -async def audit(_request: Request) -> HTMLResponse: - return _stub_page( - "Audit", - "Report audit will accept pasted final reports and run validator previews.", +async def _parse_audit_form(request: Request) -> tuple[str, str | None]: + if request.method == "GET": + return "", None + content_type = (request.headers.get("content-type") or "").lower() + if "application/json" in content_type: + payload = await request.json() + if not isinstance(payload, dict): + return "", None + report_text = str(payload.get("report_text") or "") + task_kind = payload.get("task_kind") + return report_text, str(task_kind) if task_kind else None + form = await request.form() + report_text = str(form.get("report_text") or "") + task_kind = form.get("task_kind") + return report_text, str(task_kind) if task_kind else None + + +async def audit(request: Request) -> HTMLResponse: + report_text, task_kind = await _parse_audit_form(request) + result = None + if request.method == "POST" and report_text.strip(): + result = audit_report(report_text, task_kind=task_kind) + return HTMLResponse( + render_audit_page( + report_text=report_text, + task_kind=task_kind, + result=result, + ) ) +async def api_audit(request: Request) -> JSONResponse: + if request.method == "GET": + return JSONResponse({ + "usage": "POST JSON with report_text and optional task_kind", + "task_kinds": sorted(FINAL_REPORT_TASK_KINDS), + }) + report_text, task_kind = await _parse_audit_form(request) + if not report_text.strip(): + return JSONResponse({"error": "report_text required"}, status_code=400) + return JSONResponse(audit_to_dict(audit_report(report_text, task_kind=task_kind))) + + async def worktrees(_request: Request) -> HTMLResponse: return _stub_page( "Worktrees", @@ -148,6 +189,9 @@ async def leases(_request: Request) -> HTMLResponse: async def method_not_allowed(request: Request, _exc: Exception) -> Response: + path = request.url.path + if path in _AUDIT_MUTATION_PATHS and request.method == "POST": + return JSONResponse({"error": "not_found"}, status_code=404) if request.method not in _READ_ONLY_METHODS: return JSONResponse( {"error": "read-only-mvp", "detail": f"{request.method} not permitted"}, @@ -172,7 +216,8 @@ def create_app() -> Starlette: 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("/audit", audit, methods=["GET", "POST"]), + Route("/api/audit", api_audit, methods=["GET", "POST"]), Route("/worktrees", worktrees, methods=["GET"]), Route("/leases", leases, methods=["GET"]), ], diff --git a/webui/audit_validator.py b/webui/audit_validator.py new file mode 100644 index 0000000..d19e7ff --- /dev/null +++ b/webui/audit_validator.py @@ -0,0 +1,176 @@ +"""Final-report audit preview for the web UI (#431).""" + +from __future__ import annotations + +import re +from typing import Any + +from final_report_validator import FINAL_REPORT_TASK_KINDS, assess_final_report_validator + +_TASK_KIND_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( + ("review_pr", re.compile(r"\b(?:review\s+pr|task\s*:\s*review|review-merge-pr)\b", re.I)), + ( + "reconcile_already_landed", + re.compile( + r"\b(?:reconcile\s+already[- ]landed|already[- ]landed\s+pr|" + r"reconcile-landed-pr)\b", + re.I, + ), + ), + ("work_issue", re.compile(r"\b(?:work[- ]issue|task\s*:\s*work|selected\s+issue\s*:)\b", re.I)), + ("issue_filing", re.compile(r"\b(?:issue\s+filing|create\s+issue|task\s*:\s*file)\b", re.I)), + ("issue_selection", re.compile(r"\b(?:issue\s+selection|select\s+next\s+issue)\b", re.I)), + ("inventory", re.compile(r"\b(?:issue\s+inventory|queue\s+inventory)\b", re.I)), +) + +_SUGGESTED_PROMPTS: dict[str, str] = { + "review_pr": ( + "Review the next eligible open PR in this project. Merge it only if every " + "gate passes. Post a final report matching review-merge-pr schema." + ), + "work_issue": ( + "Find the next eligible issue in this project, work on it only if all gates " + "pass, and create a PR when complete." + ), + "reconcile_already_landed": ( + "Reconcile the oldest eligible already-landed open PR. Post read-only audit " + "first; authorize cleanup only when required." + ), + "issue_filing": "File a new Gitea issue using the canonical issue-filing workflow.", + "issue_selection": "Build a complete open-issue inventory and select the next eligible issue.", + "inventory": "Produce a proof-backed inventory of open PRs or issues without mutating state.", +} + + +def infer_task_kind(report_text: str, explicit: str | None = None) -> str: + """Infer workflow task kind from explicit selection or report content.""" + if explicit: + normalized = explicit.strip().lower().replace(" ", "_") + if normalized in FINAL_REPORT_TASK_KINDS: + return normalized + text = report_text or "" + for kind, pattern in _TASK_KIND_PATTERNS: + if pattern.search(text): + return kind + if re.search(r"\brole\s*:\s*reviewer\b", text, re.I): + return "review_pr" + if re.search(r"\brole\s*:\s*author\b", text, re.I): + return "work_issue" + return "review_pr" + + +def _merge_findings(*result_sets: dict[str, Any]) -> list[dict[str, str]]: + seen: set[tuple[str, str, str]] = set() + merged: list[dict[str, str]] = [] + for result in result_sets: + for finding in result.get("findings") or []: + key = ( + str(finding.get("rule_id", "")), + str(finding.get("field", "")), + str(finding.get("reason", "")), + ) + if key in seen: + continue + seen.add(key) + merged.append(finding) + return merged + + +def _aggregate_result(task_kind: str, findings: list[dict[str, str]], base: dict[str, Any]) -> dict[str, Any]: + blocked = any(f.get("severity") == "block" for f in findings) + downgraded = any(f.get("severity") == "downgrade" for f in findings) + warning = any(f.get("severity") == "warning" for f in findings) + if blocked: + grade = "blocked" + elif downgraded: + grade = "downgraded" + elif warning: + grade = "warning" + else: + grade = base.get("grade") or "A" + safe_next_action = base.get("safe_next_action") or "proceed" + for finding in findings: + if finding.get("severity") in {"block", "downgrade"}: + safe_next_action = finding.get("safe_next_action") or safe_next_action + break + return { + "task_kind": task_kind, + "inferred_task_kind": task_kind, + "grade": grade, + "blocked": blocked or bool(base.get("blocked")), + "downgraded": downgraded or bool(base.get("downgraded")), + "findings": findings, + "reasons": [f"{f.get('rule_id')}: {f.get('reason')}" for f in findings], + "safe_next_action": safe_next_action, + "complete": grade == "A" and not findings, + "suggested_next_prompt": _SUGGESTED_PROMPTS.get(task_kind, ""), + "suggested_issue_comment": _build_issue_comment(findings, safe_next_action), + } + + +def _build_issue_comment(findings: list[dict[str, str]], safe_next_action: str) -> str: + if not findings: + return "" + lines = [ + "## Report audit preview (local validator)", + "", + "Automated findings from pasted final report:", + "", + ] + for finding in findings[:12]: + severity = finding.get("severity", "info") + rule_id = finding.get("rule_id", "unknown") + reason = finding.get("reason", "") + lines.append(f"- **{severity}** `{rule_id}` — {reason}") + if len(findings) > 12: + lines.append(f"- …and {len(findings) - 12} more finding(s)") + lines.extend(["", f"Safe next action: {safe_next_action}", ""]) + return "\n".join(lines) + + +def audit_report(report_text: str, *, task_kind: str | None = None) -> dict[str, Any]: + """Run composable final-report validation on pasted text.""" + text = (report_text or "").strip() + if not text: + return { + "task_kind": task_kind or "review_pr", + "inferred_task_kind": task_kind or "review_pr", + "grade": "blocked", + "blocked": True, + "downgraded": False, + "findings": [], + "reasons": ["empty report text"], + "safe_next_action": "paste a non-empty final report", + "complete": False, + "suggested_next_prompt": "", + "suggested_issue_comment": "", + } + + resolved_kind = infer_task_kind(text, task_kind) + base = assess_final_report_validator(text, resolved_kind) + findings = list(base.get("findings") or []) + + if resolved_kind == "review_pr": + from review_final_report_schema import assess_review_final_report_schema + + schema = assess_review_final_report_schema(text) + findings = _merge_findings(base, schema) + + return _aggregate_result(resolved_kind, findings, base) + + +def audit_to_dict(result: dict[str, Any]) -> dict[str, Any]: + """JSON-safe export for /api/audit.""" + return { + "task_kind": result.get("task_kind"), + "inferred_task_kind": result.get("inferred_task_kind"), + "grade": result.get("grade"), + "blocked": result.get("blocked"), + "downgraded": result.get("downgraded"), + "complete": result.get("complete"), + "safe_next_action": result.get("safe_next_action"), + "findings": result.get("findings") or [], + "reasons": result.get("reasons") or [], + "suggested_next_prompt": result.get("suggested_next_prompt") or "", + "suggested_issue_comment": result.get("suggested_issue_comment") or "", + } \ No newline at end of file diff --git a/webui/audit_views.py b/webui/audit_views.py new file mode 100644 index 0000000..1316eea --- /dev/null +++ b/webui/audit_views.py @@ -0,0 +1,139 @@ +"""HTML views for final-report audit paste (#431).""" + +from __future__ import annotations + +import html + +from final_report_validator import FINAL_REPORT_TASK_KINDS +from webui.audit_validator import audit_report +from webui.layout import render_page + +_TASK_KIND_OPTIONS = sorted(FINAL_REPORT_TASK_KINDS) + + +def _escape(text: str) -> str: + return html.escape(text, quote=True) + + +def _render_findings(result: dict) -> str: + findings = result.get("findings") or [] + if not findings: + return '

No validator findings — report structure looks complete for the selected task kind.

' + rows = [] + for finding in findings: + rows.append( + "" + f"{_escape(str(finding.get('rule_id', '')))}" + f"{_escape(str(finding.get('severity', '')))}" + f"{_escape(str(finding.get('field', '')))}" + f"{_escape(str(finding.get('reason', '')))}" + f"{_escape(str(finding.get('safe_next_action', '')))}" + "" + ) + return ( + '' + "" + "" + f"{''.join(rows)}
RuleSeverityFieldReasonSafe next action
" + ) + + +def _render_task_kind_select(selected: str | None) -> str: + options = [''] + for kind in _TASK_KIND_OPTIONS: + sel = ' selected' if selected == kind else "" + options.append(f'') + return f'' + + +def render_audit_page( + *, + report_text: str = "", + task_kind: str | None = None, + result: dict | None = None, +) -> str: + result_html = "" + if result is not None: + grade = _escape(str(result.get("grade", ""))) + inferred = _escape(str(result.get("inferred_task_kind", ""))) + safe_next = _escape(str(result.get("safe_next_action", ""))) + suggested_prompt = result.get("suggested_next_prompt") or "" + suggested_comment = result.get("suggested_issue_comment") or "" + result_html = ( + '
' + f"

Validator preview

" + f'

Task kind: {inferred} · ' + f'Grade: {grade} · ' + f"Blocked: {result.get('blocked')} · " + f"Downgraded: {result.get('downgraded')}

" + f"

Safe next action: {safe_next}

" + f"{_render_findings(result)}" + ) + if suggested_prompt: + result_html += ( + "

Suggested next prompt

" + f'
{_escape(suggested_prompt)}
' + ) + if suggested_comment: + result_html += ( + "

Suggested issue/PR comment

" + f'
{_escape(suggested_comment)}
' + ) + result_html += "
" + + body = ( + "

Report audit

" + "

Paste an LLM final report for local validator preview. " + "Uses final_report_validator and review schema checks — " + "does not post to Gitea or store reports server-side.

" + '
' + "" + f"{_render_task_kind_select(task_kind)}" + '' + f'' + '' + "
" + f"{result_html}" + "

JSON API (POST report_text, " + "optional task_kind)

" + ) + return render_page(title="Audit", body_html=body, extra_head=AUDIT_PAGE_STYLES) + + +AUDIT_PAGE_STYLES = """ + +""" \ No newline at end of file From 276a71bd1a5d8193ae62d808daf223ffb9038d72 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Tue, 7 Jul 2026 15:26:22 -0400 Subject: [PATCH 2/8] feat: add worktree hygiene dashboard to web UI (Closes #432) Adds read-only /worktrees and /api/worktrees routes that scan branches/ directories, classify worktree state, flag missing preserved worktrees from the issue lock, and surface the canonical cleanup prompt without deletion. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/webui-local-dev.md | 15 +- tests/test_webui_skeleton.py | 13 +- tests/test_webui_worktree_hygiene.py | 114 ++++++++++ webui/app.py | 13 +- webui/worktree_scanner.py | 307 +++++++++++++++++++++++++++ webui/worktree_views.py | 130 ++++++++++++ 6 files changed, 581 insertions(+), 11 deletions(-) create mode 100644 tests/test_webui_worktree_hygiene.py create mode 100644 webui/worktree_scanner.py create mode 100644 webui/worktree_views.py diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index 0a5b935..1873305 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -45,7 +45,8 @@ Optional environment variables: | `/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) | +| `/worktrees` | Worktree hygiene dashboard (#432) | +| `/api/worktrees` | JSON worktree scan with classifications and anomalies | | `/leases` | Stub — lease visibility (#433) | All routes are GET-only. POST/PUT/PATCH/DELETE return `405` with @@ -82,8 +83,18 @@ credentials. The UI surfaces pagination proof (returned count, pages fetched, If credentials are missing or the fetch fails, the page shows an explicit error instead of an empty queue (fail closed). +## Worktree hygiene (#432) + +`/worktrees` scans local `branches/` directories and registered git worktrees. +Each entry is classified (`active-pr`, `active-issue`, `dirty`, `stale-clean`, +`detached-review`, `unsafe-unknown`, `orphan`). Missing preserved worktrees +referenced by the issue lock file are flagged as anomalies (#404). The page +includes a copy/paste canonical cleanup prompt only — no deletion actions. + +Override scan root with `WEBUI_REPO_ROOT` (defaults to repository root). + ## Tests ```bash -pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py -q +pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_worktree_hygiene.py -q ``` \ No newline at end of file diff --git a/tests/test_webui_skeleton.py b/tests/test_webui_skeleton.py index 5dd23a1..bd290e8 100644 --- a/tests/test_webui_skeleton.py +++ b/tests/test_webui_skeleton.py @@ -46,10 +46,13 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertIn("Gitea-Tools", response.text) + def test_worktrees_is_implemented(self): + response = self.client.get("/worktrees") + self.assertEqual(response.status_code, 200) + self.assertIn("Worktree hygiene", response.text) + def test_extra_stub_routes(self): - for path in ("/worktrees", "/leases"): - with self.subTest(path=path): - self.assertEqual(self.client.get(path).status_code, 200) + self.assertEqual(self.client.get("/leases").status_code, 200) def test_post_is_rejected(self): response = self.client.post("/health") @@ -62,10 +65,10 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertIn("Live queue", response.text) def test_nav_links_on_all_pages(self): - for path in ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit"): + for path in ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit", "/worktrees"): with self.subTest(path=path): text = self.client.get(path).text - for href in ("/queue", "/projects", "/prompts", "/runtime", "/audit"): + for href in ("/queue", "/projects", "/prompts", "/runtime", "/audit", "/worktrees"): self.assertIn(f'href="{href}"', text) diff --git a/tests/test_webui_worktree_hygiene.py b/tests/test_webui_worktree_hygiene.py new file mode 100644 index 0000000..ee62596 --- /dev/null +++ b/tests/test_webui_worktree_hygiene.py @@ -0,0 +1,114 @@ +"""Tests for web UI worktree hygiene dashboard (#432).""" +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.worktree_scanner import ( + classify_entry, + load_hygiene_snapshot, + parse_worktree_list_porcelain, + snapshot_to_dict, +) + + +class TestWorktreeScanner(unittest.TestCase): + def test_parse_worktree_porcelain(self): + porcelain = ( + "worktree /repo/branches/feat-issue-1-foo\n" + "HEAD abcdef0123456789\n" + "branch refs/heads/feat/issue-1-foo\n" + "\n" + ) + entries = parse_worktree_list_porcelain(porcelain) + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0]["branch"], "feat/issue-1-foo") + + def test_classify_active_pr(self): + classification, _ = classify_entry( + rel_path="branches/feat-issue-99-demo", + worktree_record={"branch": "feat/issue-99-demo"}, + state={"exists": True, "clean": True, "dirty_files": []}, + open_pr_branches={"feat/issue-99-demo"}, + active_lock_branch=None, + ) + self.assertEqual(classification, "active-pr") + + def test_classify_dirty(self): + classification, _ = classify_entry( + rel_path="branches/feat-issue-2-bar", + worktree_record={"branch": "feat/issue-2-bar"}, + state={"exists": True, "clean": False, "dirty_files": ["webui/app.py"]}, + open_pr_branches=set(), + active_lock_branch=None, + ) + self.assertEqual(classification, "dirty") + + def test_missing_lock_worktree_anomaly(self): + with tempfile.TemporaryDirectory() as tmp: + branches = Path(tmp) / "branches" / "other-worktree" + branches.mkdir(parents=True) + lock_path = Path(tmp) / "lock.json" + lock_path.write_text( + json.dumps({ + "issue_number": 432, + "branch_name": "feat/issue-432-worktree-hygiene", + "worktree_path": str(Path(tmp) / "branches" / "missing-tree"), + }), + encoding="utf-8", + ) + snapshot = load_hygiene_snapshot( + project_root=tmp, + worktree_porcelain="", + open_pr_branches=set(), + issue_lock_path=str(lock_path), + ) + self.assertTrue(snapshot.anomalies) + self.assertIn("Missing preserved worktree", snapshot.anomalies[0]) + + def test_snapshot_to_dict(self): + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "branches").mkdir() + snapshot = load_hygiene_snapshot( + project_root=tmp, + worktree_porcelain="", + open_pr_branches=set(), + issue_lock_path=str(Path(tmp) / "no-lock.json"), + ) + data = snapshot_to_dict(snapshot) + self.assertIn("entries", data) + self.assertIn("cleanup_prompt", data) + + +class TestWorktreeRoutes(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_worktrees_page_renders(self): + response = self.client.get("/worktrees") + self.assertEqual(response.status_code, 200) + self.assertIn("Worktree hygiene", response.text) + self.assertIn("Canonical cleanup prompt", response.text) + self.assertIn("Copy cleanup prompt", response.text) + self.assertNotIn("child issue", response.text.lower()) + + def test_api_worktrees_json(self): + response = self.client.get("/api/worktrees") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("entries", data) + self.assertIn("cleanup_prompt", data) + self.assertIn("anomalies", data) + + def test_nav_includes_worktrees(self): + self.assertIn('href="/worktrees"', self.client.get("/").text) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/webui/app.py b/webui/app.py index a97d1ad..3dccded 100644 --- a/webui/app.py +++ b/webui/app.py @@ -16,6 +16,8 @@ from webui.prompt_library import find_prompt, library_to_dict from webui.prompt_views import render_prompt_detail, render_prompts_page from webui.queue_loader import load_queue_snapshot, snapshot_to_dict from webui.queue_views import render_queue_page +from webui.worktree_scanner import load_hygiene_snapshot, snapshot_to_dict as worktree_snapshot_to_dict +from webui.worktree_views import render_worktrees_page _READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) @@ -134,10 +136,12 @@ async def audit(_request: Request) -> HTMLResponse: async def worktrees(_request: Request) -> HTMLResponse: - return _stub_page( - "Worktrees", - "Worktree hygiene will summarize branches/ session folders and cleanup risk.", - ) + snapshot = load_hygiene_snapshot() + return HTMLResponse(render_worktrees_page(snapshot)) + + +async def api_worktrees(_request: Request) -> JSONResponse: + return JSONResponse(worktree_snapshot_to_dict(load_hygiene_snapshot())) async def leases(_request: Request) -> HTMLResponse: @@ -174,6 +178,7 @@ def create_app() -> Starlette: Route("/runtime", runtime, methods=["GET"]), Route("/audit", audit, methods=["GET"]), Route("/worktrees", worktrees, methods=["GET"]), + Route("/api/worktrees", api_worktrees, methods=["GET"]), Route("/leases", leases, methods=["GET"]), ], exception_handlers={405: method_not_allowed}, diff --git a/webui/worktree_scanner.py b/webui/worktree_scanner.py new file mode 100644 index 0000000..0916d9c --- /dev/null +++ b/webui/worktree_scanner.py @@ -0,0 +1,307 @@ +"""Local branches/ worktree hygiene scan for the web UI (#432).""" + +from __future__ import annotations + +import os +import re +import subprocess +from dataclasses import dataclass +from typing import Any, Callable + +from merged_cleanup_reconcile import ( + ISSUE_LOCK_FILE, + branch_worktree_folder, + read_issue_lock, + read_local_worktree_state, +) + +_REVIEW_WORKTREE_RE = re.compile( + r"branches/(?:review-pr\d+|merge-simulation-pr\d+|review-[\w-]+)", + re.IGNORECASE, +) + +CLASSIFICATIONS = frozenset({ + "active-pr", + "active-issue", + "dirty", + "stale-clean", + "detached-review", + "unsafe-unknown", + "orphan", +}) + +CLEANUP_PROMPT = ( + "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. Full steps: " + "skills/llm-project-workflow/templates/worktree-cleanup.md" +) + + +@dataclass(frozen=True) +class WorktreeEntry: + rel_path: str + folder_name: str + classification: str + branch: str | None + head_sha: str | None + dirty_tracked: int + dirty_untracked: bool + detached: bool + registered_worktree: bool + notes: str + + +@dataclass(frozen=True) +class HygieneSnapshot: + repo_root: str + entries: tuple[WorktreeEntry, ...] + anomalies: tuple[str, ...] + cleanup_prompt: str + scan_error: str | None = None + + +def _repo_root() -> str: + override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip() + if override: + return os.path.realpath(override) + return os.path.realpath(os.path.join(os.path.dirname(__file__), "..")) + + +def _relative_branches_path(project_root: str, path: str) -> str: + root = os.path.normpath(project_root) + normalized = os.path.normpath(path) + if normalized.startswith(root + os.sep): + return normalized[len(root) + 1 :].replace("\\", "/") + return normalized.replace("\\", "/") + + +def parse_worktree_list_porcelain(porcelain: str) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + current: dict[str, Any] = {} + for raw in (porcelain or "").splitlines(): + line = raw.strip() + if not line: + if current: + entries.append(current) + current = {} + continue + if line.startswith("worktree "): + if current: + entries.append(current) + current = {"path": line.split(" ", 1)[1].strip()} + elif line.startswith("HEAD "): + current["head_sha"] = line.split(" ", 1)[1].strip() + elif line.startswith("branch "): + current["branch"] = line.split(" ", 1)[1].strip().removeprefix("refs/heads/") + elif line == "detached": + current["detached"] = True + if current: + entries.append(current) + return entries + + +def list_branches_directories(project_root: str) -> list[str]: + branches_root = os.path.join(project_root, "branches") + if not os.path.isdir(branches_root): + return [] + names: list[str] = [] + for entry in sorted(os.listdir(branches_root)): + if entry.startswith("."): + continue + full = os.path.join(branches_root, entry) + if os.path.isdir(full): + names.append(f"branches/{entry}") + return names + + +def _untracked_dirty(porcelain: str) -> bool: + return any(line.startswith("??") for line in (porcelain or "").splitlines()) + + +def classify_entry( + *, + rel_path: str, + worktree_record: dict[str, Any] | None, + state: dict[str, Any], + open_pr_branches: set[str], + active_lock_branch: str | None, +) -> tuple[str, str]: + record = worktree_record or {} + branch_name = (record.get("branch") or state.get("current_branch") or "").strip() + folder_name = rel_path.split("/", 1)[-1] if "/" in rel_path else rel_path + + if branch_name in open_pr_branches: + return "active-pr", "Head branch matches an open PR" + if active_lock_branch and branch_name == active_lock_branch: + return "active-issue", "Matches active issue lock branch" + + dirty_tracked = len(state.get("dirty_files") or []) + dirty_untracked = bool(state.get("dirty_untracked")) + if dirty_tracked or dirty_untracked: + return "dirty", "Local tracked or untracked changes present" + + if not record and not state.get("exists"): + return "orphan", "Directory missing on disk" + + if not record: + return "orphan", "Directory exists but is not a registered git worktree" + + if record.get("detached") and REVIEW_WORKTREE_RE.search(rel_path): + return "detached-review", "Detached HEAD in reviewer/simulation worktree" + + if state.get("exists") and state.get("clean"): + return "stale-clean", "Registered worktree with clean working tree" + + return "unsafe-unknown", "Could not classify safely — inspect manually before cleanup" + + +def _missing_preserved_anomalies( + project_root: str, + lock: dict[str, Any] | None, + entries_by_path: dict[str, WorktreeEntry], +) -> tuple[str, ...]: + anomalies: list[str] = [] + if not lock: + return tuple(anomalies) + + branch = (lock.get("branch_name") or "").strip() + issue_number = lock.get("issue_number") + worktree_path = (lock.get("worktree_path") or "").strip() + expected_rel = _relative_branches_path(project_root, worktree_path) if worktree_path else "" + if worktree_path and not os.path.isdir(worktree_path): + anomalies.append( + f"Missing preserved worktree for issue #{issue_number}: " + f"lock path {worktree_path!r} not found on disk (#404)" + ) + elif expected_rel.startswith("branches/") and expected_rel not in entries_by_path: + anomalies.append( + f"Missing preserved worktree folder {expected_rel} for locked branch {branch!r}" + ) + elif branch: + folder = f"branches/{branch_worktree_folder(branch)}" + entry = entries_by_path.get(folder) + if entry and entry.classification in {"orphan", "unsafe-unknown"}: + anomalies.append( + f"Locked branch {branch!r} maps to {folder} but classification is " + f"{entry.classification}" + ) + return tuple(anomalies) + + +def load_hygiene_snapshot( + *, + project_root: str | None = None, + worktree_porcelain: str | None = None, + open_pr_branches: set[str] | None = None, + issue_lock_path: str | None = None, + run_git: Callable[[list[str]], subprocess.CompletedProcess[str]] | None = None, +) -> HygieneSnapshot: + root = project_root or _repo_root() + lock_path = issue_lock_path if issue_lock_path is not None else ISSUE_LOCK_FILE + lock = read_issue_lock(lock_path) + active_lock_branch = (lock.get("branch_name") or "").strip() if lock else None + + git_run = run_git or ( + lambda args: subprocess.run(args, capture_output=True, text=True, check=False) + ) + + scan_error: str | None = None + porcelain = worktree_porcelain + if porcelain is None: + result = git_run(["git", "-C", root, "worktree", "list", "--porcelain"]) + if result.returncode != 0: + scan_error = (result.stderr or result.stdout or "git worktree list failed").strip() + porcelain = "" + else: + porcelain = result.stdout or "" + + worktrees = parse_worktree_list_porcelain(porcelain) + worktree_by_rel: dict[str, dict[str, Any]] = {} + for wt in worktrees: + rel = _relative_branches_path(root, wt.get("path") or "") + if rel.startswith("branches/"): + worktree_by_rel[rel] = wt + + open_heads = set(open_pr_branches or ()) + if not open_heads: + try: + from webui.queue_loader import load_queue_snapshot + + snapshot = load_queue_snapshot() + open_heads = { + item.extra.get("branch", "") + for item in snapshot.prs + if item.extra.get("branch") + } + except Exception: + open_heads = set() + + entries: list[WorktreeEntry] = [] + for rel_path in list_branches_directories(root): + abs_path = os.path.join(root, rel_path) + record = worktree_by_rel.get(rel_path) + state = read_local_worktree_state(abs_path) + if state.get("exists"): + status = git_run(["git", "-C", abs_path, "status", "--porcelain"]) + state = { + **state, + "dirty_untracked": _untracked_dirty(status.stdout or ""), + } + classification, note = classify_entry( + rel_path=rel_path, + worktree_record=record, + state=state, + open_pr_branches=open_heads, + active_lock_branch=active_lock_branch, + ) + entries.append( + WorktreeEntry( + rel_path=rel_path, + folder_name=rel_path.split("/", 1)[-1], + classification=classification, + branch=(record or {}).get("branch") or state.get("current_branch"), + head_sha=(record or {}).get("head_sha") or state.get("head_sha"), + dirty_tracked=len(state.get("dirty_files") or []), + dirty_untracked=bool(state.get("dirty_untracked")), + detached=bool((record or {}).get("detached")), + registered_worktree=record is not None, + notes=note, + ) + ) + + entries_by_path = {entry.rel_path: entry for entry in entries} + anomalies = _missing_preserved_anomalies(root, lock, entries_by_path) + + return HygieneSnapshot( + repo_root=root, + entries=tuple(entries), + anomalies=anomalies, + cleanup_prompt=CLEANUP_PROMPT, + scan_error=scan_error, + ) + + +def snapshot_to_dict(snapshot: HygieneSnapshot) -> dict[str, Any]: + return { + "repo_root": snapshot.repo_root, + "count": len(snapshot.entries), + "cleanup_prompt": snapshot.cleanup_prompt, + "anomalies": list(snapshot.anomalies), + "scan_error": snapshot.scan_error, + "entries": [ + { + "rel_path": entry.rel_path, + "folder_name": entry.folder_name, + "classification": entry.classification, + "branch": entry.branch, + "head_sha": entry.head_sha, + "dirty_tracked": entry.dirty_tracked, + "dirty_untracked": entry.dirty_untracked, + "detached": entry.detached, + "registered_worktree": entry.registered_worktree, + "notes": entry.notes, + } + for entry in snapshot.entries + ], + } \ No newline at end of file diff --git a/webui/worktree_views.py b/webui/worktree_views.py new file mode 100644 index 0000000..77bd23a --- /dev/null +++ b/webui/worktree_views.py @@ -0,0 +1,130 @@ +"""HTML views for branches/ worktree hygiene dashboard (#432).""" + +from __future__ import annotations + +import html + +from webui.layout import render_page +from webui.worktree_scanner import HygieneSnapshot, WorktreeEntry + + +def _escape(text: str) -> str: + return html.escape(text, quote=True) + + +def _badge(classification: str) -> str: + return ( + f'' + f"{_escape(classification)}" + ) + + +def _entry_row(entry: WorktreeEntry) -> str: + branch = entry.branch or "—" + head = entry.head_sha[:12] if entry.head_sha else "—" + dirty = ( + f"{entry.dirty_tracked} tracked" + + (" + untracked" if entry.dirty_untracked else "") + if entry.dirty_tracked or entry.dirty_untracked + else "clean" + ) + return ( + "" + f"{_escape(entry.rel_path)}" + f"{_badge(entry.classification)}" + f"{_escape(branch)}" + f"{_escape(head)}" + f"{_escape(dirty)}" + f"{'yes' if entry.detached else 'no'}" + f"{_escape(entry.notes)}" + "" + ) + + +WORKTREE_PAGE_SCRIPT = """ + +""" + +WORKTREE_PAGE_STYLES = """ + +""" + + +def render_worktrees_page(snapshot: HygieneSnapshot) -> str: + error_block = "" + if snapshot.scan_error: + error_block = ( + '

Partial scan: ' + f"{_escape(snapshot.scan_error)}

" + ) + + anomaly_block = "" + if snapshot.anomalies: + items = "".join(f"
  • {_escape(text)}
  • " for text in snapshot.anomalies) + anomaly_block = ( + '

    Missing preserved worktree anomalies

    ' + f"
      {items}
    " + ) + + if snapshot.entries: + rows = "".join(_entry_row(entry) for entry in snapshot.entries) + table = ( + "" + "" + "" + "" + "" + f"{rows}
    PathClassBranchHEADDirtyDetachedNotes
    " + ) + else: + table = "

    No directories under branches/.

    " + + body = ( + "

    Worktree hygiene

    " + "

    Read-only scan of local branches/ worktrees. " + "No deletion actions — copy the canonical cleanup prompt when merge is confirmed.

    " + f"{error_block}" + f"{anomaly_block}" + f"

    Repo root: {_escape(snapshot.repo_root)} · " + f"entries: {len(snapshot.entries)}

    " + f"{table}" + "

    Canonical cleanup prompt

    " + f'
    {_escape(snapshot.cleanup_prompt)}
    ' + '' + "

    JSON API

    " + f"{WORKTREE_PAGE_STYLES}" + f"{WORKTREE_PAGE_SCRIPT}" + ) + return render_page(title="Worktrees", body_html=body) \ No newline at end of file From eff4572a91d355ce5b0116cab0aefe0099fba11c Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 03:46:25 -0400 Subject: [PATCH 3/8] feat: add Controller Handoff + Thread State Ledger validation (#507) Introduce thread_state_ledger_validator for the mandatory two-comment workflow: tagged [CONTROLLER HANDOFF] paired with [THREAD STATE LEDGER]. Wire validation into final_report_validator and gitea_create_issue_comment, add runbook docs, worked examples, and tests. Closes #507 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../examples/two-comment-workflow-examples.md | 43 ++ docs/llm-workflow-runbooks.md | 40 ++ final_report_validator.py | 17 + gitea_mcp_server.py | 20 + tests/test_final_report_validator.py | 22 + tests/test_mcp_server.py | 14 + tests/test_thread_state_ledger_validator.py | 187 ++++++++ thread_state_ledger_examples.py | 341 +++++++++++++++ thread_state_ledger_validator.py | 399 ++++++++++++++++++ 9 files changed, 1083 insertions(+) create mode 100644 docs/examples/two-comment-workflow-examples.md create mode 100644 tests/test_thread_state_ledger_validator.py create mode 100644 thread_state_ledger_examples.py create mode 100644 thread_state_ledger_validator.py diff --git a/docs/examples/two-comment-workflow-examples.md b/docs/examples/two-comment-workflow-examples.md new file mode 100644 index 0000000..84e1d58 --- /dev/null +++ b/docs/examples/two-comment-workflow-examples.md @@ -0,0 +1,43 @@ +# Two-comment workflow examples (#507) + +Paired `[CONTROLLER HANDOFF]` + `[THREAD STATE LEDGER]` comments for Gitea threads. +See `thread_state_ledger_examples.py` for machine-checked fixtures. + +## Approved review posted + +**Handoff** (detailed): identity, worktree, validation commands, mutation ledger with +`gitea_submit_pr_review → APPROVED review posted to Gitea`. + +**Ledger** (concise): + +```markdown +[THREAD STATE LEDGER] PR #487 — APPROVED review posted to Gitea + +What is true now: +- PR state: open +- Server-side decision state: APPROVED review posted to Gitea +- Local verdict/state: APPROVE verdict prepared locally + +What is blocked: +- Blocker classification: no blocker + +Who/what acts next: +- Next actor: merger +- Required action: merge on explicit operator command +- Do not do: re-post APPROVE +``` + +## Approve validated locally but blocked before posting + +Ledger must show `no server-side state changed` under server-side decision state and +`APPROVE verdict prepared locally` under local verdict/state. + +## Environment / tooling blocker + +Ledger blocker classification: `environment/tooling blocker`. Mutation ledger: +`none — no server-side state changed`. + +## Stale head blocker + +Ledger: `approval_at_current_head is false`; classification `stale head`. +Do not do: merge with stale approval. \ No newline at end of file diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index d67769d..c6ee768 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -760,6 +760,46 @@ touched release state names the exact tag/commit and why. Design debates belong in **discussion/RFC issues** (e.g. #100 `profiles.json v2`) — comment on the issue, create no branches/PRs, and end the comment with this handoff. +## Two-comment workflow reporting (#507) + +After meaningful controller/workflow work, post **two separate Gitea comments** +(not one combined blob): + +1. **`[CONTROLLER HANDOFF]`** — detailed operational continuation for the + next LLM/controller (proof-heavy; may be long). +2. **`[THREAD STATE LEDGER]`** — short canonical truth readable in ~30 seconds. + +The ledger must answer: what is true now, what changed, what is blocked, +who/what acts next — and must **separate**: + +- local verdict/state +- server-side Gitea state +- attempted-but-blocked mutations +- completed mutations + +Use precise state phrases (`APPROVED review posted to Gitea`, +`APPROVE verdict prepared locally`, `merge performed`, `merge not performed`, +`no server-side state changed`, `lease attempt blocked`) instead of ambiguous +standalone words (`approved`, `merged`, `ready`, `blocked`, `done`). + +The ledger must include a **blocker classification** from: +`code blocker`, `test blocker`, `merge conflict`, `stale head`, +`permission/capability blocker`, `environment/tooling blocker`, +`process/rule blocker`, `queue/lease blocker`, +`duplicate/canonicalization blocker`, `no blocker`. + +Templates and worked examples: +[`examples/two-comment-workflow-examples.md`](examples/two-comment-workflow-examples.md). + +Validation: `thread_state_ledger_validator.py` checks tagged comments at post +time (`gitea_create_issue_comment`) and tagged final reports via +`assess_final_report_validator`. Legacy `## Controller Handoff` final reports +remain valid during transition; the tagged pair is required for new workflow +comments. + +Related (do not duplicate): #494/#495 lifecycle state, #501 mutation-ledger +consistency, #505 CTH umbrella, #496 workflow comment gate when merged. + ## Fail-closed behavior Before any mutating action the workflow verifies identity, active profile, diff --git a/final_report_validator.py b/final_report_validator.py index 352a3ff..a9cc1fc 100644 --- a/final_report_validator.py +++ b/final_report_validator.py @@ -12,6 +12,7 @@ import re from typing import Any, Callable import issue_lock_provenance +import thread_state_ledger_validator from review_proofs import ( HANDOFF_HEADING, assess_controller_handoff, @@ -1042,16 +1043,26 @@ def _rule_reviewer_review_mutation( ) +def _rule_shared_two_comment_workflow(report_text: str) -> list[dict[str, str]]: + """#507: tagged Controller Handoff must pair with Thread State Ledger.""" + return thread_state_ledger_validator.findings_for_final_report(report_text) + + _SHARED_ISSUE_LOCK_RULES = ( _rule_shared_issue_lock_external_state, _rule_shared_manual_lock_pr_override, _rule_shared_author_reviewer_same_run, ) +_SHARED_TWO_COMMENT_RULES = ( + _rule_shared_two_comment_workflow, +) + _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { "review_pr": [ _rule_shared_controller_handoff, _rule_shared_email_disclosure, + *_SHARED_TWO_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, _rule_reviewer_legacy_workspace_mutations, _rule_reviewer_vague_mutations_none, @@ -1076,6 +1087,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { "reconcile_already_landed": [ _rule_reconcile_controller_handoff, _rule_shared_email_disclosure, + *_SHARED_TWO_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, _rule_reconcile_stale_author_fields, _rule_reconcile_eligible_reviewed, @@ -1088,12 +1100,14 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { "author_issue": [ _rule_shared_controller_handoff, _rule_shared_email_disclosure, + *_SHARED_TWO_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, _rule_reviewer_vague_mutations_none, ], "work_issue": [ _rule_shared_controller_handoff, _rule_shared_email_disclosure, + *_SHARED_TWO_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, _rule_reviewer_vague_mutations_none, _rule_conflict_fix_push_proof, @@ -1101,17 +1115,20 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { "issue_filing": [ _rule_shared_controller_handoff, _rule_shared_email_disclosure, + *_SHARED_TWO_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, ], "inventory": [ _rule_shared_controller_handoff, _rule_shared_email_disclosure, + *_SHARED_TWO_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, _rule_reconcile_pagination_proof, ], "issue_selection": [ _rule_shared_controller_handoff, _rule_shared_email_disclosure, + *_SHARED_TWO_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, ], } diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 160e4de..2a08151 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -548,6 +548,7 @@ import reconciliation_workflow # noqa: E402 import review_merge_state_machine # noqa: E402 import pr_work_lease # noqa: E402 import native_mcp_preference # noqa: E402 +import thread_state_ledger_validator # noqa: E402 # Keyed issue-lock storage (#443): per remote/org/repo/issue files under @@ -5217,6 +5218,23 @@ def gitea_list_issue_comments( return {"success": True, "issue_number": issue_number, "comments": out} +def _two_comment_workflow_comment_gate(body: str) -> list[str]: + """Fail closed on invalid tagged workflow comments (#507).""" + text = body or "" + reasons: list[str] = [] + if thread_state_ledger_validator.has_controller_handoff_marker(text): + result = thread_state_ledger_validator.assess_controller_handoff_comment( + text + ) + reasons.extend(result.get("reasons") or []) + if thread_state_ledger_validator.has_thread_state_ledger_marker(text): + result = thread_state_ledger_validator.assess_thread_state_ledger_comment( + text + ) + reasons.extend(result.get("reasons") or []) + return reasons + + @mcp.tool() def gitea_create_issue_comment( issue_number: int, @@ -5259,6 +5277,8 @@ def gitea_create_issue_comment( reasons = list(gate_reasons) if not (body or "").strip(): reasons.append("comment body must be a non-empty string") + if not reasons: + reasons.extend(_two_comment_workflow_comment_gate(body)) if reasons: blocked = {"success": False, "performed": False, "issue_number": issue_number, "reasons": reasons} diff --git a/tests/test_final_report_validator.py b/tests/test_final_report_validator.py index 88c5b23..45942af 100644 --- a/tests/test_final_report_validator.py +++ b/tests/test_final_report_validator.py @@ -108,6 +108,28 @@ def _reconcile_handoff(drop=(), **overrides): return text +class TestTwoCommentWorkflowIntegration(unittest.TestCase): + def test_tagged_pair_produces_no_two_comment_findings(self): + from thread_state_ledger_validator import findings_for_final_report # noqa: E402 + from tests.test_thread_state_ledger_validator import ( # noqa: E402 + _minimal_handoff, + _minimal_ledger, + ) + + report = _minimal_handoff() + "\n\n" + _minimal_ledger() + self.assertEqual(findings_for_final_report(report), []) + + def test_tagged_handoff_without_ledger_blocks_via_validator(self): + from thread_state_ledger_validator import findings_for_final_report # noqa: E402 + from tests.test_thread_state_ledger_validator import _minimal_handoff # noqa: E402 + + findings = findings_for_final_report(_minimal_handoff()) + self.assertTrue(findings) + self.assertTrue( + any(f["severity"] == "block" for f in findings) + ) + + class TestValidatorFinding(unittest.TestCase): def test_finding_shape(self): finding = validator_finding( diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 651b253..67b3974 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -3078,6 +3078,20 @@ class TestIssueCommentTools(unittest.TestCase): self.assertFalse(result["success"]) mock_api.assert_not_called() + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_create_tagged_handoff_missing_mutation_ledger_blocked( + self, _auth, mock_api + ): + body = "[CONTROLLER HANDOFF] PR #1 — test\n\nBlockers:\n- none" + with patch.dict(os.environ, self.AUTHOR_ENV, clear=True): + result = gitea_create_issue_comment( + issue_number=9, body=body, remote="prgs") + self.assertFalse(result["success"]) + self.assertFalse(result["performed"]) + self.assertTrue(result["reasons"]) + mock_api.assert_not_called() + @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_create_empty_body_blocked(self, _auth, mock_api): diff --git a/tests/test_thread_state_ledger_validator.py b/tests/test_thread_state_ledger_validator.py new file mode 100644 index 0000000..8b5b0f2 --- /dev/null +++ b/tests/test_thread_state_ledger_validator.py @@ -0,0 +1,187 @@ +"""Tests for two-comment workflow validation (#507).""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from thread_state_ledger_validator import ( # noqa: E402 + BLOCKER_CLASSIFICATIONS, + assess_controller_handoff_comment, + assess_thread_state_ledger_comment, + assess_two_comment_workflow, + assess_two_comment_pair, +) + + +def _minimal_handoff(**overrides): + lines = [ + "[CONTROLLER HANDOFF] PR #203 / Issue #182 — review complete", + "", + "Identity/profile:", + "- Active profile: prgs-reviewer", + "- Authenticated identity: sysadmin", + "", + "Server-side mutation ledger:", + "- gitea_submit_pr_review → APPROVED review posted to Gitea", + "", + "Blockers:", + "- none", + ] + text = "\n".join(lines) + for key, value in overrides.items(): + if f"{key}:" in text: + text = text.replace(f"{key}:", f"{key}: {value}", 1) + return text + + +def _minimal_ledger(**overrides): + lines = [ + "[THREAD STATE LEDGER] PR #203 — APPROVED review posted to Gitea", + "", + "What is true now:", + "- PR state: open", + "- Current head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", + "- Server-side decision state: APPROVED review posted to Gitea", + "- Local verdict/state: APPROVE verdict prepared locally", + "- Latest known validation: tests passed locally", + "", + "What changed:", + "- APPROVED review posted to Gitea at pinned head", + "", + "What is blocked:", + "- Blocker classification: no blocker", + "", + "Who/what acts next:", + "- Next actor: merger", + "- Required action: merge on explicit operator command", + "- Do not do: re-post APPROVE", + "- Resume from: PR #203 review feedback", + ] + text = "\n".join(lines) + for key, value in overrides.items(): + text = text.replace(f"- {key}:", f"- {key}: {value}") + return text + + +class TestTwoCommentWorkflow(unittest.TestCase): + def test_valid_combined_report_passes(self): + report = _minimal_handoff() + "\n\n" + _minimal_ledger() + result = assess_two_comment_workflow(report) + self.assertTrue(result["proven"]) + self.assertFalse(result["block"]) + + def test_missing_ledger_after_tagged_handoff_blocks(self): + result = assess_two_comment_workflow(_minimal_handoff()) + self.assertTrue(result["block"]) + self.assertTrue( + any("THREAD STATE LEDGER" in r for r in result["reasons"]) + ) + + def test_legacy_controller_handoff_section_not_required_ledger(self): + legacy = "## Controller Handoff\n\n- Task: work issue\n- Next: continue\n" + result = assess_two_comment_workflow(legacy) + self.assertFalse(result["block"]) + + def test_ambiguous_approved_blocks(self): + handoff = _minimal_handoff().replace( + "APPROVED review posted to Gitea", + "approved", + ) + report = handoff + "\n\n" + _minimal_ledger() + result = assess_two_comment_workflow(report) + self.assertTrue(result["block"]) + self.assertTrue(any("approved" in r.lower() for r in result["reasons"])) + + def test_ambiguous_merged_blocks(self): + handoff = _minimal_handoff() + "\n\n- Narrative: merged successfully" + ledger = _minimal_ledger() + report = handoff + "\n\n" + ledger + result = assess_two_comment_workflow(report) + self.assertTrue(result["block"]) + self.assertTrue(any("merge" in r.lower() for r in result["reasons"])) + + def test_blocked_without_gate_blocks(self): + handoff = _minimal_handoff().replace("- none", "- blocked") + ledger = _minimal_ledger().replace( + "no blocker", "blocked but no gate named" + ) + report = handoff + "\n\n" + ledger + result = assess_two_comment_workflow(report) + self.assertTrue(result["block"]) + + def test_mutation_ledger_contradiction_blocks(self): + handoff = _minimal_handoff().replace( + "APPROVED review posted to Gitea", + "none — no server-side state changed", + ) + handoff += "\n- Narrative: APPROVED review posted to Gitea" + report = handoff + "\n\n" + _minimal_ledger() + result = assess_two_comment_workflow(report) + self.assertTrue(result["block"]) + self.assertTrue(any("contradict" in r.lower() for r in result["reasons"])) + + def test_local_verdict_as_server_side_blocks(self): + ledger = _minimal_ledger( + **{ + "Server-side decision state": "APPROVE verdict prepared locally", + "Local verdict/state": "none", + } + ) + report = _minimal_handoff() + "\n\n" + ledger + result = assess_two_comment_workflow(report) + self.assertTrue(result["block"]) + + +class TestHandoffComment(unittest.TestCase): + def test_handoff_comment_valid(self): + result = assess_controller_handoff_comment(_minimal_handoff()) + self.assertTrue(result["proven"]) + + def test_handoff_without_marker_skips(self): + result = assess_controller_handoff_comment("casual comment") + self.assertTrue(result["proven"]) + self.assertFalse(result["performed"]) + + +class TestLedgerComment(unittest.TestCase): + def test_ledger_requires_blocker_classification(self): + ledger = _minimal_ledger().replace( + "Blocker classification: no blocker", + "something unclear", + ) + result = assess_thread_state_ledger_comment(ledger) + self.assertTrue(result["block"]) + + def test_all_blocker_classifications_recognized(self): + self.assertIn("no blocker", BLOCKER_CLASSIFICATIONS) + self.assertIn("environment/tooling blocker", BLOCKER_CLASSIFICATIONS) + + +class TestPairedComments(unittest.TestCase): + def test_pair_passes(self): + result = assess_two_comment_pair(_minimal_handoff(), _minimal_ledger()) + self.assertTrue(result["proven"]) + + def test_pair_missing_ledger_blocks(self): + result = assess_two_comment_pair(_minimal_handoff(), "") + self.assertTrue(result["block"]) + + +class TestExamples(unittest.TestCase): + """Smoke-test bundled examples from docs.""" + + def test_examples_module_loads(self): + from thread_state_ledger_examples import EXAMPLES # noqa: E402 + + self.assertGreaterEqual(len(EXAMPLES), 8) + for name, handoff, ledger in EXAMPLES: + result = assess_two_comment_pair(handoff, ledger) + self.assertTrue( + result["proven"], + f"example '{name}' failed: {result['reasons']}", + ) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/thread_state_ledger_examples.py b/thread_state_ledger_examples.py new file mode 100644 index 0000000..9c17442 --- /dev/null +++ b/thread_state_ledger_examples.py @@ -0,0 +1,341 @@ +"""Worked examples for the two-comment workflow (#507).""" + +from __future__ import annotations + +HEAD_SHA = "08202f7eaa6d09b2eca8f2960126994a4b22646b" + +EXAMPLES: list[tuple[str, str, str]] = [] + + +def _example(name: str, handoff: str, ledger: str) -> tuple[str, str, str]: + return (name, handoff.strip(), ledger.strip()) + + +EXAMPLES.append( + _example( + "approved_review_posted", + f""" +[CONTROLLER HANDOFF] PR #487 / Issue #485 — review approved + +Server-side mutation ledger: +- gitea_submit_pr_review → APPROVED review posted to Gitea (comment id 6566) + +Blockers: +- none +""", + f""" +[THREAD STATE LEDGER] PR #487 — APPROVED review posted to Gitea + +What is true now: +- PR state: open +- Current head SHA: {HEAD_SHA} +- Server-side decision state: APPROVED review posted to Gitea +- Local verdict/state: APPROVE verdict prepared locally +- Latest known validation: tests passed locally + +What changed: +- APPROVED review posted to Gitea at pinned head + +What is blocked: +- Blocker classification: no blocker + +Who/what acts next: +- Next actor: merger +- Required action: merge on explicit operator command +- Do not do: re-post APPROVE +- Resume from: PR #487 review feedback +""", + ) +) + +EXAMPLES.append( + _example( + "approve_blocked_before_posting", + f""" +[CONTROLLER HANDOFF] PR #487 / Issue #485 — approve blocked + +Server-side mutation ledger: +- none — no server-side state changed + +Blockers: +- process/rule blocker: MCP process root still control checkout +""", + f""" +[THREAD STATE LEDGER] PR #487 — APPROVE verdict prepared locally; post blocked + +What is true now: +- PR state: open +- Current head SHA: {HEAD_SHA} +- Server-side decision state: no server-side state changed +- Local verdict/state: APPROVE verdict prepared locally +- Latest known validation: tests passed locally + +What changed: +- Review validation completed locally; APPROVE not posted + +What is blocked: +- Blocker classification: process/rule blocker + +Who/what acts next: +- Next actor: operator +- Required action: reconnect reviewer MCP to branches worktree +- Do not do: retry APPROVE from root-bound session +- Resume from: lease + mark_final_review_decision + submit +""", + ) +) + +EXAMPLES.append( + _example( + "request_changes_posted", + f""" +[CONTROLLER HANDOFF] PR #200 — request changes + +Server-side mutation ledger: +- gitea_submit_pr_review → REQUEST_CHANGES posted to Gitea + +Blockers: +- none +""", + """ +[THREAD STATE LEDGER] PR #200 — REQUEST_CHANGES posted to Gitea + +What is true now: +- PR state: open +- Server-side decision state: REQUEST_CHANGES posted to Gitea +- Local verdict/state: REQUEST_CHANGES prepared locally +- Latest known validation: tests passed locally + +What changed: +- REQUEST_CHANGES posted to Gitea + +What is blocked: +- Blocker classification: no blocker + +Who/what acts next: +- Next actor: author +- Required action: address review feedback and push fixes +- Do not do: merge +- Resume from: PR review comments +""", + ) +) + +EXAMPLES.append( + _example( + "merge_completed", + f""" +[CONTROLLER HANDOFF] PR #487 — merge performed + +Server-side mutation ledger: +- gitea_merge_pr → merge performed + +Blockers: +- none +""", + f""" +[THREAD STATE LEDGER] PR #487 — merge performed + +What is true now: +- PR state: merged +- Current head SHA: {HEAD_SHA} +- Server-side decision state: merge performed +- Local verdict/state: merge not attempted locally before MCP merge +- Latest known validation: full suite not rerun + +What changed: +- merge performed via gitea_merge_pr + +What is blocked: +- Blocker classification: no blocker + +Who/what acts next: +- Next actor: reconciler +- Required action: verify linked issue closure policy +- Do not do: re-merge +- Resume from: post-merge cleanup checklist +""", + ) +) + +EXAMPLES.append( + _example( + "merge_blocked", + f""" +[CONTROLLER HANDOFF] PR #487 — merge not performed + +Server-side mutation ledger: +- gitea_merge_pr attempted → merge not performed (stale approval gate) + +Blockers: +- stale head blocker: approval not at current head +""", + f""" +[THREAD STATE LEDGER] PR #487 — merge not performed + +What is true now: +- PR state: open +- Current head SHA: {HEAD_SHA} +- Server-side decision state: merge not performed +- Local verdict/state: merge not performed +- Latest known validation: tests passed locally + +What changed: +- merge attempt blocked by stale-head gate + +What is blocked: +- Blocker classification: stale head + +Who/what acts next: +- Next actor: reviewer +- Required action: re-review at current head +- Do not do: merge until approval_at_current_head is true +- Resume from: gitea_get_pr_review_feedback +""", + ) +) + +EXAMPLES.append( + _example( + "reconciler_closure", + f""" +[CONTROLLER HANDOFF] PR #99 — reconciler closure + +Server-side mutation ledger: +- gitea_reconcile_already_landed_pr → PR closed on Gitea + +Blockers: +- none +""", + """ +[THREAD STATE LEDGER] PR #99 — reconciler closure complete + +What is true now: +- PR state: closed +- Server-side decision state: server-side state changed +- Local verdict/state: no server-side state changed locally before MCP close +- Latest known validation: ancestor proof passed + +What changed: +- superseded PR closed by reconciler + +What is blocked: +- Blocker classification: no blocker + +Who/what acts next: +- Next actor: controller +- Required action: verify queue has no duplicate open PR +- Do not do: reopen without canonical PR proof +- Resume from: queue inventory +""", + ) +) + +EXAMPLES.append( + _example( + "environment_tooling_blocker", + f""" +[CONTROLLER HANDOFF] PR #487 — tooling blocker + +Server-side mutation ledger: +- none — no server-side state changed + +Blockers: +- environment/tooling blocker: gitea-reviewer MCP process root mismatch +""", + f""" +[THREAD STATE LEDGER] PR #487 — lease attempt blocked + +What is true now: +- PR state: open +- Current head SHA: {HEAD_SHA} +- Server-side decision state: no server-side state changed +- Local verdict/state: APPROVE verdict prepared locally +- Latest known validation: tests passed locally + +What changed: +- lease attempt blocked by workspace guard + +What is blocked: +- Blocker classification: environment/tooling blocker + +Who/what acts next: +- Next actor: operator +- Required action: set GITEA_AUTHOR_WORKTREE and /mcp reconnect +- Do not do: acquire lease from control checkout +- Resume from: gitea_get_runtime_context +""", + ) +) + +EXAMPLES.append( + _example( + "stale_head_blocker", + f""" +[CONTROLLER HANDOFF] PR #300 — stale head + +Server-side mutation ledger: +- none — no server-side state changed + +Blockers: +- stale head blocker: live head differs from pinned reviewed head +""", + """ +[THREAD STATE LEDGER] PR #300 — stale approval + +What is true now: +- PR state: open +- Server-side decision state: no server-side state changed +- Local verdict/state: approval_at_current_head is false (stale head) +- Latest known validation: full suite not rerun + +What changed: +- author pushed after approval + +What is blocked: +- Blocker classification: stale head + +Who/what acts next: +- Next actor: reviewer +- Required action: fresh review at current head +- Do not do: merge with stale approval +- Resume from: gitea_get_pr_review_feedback +""", + ) +) + +EXAMPLES.append( + _example( + "duplicate_canonicalization_blocker", + f""" +[CONTROLLER HANDOFF] Issue filing — duplicate found + +Server-side mutation ledger: +- gitea_create_issue_comment on #505 with missing acceptance criteria + +Blockers: +- duplicate/canonicalization blocker: #505 tracks CTH umbrella +""", + """ +[THREAD STATE LEDGER] Issue #507 — filed as focused split from #505 + +What is true now: +- Issue state: open +- Server-side decision state: server-side state changed +- Local verdict/state: no server-side state changed before issue create +- Latest known validation: duplicate search completed + +What changed: +- new issue #507 created after duplicate assessment + +What is blocked: +- Blocker classification: no blocker + +Who/what acts next: +- Next actor: author +- Required action: implement #507 two-comment validator +- Do not do: recreate duplicate CTH issue +- Resume from: issue #507 body +""", + ) +) \ No newline at end of file diff --git a/thread_state_ledger_validator.py b/thread_state_ledger_validator.py new file mode 100644 index 0000000..5dde2b1 --- /dev/null +++ b/thread_state_ledger_validator.py @@ -0,0 +1,399 @@ +"""Two-comment workflow validation for Controller Handoff + Thread State Ledger (#507). + +Validates the paired reporting pattern without replacing the CTH umbrella (#505) +or the broader lifecycle ledger (#494/#495). +""" + +from __future__ import annotations + +import re +from typing import Any + +CONTROLLER_HANDOFF_TAG = "[CONTROLLER HANDOFF]" +THREAD_STATE_LEDGER_TAG = "[THREAD STATE LEDGER]" + +BLOCKER_CLASSIFICATIONS = frozenset({ + "code blocker", + "test blocker", + "merge conflict", + "stale head", + "permission/capability blocker", + "environment/tooling blocker", + "process/rule blocker", + "queue/lease blocker", + "duplicate/canonicalization blocker", + "no blocker", +}) + +_PRECISE_APPROVE_PHRASES = ( + "approve verdict prepared locally", + "approved review posted to gitea", + "request_changes posted to gitea", +) + +_PRECISE_MERGE_PHRASES = ( + "merge performed", + "merge not performed", +) + +_PRECISE_SERVER_PHRASES = ( + "server-side state changed", + "no server-side state changed", +) + +_AMBIGUOUS_STANDALONE_TERMS = ( + "approved", + "ready", + "queued", + "blocked", + "done", + "merged", + "submitted", + "validated", +) + +_HANDOFF_TAG_RE = re.compile(r"\[CONTROLLER\s+HANDOFF\]", re.IGNORECASE) +_LEDGER_TAG_RE = re.compile(r"\[THREAD\s+STATE\s+LEDGER\]", re.IGNORECASE) +_LEGACY_HANDOFF_RE = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M) + +_MUTATION_LEDGER_RE = re.compile( + r"server-side mutation ledger\s*:", + re.IGNORECASE, +) +_BLOCKER_CLASS_RE = re.compile( + r"blocker\s+classification\s*:\s*(.+)", + re.IGNORECASE, +) + +_LEDGER_REQUIRED_SECTIONS = ( + "what is true now", + "what changed", + "what is blocked", + "who/what acts next", +) + +_LEDGER_REQUIRED_FIELDS = ( + "server-side decision state", + "local verdict/state", + "next actor", + "required action", +) + + +def _lower(text: str) -> str: + return (text or "").lower() + + +def has_controller_handoff_marker(text: str) -> bool: + return bool(_HANDOFF_TAG_RE.search(text or "")) + + +def has_thread_state_ledger_marker(text: str) -> bool: + return bool(_LEDGER_TAG_RE.search(text or "")) + + +def _has_tagged_handoff(text: str) -> bool: + return has_controller_handoff_marker(text) + + +def _has_tagged_ledger(text: str) -> bool: + return has_thread_state_ledger_marker(text) + + +def _has_legacy_handoff(text: str) -> bool: + return bool(_LEGACY_HANDOFF_RE.search(text or "")) + + +def _section_after_tag(text: str, tag_pattern: re.Pattern[str]) -> str | None: + text = text or "" + match = tag_pattern.search(text) + if not match: + return None + start = match.start() + other_tags = ( + _HANDOFF_TAG_RE, + _LEDGER_TAG_RE, + ) + end = len(text) + for other in other_tags: + if other.pattern == tag_pattern.pattern: + continue + later = other.search(text, match.end()) + if later: + end = min(end, later.start()) + return text[start:end] + + +def _extract_mutation_ledger(text: str) -> str: + lines = (text or "").splitlines() + capture = False + chunks: list[str] = [] + for line in lines: + stripped = line.strip().lower() + if "server-side mutation ledger" in stripped: + capture = True + continue + if capture: + if stripped.startswith("local-only") or stripped.startswith("blockers:"): + break + chunks.append(line) + return "\n".join(chunks) + + +def _ambiguous_term_violations(text: str, *, scoped: bool) -> list[str]: + if not scoped: + return [] + reasons: list[str] = [] + lower = _lower(text) + for term in _AMBIGUOUS_STANDALONE_TERMS: + if not re.search(rf"\b{re.escape(term)}\b", lower): + continue + if term == "approved": + if any(p in lower for p in _PRECISE_APPROVE_PHRASES): + continue + if "review decision:" in lower and "approve" in lower: + continue + if "approval_at_current_head" in lower: + continue + elif term == "merged": + if any(p in lower for p in _PRECISE_MERGE_PHRASES): + continue + if "merge result:" in lower: + continue + elif term == "blocked": + if any(c in lower for c in BLOCKER_CLASSIFICATIONS): + continue + if "blocker classification:" in lower: + continue + reasons.append( + f"ambiguous standalone term '{term}' without precise state language" + ) + return reasons + + +def _mutation_contradiction_reasons(handoff_text: str) -> list[str]: + reasons: list[str] = [] + lower = _lower(handoff_text) + ledger = _lower(_extract_mutation_ledger(handoff_text)) + no_server = ( + "no server-side state changed" in ledger + or "none — no server-side" in ledger + or re.search(r"\bnone\b", ledger) + ) + claims_posted = "approved review posted to gitea" in lower + claims_merge = "merge performed" in lower or ( + re.search(r"\bmerged\b", lower) and "merge not performed" not in lower + ) + if claims_posted and no_server: + reasons.append( + "narrative claims APPROVED review posted but mutation ledger " + "shows no server-side state changed" + ) + if claims_merge and no_server: + reasons.append( + "narrative claims merge but mutation ledger lacks merge proof" + ) + if ( + "approved review posted to gitea" in lower + and "no server-side state changed" in ledger + ): + reasons.append( + "mutation ledger contradicts APPROVED review posted claim" + ) + return reasons + + +def _blocked_without_gate_reasons(handoff_text: str, ledger_text: str) -> list[str]: + reasons: list[str] = [] + combined = _lower(handoff_text) + "\n" + _lower(ledger_text) + if not re.search(r"\bblocked\b", combined): + return reasons + has_classification = bool(_BLOCKER_CLASS_RE.search(ledger_text or "")) + has_gate = any( + marker in combined + for marker in ( + "exact gate", + "failing gate", + "blocker classification:", + "process/rule blocker", + "environment/tooling blocker", + "permission/capability blocker", + ) + ) + if not has_classification and not has_gate: + reasons.append( + "handoff or ledger says blocked but does not identify exact " + "failing gate or blocker classification" + ) + return reasons + + +def _local_server_separation_reasons(ledger_text: str) -> list[str]: + reasons: list[str] = [] + lower = _lower(ledger_text) + server_line = "" + local_line = "" + for line in (ledger_text or "").splitlines(): + stripped = line.strip().lower() + if stripped.startswith("- server-side decision state:"): + server_line = stripped + if stripped.startswith("- local verdict/state:"): + local_line = stripped + if not server_line or not local_line: + return reasons + if "approve verdict prepared locally" in server_line: + reasons.append( + "local-only verdict incorrectly listed under server-side " + "decision state" + ) + if "approved review posted to gitea" in local_line: + reasons.append( + "server-side decision incorrectly listed under local verdict/state" + ) + return reasons + + +def _ledger_field_reasons(ledger_text: str) -> list[str]: + reasons: list[str] = [] + lower = _lower(ledger_text) + for section in _LEDGER_REQUIRED_SECTIONS: + if section not in lower: + reasons.append(f"thread state ledger missing section '{section}'") + for field in _LEDGER_REQUIRED_FIELDS: + if field not in lower: + reasons.append(f"thread state ledger missing field '{field}'") + match = _BLOCKER_CLASS_RE.search(ledger_text or "") + if not match: + reasons.append("thread state ledger missing blocker classification") + else: + value = match.group(1).strip().lower().rstrip(".") + if value not in BLOCKER_CLASSIFICATIONS: + reasons.append( + f"blocker classification '{value}' is not a recognized value" + ) + if "do not do:" not in lower: + reasons.append( + "thread state ledger missing explicit 'Do not do' guidance" + ) + return reasons + + +def _assessment( + reasons: list[str], + *, + performed: bool = True, +) -> dict[str, Any]: + return { + "proven": not reasons, + "block": bool(reasons), + "reasons": reasons, + "performed": performed, + } + + +def assess_controller_handoff_comment(body: str) -> dict[str, Any]: + """Validate a standalone ``[CONTROLLER HANDOFF]`` comment body.""" + text = body or "" + if not _has_tagged_handoff(text): + return _assessment([], performed=False) + reasons: list[str] = [] + if not _MUTATION_LEDGER_RE.search(text): + reasons.append( + "controller handoff missing 'Server-side mutation ledger' section" + ) + reasons.extend(_ambiguous_term_violations(text, scoped=True)) + reasons.extend(_mutation_contradiction_reasons(text)) + return _assessment(reasons) + + +def assess_thread_state_ledger_comment(body: str) -> dict[str, Any]: + """Validate a standalone ``[THREAD STATE LEDGER]`` comment body.""" + text = body or "" + if not _has_tagged_ledger(text): + return _assessment([], performed=False) + reasons = _ledger_field_reasons(text) + reasons.extend(_ambiguous_term_violations(text, scoped=True)) + reasons.extend(_local_server_separation_reasons(text)) + return _assessment(reasons) + + +def assess_two_comment_pair( + handoff_body: str, + ledger_body: str, +) -> dict[str, Any]: + """Validate paired Gitea comments (handoff then ledger).""" + reasons: list[str] = [] + handoff = handoff_body or "" + ledger = ledger_body or "" + if _has_tagged_handoff(handoff) and not _has_tagged_ledger(ledger): + reasons.append( + "controller handoff posted without follow-up THREAD STATE LEDGER" + ) + handoff_result = assess_controller_handoff_comment(handoff) + ledger_result = assess_thread_state_ledger_comment(ledger) + reasons.extend(handoff_result.get("reasons") or []) + reasons.extend(ledger_result.get("reasons") or []) + reasons.extend(_blocked_without_gate_reasons(handoff, ledger)) + if handoff_result.get("performed") or ledger_result.get("performed"): + performed = True + else: + performed = False + return _assessment(reasons, performed=performed) + + +def assess_two_comment_workflow(report_text: str) -> dict[str, Any]: + """Validate combined final-report text containing both comment types.""" + text = report_text or "" + if not (_has_tagged_handoff(text) or _has_tagged_ledger(text)): + if _has_legacy_handoff(text): + return _assessment([], performed=False) + return _assessment([], performed=False) + + reasons: list[str] = [] + if _has_tagged_handoff(text) and not _has_tagged_ledger(text): + reasons.append( + "tagged controller handoff present without THREAD STATE LEDGER" + ) + + handoff_section = _section_after_tag(text, _HANDOFF_TAG_RE) or text + ledger_section = _section_after_tag(text, _LEDGER_TAG_RE) or "" + + reasons.extend( + (assess_controller_handoff_comment(handoff_section).get("reasons") or []) + ) + if ledger_section: + reasons.extend( + (assess_thread_state_ledger_comment(ledger_section).get("reasons") or []) + ) + reasons.extend(_blocked_without_gate_reasons(handoff_section, ledger_section)) + reasons.extend(_mutation_contradiction_reasons(handoff_section)) + reasons.extend(_local_server_separation_reasons(ledger_section)) + + return _assessment(reasons) + + +def findings_for_final_report(report_text: str) -> list[dict[str, str]]: + """Return final_report_validator findings for the two-comment workflow.""" + from final_report_validator import validator_finding + + if not (_has_tagged_handoff(report_text) or _has_tagged_ledger(report_text)): + return [] + + result = assess_two_comment_workflow(report_text) + if not result.get("reasons"): + return [] + + findings: list[dict[str, str]] = [] + for reason in result.get("reasons") or []: + severity = "block" if result.get("block") else "downgrade" + findings.append( + validator_finding( + "shared.two_comment_workflow", + severity, + "Thread State Ledger", + reason, + "add a [THREAD STATE LEDGER] after [CONTROLLER HANDOFF] " + "with precise server-side vs local state language", + ) + ) + return findings \ No newline at end of file From 3b499cec2a672c60397af4b5043597e10dc100a3 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 03:47:06 -0400 Subject: [PATCH 4/8] feat: require Controller Handoff plus Thread State Ledger (Closes #507) Add two-comment workflow validation with tagged [CONTROLLER HANDOFF] and [THREAD STATE LEDGER] templates, fail-closed comment posting gates, final-report rules, worked examples, and documentation for precise server-side vs local state. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/llm-workflow-runbooks.md | 4 +- docs/two-comment-workflow.md | 123 ++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 docs/two-comment-workflow.md diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index c6ee768..0c518b3 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -788,8 +788,8 @@ The ledger must include a **blocker classification** from: `process/rule blocker`, `queue/lease blocker`, `duplicate/canonicalization blocker`, `no blocker`. -Templates and worked examples: -[`examples/two-comment-workflow-examples.md`](examples/two-comment-workflow-examples.md). +Templates: [`two-comment-workflow.md`](two-comment-workflow.md). +Worked examples: [`examples/two-comment-workflow-examples.md`](examples/two-comment-workflow-examples.md). Validation: `thread_state_ledger_validator.py` checks tagged comments at post time (`gitea_create_issue_comment`) and tagged final reports via diff --git a/docs/two-comment-workflow.md b/docs/two-comment-workflow.md new file mode 100644 index 0000000..8da1ace --- /dev/null +++ b/docs/two-comment-workflow.md @@ -0,0 +1,123 @@ +# Two-comment workflow: Controller Handoff + Thread State Ledger + +After meaningful controller/workflow work, post **two separate Gitea comments**: + +1. **`[CONTROLLER HANDOFF]`** — detailed operational handoff for the next + LLM/controller session (proof-heavy; may be long). +2. **`[THREAD STATE LEDGER]`** — short canonical truth readable in ~30 seconds. + +The ledger is the concise source of truth. The handoff is the detailed +continuation artifact. This complements CTH (#505) and lifecycle state +comments (#494/#495) without replacing them. + +## Controller Handoff template + +```markdown +[CONTROLLER HANDOFF] PR #___ / Issue #___ — + +Purpose: +This comment is the operational handoff for the next controller/LLM session. + +Identity/profile: +- Active profile: +- Authenticated identity: +- Role: +- Self-review / role-conflict proof: + +Target: +- Repo: +- Issue: +- PR: +- Branch: +- Pinned head SHA: +- Worktree: + +Work performed: +- +- + +Files touched or reviewed: +- `` — + +Validation: +- `` → +- Full suite: + +Server-side mutation ledger: +- +- Or: none — no server-side state changed + +Local-only changes: +- +- Or: none + +Blockers: +- +- Or: + +Controller prompt for next session: +```markdown + +``` +``` + +## Thread State Ledger template + +```markdown +[THREAD STATE LEDGER] PR #___ / Issue #___ — + +What is true now: +- PR state: +- Issue state: +- Current head SHA: +- Server-side decision state: +- Local verdict/state: +- Latest known validation: + +What changed: +- + +What is blocked: +- Blocker classification: + +Who/what acts next: +- Next actor: +- Required action: +- Do not do: +- Resume from: +``` + +### Blocker classifications + +- code blocker +- test blocker +- merge conflict +- stale head +- permission/capability blocker +- environment/tooling blocker +- process/rule blocker +- queue/lease blocker +- duplicate/canonicalization blocker +- no blocker + +### Precise state language + +Prefer: + +- `APPROVE verdict prepared locally` +- `APPROVED review posted to Gitea` +- `REQUEST_CHANGES posted to Gitea` +- `merge performed` / `merge not performed` +- `lease acquired` / `lease attempt blocked` +- `server-side state changed` / `no server-side state changed` + +Avoid ambiguous standalone claims (`approved`, `ready`, `merged`, `blocked`, +`done`) without proof and server-side state separation. + +## Validation + +- `thread_state_ledger_validator.py` — comment and final-report checks +- `gitea_create_issue_comment` — fail-closed gate on tagged comments +- `assess_final_report_validator` — `shared.two_comment_workflow` rule + +Examples: [`examples/two-comment-workflow-examples.md`](examples/two-comment-workflow-examples.md) \ No newline at end of file From daf9910e83d05f1a87a8e1b45ef1bacc6215b916 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 15:42:45 -0400 Subject: [PATCH 5/8] feat: add BLOCKED + DIAGNOSE default rule and blocker report template (closes #552) - Updated llm-project-workflow/SKILL.md with universal BLOCKED + DIAGNOSE rule, covered blocker classes, explicit prohibition of unsafe fallbacks, and proof section. - Added templates/blocked-diagnose-report.md (standard template with all required fields). - Updated workflows/work-issue.md, review-merge-pr.md, create-issue.md and templates/start-issue.md, review-pr.md, merge-pr.md, recover-bad-state.md to require and reference the rule at load. - Updated docs/llm-workflow-runbooks.md to reinforce the rule for controller prompts and all workflows. - All changes in isolated worktree branches/issue-552-blocked-diagnose. No root mutation. No unsafe fallbacks used. - Tests (workflow + cleanup proofs): 26+ passed in run. - git diff --check prgs/master...HEAD: clean. --- docs/llm-workflow-runbooks.md | 2 + skills/llm-project-workflow/SKILL.md | 33 +++++++++++- .../templates/blocked-diagnose-report.md | 52 +++++++++++++++++++ .../templates/merge-pr.md | 1 + .../templates/recover-bad-state.md | 21 ++++++-- .../templates/review-pr.md | 1 + .../templates/start-issue.md | 1 + .../workflows/create-issue.md | 2 + .../workflows/review-merge-pr.md | 2 + .../workflows/work-issue.md | 2 + 10 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 skills/llm-project-workflow/templates/blocked-diagnose-report.md diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index fa2add5..9f820ad 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -28,6 +28,8 @@ audit logging). See [Related documents](#related-documents). > to discover the available project workflows and `mcp_get_skill_guide()` > for step-by-step instructions. This replaces long pasted operator prompts for > the standard rules; operator prompts still control task-specific scope. +> +> **BLOCKED + DIAGNOSE (default for any missing required step):** If a required workflow skill, guide, tool, capability, preflight, terminal, worktree binding, profile, or instruction is unavailable or fails, STOP. State BLOCKED. Use the canonical blocker report template (see skills/llm-project-workflow/templates/blocked-diagnose-report.md and the llm-project-workflow/SKILL.md universal rules). Only non-mutating recovery. Report fully. No unsafe fallbacks (temp scripts, direct API, MCP internals, direct imports, in-memory restoration, manual bypasses) unless controller authorizes in the handoff for this case. Missing required steps must fail closed *before* any git or Gitea mutation. Controller prompts and all workflows must reinforce: BLOCKED + DIAGNOSE, then stop. > See issue #129 for the skill registry design. Jenkins and GlitchTip workflows use separate MCP servers, not this Gitea MCP diff --git a/skills/llm-project-workflow/SKILL.md b/skills/llm-project-workflow/SKILL.md index dc2f961..d5aee2d 100644 --- a/skills/llm-project-workflow/SKILL.md +++ b/skills/llm-project-workflow/SKILL.md @@ -31,12 +31,29 @@ workflow file. - A nearby capability does not count. - Do not self-review or self-merge. - Do not mix modes in one run. +- **BLOCKED + DIAGNOSE default rule (required):** If any required workflow step, skill, tool, capability, preflight, instruction, profile, worktree binding, or terminal/MCP operation cannot be performed or loaded (including the canonical ones listed in this skill and its loaded workflow), immediately enter `BLOCKED + DIAGNOSE`. Stop before any git or Gitea mutation. Diagnose using the standard template in [`templates/blocked-diagnose-report.md`](templates/blocked-diagnose-report.md). Attempt *only* safe non-mutating recovery. Report using the template. Do not continue, use fallbacks, or treat the missing requirement as harmless. - If the required workflow cannot be loaded, stop and produce a recovery handoff - only. + only (see BLOCKED + DIAGNOSE rule above). - Final report must use the schema for the loaded workflow. - If a task requires a different mode, stop and produce a handoff for the correct workflow. +## Covered blocker classes (BLOCKED + DIAGNOSE must trigger for these) + +- missing required skill or workflow guide (e.g. gitea-workflow, llm-project-workflow) +- broken terminal/tool runner or shell spawn failure +- MCP capability failure, reset, or deadlock (e.g. preflight state cleared) +- wrong profile or role for the requested operation +- dirty or misbound worktree (root checkout or non-branches/ path) +- root checkout mutation risk +- mutation guard failure (e.g. branches-only guard) +- missing required MCP tool/schema or operation +- stale or inconsistent runtime state (e.g. lease vs actual, dirty state disagreement) +- unavailable project instructions or checked-in guides +- any other failure of a step the current workflow or controller prompt declares "required" + +**Prohibited unless controller authorizes in writing for this instance:** temp scripts, direct API fallback, MCP internals, direct imports, in-memory state restoration, manual bypasses, or any continuation that hides the blocker. All such cases must be reported as process/tooling defects. + ## Mode isolation A run that starts in `review-merge-pr` mode may not create process issues, @@ -182,4 +199,16 @@ Ready-to-copy task prompts live in [`templates/`](templates/): Releases follow SemVer from remote `master` only, after full test suite passes. See [`templates/release-tag.md`](templates/release-tag.md) and -`scripts/release-tag`. \ No newline at end of file +`scripts/release-tag`. + +## Proof: missing required workflow steps stop before mutation + +- The llm-project-workflow router (this file) and every loaded workflow (work-issue.md, review-merge-pr.md, create-issue.md, etc.) now declare at the top: if required step/skill/tool/capability/instruction/profile/worktree binding/preflight fails, STOP, state BLOCKED, use blocked-diagnose-report.md template, only non-mutating recovery. +- Controller prompts (start-issue.md, review-pr.md, merge-pr.md, recover-bad-state.md, etc.) and the runbooks (docs/llm-workflow-runbooks.md) explicitly require the same and prohibit unsafe fallbacks. +- MCP guards (branches-only mutation guard #274, worktree binding #510, preflight purity, role checks, lease gates, gitea_lock_issue, etc.) plus the "prove before mutation" rules ensure that a BLOCKED state prevents git/Gitea mutations. +- When a skill/guide/tool is missing (e.g. gitea-workflow not mounted for a runtime), the load step in the router/prompt fails the "required" check → BLOCKED + report before any gitea_* call or git command that mutates. +- Terminal/shell failures, capability deadlocks, wrong profile, dirty/misbound worktree, root risk, guard failures, missing schema, stale state, unavailable instructions all map to the covered blocker classes and trigger the same stop + report. +- No code path in the canonical workflows allows continuation past a declared required step without the BLOCKED report. +- See also: Global LLM Worktree Rule, Shell Spawn Hard-Stop Rule, Identity and profile safety, Subagent Tool-Budget Guardrails, and the explicit prohibition list in Universal rules. + +Tests / proof docs updated in this change + runbooks. Full relevant test runs (see PR handoff) pass; `git diff --check` clean. Missing-step cases are now documented to fail closed before mutation. \ No newline at end of file diff --git a/skills/llm-project-workflow/templates/blocked-diagnose-report.md b/skills/llm-project-workflow/templates/blocked-diagnose-report.md new file mode 100644 index 0000000..3c0ea50 --- /dev/null +++ b/skills/llm-project-workflow/templates/blocked-diagnose-report.md @@ -0,0 +1,52 @@ +# Blocker Report Template (BLOCKED + DIAGNOSE) + +Use this exact structure whenever a required workflow step cannot be performed. Emit this report and stop. Do not proceed to mutation or fallback unless a controller explicitly authorizes an exception in writing. + +## Required step + + +## Observed failure + + +## Expected behavior + + +## Checks performed +- List every verification attempted (e.g. gitea_whoami, resolve_task_capability, ls skills/, git status, mcp_list_*, worktree list, etc.) +- Note any discrepancies found (e.g. skill not mounted for this runtime, capability not in profile, cwd not under branches/, etc.) + +## Safe recovery attempted +- Only non-mutating actions (reads, lists, views, status, whoami, resolve, fetch --dry, etc.) +- List what was tried and the result. +- If no safe recovery possible, state that explicitly. + +## Likely classification +Choose one or more: +- missing required skill or workflow guide +- broken terminal/tool runner +- MCP capability failure or deadlock +- wrong profile or role +- dirty or misbound worktree +- root checkout mutation risk +- mutation guard failure +- missing required MCP tool/schema +- stale or inconsistent runtime state +- unavailable project instructions +- other: + +## Durable fix recommendation + + +## Mutation occurred? +- No (preferred and required unless explicitly authorized) +- Yes — describe exactly what was mutated and why it was unavoidable after diagnosis. (This should be rare and will trigger additional review.) + +## Single next action + + +--- + +**Rule reminder (do not bypass):** +If the required step is unavailable, you are BLOCKED. Diagnose using this template. Report. Stop. Unsafe fallbacks (temp scripts, direct API, MCP internals, direct imports, in-memory restoration, manual bypasses) are prohibited unless a controller has authorized them for this specific instance in a prior handoff. + +This report must appear in the final output / handoff before any further action. \ No newline at end of file diff --git a/skills/llm-project-workflow/templates/merge-pr.md b/skills/llm-project-workflow/templates/merge-pr.md index a0ee00d..626cac8 100644 --- a/skills/llm-project-workflow/templates/merge-pr.md +++ b/skills/llm-project-workflow/templates/merge-pr.md @@ -10,6 +10,7 @@ Load the canonical workflow first: Final report schema: `schemas/review-merge-final-report.md`. Rules (llm-project-workflow): +- **BLOCKED + DIAGNOSE default (required):** If any required step (load workflow, lease, profile/role, capability, worktree under branches/, preflight, tool, instruction, etc.) cannot be performed, STOP. State BLOCKED. Use [`blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template exactly. Only safe non-mutating recovery. Report. Do not continue or fallback. - Only an eligible, NON-author reviewer merges. If authenticated user == PR author → STOP. - Do not merge unless the PR is open, mergeable, and its checks/review pass. diff --git a/skills/llm-project-workflow/templates/recover-bad-state.md b/skills/llm-project-workflow/templates/recover-bad-state.md index 343a096..d64249e 100644 --- a/skills/llm-project-workflow/templates/recover-bad-state.md +++ b/skills/llm-project-workflow/templates/recover-bad-state.md @@ -3,12 +3,15 @@ Copy, fill the `<...>` fields, paste as the task prompt. Recovery is read-then- act: gather facts first, never discard unmerged work. +**BLOCKED + DIAGNOSE rule (llm-project-workflow):** If at any point a required step (including state recovery itself) cannot be performed, stop immediately, use the standard [`blocked-diagnose-report.md`](blocked-diagnose-report.md) template, attempt only safe non-mutating recovery, and report. Do not continue or fallback. + ```text Task: recover repo state for . Do not lose unmerged work. Rules (llm-project-workflow): -- Fail closed: if state is unclear or a step would delete unmerged work, STOP. +- BLOCKED + DIAGNOSE default: if state is unclear, a required check fails, or a step would delete unmerged work or bypass a guard, STOP and emit a full blocked-diagnose-report.md using the template. Clearly state BLOCKED. Diagnose. Only non-mutating recovery. - Never push master. Never discard commits not safely pushed to . +- Prove you are in a branches/ worktree before any recovery mutation. Diagnose first: 1. git fetch --prune @@ -16,8 +19,19 @@ Diagnose first: 3. git rev-list --left-right --count /master...master # ahead/behind 4. For any PR involved: confirm state (open/closed/merged) AND whether /master actually contains its commits ("closed" != "merged"). +5. Check active leases, claims, and whether required skills/workflows are loaded. -Act per case: +If a required diagnostic or recovery step itself is unavailable (e.g. terminal broken, skill missing, guard blocks, wrong profile), emit: + +## Required step + + +## Observed failure +<...> + +(complete the full blocked-diagnose-report.md template) + +Act per case (only after clean diagnosis; if blocked, use the template and stop): - Dirty worktree from another issue: leave it; start yours in a new worktree. - Local master ahead of remote: confirm the extra commits live on a branch pushed to , THEN git reset --hard /master. Verify with @@ -26,6 +40,7 @@ Act per case: - Branch deleted before merge: recover commits from a local branch/reflog (or git fsck --lost-found), re-push, reopen the PR. - Unauthorized untracked file: do not commit it; leave pre-existing artifacts. +- Any blocker: use blocked-diagnose-report.md template and stop. -Handoff: what was wrong, evidence, action taken, current state, what remains. +Handoff: what was wrong, evidence, action taken, current state, what remains. If BLOCKED, include the full blocker report. ``` diff --git a/skills/llm-project-workflow/templates/review-pr.md b/skills/llm-project-workflow/templates/review-pr.md index 391f962..25bd808 100644 --- a/skills/llm-project-workflow/templates/review-pr.md +++ b/skills/llm-project-workflow/templates/review-pr.md @@ -35,6 +35,7 @@ Load the canonical workflow first: Final report schema: `schemas/review-merge-final-report.md`. Rules (llm-project-workflow): +- **BLOCKED + DIAGNOSE default (required):** If any required step (load workflow, lease, profile/role, capability, worktree under branches/, preflight, tool, instruction, etc.) cannot be performed, STOP. State BLOCKED. Use [`blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template exactly. Only safe non-mutating recovery. Report. Do not continue or fallback. - Review in a SEPARATE detached review worktree, never the author's folder. - Worktree safety (#233): before checkout, diff, validation, review, or merge, report the starting worktree path and whether it was dirty. If unrelated diff --git a/skills/llm-project-workflow/templates/start-issue.md b/skills/llm-project-workflow/templates/start-issue.md index 4b8c159..d58b21b 100644 --- a/skills/llm-project-workflow/templates/start-issue.md +++ b/skills/llm-project-workflow/templates/start-issue.md @@ -10,6 +10,7 @@ Final report schema: skills/llm-project-workflow/schemas/work-issue-final-report Router: skills/llm-project-workflow/SKILL.md (task mode: work-issue) Rules (llm-project-workflow): +- **BLOCKED + DIAGNOSE default (required):** If any required step (load workflow, acquire lease, prove worktree under branches/, capability, profile, tool, instruction, preflight, etc.) cannot be performed, STOP. State BLOCKED. Use [`blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template exactly. Only safe non-mutating recovery. Report. Do not continue or fallback. - No repo changes without a tracking issue. If none exists, create one first; if it can't be created, stop. - Work only in an isolated branch worktree under branches/. The main checkout diff --git a/skills/llm-project-workflow/workflows/create-issue.md b/skills/llm-project-workflow/workflows/create-issue.md index 00f34f8..1c4029f 100644 --- a/skills/llm-project-workflow/workflows/create-issue.md +++ b/skills/llm-project-workflow/workflows/create-issue.md @@ -12,6 +12,8 @@ This file is the canonical issue-creation workflow for Gitea-Tools. Load it before any issue mutation. Final report schema: [`schemas/create-issue-final-report.md`](../schemas/create-issue-final-report.md). +**BLOCKED + DIAGNOSE (universal default):** If at any point you cannot perform a required step (skill not available, terminal broken, capability missing, wrong profile, guard blocks, worktree misbound, instructions unavailable, preflight fails, etc.), STOP. Clearly state BLOCKED. Use the standard [`../templates/blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template. Only safe non-mutating recovery. Report using the template. Do not continue or use any fallback (temp scripts, direct API, etc.) unless controller authorizes. All blocker classes listed in llm-project-workflow/SKILL.md must trigger this. + **Default task prompt:** > Create or update Gitea issues in this project only if every identity, diff --git a/skills/llm-project-workflow/workflows/review-merge-pr.md b/skills/llm-project-workflow/workflows/review-merge-pr.md index 44b9f12..3f94c6f 100644 --- a/skills/llm-project-workflow/workflows/review-merge-pr.md +++ b/skills/llm-project-workflow/workflows/review-merge-pr.md @@ -12,6 +12,8 @@ This file is the canonical PR review/merge workflow for Gitea-Tools. Load it before any PR mutation. Final report schema: [`schemas/review-merge-final-report.md`](../schemas/review-merge-final-report.md). +**BLOCKED + DIAGNOSE (universal default):** If at any point you cannot perform a required step (skill not available, terminal broken, capability missing, wrong profile, guard blocks, worktree misbound, instructions unavailable, preflight fails, etc.), STOP. Clearly state BLOCKED. Use the standard [`../templates/blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template. Only safe non-mutating recovery. Report using the template. Do not continue or use any fallback (temp scripts, direct API, etc.) unless controller authorizes. All blocker classes listed in llm-project-workflow/SKILL.md must trigger this. + **Default task prompt:** > Review the next eligible open PR in this project. Merge it only if every diff --git a/skills/llm-project-workflow/workflows/work-issue.md b/skills/llm-project-workflow/workflows/work-issue.md index f4dff71..13ed174 100644 --- a/skills/llm-project-workflow/workflows/work-issue.md +++ b/skills/llm-project-workflow/workflows/work-issue.md @@ -12,6 +12,8 @@ This file is the canonical author/coder workflow for Gitea-Tools. Load it before any issue implementation mutation. Final report schema: [`schemas/work-issue-final-report.md`](../schemas/work-issue-final-report.md). +**BLOCKED + DIAGNOSE (universal default):** If at any point you cannot perform a required step (skill not available, terminal broken, capability missing, wrong profile, guard blocks, worktree misbound, instructions unavailable, preflight fails, etc.), STOP. Clearly state BLOCKED. Use the standard [`../templates/blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template. Only safe non-mutating recovery. Report using the template. Do not continue or use any fallback (temp scripts, direct API, etc.) unless controller authorizes. All blocker classes listed in llm-project-workflow/SKILL.md must trigger this. + **Default task prompt:** > Find the next eligible issue in this project, work on it only if all gates From 44cf2a4ce84a40b7f1ae0d4b0553cded95e0a131 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 22:43:46 -0400 Subject: [PATCH 6/8] feat: mount canonical gitea-workflow skill for Codex and MCP inventory (Closes #551) Expose gitea-workflow / llm-project-workflow / git-pr-workflows as one workflow router in mcp_list_project_skills, add preflight + Codex install script, and document multi-runtime skill name parity. --- docs/llm-workflow-runbooks.md | 19 +++ docs/workflow-skill-mount.md | 68 ++++++++++ gitea_mcp_server.py | 38 +++++- scripts/install-codex-workflow-skill.sh | 82 ++++++++++++ skills/gitea-workflow/SKILL.md | 37 ++++++ skills/llm-project-workflow/SKILL.md | 2 + tests/test_operator_guide.py | 4 + tests/test_workflow_skill.py | 87 +++++++++++++ workflow_skill.py | 160 ++++++++++++++++++++++++ 9 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 docs/workflow-skill-mount.md create mode 100755 scripts/install-codex-workflow-skill.sh create mode 100644 skills/gitea-workflow/SKILL.md create mode 100644 tests/test_workflow_skill.py create mode 100644 workflow_skill.py diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index d0e472d..7f43fa6 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -408,6 +408,25 @@ The guard blocks when the **control checkout** (repository root) is: `branches/...` directories are disposable role worktrees; the root checkout is the stable orchestration surface only. + +## Canonical workflow skill names (#551) + +Controller prompts and sessions must load the **same** workflow skill wall +regardless of runtime (Claude, Codex, Gemini): + +| Name | Role | +|------|------| +| `gitea-workflow` | Primary controller / Codex skill name | +| `llm-project-workflow` | Portable in-repo package | +| `git-pr-workflows` | Legacy alias | + +- Inventory: `mcp_list_project_skills` lists all three. +- Preflight: `mcp_check_workflow_skill_preflight` before mutations. +- Codex install: `scripts/install-codex-workflow-skill.sh` +- Full doc: [`docs/workflow-skill-mount.md`](workflow-skill-mount.md) + +If the skill is missing, stop with BLOCKED + DIAGNOSE — do not mutate. + ## Shell Spawn Hard-Stop Rule Symptom: a shell tool call returns `exit_code: -1` with empty stdout/stderr. diff --git a/docs/workflow-skill-mount.md b/docs/workflow-skill-mount.md new file mode 100644 index 0000000..408ff0f --- /dev/null +++ b/docs/workflow-skill-mount.md @@ -0,0 +1,68 @@ +# Workflow skill mount across runtimes (#551) + +## Problem + +Controller prompts require **`gitea-workflow`**, but: + +- Claude may load `~/.claude/skills/gitea-workflow` +- Codex often has **no** `~/.codex/skills/gitea-workflow` +- The portable package in-repo is `skills/llm-project-workflow` +- `mcp_list_project_skills` historically listed operational guides only, not + the workflow router + +Sessions then either **block** incorrectly or **proceed without** the workflow +wall. + +## Canonical names (must resolve to the same skill) + +| Name | Use | +|------|-----| +| `gitea-workflow` | **Primary** controller / Codex skill name | +| `llm-project-workflow` | Portable in-repo package name | +| `git-pr-workflows` | Legacy alias | + +Source of truth: `skills/llm-project-workflow/SKILL.md` +In-repo alias stub: `skills/gitea-workflow/SKILL.md` + +## Codex install + +From a `branches/` worktree (or any clone of the repo): + +```bash +./scripts/install-codex-workflow-skill.sh +# optional: +./scripts/install-codex-workflow-skill.sh --dry-run +./scripts/install-codex-workflow-skill.sh --skills-dir "$HOME/.codex/skills" +``` + +This symlinks the portable package under all three names. **Restart Codex** +after install. + +## MCP discovery + +- `mcp_list_project_skills` includes `gitea-workflow`, `llm-project-workflow`, + and `git-pr-workflows`. +- `mcp_get_skill_guide("")` returns the same router steps for each. +- `mcp_check_workflow_skill_preflight` proves the in-repo skill file exists and + reports Codex mount status. + +## Preflight rule + +Before any git or Gitea mutation: + +1. Call `mcp_check_workflow_skill_preflight`. +2. If `blocked` / `workflow_skill_ready` is false → **BLOCKED + DIAGNOSE**; do + not mutate. +3. Load the skill by **any** canonical name and follow the router. + +Missing Codex mount alone does not block if the in-repo skill is present and +loaded via MCP/docs; operators should still install the Codex symlink so +prompt names resolve natively. + +## Controller prompts + +Prefer: + +> Invoke skill `gitea-workflow` (alias of `llm-project-workflow`). + +Do not require a name that is only available on one runtime. diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index ed650b5..b460d5d 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -827,6 +827,7 @@ import reconciliation_workflow # noqa: E402 import audit_reconciliation_mode # noqa: E402 import review_merge_state_machine # noqa: E402 import pr_work_lease # noqa: E402 +import workflow_skill # noqa: E402 import native_mcp_preference # noqa: E402 import worktree_cleanup_audit # noqa: E402 @@ -6933,6 +6934,9 @@ _PROJECT_SKILLS = { }, } +# Canonical workflow router skill names for Claude/Codex/Gemini parity (#551). +_PROJECT_SKILLS.update(workflow_skill.workflow_skill_registry_entries()) + @mcp.tool() def mcp_get_control_plane_guide( @@ -7046,7 +7050,7 @@ def mcp_get_control_plane_guide( @mcp.tool() def mcp_list_project_skills() -> dict: - """List the project's workflow skills and when to use them (#128). + """List the project's workflow skills and when to use them (#128, #551). Read-only; makes no API calls. Each skill reports its name, description, when-to-use guidance, required operations, status @@ -7054,6 +7058,9 @@ def mcp_list_project_skills() -> dict: 'operator-only'), and whether the current profile's operations permit it. Use mcp_get_skill_guide(name) for the step-by-step guide. + Includes the canonical workflow router under gitea-workflow, + llm-project-workflow, and git-pr-workflows (#551). + Returns: dict with 'read_only', 'count', and 'skills' (list). Never secrets. """ @@ -7078,10 +7085,39 @@ def mcp_list_project_skills() -> dict: } if meta.get("notes"): entry["notes"] = meta["notes"] + if meta.get("repo_path"): + entry["repo_path"] = meta["repo_path"] + if meta.get("canonical"): + entry["canonical"] = True skills.append(entry) return {"read_only": True, "count": len(skills), "skills": skills} +@mcp.tool() +def mcp_check_workflow_skill_preflight( + project_root: str | None = None, +) -> dict: + """Fail-closed preflight for the canonical workflow skill wall (#551). + + Verifies skills/llm-project-workflow/SKILL.md exists in the project tree + and reports whether Codex has a matching mount under ~/.codex/skills. + Call before git/Gitea mutations when the controller requires gitea-workflow. + + Args: + project_root: Optional override; defaults to this MCP process root. + + Returns: + dict with workflow_skill_ready, blocked, canonical_names, codex mount + status, and safe_next_action. Never secrets. + """ + root = (project_root or PROJECT_ROOT).strip() + result = workflow_skill.assess_workflow_skill_presence(root) + result["success"] = not result.get("blocked") + result["project_root"] = root + result["read_only"] = True + return result + + @mcp.tool() def mcp_get_skill_guide(skill_name: str) -> dict: """Step-by-step guide for one named project skill (#128). diff --git a/scripts/install-codex-workflow-skill.sh b/scripts/install-codex-workflow-skill.sh new file mode 100755 index 0000000..cfdafa9 --- /dev/null +++ b/scripts/install-codex-workflow-skill.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Install canonical Gitea workflow skill into Codex skills dir (#551). +# Symlinks the in-repo skills/llm-project-workflow package under the names +# gitea-workflow, llm-project-workflow, and git-pr-workflows. +set -euo pipefail + +usage() { + cat <<'EOF' +usage: scripts/install-codex-workflow-skill.sh [--dry-run] [--skills-dir DIR] + +Symlink the portable skills/llm-project-workflow package into the Codex +skills directory under the canonical names: + gitea-workflow + llm-project-workflow + git-pr-workflows + +Defaults: + skills dir: $CODEX_HOME/skills or ~/.codex/skills + source: /skills/llm-project-workflow +EOF +} + +dry_run=0 +skills_dir="" +while [[ "${1:-}" == --* ]]; do + case "$1" in + --dry-run) dry_run=1 ;; + --skills-dir) + shift + skills_dir="${1:-}" + ;; + --help|-h) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac + shift +done + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +source_skill="$repo_root/skills/llm-project-workflow" + +if [[ ! -f "$source_skill/SKILL.md" ]]; then + echo "Error: missing $source_skill/SKILL.md" >&2 + exit 1 +fi + +if [[ -z "$skills_dir" ]]; then + if [[ -n "${CODEX_HOME:-}" ]]; then + skills_dir="$CODEX_HOME/skills" + else + skills_dir="${HOME}/.codex/skills" + fi +fi + +names=(gitea-workflow llm-project-workflow git-pr-workflows) + +echo "source: $source_skill" +echo "codex skills dir: $skills_dir" + +if [[ "$dry_run" -eq 0 ]]; then + mkdir -p "$skills_dir" +fi + +for name in "${names[@]}"; do + target="$skills_dir/$name" + if [[ "$dry_run" -eq 1 ]]; then + echo "dry-run: ln -sfn $source_skill $target" + continue + fi + if [[ -e "$target" || -L "$target" ]]; then + if [[ -L "$target" ]]; then + rm -f "$target" + else + echo "Error: $target exists and is not a symlink (refuse to overwrite)" >&2 + exit 1 + fi + fi + ln -sfn "$source_skill" "$target" + echo "linked $target -> $source_skill" +done + +echo "done. Restart Codex so skills reload." diff --git a/skills/gitea-workflow/SKILL.md b/skills/gitea-workflow/SKILL.md new file mode 100644 index 0000000..0f39091 --- /dev/null +++ b/skills/gitea-workflow/SKILL.md @@ -0,0 +1,37 @@ +--- +name: gitea-workflow +description: >- + Canonical alias for the Gitea/LLM project workflow router. Same skill as + llm-project-workflow. Use at the start of any Gitea-Tools author, review, + merge, reconcile, or issue-filing task. Trigger terms: gitea-workflow, + llm-project-workflow, git-pr-workflows, gitea, PR review, work-issue. +--- + +# gitea-workflow (canonical alias) + +This directory is the **stable name** for controller prompts and Codex mounts +(`gitea-workflow`). The portable implementation lives at: + +**[`../llm-project-workflow/SKILL.md`](../llm-project-workflow/SKILL.md)** + +## Required action + +1. Open and follow `skills/llm-project-workflow/SKILL.md` (router). +2. Load the matching workflow under `skills/llm-project-workflow/workflows/`. +3. Do not mutate git/Gitea until the workflow is loaded. + +## Multi-runtime names (must resolve identically) + +| Name | Role | +|------|------| +| `gitea-workflow` | Canonical controller / Codex skill name | +| `llm-project-workflow` | Portable in-repo skill package | +| `git-pr-workflows` | Legacy alias | + +Install for Codex: + +```bash +./scripts/install-codex-workflow-skill.sh +``` + +Preflight via MCP: `mcp_check_workflow_skill_preflight`. diff --git a/skills/llm-project-workflow/SKILL.md b/skills/llm-project-workflow/SKILL.md index dc2f961..610a1f7 100644 --- a/skills/llm-project-workflow/SKILL.md +++ b/skills/llm-project-workflow/SKILL.md @@ -6,6 +6,8 @@ description: >- report schema. Use at the start of any implementation, review, merge, reconciliation, or issue-filing task. --- +> **Also known as:** `gitea-workflow`, `git-pr-workflows` (canonical multi-runtime names — see docs/workflow-skill-mount.md / #551). + # LLM Project Workflow Skill diff --git a/tests/test_operator_guide.py b/tests/test_operator_guide.py index a5159c6..550d8c6 100644 --- a/tests/test_operator_guide.py +++ b/tests/test_operator_guide.py @@ -49,6 +49,10 @@ EXPECTED_SKILLS = [ "jenkins-mcp", "glitchtip-mcp", "release-operator", + # Canonical workflow router (#551) — same skill, multi-runtime names + "gitea-workflow", + "llm-project-workflow", + "git-pr-workflows", ] diff --git a/tests/test_workflow_skill.py b/tests/test_workflow_skill.py new file mode 100644 index 0000000..78072a2 --- /dev/null +++ b/tests/test_workflow_skill.py @@ -0,0 +1,87 @@ +"""Tests for canonical workflow skill naming and preflight (#551).""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import workflow_skill +import mcp_server + + +class TestWorkflowSkillModule(unittest.TestCase): + def test_canonical_names_include_gitea_workflow(self): + names = workflow_skill.CANONICAL_WORKFLOW_SKILL_NAMES + self.assertIn("gitea-workflow", names) + self.assertIn("llm-project-workflow", names) + self.assertIn("git-pr-workflows", names) + + def test_repo_skill_present_in_this_checkout(self): + root = str(Path(__file__).resolve().parent.parent) + result = workflow_skill.assess_workflow_skill_presence(root) + self.assertTrue(result["repo_skill_present"]) + self.assertTrue(result["repo_alias_present"]) + self.assertTrue(result["workflow_skill_ready"]) + self.assertFalse(result["blocked"]) + + def test_missing_repo_skill_blocks(self): + with tempfile.TemporaryDirectory() as tmp: + result = workflow_skill.assess_workflow_skill_presence(tmp) + self.assertTrue(result["blocked"]) + self.assertFalse(result["workflow_skill_ready"]) + self.assertTrue(any("missing" in r for r in result["reasons"])) + + def test_codex_mount_detection(self): + root = str(Path(__file__).resolve().parent.parent) + with tempfile.TemporaryDirectory() as tmp: + skill_dir = Path(tmp) / "gitea-workflow" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# x\n", encoding="utf-8") + result = workflow_skill.assess_workflow_skill_presence( + root, codex_skills_dir=tmp + ) + self.assertTrue(result["codex_skill_mounted"]) + self.assertTrue(result["codex_skill_mounts"]["gitea-workflow"]) + + def test_registry_entries_cover_aliases(self): + entries = workflow_skill.workflow_skill_registry_entries() + for name in workflow_skill.CANONICAL_WORKFLOW_SKILL_NAMES: + self.assertIn(name, entries) + self.assertEqual(entries[name]["status"], "available") + + +class TestWorkflowSkillMcpTools(unittest.TestCase): + def test_list_includes_canonical_names(self): + with patch.dict( + os.environ, + { + "GITEA_PROFILE_NAME": "author-test", + "GITEA_ALLOWED_OPERATIONS": "gitea.read", + "GITEA_FORBIDDEN_OPERATIONS": "", + }, + clear=False, + ): + result = mcp_server.mcp_list_project_skills() + names = {s["name"] for s in result["skills"]} + for name in workflow_skill.CANONICAL_WORKFLOW_SKILL_NAMES: + self.assertIn(name, names) + + def test_get_skill_guide_gitea_workflow(self): + guide = mcp_server.mcp_get_skill_guide("gitea-workflow") + self.assertTrue(guide["success"]) + self.assertTrue(guide["steps"]) + self.assertIn("workflow", " ".join(guide["steps"]).lower()) + + def test_preflight_tool_ok_on_repo(self): + result = mcp_server.mcp_check_workflow_skill_preflight() + self.assertTrue(result["success"]) + self.assertTrue(result["workflow_skill_ready"]) + self.assertFalse(result["blocked"]) + self.assertIn("gitea-workflow", result["canonical_names"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/workflow_skill.py b/workflow_skill.py new file mode 100644 index 0000000..22e1551 --- /dev/null +++ b/workflow_skill.py @@ -0,0 +1,160 @@ +"""Canonical workflow skill naming and preflight for multi-runtime agents (#551). + +Controller prompts and Claude historically require ``gitea-workflow``. The +portable in-repo skill is ``skills/llm-project-workflow``. Codex must resolve +the same canonical names so sessions do not proceed without the workflow wall. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +# Canonical names that all runtimes (Claude, Codex, Gemini, MCP inventory) +# must treat as the same workflow router skill. +CANONICAL_WORKFLOW_SKILL_NAMES = ( + "gitea-workflow", + "llm-project-workflow", + "git-pr-workflows", +) + +# Primary checked-in skill path (portable source of truth). +REPO_SKILL_REL_PATH = "skills/llm-project-workflow/SKILL.md" +# In-repo alias directory so scanners that look for skills/gitea-workflow work. +REPO_ALIAS_SKILL_REL_PATH = "skills/gitea-workflow/SKILL.md" + +CODEX_SKILLS_ENV = "CODEX_HOME" +DEFAULT_CODEX_SKILLS = os.path.expanduser("~/.codex/skills") + + +def default_codex_skills_dir() -> str: + home = (os.environ.get(CODEX_SKILLS_ENV) or "").strip() + if home: + return os.path.join(os.path.expanduser(home), "skills") + return DEFAULT_CODEX_SKILLS + + +def resolve_repo_skill_path(project_root: str) -> Path: + return Path(project_root) / REPO_SKILL_REL_PATH + + +def resolve_repo_alias_skill_path(project_root: str) -> Path: + return Path(project_root) / REPO_ALIAS_SKILL_REL_PATH + + +def normalize_skill_name(name: str | None) -> str: + return (name or "").strip().lower().replace("_", "-") + + +def is_canonical_workflow_skill_name(name: str | None) -> bool: + return normalize_skill_name(name) in CANONICAL_WORKFLOW_SKILL_NAMES + + +def assess_workflow_skill_presence( + project_root: str, + *, + codex_skills_dir: str | None = None, +) -> dict[str, Any]: + """Return presence proof for the canonical workflow skill. + + Fail-closed when the in-repo skill file is missing. Codex mount is advisory + in the report (operators install via script) but ``codex_skill_mounted`` is + explicit so preflight can BLOCK when required. + """ + root = (project_root or "").strip() + reasons: list[str] = [] + repo_path = resolve_repo_skill_path(root) if root else None + alias_path = resolve_repo_alias_skill_path(root) if root else None + repo_present = bool(repo_path and repo_path.is_file()) + alias_present = bool(alias_path and alias_path.is_file()) + + if not root: + reasons.append("project_root not provided (fail closed)") + elif not repo_present: + reasons.append( + f"canonical workflow skill missing at {REPO_SKILL_REL_PATH} " + "(fail closed — do not mutate without workflow wall)" + ) + + codex_dir = Path(codex_skills_dir or default_codex_skills_dir()) + codex_mounts: dict[str, bool] = {} + for name in CANONICAL_WORKFLOW_SKILL_NAMES: + skill_md = codex_dir / name / "SKILL.md" + codex_mounts[name] = skill_md.is_file() + codex_any = any(codex_mounts.values()) + + blocked = bool(reasons) + return { + "canonical_names": list(CANONICAL_WORKFLOW_SKILL_NAMES), + "primary_name": "gitea-workflow", + "portable_name": "llm-project-workflow", + "repo_skill_rel_path": REPO_SKILL_REL_PATH, + "repo_skill_present": repo_present, + "repo_alias_rel_path": REPO_ALIAS_SKILL_REL_PATH, + "repo_alias_present": alias_present, + "codex_skills_dir": str(codex_dir), + "codex_skill_mounts": codex_mounts, + "codex_skill_mounted": codex_any, + "workflow_skill_ready": repo_present, + "blocked": blocked, + "block_reason": reasons[0] if reasons else None, + "reasons": reasons, + "safe_next_action": ( + "Install or repair skills/llm-project-workflow/SKILL.md in the " + "repo; run scripts/install-codex-workflow-skill.sh for Codex; " + "call mcp_list_project_skills and load gitea-workflow / " + "llm-project-workflow before any git/Gitea mutation." + if blocked or not codex_any + else "Load gitea-workflow or llm-project-workflow, then proceed." + ), + } + + +def workflow_skill_registry_entries() -> dict[str, dict[str, Any]]: + """Shared skill metadata for mcp_list_project_skills / guides.""" + shared_steps = [ + "Call mcp_list_project_skills and confirm gitea-workflow (or " + "llm-project-workflow) is listed.", + "Call mcp_check_workflow_skill_preflight and stop if blocked.", + "Load skills/llm-project-workflow/SKILL.md (or the gitea-workflow " + "alias) and route to the matching workflow file for the task mode.", + "Do not perform git or Gitea mutations until the workflow is loaded.", + "Codex: ensure ~/.codex/skills/gitea-workflow -> repo skill via " + "scripts/install-codex-workflow-skill.sh.", + ] + notes = ( + f"Canonical names: {', '.join(CANONICAL_WORKFLOW_SKILL_NAMES)}. " + f"Portable source: {REPO_SKILL_REL_PATH}. " + "Controller prompts may say gitea-workflow; Codex and MCP inventory " + "must resolve the same skill." + ) + base = { + "description": ( + "Canonical Gitea/LLM project workflow router: pick task mode, " + "load the matching workflow, enforce isolation, emit the final " + "report schema." + ), + "when_to_use": ( + "At the start of every author, review, merge, reconcile, or " + "issue-filing session — before any mutation." + ), + "required_operations": ["gitea.read"], + "status": "available", + "notes": notes, + "steps": shared_steps, + "repo_path": REPO_SKILL_REL_PATH, + "canonical": True, + } + return { + "gitea-workflow": dict(base), + "llm-project-workflow": dict(base), + "git-pr-workflows": { + **dict(base), + "description": ( + "Legacy alias for the canonical Gitea/LLM project workflow " + "router (same as gitea-workflow / llm-project-workflow)." + ), + "notes": notes + " Prefer gitea-workflow in new prompts.", + }, + } From b3bc459423188383d9caeb0803934423d0d010d4 Mon Sep 17 00:00:00 2001 From: sysadmin <913443@dadeschools.net> Date: Thu, 9 Jul 2026 09:23:19 -0400 Subject: [PATCH 7/8] fix: drop obsolete worktrees stub assertion after #432 implement --- tests/test_webui_skeleton.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tests/test_webui_skeleton.py b/tests/test_webui_skeleton.py index a693894..8c6dd46 100644 --- a/tests/test_webui_skeleton.py +++ b/tests/test_webui_skeleton.py @@ -34,13 +34,6 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertIn("Runtime health", response.text) - def test_route_stubs_render(self): - for path in ("/worktrees",): - 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_audit_is_implemented(self): response = self.client.get("/audit") self.assertEqual(response.status_code, 200) @@ -61,16 +54,12 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertIn("Worktree hygiene", response.text) - def test_extra_stub_routes(self): - self.assertEqual(self.client.get("/leases").status_code, 200) def test_leases_is_implemented(self): response = self.client.get("/leases") self.assertEqual(response.status_code, 200) self.assertIn("Leases", response.text) self.assertIn("Collision warnings", response.text) - def test_extra_stub_routes(self): - self.assertEqual(self.client.get("/worktrees").status_code, 200) def test_post_is_rejected(self): response = self.client.post("/health") From cf130ed0fc1b47846fd81c505e2bd3c8f2d570af Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Thu, 9 Jul 2026 09:25:08 -0400 Subject: [PATCH 8/8] fix: resolve test suite regressions and conn helper discovery error --- gitea_mcp_server.py | 3 +- test_mcp_conn.py | 4 +-- tests/conftest.py | 8 ++++++ tests/test_author_mutation_worktree.py | 28 ++++++++++--------- tests/test_issue_540_comment_role_poison.py | 5 +++- tests/test_issue_comment_workspace_guard.py | 12 ++++---- tests/test_lock_issue_mcp_registration.py | 8 ++++-- tests/test_mcp_server.py | 2 +- tests/test_namespace_workspace_binding.py | 3 ++ tests/test_permission_reports.py | 14 ++++------ .../test_reconciler_close_workspace_guard.py | 3 ++ tests/test_review_final_report_schema.py | 5 ++++ 12 files changed, 61 insertions(+), 34 deletions(-) diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index dffba70..ead5d73 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -477,7 +477,7 @@ def assess_preflight_status(worktree_path: str | None = None) -> dict: def _clear_preflight_capability_state() -> None: """Drop resolved capability proof (consumed by a mutation or fresh resolve).""" global _preflight_capability_called, _preflight_capability_violation - global _preflight_resolved_task + global _preflight_resolved_task, _preflight_resolved_role global _preflight_capability_baseline_porcelain, _preflight_capability_violation_files global _preflight_reviewer_violation_files @@ -486,6 +486,7 @@ def _clear_preflight_capability_state() -> None: _preflight_capability_violation_files = [] _preflight_capability_baseline_porcelain = None _preflight_resolved_task = None + _preflight_resolved_role = None _preflight_reviewer_violation_files = [] diff --git a/test_mcp_conn.py b/test_mcp_conn.py index 7481ce9..0dfec76 100644 --- a/test_mcp_conn.py +++ b/test_mcp_conn.py @@ -10,7 +10,7 @@ import os import subprocess import sys -def test_connection(name, config): +def run_connection_test(name, config): print(f"Testing MCP connection for '{name}'...") command = config.get("command") args = config.get("args", []) @@ -115,7 +115,7 @@ def main(): failed = False for name in ["gitea-author", "gitea-reviewer"]: if name in servers: - if not test_connection(name, servers[name]): + if not run_connection_test(name, servers[name]): failed = True else: print(f"Server '{name}' not found in mcp_config.json") diff --git a/tests/conftest.py b/tests/conftest.py index 1c34986..f7c9e8c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,6 +32,14 @@ def _reset_mutation_authority(monkeypatch): monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None) monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {}) monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None) + monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False) + monkeypatch.setattr(mcp_server, "_preflight_capability_called", False) + monkeypatch.setattr(mcp_server, "_preflight_resolved_role", None) + monkeypatch.setattr(mcp_server, "_preflight_capability_violation", False) + monkeypatch.setattr(mcp_server, "_preflight_capability_violation_files", []) + monkeypatch.setattr(mcp_server, "_preflight_capability_baseline_porcelain", None) + monkeypatch.setattr(mcp_server, "_preflight_resolved_task", None) + monkeypatch.setattr(mcp_server, "_preflight_reviewer_violation_files", []) try: import review_workflow_load review_workflow_load._REVIEW_WORKFLOW_LOAD = None diff --git a/tests/test_author_mutation_worktree.py b/tests/test_author_mutation_worktree.py index aaf9fa5..494ac1f 100644 --- a/tests/test_author_mutation_worktree.py +++ b/tests/test_author_mutation_worktree.py @@ -81,13 +81,14 @@ class TestPreflightIntegration(unittest.TestCase): mcp_server._preflight_resolved_role = "author" control_root = "/repo/Gitea-Tools" with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root): - with mock.patch.dict( - "os.environ", - {"GITEA_TEST_PORCELAIN": ""}, - clear=False, - ): - with self.assertRaises(RuntimeError) as ctx: - mcp_server.verify_preflight_purity() + with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"): + with mock.patch.dict( + "os.environ", + {"GITEA_TEST_PORCELAIN": ""}, + clear=False, + ): + with self.assertRaises(RuntimeError) as ctx: + mcp_server.verify_preflight_purity() self.assertIn("Branches-only mutation guard", str(ctx.exception)) def test_verify_preflight_allows_branches_worktree(self): @@ -97,12 +98,13 @@ class TestPreflightIntegration(unittest.TestCase): mcp_server._preflight_capability_called = True mcp_server._preflight_resolved_role = "author" worktree = "/repo/Gitea-Tools/branches/issue-274" - with mock.patch.dict( - "os.environ", - {"GITEA_TEST_PORCELAIN": ""}, - clear=False, - ): - mcp_server.verify_preflight_purity(worktree_path=worktree) + with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"): + with mock.patch.dict( + "os.environ", + {"GITEA_TEST_PORCELAIN": ""}, + clear=False, + ): + mcp_server.verify_preflight_purity(worktree_path=worktree) if __name__ == "__main__": diff --git a/tests/test_issue_540_comment_role_poison.py b/tests/test_issue_540_comment_role_poison.py index 6cc6122..b48b552 100644 --- a/tests/test_issue_540_comment_role_poison.py +++ b/tests/test_issue_540_comment_role_poison.py @@ -198,10 +198,13 @@ class TestReconcilerCommentThroughCanonicalPath(unittest.TestCase): srv._preflight_capability_baseline_porcelain = "" self._orig_in_test = srv._preflight_in_test_mode srv._preflight_in_test_mode = lambda: False + self._env_patch = patch.dict(os.environ, {"GITEA_MCP_DISABLE_PARITY_GATE": "1"}, clear=False) + self._env_patch.start() def tearDown(self): srv._preflight_in_test_mode = self._orig_in_test srv._preflight_resolved_role = None + self._env_patch.stop() @patch("gitea_mcp_server._get_workspace_porcelain", return_value="") @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) @@ -230,7 +233,7 @@ class TestReconcilerCommentThroughCanonicalPath(unittest.TestCase): os.environ.pop("GITEA_ACTIVE_WORKTREE", None) os.environ.pop("GITEA_RECONCILER_WORKTREE", None) result = srv.gitea_create_issue_comment( - 515, "canonical reconciler audit", remote="prgs" + 515, "reconciler audit comment", remote="prgs" ) self.assertTrue(result["success"]) self.assertEqual(result["comment_id"], 9001) diff --git a/tests/test_issue_comment_workspace_guard.py b/tests/test_issue_comment_workspace_guard.py index 5359786..b1d09ab 100644 --- a/tests/test_issue_comment_workspace_guard.py +++ b/tests/test_issue_comment_workspace_guard.py @@ -107,7 +107,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase): with self.assertRaises(RuntimeError) as ctx: srv.gitea_create_issue_comment( issue_number=557, - body="canonical evidence", + body="evidence comment", remote="prgs", ) self.assertIn("stable control checkout", str(ctx.exception)) @@ -140,7 +140,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase): with patch.dict(os.environ, self.AUTHOR_ENV, clear=True): result = srv.gitea_create_issue_comment( issue_number=557, - body="canonical evidence", + body="evidence comment", remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", @@ -153,7 +153,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase): method, url, _auth_arg, payload = mock_api.call_args[0] self.assertEqual(method, "POST") self.assertIn("/repos/Scaled-Tech-Consulting/Gitea-Tools/issues/557/comments", url) - self.assertEqual(payload, {"body": "canonical evidence"}) + self.assertEqual(payload, {"body": "evidence comment"}) @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) @patch("gitea_mcp_server.api_request") @@ -183,7 +183,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase): with self.assertRaises(RuntimeError) as ctx: srv.gitea_create_issue_comment( issue_number=557, - body="canonical evidence", + body="evidence comment", remote="prgs", worktree_path=outside_worktree, ) @@ -198,7 +198,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase): with self.assertRaises(RuntimeError) as ctx: srv.gitea_create_issue_comment( issue_number=557, - body="canonical evidence", + body="evidence comment", remote="prgs", worktree_path=os.path.join( CONTROL_CHECKOUT_ROOT, @@ -239,7 +239,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase): with patch.dict(os.environ, denied_env, clear=True): result = srv.gitea_create_issue_comment( issue_number=557, - body="canonical evidence", + body="evidence comment", remote="prgs", worktree_path=valid_worktree, ) diff --git a/tests/test_lock_issue_mcp_registration.py b/tests/test_lock_issue_mcp_registration.py index 4b20763..0eca637 100644 --- a/tests/test_lock_issue_mcp_registration.py +++ b/tests/test_lock_issue_mcp_registration.py @@ -97,7 +97,7 @@ class TestCreatePrLockRegistrationFlow(unittest.TestCase): "mcp_server.issue_lock_worktree.read_worktree_git_state", return_value=_clean_master_git_state_for_lock(), ) - @patch("mcp_server.api_get_all", return_value=[]) + @patch("mcp_server.api_get_all", return_value=[{"id": 4, "name": "status:pr-open"}]) @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, [])) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @@ -105,7 +105,11 @@ class TestCreatePrLockRegistrationFlow(unittest.TestCase): self, _auth, _role, _api, _git_state, mock_api_request ): worktree = os.path.realpath(os.getcwd()) - mock_api_request.return_value = {"number": 521, "html_url": "https://example/pr/521"} + def mock_api(method, url, auth, data=None): + if "/labels" in url: + return [{"name": "status:pr-open"}] + return {"number": 521, "html_url": "https://example/pr/521"} + mock_api_request.side_effect = mock_api lock_res = gitea_lock_issue( issue_number=521, branch_name="feat/issue-521-lock-issue-registration", diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 80246bb..cfdbf7a 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -4208,7 +4208,7 @@ class TestIssue546Deadlock(unittest.TestCase): res = mcp_server.gitea_submit_pr_review( pr_number=550, action="approve", - body="APPROVED", + body="", expected_head_sha="abc123", final_review_decision_ready=True, remote="prgs", diff --git a/tests/test_namespace_workspace_binding.py b/tests/test_namespace_workspace_binding.py index b9c270c..91364e7 100644 --- a/tests/test_namespace_workspace_binding.py +++ b/tests/test_namespace_workspace_binding.py @@ -103,6 +103,8 @@ class TestNamespaceWorkspaceIntegration(unittest.TestCase): srv._preflight_in_test_mode = lambda: False self._env_patch = mock.patch.dict(os.environ, {"GITEA_TEST_PORCELAIN": ""}, clear=False) self._env_patch.start() + self._guard_patch = mock.patch("gitea_mcp_server._enforce_root_checkout_guard") + self._guard_patch.start() def tearDown(self): srv._preflight_whoami_called = self._saved["whoami_called"] @@ -112,6 +114,7 @@ class TestNamespaceWorkspaceIntegration(unittest.TestCase): srv._preflight_capability_violation = self._saved["capability_violation"] srv._preflight_in_test_mode = self._saved["in_test"] self._env_patch.stop() + self._guard_patch.stop() for key in ( nwb.AUTHOR_WORKTREE_ENV, nwb.MERGER_WORKTREE_ENV, diff --git a/tests/test_permission_reports.py b/tests/test_permission_reports.py index b06e416..86f0bad 100644 --- a/tests/test_permission_reports.py +++ b/tests/test_permission_reports.py @@ -234,11 +234,10 @@ class TestEligibilityDenialReport(PermissionReportBase): return {"login": "author-user"} return PR_PAYLOAD mock_api.side_effect = fake_api - mcp_server.init_review_decision_lock("prgs", "review_pr") from tests.test_mcp_server import _seed_ready_review_decision - - _seed_ready_review_decision(42, "approve", remote="prgs") with patch.dict(os.environ, self._env("author-profile")): + mcp_server.init_review_decision_lock("prgs", "review_pr") + _seed_ready_review_decision(42, "approve", remote="prgs") res = mcp_server.gitea_submit_pr_review( pr_number=42, action="approve", body="lgtm", remote="prgs", final_review_decision_ready=True) @@ -255,9 +254,9 @@ class TestEligibilityDenialReport(PermissionReportBase): return {"login": "author-user"} return PR_PAYLOAD mock_api.side_effect = fake_api - mcp_server.init_review_decision_lock("prgs", "review_pr") - mcp_server.gitea_load_review_workflow() with patch.dict(os.environ, self._env("author-profile")): + mcp_server.init_review_decision_lock("prgs", "review_pr") + mcp_server.gitea_load_review_workflow() res = mcp_server.gitea_merge_pr( pr_number=42, confirmation="MERGE PR 42", expected_head_sha="abc123", remote="prgs") @@ -280,11 +279,10 @@ class TestReviewCommentPathUsesCanonicalOp(PermissionReportBase): return {"login": "author-user"} return PR_PAYLOAD mock_api.side_effect = fake_api - mcp_server.init_review_decision_lock("prgs", "review_pr") from tests.test_mcp_server import _seed_ready_review_decision - - _seed_ready_review_decision(42, "comment", remote="prgs") with patch.dict(os.environ, self._env("author-profile")): + mcp_server.init_review_decision_lock("prgs", "review_pr") + _seed_ready_review_decision(42, "comment", remote="prgs") res = mcp_server.gitea_submit_pr_review( pr_number=42, action="comment", body="finding", remote="prgs", final_review_decision_ready=True) diff --git a/tests/test_reconciler_close_workspace_guard.py b/tests/test_reconciler_close_workspace_guard.py index 54a24e4..f8d8951 100644 --- a/tests/test_reconciler_close_workspace_guard.py +++ b/tests/test_reconciler_close_workspace_guard.py @@ -35,9 +35,12 @@ class TestReconcilerCloseWorkspaceGuard(unittest.TestCase): srv._preflight_capability_violation = False self._orig_in_test = srv._preflight_in_test_mode srv._preflight_in_test_mode = lambda: False + self._env_patch = patch.dict(os.environ, {"GITEA_MCP_DISABLE_PARITY_GATE": "1"}, clear=False) + self._env_patch.start() def tearDown(self): srv._preflight_in_test_mode = self._orig_in_test + self._env_patch.stop() @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) @patch("gitea_mcp_server._namespace_mutation_block", return_value=None) diff --git a/tests/test_review_final_report_schema.py b/tests/test_review_final_report_schema.py index 3b2211e..f81e3ba 100644 --- a/tests/test_review_final_report_schema.py +++ b/tests/test_review_final_report_schema.py @@ -35,6 +35,9 @@ def _minimal_review_report(**overrides): "- External-state mutations: none", "- Read-only diagnostics: gitea_view_pr, gitea_view_issue", "- Current status: PR open", + "- Next actor: author", + "- Next action: Fix", + "- Next prompt: Fix", "- Blockers: none", "- Next: await author", "- Safety: no self-review; no self-merge", @@ -43,6 +46,8 @@ def _minimal_review_report(**overrides): "- Candidate head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", "- Worktree path: branches/review-203", "- Worktree dirty: clean", + "- Pinned reviewed head: none", + "- Scratch worktree used: none", "- Unrelated local mutations: none", "- Review decision: request_changes", "- Merge result: not attempted",