Add templates, validators, and documentation for discussion/issue/PR state comments and final-report next-action handoff fields. Wire enforcement into assess_final_report_validator across all workflow task kinds. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
374 lines
11 KiB
Python
374 lines
11 KiB
Python
"""Canonical state handoff ledger for discussions, issues, and PRs (#494)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
HANDOFF_HEADING = "Controller Handoff"
|
|
|
|
ISSUE_STATE_FIELDS = (
|
|
"STATE",
|
|
"NEXT_ACTOR",
|
|
"NEXT_ACTION",
|
|
"NEXT_PROMPT",
|
|
"STATUS",
|
|
"RELATED_PRS",
|
|
"BRANCH",
|
|
"HEAD_SHA",
|
|
"VALIDATION",
|
|
"BLOCKERS",
|
|
"DUPLICATES_OR_SUPERSEDES",
|
|
"LAST_UPDATED_BY",
|
|
)
|
|
|
|
PR_STATE_FIELDS = (
|
|
"STATE",
|
|
"NEXT_ACTOR",
|
|
"NEXT_ACTION",
|
|
"NEXT_PROMPT",
|
|
"ISSUE",
|
|
"BASE",
|
|
"HEAD",
|
|
"HEAD_SHA",
|
|
"REVIEW_STATUS",
|
|
"VALIDATION",
|
|
"BLOCKERS",
|
|
"SUPERSEDES",
|
|
"SUPERSEDED_BY",
|
|
"MERGE_READY",
|
|
"LAST_UPDATED_BY",
|
|
)
|
|
|
|
DISCUSSION_STATE_FIELDS = (
|
|
"STATE",
|
|
"NEXT_ACTOR",
|
|
"NEXT_ACTION",
|
|
"NEXT_PROMPT",
|
|
"COMMENT_COUNT",
|
|
"SUBSTANTIVE_COMMENTS",
|
|
"URGENCY",
|
|
"BLOCKERS",
|
|
"LAST_UPDATED_BY",
|
|
)
|
|
|
|
DISCUSSION_SUMMARY_FIELDS = (
|
|
"DECISION",
|
|
"ISSUES_TO_CREATE",
|
|
"NON_GOALS",
|
|
"UNRESOLVED_QUESTIONS",
|
|
"NEXT_PROMPT",
|
|
"LAST_UPDATED_BY",
|
|
)
|
|
|
|
QUEUE_CONTROLLER_FIELDS = (
|
|
"QUEUE_STATE",
|
|
"SELECTED_OBJECT",
|
|
"SELECTED_OBJECT_TYPE",
|
|
"NEXT_ACTOR",
|
|
"NEXT_ACTION",
|
|
"NEXT_PROMPT",
|
|
"EVIDENCE",
|
|
"LAST_UPDATED_BY",
|
|
)
|
|
|
|
FINAL_REPORT_NEXT_ACTION_FIELDS = (
|
|
("Current status", ("current status",)),
|
|
("Next actor", ("next actor", "next_actor")),
|
|
("Next action", ("next action", "next_action")),
|
|
("Next prompt", ("next prompt", "next_prompt")),
|
|
)
|
|
|
|
STATE_COMMENT_KINDS = frozenset({
|
|
"issue_state",
|
|
"pr_state",
|
|
"discussion_state",
|
|
"discussion_summary",
|
|
"queue_controller",
|
|
})
|
|
|
|
_FIELDS_BY_KIND = {
|
|
"issue_state": ISSUE_STATE_FIELDS,
|
|
"pr_state": PR_STATE_FIELDS,
|
|
"discussion_state": DISCUSSION_STATE_FIELDS,
|
|
"discussion_summary": DISCUSSION_SUMMARY_FIELDS,
|
|
"queue_controller": QUEUE_CONTROLLER_FIELDS,
|
|
}
|
|
|
|
_FIELD_LINE_RE = re.compile(
|
|
r"^([A-Z][A-Z0-9_]+)\s*:\s*(.*)$",
|
|
re.MULTILINE,
|
|
)
|
|
_HANDOFF_SECTION_RE = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M)
|
|
_READY_TO_MERGE_RE = re.compile(
|
|
r"\b(?:ready[_ ]to[_ ]merge|merge[_ ]ready\s*:\s*(?:yes|true))\b",
|
|
re.IGNORECASE,
|
|
)
|
|
_APPROVAL_PROOF_RE = re.compile(
|
|
r"\b(?:review decision\s*:\s*approve|approved at|approval at current head|"
|
|
r"formal approval|review_status\s*:\s*approved)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
_EXTERNAL_NONE_RE = re.compile(
|
|
r"^\s*[-*]?\s*external[- ]state mutations\s*:\s*none\s*$",
|
|
re.IGNORECASE | re.MULTILINE,
|
|
)
|
|
_LOCK_MUTATION_RE = re.compile(
|
|
r"\b(?:gitea_lock_issue|issue-locks/|lock file edited)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
_ISSUE_DONE_RE = re.compile(
|
|
r"\b(?:issue (?:done|closed)\b|current status\s*:\s*(?:done|closed)\b|"
|
|
r"current status\s*:\s*issue complete\b)",
|
|
re.IGNORECASE,
|
|
)
|
|
_PR_MERGE_PROOF_RE = re.compile(
|
|
r"\b(?:pr (?:merged|number)|merge result\s*:\s*merged|pr mutations\s*:\s*created)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
_DISCUSSION_COMPLETE_RE = re.compile(
|
|
r"\b(?:discussion complete|discussion ready for issue creation)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
_DISCUSSION_SUMMARY_PROOF_RE = re.compile(
|
|
r"\b(?:discussion summary|issues to create|issues created|linked issues)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
MIN_DISCUSSION_COMMENTS_DEFAULT = 5
|
|
|
|
|
|
def _render_fields(fields: tuple[str, ...], values: dict[str, Any]) -> str:
|
|
lines = ["```text"]
|
|
for name in fields:
|
|
raw = values.get(name, values.get(name.lower(), "none"))
|
|
text = str(raw).strip() if raw is not None else "none"
|
|
lines.append(f"{name}: {text or 'none'}")
|
|
lines.append("```")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def render_issue_state_comment(**values: Any) -> str:
|
|
"""Render canonical issue state comment body."""
|
|
return _render_fields(ISSUE_STATE_FIELDS, values)
|
|
|
|
|
|
def render_pr_state_comment(**values: Any) -> str:
|
|
"""Render canonical PR state comment body."""
|
|
return _render_fields(PR_STATE_FIELDS, values)
|
|
|
|
|
|
def render_discussion_state_comment(**values: Any) -> str:
|
|
"""Render canonical discussion state comment body."""
|
|
return _render_fields(DISCUSSION_STATE_FIELDS, values)
|
|
|
|
|
|
def render_discussion_summary_comment(**values: Any) -> str:
|
|
"""Render discussion-to-issue conversion summary comment."""
|
|
return _render_fields(DISCUSSION_SUMMARY_FIELDS, values)
|
|
|
|
|
|
def render_queue_controller_report(**values: Any) -> str:
|
|
"""Render queue-controller selection report."""
|
|
return _render_fields(QUEUE_CONTROLLER_FIELDS, values)
|
|
|
|
|
|
def parse_state_comment(text: str) -> dict[str, str]:
|
|
"""Parse KEY: value lines from a canonical state comment."""
|
|
parsed: dict[str, str] = {}
|
|
for match in _FIELD_LINE_RE.finditer(text or ""):
|
|
parsed[match.group(1)] = match.group(2).strip()
|
|
return parsed
|
|
|
|
|
|
def _handoff_field_map(report_text: str) -> dict[str, str]:
|
|
section_match = _HANDOFF_SECTION_RE.search(report_text or "")
|
|
if not section_match:
|
|
return {}
|
|
section = (report_text or "")[section_match.end():]
|
|
fields: dict[str, str] = {}
|
|
for line in section.splitlines():
|
|
stripped = line.strip().lstrip("-*").strip()
|
|
if ":" not in stripped:
|
|
continue
|
|
key, value = stripped.split(":", 1)
|
|
fields[key.strip().lower()] = value.strip()
|
|
return fields
|
|
|
|
|
|
def assess_state_comment(text: str, *, kind: str) -> dict[str, Any]:
|
|
"""Validate a canonical Gitea state comment for *kind*."""
|
|
normalized = (kind or "").strip().lower()
|
|
if normalized not in STATE_COMMENT_KINDS:
|
|
return {
|
|
"complete": False,
|
|
"block": True,
|
|
"missing_fields": [],
|
|
"reasons": [f"unknown state comment kind '{kind}'"],
|
|
"safe_next_action": "use a supported state comment kind",
|
|
}
|
|
|
|
required = _FIELDS_BY_KIND[normalized]
|
|
parsed = parse_state_comment(text)
|
|
missing = [name for name in required if name not in parsed or not parsed[name].strip()]
|
|
reasons = [f"state comment missing field: {name}" for name in missing]
|
|
|
|
if normalized == "discussion_state":
|
|
urgency = parsed.get("URGENCY", "").strip().lower()
|
|
count_raw = parsed.get("COMMENT_COUNT", "").strip()
|
|
try:
|
|
count = int(count_raw)
|
|
except (TypeError, ValueError):
|
|
count = -1
|
|
if (
|
|
urgency not in {"urgent", "trivial"}
|
|
and count >= 0
|
|
and count < MIN_DISCUSSION_COMMENTS_DEFAULT
|
|
):
|
|
reasons.append(
|
|
f"discussion has {count} comments; need at least "
|
|
f"{MIN_DISCUSSION_COMMENTS_DEFAULT} substantive comments "
|
|
"or URGENCY: urgent|trivial"
|
|
)
|
|
|
|
block = bool(missing or reasons)
|
|
return {
|
|
"complete": not block,
|
|
"block": block,
|
|
"missing_fields": missing,
|
|
"parsed_fields": parsed,
|
|
"reasons": reasons,
|
|
"safe_next_action": (
|
|
"fill all required state comment fields before posting"
|
|
if block
|
|
else "proceed"
|
|
),
|
|
}
|
|
|
|
|
|
def assess_final_report_next_action_handoff(report_text: str) -> dict[str, Any]:
|
|
"""Require current status plus next actor/action/prompt in Controller Handoff."""
|
|
fields = _handoff_field_map(report_text)
|
|
if not fields:
|
|
return {
|
|
"complete": False,
|
|
"block": True,
|
|
"missing_fields": [name for name, _ in FINAL_REPORT_NEXT_ACTION_FIELDS],
|
|
"reasons": [f"final report has no section titled exactly '{HANDOFF_HEADING}'"],
|
|
"safe_next_action": f"add a complete {HANDOFF_HEADING} section",
|
|
}
|
|
|
|
missing = []
|
|
for name, aliases in FINAL_REPORT_NEXT_ACTION_FIELDS:
|
|
value = ""
|
|
for alias in aliases:
|
|
if alias in fields:
|
|
value = fields[alias]
|
|
break
|
|
if not value or value.lower() in {"none", "n/a", "not verified in this session", ""}:
|
|
missing.append(name)
|
|
|
|
reasons = [f"handoff missing next-action field: {name}" for name in missing]
|
|
block = bool(missing)
|
|
return {
|
|
"complete": not block,
|
|
"block": block,
|
|
"missing_fields": missing,
|
|
"reasons": reasons,
|
|
"safe_next_action": (
|
|
"add Current status, Next actor, Next action, and Next prompt to Controller Handoff"
|
|
if block
|
|
else "proceed"
|
|
),
|
|
}
|
|
|
|
|
|
def assess_contradictory_state_handoff(report_text: str) -> dict[str, Any]:
|
|
"""Fail closed on contradictory queue/state claims in final reports."""
|
|
text = report_text or ""
|
|
fields = _handoff_field_map(text)
|
|
reasons: list[str] = []
|
|
|
|
review_decision = fields.get("review decision", fields.get("review status", ""))
|
|
has_approval = (
|
|
review_decision.lower() in {"approve", "approved"}
|
|
or bool(_APPROVAL_PROOF_RE.search(text))
|
|
)
|
|
if _READY_TO_MERGE_RE.search(text) and not has_approval:
|
|
reasons.append(
|
|
"report claims ready to merge without approval-at-current-head proof"
|
|
)
|
|
|
|
lock_val = fields.get("external-state mutations", "")
|
|
mutation_lines = " ".join(
|
|
fields.get(key, "")
|
|
for key in (
|
|
"issue mutations",
|
|
"mcp/gitea mutations",
|
|
"external-state mutations",
|
|
"claim/lock state",
|
|
)
|
|
)
|
|
if (
|
|
(_EXTERNAL_NONE_RE.search(text) or lock_val.lower() == "none")
|
|
and _LOCK_MUTATION_RE.search(mutation_lines)
|
|
):
|
|
reasons.append(
|
|
"report claims External-state mutations: none while lock "
|
|
"activity is documented in mutation fields"
|
|
)
|
|
|
|
if _ISSUE_DONE_RE.search(text) and not _PR_MERGE_PROOF_RE.search(text):
|
|
reasons.append(
|
|
"report claims issue done/complete without PR or merge proof"
|
|
)
|
|
|
|
if _DISCUSSION_COMPLETE_RE.search(text) and not _DISCUSSION_SUMMARY_PROOF_RE.search(text):
|
|
reasons.append(
|
|
"report claims discussion complete without summary or issue-creation proof"
|
|
)
|
|
|
|
review_status = fields.get("review status", fields.get("review decision", ""))
|
|
merge_ready = fields.get("merge ready", "")
|
|
if (
|
|
merge_ready.lower() in {"yes", "true", "ready"}
|
|
and review_status.lower() not in {"approve", "approved"}
|
|
and not _APPROVAL_PROOF_RE.search(text)
|
|
):
|
|
reasons.append(
|
|
"MERGE_READY or merge-ready claim without approved review status"
|
|
)
|
|
|
|
block = bool(reasons)
|
|
return {
|
|
"complete": not block,
|
|
"block": block,
|
|
"reasons": reasons,
|
|
"safe_next_action": (
|
|
"correct contradictory state claims or add missing proof"
|
|
if block
|
|
else "proceed"
|
|
),
|
|
}
|
|
|
|
|
|
def assess_state_handoff_ledger_report(report_text: str) -> dict[str, Any]:
|
|
"""Composite #494 assessment for final reports."""
|
|
next_action = assess_final_report_next_action_handoff(report_text)
|
|
contradictions = assess_contradictory_state_handoff(report_text)
|
|
reasons = list(next_action.get("reasons") or []) + list(contradictions.get("reasons") or [])
|
|
block = bool(next_action.get("block") or contradictions.get("block"))
|
|
return {
|
|
"complete": not block,
|
|
"block": block,
|
|
"next_action": next_action,
|
|
"contradictions": contradictions,
|
|
"reasons": reasons,
|
|
"safe_next_action": (
|
|
next_action.get("safe_next_action")
|
|
if next_action.get("block")
|
|
else contradictions.get("safe_next_action")
|
|
if contradictions.get("block")
|
|
else "proceed"
|
|
),
|
|
} |