Require and validate Controller Handoff sections in workflow final reports
- Upgrade SKILL.md §K compact format to the issue #182 canonical field set (Task/Repo/Role/Identity/Issue-PR/Branch-SHA/Files/Validation/Mutations/ Current status/Blockers/Next/Safety) plus role-specific field lists for review/merge, author, and queue/inventory tasks. - Point the review-pr, merge-pr, and start-issue template handoff lines at the exactly-titled Controller Handoff section with their role fields. - Add review_proofs.assess_controller_handoff(): reports without the exact section are 'missing' (downgraded), present-but-partial are 'incomplete' with the absent fields listed, and role extras are enforced per role. - Add TestControllerHandoff (8 tests) including a SKILL.md doc-contract test so the documented requirement cannot silently rot. The handoff supplements the full report; full-report validation rules are unchanged. Closes #182 Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -409,3 +409,127 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
|
||||
"merge_performed": bool(merge_performed),
|
||||
"issue_status_verified": bool(issue_status_verified),
|
||||
}
|
||||
|
||||
|
||||
# ── Controller Handoff validation (Issue #182) ────────────────────────────────
|
||||
#
|
||||
# Every final report must end with a compact section titled exactly
|
||||
# "Controller Handoff". Each required field is a (canonical name, aliases)
|
||||
# pair; a field counts as present when any alias starts a bullet/label line
|
||||
# inside the handoff section.
|
||||
|
||||
HANDOFF_HEADING = "Controller Handoff"
|
||||
|
||||
HANDOFF_BASE_FIELDS = (
|
||||
("Task", ("task",)),
|
||||
("Repo", ("repo", "repository", "repo/state")),
|
||||
("Role", ("role",)),
|
||||
("Identity", ("identity",)),
|
||||
("Issue/PR", ("issue/pr", "issues/prs", "issue", "pr")),
|
||||
("Branch/SHA", ("branch/sha", "branch", "head sha")),
|
||||
("Files changed", ("files changed", "changed", "files")),
|
||||
("Validation", ("validation",)),
|
||||
("Mutations", ("mutations",)),
|
||||
("Current status", ("current status", "status")),
|
||||
("Blockers", ("blockers",)),
|
||||
("Next", ("next",)),
|
||||
("Safety", ("safety",)),
|
||||
)
|
||||
|
||||
HANDOFF_ROLE_FIELDS = {
|
||||
"review": (
|
||||
("Selected PR", ("selected pr",)),
|
||||
("Reviewer eligibility", ("reviewer eligibility", "eligibility")),
|
||||
("Pinned reviewed head", ("pinned reviewed head", "pinned head")),
|
||||
("Review decision", ("review decision", "decision")),
|
||||
("Merge result", ("merge result",)),
|
||||
("Linked issue status", ("linked issue status", "linked issue")),
|
||||
("Cleanup status", ("cleanup status", "cleanup")),
|
||||
),
|
||||
"author": (
|
||||
("Selected issue", ("selected issue",)),
|
||||
("Claim/comment status", ("claim/comment status", "claim status",
|
||||
"claim")),
|
||||
("PR number opened", ("pr number opened", "pr opened", "pr number")),
|
||||
("No review/merge confirmation", ("no review/merge",
|
||||
"no review or merge")),
|
||||
),
|
||||
"inventory": (
|
||||
("Repositories checked", ("repositories checked", "repos checked")),
|
||||
("Open PR counts", ("open pr counts", "open pr count",
|
||||
"open prs per repo")),
|
||||
("Selected PR or reason", ("selected pr", "none selected",
|
||||
"reason none selected")),
|
||||
("Inventory completeness", ("inventory complete", "inventory scoped",
|
||||
"inventory completeness")),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _handoff_section_lines(report_text):
|
||||
"""Return the lines of the Controller Handoff section, or None."""
|
||||
lines = (report_text or "").splitlines()
|
||||
start = None
|
||||
for i, line in enumerate(lines):
|
||||
bare = line.strip().lstrip("#").strip().rstrip(":")
|
||||
if bare == HANDOFF_HEADING:
|
||||
start = i + 1
|
||||
break
|
||||
if start is None:
|
||||
return None
|
||||
return lines[start:]
|
||||
|
||||
|
||||
def assess_controller_handoff(report_text, role=None):
|
||||
"""Issue #182: final reports without a Controller Handoff downgrade.
|
||||
|
||||
Verdicts:
|
||||
- 'missing' — no exactly-titled section; the report is downgraded.
|
||||
- 'incomplete' — section present but required fields absent (listed).
|
||||
- 'complete' — all base fields plus the role-specific fields present.
|
||||
|
||||
*role* is 'review', 'author', 'inventory', or None (base fields only).
|
||||
The handoff supplements the full report; this helper never validates
|
||||
the full report body, only the continuation summary.
|
||||
"""
|
||||
section = _handoff_section_lines(report_text)
|
||||
if section is None:
|
||||
return {
|
||||
"verdict": "missing",
|
||||
"downgraded": True,
|
||||
"missing_fields": [name for name, _ in HANDOFF_BASE_FIELDS],
|
||||
"reasons": [
|
||||
"final report has no section titled exactly "
|
||||
f"'{HANDOFF_HEADING}'"
|
||||
],
|
||||
}
|
||||
|
||||
labels = []
|
||||
for line in section:
|
||||
stripped = line.strip().lstrip("-*").strip()
|
||||
if ":" in stripped:
|
||||
labels.append(stripped.split(":", 1)[0].strip().lower())
|
||||
|
||||
required = list(HANDOFF_BASE_FIELDS)
|
||||
required.extend(HANDOFF_ROLE_FIELDS.get(role or "", ()))
|
||||
|
||||
missing = []
|
||||
for name, aliases in required:
|
||||
if not any(label.startswith(alias)
|
||||
for label in labels for alias in aliases):
|
||||
missing.append(name)
|
||||
|
||||
if missing:
|
||||
return {
|
||||
"verdict": "incomplete",
|
||||
"downgraded": True,
|
||||
"missing_fields": missing,
|
||||
"reasons": [f"handoff missing required field: {m}"
|
||||
for m in missing],
|
||||
}
|
||||
return {
|
||||
"verdict": "complete",
|
||||
"downgraded": False,
|
||||
"missing_fields": [],
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user