Merge remote-tracking branch 'prgs/master' into feat/issue-514-branch-delete-guard

# Conflicts:
#	gitea_mcp_server.py
This commit is contained in:
2026-07-09 09:35:55 -04:00
44 changed files with 2932 additions and 61 deletions
@@ -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.
+60
View File
@@ -33,6 +33,8 @@ audit logging). See [Related documents](#related-documents).
> to discover the available project workflows and `mcp_get_skill_guide(<name>)`
> 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
@@ -413,6 +415,24 @@ 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.
## No direct-import mutation path (#558)
Never `import gitea_mcp_server` or call `gitea_auth.get_auth_header` /
@@ -899,6 +919,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: [`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
`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.
## Canonical comment validation (#496)
Workflow-changing issue/PR/review comments must carry durable next-action
+123
View File
@@ -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 #___ — <short title>
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:
- <step 1>
- <step 2>
Files touched or reviewed:
- `<file>` — <why it matters>
Validation:
- `<command>` → <result>
- Full suite: <result or not run + reason>
Server-side mutation ledger:
- <mutation 1, including tool/action/comment id if available>
- Or: none — no server-side state changed
Local-only changes:
- <worktree created, files edited locally, etc.>
- Or: none
Blockers:
- <none>
- Or: <exact blocker, exact gate, exact reason>
Controller prompt for next session:
```markdown
<ready-to-paste prompt>
```
```
## Thread State Ledger template
```markdown
[THREAD STATE LEDGER] PR #___ / Issue #___ — <current state in one line>
What is true now:
- PR state:
- Issue state:
- Current head SHA:
- Server-side decision state:
- Local verdict/state:
- Latest known validation:
What changed:
- <short summary since prior ledger>
What is blocked:
- Blocker classification: <see list below>
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)
+26 -3
View File
@@ -45,13 +45,26 @@ Optional environment variables:
| `/api/prompts` | JSON prompt export with workflow hashes |
| `/runtime` | MCP runtime health and stale detection (#430) |
| `/api/runtime` | JSON runtime health export |
| `/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` | Worktree hygiene dashboard (#432) |
| `/api/worktrees` | JSON worktree scan with classifications and anomalies |
| `/leases` | Stub — lease visibility (#433) |
| `/worktrees` | Stub — hygiene dashboard (#432) |
| `/leases` | Lease and collision visibility (#433) |
| `/api/leases` | JSON lease/collision export |
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)
@@ -84,6 +97,15 @@ 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).
## Lease visibility (#433)
`/leases` surfaces read-only lease and collision state: local issue lock file,
@@ -104,5 +126,6 @@ no tokens or MCP restart actions are exposed.
## 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 tests/test_webui_audit.py tests/test_webui_worktree_hygiene.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_lease_visibility.py tests/test_webui_runtime_health.py -q
```
+68
View File
@@ -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("<name>")` 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.
+18
View File
@@ -15,6 +15,7 @@ import branch_cleanup_guard
import issue_acceptance_gate
import issue_lock_provenance
import reviewer_handoff_consistency
import thread_state_ledger_validator
from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
from review_proofs import (
@@ -1388,6 +1389,11 @@ 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)
def _rule_shared_canonical_state_update(report_text: str) -> list[dict[str, str]]:
from canonical_state_comments import validate_final_report_state_update
@@ -1404,6 +1410,8 @@ def _rule_shared_canonical_state_update(report_text: str) -> list[dict[str, str]
)
for reason in (result.get("reasons") or ["invalid canonical state update"])
]
def _rule_reviewer_mutation_capability_proof(report_text: str) -> list[dict[str, str]]:
from reviewer_mutation_capability_proof import assess_mutation_capability_proof
@@ -1456,6 +1464,9 @@ _SHARED_ISSUE_LOCK_RULES = (
_rule_shared_canonical_state_update,
)
_SHARED_TWO_COMMENT_RULES = (
_rule_shared_two_comment_workflow,
)
_SHARED_CLEANUP_PROOF_RULES = (
_rule_shared_mcp_native_cleanup_proof,
)
@@ -1469,6 +1480,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_legacy_workspace_mutations,
@@ -1512,6 +1524,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_reconcile_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_ISSUE_LOCK_RULES,
*_SHARED_CLEANUP_PROOF_RULES,
@@ -1530,6 +1543,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_vague_mutations_none,
@@ -1538,6 +1552,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_ISSUE_LOCK_RULES,
_rule_shared_issue_acceptance_gate,
@@ -1550,6 +1565,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_ISSUE_LOCK_RULES,
],
@@ -1557,6 +1573,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reconcile_pagination_proof,
@@ -1565,6 +1582,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_ISSUE_LOCK_RULES,
],
+59 -2
View File
@@ -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 = []
@@ -828,9 +829,11 @@ 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 conflict_fix_classification # noqa: E402
import native_mcp_preference # noqa: E402
import branch_cleanup_guard # noqa: E402
import thread_state_ledger_validator # noqa: E402
import master_parity_gate # noqa: E402
# Master-parity baseline (#420): the commit this server process started at.
@@ -6865,6 +6868,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,
@@ -6909,6 +6929,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}
@@ -7417,6 +7439,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(
@@ -7538,7 +7563,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
@@ -7546,6 +7571,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.
"""
@@ -7570,10 +7598,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).
+82
View File
@@ -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: <repo>/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."
+37
View File
@@ -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`.
+32 -1
View File
@@ -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
@@ -31,12 +33,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,
@@ -185,6 +204,18 @@ 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`.
## 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.
## Bootstrap Review Path (#557)
Self-hosted MCP workflow fixes can deadlock live review daemons. Do not bypass
@@ -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
<Describe the exact step, skill, tool, capability, instruction, profile, or preflight that was required. Include the canonical name and where it is defined (e.g. gitea-workflow skill, specific workflow file, gitea_xxx tool).>
## Observed failure
<Exact symptom, error message, missing output, guard error, 404, schema error, dirty state, wrong profile, etc. Quote relevant output or tool response.>
## Expected behavior
<What the workflow/docs/prompts say should happen. Reference the specific rule, template, or preflight that requires this step.>
## 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: <describe>
## Durable fix recommendation
<Specific, actionable recommendation that fixes the root process/tooling issue (e.g. "Mount gitea-workflow skill for Codex under canonical name in ~/.codex/skills/", "Add preflight in llm-project-workflow/SKILL.md that hard-stops before any gitea_ call if X is unavailable", "Update controller prompt to require BLOCKED + this report before any fallback", "Grant capability in profile config", etc.). Do not suggest temp workarounds.>
## 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
<One concrete next step for the current actor (e.g. "Controller to approve or reject recovery", "File follow-up issue #XXX for skill mounting", "Re-launch session from clean branches/ worktree after skill installed", "Stop and wait for profile update").>
---
**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.
@@ -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.
- Find the latest CTH comment on the PR/issue thread before starting work.
Post a new CTH: Merger Handoff (or CTH: Blocker) at session end.
Template: skills/llm-project-workflow/templates/canonical-thread-handoff.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 <situation>. 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 <remote>.
- Prove you are in a branches/ worktree before any recovery mutation.
Diagnose first:
1. git fetch <remote> --prune
@@ -16,8 +19,19 @@ Diagnose first:
3. git rev-list --left-right --count <remote>/master...master # ahead/behind
4. For any PR involved: confirm state (open/closed/merged) AND whether
<remote>/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
<the 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 <remote>, THEN git reset --hard <remote>/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.
```
@@ -36,6 +36,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.
- Find the latest CTH comment on the PR/issue thread before starting work.
Post a new CTH: Reviewer Handoff (or CTH: Blocker) at session end.
Template: skills/llm-project-workflow/templates/canonical-thread-handoff.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.
- Find the latest CTH comment on the issue/PR thread before starting work.
Post a new CTH: Author Handoff (or CTH: Blocker) at session end.
Template: skills/llm-project-workflow/templates/canonical-thread-handoff.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,
@@ -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
@@ -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
+2 -2
View File
@@ -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")
+8
View File
@@ -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
+15 -13
View File
@@ -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__":
+22
View File
@@ -114,6 +114,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(
+4 -1
View File
@@ -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)
+6 -6
View File
@@ -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,
)
+6 -2
View File
@@ -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",
+15 -1
View File
@@ -3196,6 +3196,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):
@@ -4220,7 +4234,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",
@@ -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,
+4
View File
@@ -56,6 +56,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",
]
+6 -8
View File
@@ -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)
@@ -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)
+5
View File
@@ -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",
+187
View File
@@ -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()
+94
View File
@@ -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()
+13 -10
View File
@@ -34,12 +34,10 @@ 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 ("/audit",):
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
self.assertIn("child issue", response.text.lower())
def test_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")
@@ -51,14 +49,17 @@ 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_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")
@@ -71,10 +72,12 @@ 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", "/leases"):
paths = ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit", "/worktrees", "/leases")
hrefs = ("/queue", "/projects", "/prompts", "/runtime", "/audit", "/worktrees", "/leases")
for path in paths:
with self.subTest(path=path):
text = self.client.get(path).text
for href in ("/queue", "/projects", "/prompts", "/runtime", "/audit", "/leases"):
for href in hrefs:
self.assertIn(f'href="{href}"', text)
+114
View File
@@ -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()
+87
View File
@@ -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()
+341
View File
@@ -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
""",
)
)
+399
View File
@@ -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
+59 -9
View File
@@ -14,14 +14,21 @@ 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.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict
from webui.lease_views import render_leases_page
from webui.queue_loader import load_queue_snapshot, snapshot_to_dict as queue_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
from webui.runtime_health import load_runtime_snapshot, snapshot_to_dict as runtime_snapshot_to_dict
from webui.runtime_views import render_runtime_page
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
_AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"})
def _stub_page(title: str, description: str) -> HTMLResponse:
@@ -132,18 +139,56 @@ async def api_runtime(_request: Request) -> JSONResponse:
return JSONResponse(runtime_snapshot_to_dict(load_runtime_snapshot()))
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",
"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:
@@ -156,6 +201,9 @@ async def api_leases(_request: Request) -> JSONResponse:
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"},
@@ -181,8 +229,10 @@ def create_app() -> Starlette:
Route("/api/prompts", api_prompts, methods=["GET"]),
Route("/runtime", runtime, methods=["GET"]),
Route("/api/runtime", api_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("/api/worktrees", api_worktrees, methods=["GET"]),
Route("/leases", leases, methods=["GET"]),
Route("/api/leases", api_leases, methods=["GET"]),
],
+176
View File
@@ -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 "",
}
+139
View File
@@ -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 '<p class="muted">No validator findings — report structure looks complete for the selected task kind.</p>'
rows = []
for finding in findings:
rows.append(
"<tr>"
f"<td><code>{_escape(str(finding.get('rule_id', '')))}</code></td>"
f"<td>{_escape(str(finding.get('severity', '')))}</td>"
f"<td>{_escape(str(finding.get('field', '')))}</td>"
f"<td>{_escape(str(finding.get('reason', '')))}</td>"
f"<td>{_escape(str(finding.get('safe_next_action', '')))}</td>"
"</tr>"
)
return (
'<table class="detail audit-findings">'
"<thead><tr><th>Rule</th><th>Severity</th><th>Field</th>"
"<th>Reason</th><th>Safe next action</th></tr></thead>"
f"<tbody>{''.join(rows)}</tbody></table>"
)
def _render_task_kind_select(selected: str | None) -> str:
options = ['<option value="">Auto-detect from report</option>']
for kind in _TASK_KIND_OPTIONS:
sel = ' selected' if selected == kind else ""
options.append(f'<option value="{_escape(kind)}"{sel}>{_escape(kind)}</option>')
return f'<select id="task_kind" name="task_kind">{"".join(options)}</select>'
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 = (
'<section class="audit-result">'
f"<h3>Validator preview</h3>"
f'<p class="meta">Task kind: <code>{inferred}</code> · '
f'Grade: <strong>{grade}</strong> · '
f"Blocked: <code>{result.get('blocked')}</code> · "
f"Downgraded: <code>{result.get('downgraded')}</code></p>"
f"<p><strong>Safe next action:</strong> {safe_next}</p>"
f"{_render_findings(result)}"
)
if suggested_prompt:
result_html += (
"<h4>Suggested next prompt</h4>"
f'<pre class="prompt-text">{_escape(suggested_prompt)}</pre>'
)
if suggested_comment:
result_html += (
"<h4>Suggested issue/PR comment</h4>"
f'<pre class="prompt-text">{_escape(suggested_comment)}</pre>'
)
result_html += "</section>"
body = (
"<h2>Report audit</h2>"
"<p>Paste an LLM final report for local validator preview. "
"Uses <code>final_report_validator</code> and review schema checks — "
"does not post to Gitea or store reports server-side.</p>"
'<form method="post" action="/audit" class="audit-form">'
"<label for=\"task_kind\">Task kind</label>"
f"{_render_task_kind_select(task_kind)}"
'<label for="report_text">Final report</label>'
f'<textarea id="report_text" name="report_text" rows="18" '
f'placeholder="Paste final report markdown here…">{_escape(report_text)}</textarea>'
'<button type="submit" class="copy-btn">Run validator preview</button>'
"</form>"
f"{result_html}"
"<p><a href=\"/api/audit\">JSON API</a> (POST <code>report_text</code>, "
"optional <code>task_kind</code>)</p>"
)
return render_page(title="Audit", body_html=body, extra_head=AUDIT_PAGE_STYLES)
AUDIT_PAGE_STYLES = """
<style>
.audit-form label {
display: block;
margin: 0.75rem 0 0.35rem;
color: var(--muted);
font-size: 0.9rem;
}
.audit-form select,
.audit-form textarea {
width: 100%;
font: inherit;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.55rem 0.65rem;
}
.audit-form textarea {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
line-height: 1.45;
resize: vertical;
}
.audit-result {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
table.audit-findings td:nth-child(4),
table.audit-findings td:nth-child(5) {
font-size: 0.85rem;
color: var(--muted);
}
</style>
"""
+307
View File
@@ -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 issue_lock_provenance import ISSUE_LOCK_FILE
from merged_cleanup_reconcile import (
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 #<pr> / issue #<n> 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
],
}
+130
View File
@@ -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'<span class="badge badge-{_escape(classification)}">'
f"{_escape(classification)}</span>"
)
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 (
"<tr>"
f"<td><code>{_escape(entry.rel_path)}</code></td>"
f"<td>{_badge(entry.classification)}</td>"
f"<td><code>{_escape(branch)}</code></td>"
f"<td><code>{_escape(head)}</code></td>"
f"<td>{_escape(dirty)}</td>"
f"<td>{'yes' if entry.detached else 'no'}</td>"
f"<td class='muted'>{_escape(entry.notes)}</td>"
"</tr>"
)
WORKTREE_PAGE_SCRIPT = """
<script>
document.querySelectorAll('.copy-cleanup-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const node = document.getElementById('cleanup-prompt');
if (!node) return;
try {
await navigator.clipboard.writeText(node.textContent || '');
const prior = btn.textContent;
btn.textContent = 'Copied';
setTimeout(() => { btn.textContent = prior; }, 1200);
} catch (_err) {
btn.textContent = 'Copy failed';
}
});
});
</script>
"""
WORKTREE_PAGE_STYLES = """
<style>
table.worktrees td:nth-child(7) { font-size: 0.85rem; }
.anomaly {
margin: 1rem 0;
padding: 0.75rem 0.9rem;
border: 1px solid #7a3b3b;
border-radius: 8px;
background: rgba(122, 59, 59, 0.15);
color: #f0c4c4;
}
.badge-active-pr { color: #9ec8f0; border-color: #3d5f7a; }
.badge-active-issue { color: #8fd19e; border-color: #3d6b4a; }
.badge-dirty { color: #e0c27a; border-color: #6b5730; }
.badge-stale-clean { color: #c9b8e8; border-color: #5a4a78; }
.badge-detached-review { color: #9ec8f0; border-color: #3d5f7a; }
.badge-unsafe-unknown { color: #f0a8a8; border-color: #7a3b3b; }
.badge-orphan { color: #f0a8a8; border-color: #7a3b3b; }
</style>
"""
def render_worktrees_page(snapshot: HygieneSnapshot) -> str:
error_block = ""
if snapshot.scan_error:
error_block = (
'<div class="stub"><p><strong>Partial scan:</strong> '
f"{_escape(snapshot.scan_error)}</p></div>"
)
anomaly_block = ""
if snapshot.anomalies:
items = "".join(f"<li>{_escape(text)}</li>" for text in snapshot.anomalies)
anomaly_block = (
'<section class="anomaly"><h3>Missing preserved worktree anomalies</h3>'
f"<ul>{items}</ul></section>"
)
if snapshot.entries:
rows = "".join(_entry_row(entry) for entry in snapshot.entries)
table = (
"<table class='registry worktrees'>"
"<thead><tr>"
"<th>Path</th><th>Class</th><th>Branch</th><th>HEAD</th>"
"<th>Dirty</th><th>Detached</th><th>Notes</th>"
"</tr></thead>"
f"<tbody>{rows}</tbody></table>"
)
else:
table = "<p class='muted'>No directories under <code>branches/</code>.</p>"
body = (
"<h2>Worktree hygiene</h2>"
"<p>Read-only scan of local <code>branches/</code> worktrees. "
"No deletion actions — copy the canonical cleanup prompt when merge is confirmed.</p>"
f"{error_block}"
f"{anomaly_block}"
f"<p class='meta'>Repo root: <code>{_escape(snapshot.repo_root)}</code> · "
f"entries: {len(snapshot.entries)}</p>"
f"{table}"
"<h3>Canonical cleanup prompt</h3>"
f'<pre class="prompt-text" id="cleanup-prompt">{_escape(snapshot.cleanup_prompt)}</pre>'
'<button type="button" class="copy-btn copy-cleanup-btn">Copy cleanup prompt</button>'
"<p><a href=\"/api/worktrees\">JSON API</a></p>"
f"{WORKTREE_PAGE_STYLES}"
f"{WORKTREE_PAGE_SCRIPT}"
)
return render_page(title="Worktrees", body_html=body)
+160
View File
@@ -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.",
},
}