Merge remote-tracking branch 'prgs/master' into feat/issue-514-branch-delete-guard
# Conflicts: # gitea_mcp_server.py
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
@@ -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).
|
||||
|
||||
Executable
+82
@@ -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."
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
""",
|
||||
)
|
||||
)
|
||||
@@ -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
@@ -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"]),
|
||||
],
|
||||
|
||||
@@ -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 "",
|
||||
}
|
||||
@@ -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>
|
||||
"""
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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.",
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user