Extend the review workflow load gate with pre-review command classification, boundary violation tracking, and structured helper-result requirements in final reports. Adds gitea_record_pre_review_command MCP tool. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
217 lines
7.9 KiB
Python
217 lines
7.9 KiB
Python
"""Canonical review-merge workflow load proof for reviewer mutations (#389, #403)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import review_workflow_boundary as boundary
|
|
|
|
WORKFLOW_REL_PATH = (
|
|
"skills/llm-project-workflow/workflows/review-merge-pr.md"
|
|
)
|
|
SCHEMA_REL_PATH = (
|
|
"skills/llm-project-workflow/schemas/review-merge-final-report.md"
|
|
)
|
|
TASK_MODE = "review-merge-pr"
|
|
LOAD_TOOL_NAME = "gitea_load_review_workflow"
|
|
|
|
_REVIEW_WORKFLOW_LOAD: dict | None = None
|
|
|
|
|
|
def compute_content_hash(text: str) -> str:
|
|
"""Short deterministic hash for workflow/schema version proof."""
|
|
return hashlib.sha256((text or "").encode("utf-8")).hexdigest()[:12]
|
|
|
|
|
|
def _read_text(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def _canonical_paths(project_root: str) -> tuple[Path, Path]:
|
|
root = Path(project_root)
|
|
workflow = root / WORKFLOW_REL_PATH
|
|
schema = root / SCHEMA_REL_PATH
|
|
if not workflow.is_file():
|
|
raise FileNotFoundError(f"canonical workflow missing: {workflow}")
|
|
if not schema.is_file():
|
|
raise FileNotFoundError(f"final report schema missing: {schema}")
|
|
return workflow, schema
|
|
|
|
|
|
def build_canonical_workflow_metadata(
|
|
project_root: str,
|
|
*,
|
|
prompt_text: str | None = None,
|
|
) -> dict:
|
|
"""Load workflow + schema from disk and compute proof metadata."""
|
|
workflow_path, schema_path = _canonical_paths(project_root)
|
|
workflow_text = _read_text(workflow_path)
|
|
schema_text = _read_text(schema_path)
|
|
workflow_hash = compute_content_hash(workflow_text)
|
|
schema_hash = compute_content_hash(schema_text)
|
|
conflict, conflict_reasons = assess_prompt_conflict(prompt_text)
|
|
return {
|
|
"workflow_source": WORKFLOW_REL_PATH,
|
|
"workflow_path": str(workflow_path),
|
|
"task_mode": TASK_MODE,
|
|
"workflow_hash": workflow_hash,
|
|
"workflow_version": workflow_hash,
|
|
"final_report_schema_path": SCHEMA_REL_PATH,
|
|
"final_report_schema_hash": schema_hash,
|
|
"prompt_conflicts_with_workflow": conflict,
|
|
"prompt_conflict_reasons": conflict_reasons,
|
|
"load_tool": LOAD_TOOL_NAME,
|
|
}
|
|
|
|
|
|
def assess_prompt_conflict(prompt_text: str | None) -> tuple[bool, list[str]]:
|
|
"""Detect obvious task-mode conflicts between prompt and review workflow."""
|
|
if not (prompt_text or "").strip():
|
|
return False, []
|
|
text = prompt_text.lower()
|
|
reasons: list[str] = []
|
|
conflicting = (
|
|
(r"\bwork[- ]issue\b", "work-issue author mode"),
|
|
(r"\bcreate[- ]issue\b", "create-issue mode"),
|
|
(r"\bauthor/coder\b", "author/coder mode"),
|
|
(r"\breconcile[- ]landed\b", "reconcile-landed mode"),
|
|
)
|
|
for pattern, label in conflicting:
|
|
if re.search(pattern, text):
|
|
reasons.append(
|
|
f"active prompt appears to request {label} while loading "
|
|
f"{TASK_MODE} workflow"
|
|
)
|
|
return bool(reasons), reasons
|
|
|
|
|
|
def record_review_workflow_load(
|
|
project_root: str,
|
|
*,
|
|
prompt_text: str | None = None,
|
|
) -> dict:
|
|
"""Record in-process workflow load proof for the current MCP session."""
|
|
global _REVIEW_WORKFLOW_LOAD
|
|
meta = build_canonical_workflow_metadata(
|
|
project_root, prompt_text=prompt_text)
|
|
boundary_state = boundary.assess_boundary_status(project_root)
|
|
_REVIEW_WORKFLOW_LOAD = {
|
|
**meta,
|
|
"session_pid": os.getpid(),
|
|
"loaded": True,
|
|
"boundary_status": boundary_state.get("boundary_status"),
|
|
"boundary_clean": boundary_state.get("boundary_clean"),
|
|
"pre_review_command_count": boundary_state.get("pre_review_command_count"),
|
|
"boundary_violation_count": boundary_state.get("boundary_violation_count"),
|
|
"boundary_reasons": list(boundary_state.get("reasons") or []),
|
|
}
|
|
return dict(_REVIEW_WORKFLOW_LOAD)
|
|
|
|
|
|
def clear_review_workflow_load() -> None:
|
|
"""Test helper and review_pr session reset."""
|
|
global _REVIEW_WORKFLOW_LOAD
|
|
_REVIEW_WORKFLOW_LOAD = None
|
|
boundary.clear_pre_review_commands()
|
|
|
|
|
|
def workflow_load_status(project_root: str | None = None) -> dict:
|
|
"""Non-throwing status for capability/runtime reports."""
|
|
load = _REVIEW_WORKFLOW_LOAD
|
|
if load is None:
|
|
return {
|
|
"workflow_load_proof_present": False,
|
|
"workflow_load_valid": False,
|
|
"workflow_source": None,
|
|
"workflow_hash": None,
|
|
"final_report_schema_path": SCHEMA_REL_PATH,
|
|
"reasons": [
|
|
f"{LOAD_TOOL_NAME} has not been called in this session "
|
|
"(fail closed for reviewer mutations)"
|
|
],
|
|
}
|
|
reasons = _session_validation_reasons(load, project_root)
|
|
boundary_reasons = boundary.boundary_blockers(project_root)
|
|
if boundary_reasons:
|
|
reasons = list(reasons) + boundary_reasons
|
|
return {
|
|
"workflow_load_proof_present": True,
|
|
"workflow_load_valid": not reasons,
|
|
"workflow_source": load.get("workflow_source"),
|
|
"workflow_hash": load.get("workflow_hash"),
|
|
"task_mode": load.get("task_mode"),
|
|
"final_report_schema_path": load.get("final_report_schema_path"),
|
|
"final_report_schema_hash": load.get("final_report_schema_hash"),
|
|
"prompt_conflicts_with_workflow": load.get(
|
|
"prompt_conflicts_with_workflow"),
|
|
"session_pid": load.get("session_pid"),
|
|
"boundary_status": load.get("boundary_status"),
|
|
"boundary_clean": load.get("boundary_clean"),
|
|
"workflow_load_helper_result": boundary.workflow_load_helper_result(
|
|
load, project_root),
|
|
"reasons": reasons,
|
|
}
|
|
|
|
|
|
def _session_validation_reasons(
|
|
load: dict,
|
|
project_root: str | None,
|
|
) -> list[str]:
|
|
reasons: list[str] = []
|
|
if load.get("session_pid") != os.getpid():
|
|
reasons.append(
|
|
"workflow load proof was recorded in a different process "
|
|
"(fail closed)"
|
|
)
|
|
return reasons
|
|
if load.get("prompt_conflicts_with_workflow"):
|
|
reasons.extend(load.get("prompt_conflict_reasons") or [
|
|
"active prompt conflicts with loaded review-merge workflow"
|
|
])
|
|
if project_root:
|
|
try:
|
|
current = build_canonical_workflow_metadata(project_root)
|
|
except OSError as exc:
|
|
reasons.append(f"cannot re-verify workflow hash: {exc}")
|
|
return reasons
|
|
if current["workflow_hash"] != load.get("workflow_hash"):
|
|
reasons.append(
|
|
"stored workflow hash is stale; reload via "
|
|
f"{LOAD_TOOL_NAME} (fail closed)"
|
|
)
|
|
if current["final_report_schema_hash"] != load.get(
|
|
"final_report_schema_hash"):
|
|
reasons.append(
|
|
"stored final-report schema hash is stale; reload via "
|
|
f"{LOAD_TOOL_NAME} (fail closed)"
|
|
)
|
|
return reasons
|
|
|
|
|
|
def review_workflow_load_blockers(
|
|
project_root: str | None = None,
|
|
) -> list[str]:
|
|
"""Reasons reviewer mutations must fail closed."""
|
|
boundary_reasons = boundary.boundary_blockers(project_root)
|
|
if boundary_reasons and _REVIEW_WORKFLOW_LOAD is None:
|
|
return boundary_reasons
|
|
status = workflow_load_status(project_root)
|
|
if not status.get("workflow_load_proof_present"):
|
|
return list(status.get("reasons") or []) + boundary_reasons
|
|
if not status.get("workflow_load_valid"):
|
|
return list(status.get("reasons") or [])
|
|
return []
|
|
|
|
|
|
def recovery_handoff_without_replay() -> list[str]:
|
|
"""Safe next-step lines that must not include approve/merge replay."""
|
|
return [
|
|
"Reload the canonical workflow via gitea_load_review_workflow, then "
|
|
"rerun the full review-merge workflow from inventory.",
|
|
"Do not call gitea_submit_pr_review, gitea_mark_final_review_decision, "
|
|
"or gitea_merge_pr until workflow-load proof is present.",
|
|
"Do not include approve/merge replay commands in the recovery handoff.",
|
|
] |