from __future__ import annotations import argparse import csv from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parents[1] GATE4_PATH = ROOT / "docs" / "gates" / "gate-04-pilot-hardening.md" P1P2_PATH = ROOT / "docs" / "gates" / "p1-p2-defects.md" ACCEPTED_RELEASE_PATH = ROOT / "docs" / "releases" / "v1.0.0-mvp-accepted.md" def utc_now() -> datetime: return datetime.now(timezone.utc) def iso_now() -> str: return utc_now().isoformat() def _normalized_status(value: str) -> str: return value.strip().lower() def _is_open_status(value: str) -> bool: normalized = _normalized_status(value) return normalized not in {"closed", "resolved", "verified"} def parse_open_defect_counts(defect_log_path: Path) -> dict[str, int]: counts = {"P1": 0, "P2": 0, "P3": 0, "P4": 0} with defect_log_path.open("r", newline="", encoding="utf-8") as handle: reader = csv.DictReader(handle) for row in reader: severity = (row.get("severity") or "").strip().upper() status = row.get("status") or "" if severity in counts and _is_open_status(status): counts[severity] += 1 return counts def validate_session_protocol(session_protocol_path: Path) -> list[str]: content = session_protocol_path.read_text(encoding="utf-8") errors: list[str] = [] blank_markers = [ "- Session ID:", "- Environment URL:", "- Build/version:", "- Deployment date:", "- Cluster/namespace:", "- Business owner:", "- IT owner:", ] for marker in blank_markers: if f"{marker}\n" in content or f"{marker}\r\n" in content: errors.append(f"Session protocol still contains blank field: {marker}") if "## Decision" not in content or "[x]" not in content: errors.append("Session protocol does not contain a selected decision checkbox") return errors def validate_signoff_sheet(signoff_sheet_path: Path) -> list[str]: content = signoff_sheet_path.read_text(encoding="utf-8") errors: list[str] = [] placeholder_lines = [ " - Name:", " - Signature:", " - Date:", ] for placeholder in placeholder_lines: if content.count(placeholder) > 0: errors.append(f"Sign-off sheet still contains placeholder lines matching: {placeholder.strip()}") break decision_lines = [line for line in content.splitlines() if line.startswith("- [")] if sum(1 for line in decision_lines if "[x]" in line.lower()) != 1: errors.append("Sign-off sheet must have exactly one selected decision checkbox") return errors def apply_gate4_closure( content: str, *, session_id: str, session_dir: Path, acceptance: str, open_counts: dict[str, int], ) -> str: updated = content updated = updated.replace( "- [ ] UAT signed with real operators/supervisors", "- [x] UAT signed with real operators/supervisors", ) updated = updated.replace( "- [ ] P1/P2 defects closed", "- [x] P1/P2 defects closed", ) marker = "Manual closure update:" if marker in updated: updated = updated.split(marker, 1)[0].rstrip() lines = [ "", "Manual closure update:", f"- Date (UTC): {iso_now()}", f"- Session ID: {session_id}", f"- Session directory: `{session_dir.as_posix()}`", f"- Acceptance: {acceptance}", ( "- Remaining open defects: " f"P1={open_counts['P1']}, P2={open_counts['P2']}, " f"P3={open_counts['P3']}, P4={open_counts['P4']}" ), ] return updated.rstrip() + "\n" + "\n".join(lines) + "\n" def build_p1p2_register( *, session_id: str, session_dir: Path, open_counts: dict[str, int], acceptance: str, ) -> str: lines = [ "# P1/P2 Defect Register (Pilot)", "", f"Last update: {utc_now().date().isoformat()} ({session_id})", "", "## Current Status", f"- Open P1: {open_counts['P1']}", f"- Open P2: {open_counts['P2']}", "- Source: manual UAT close-out", f"- Manual session evidence: `{session_dir.as_posix()}`", "", "## Triage Policy", "- Only `P1` and `P2` issues belong to MVP remediation.", "- `P3` and `P4` issues move to `docs/roadmap/05-wave2-backlog.md` unless they block sign-off.", "", "## Manual Closure", f"- Acceptance: {acceptance}", "- P1/P2 closure confirmed by signed manual UAT package.", ] return "\n".join(lines) + "\n" def build_acceptance_release_note( *, session_id: str, session_dir: Path, acceptance: str, open_counts: dict[str, int], ) -> str: lines = [ "# Release Notes - v1.0.0-mvp Accepted", "", f"Date: {utc_now().date().isoformat()}", "", "## Acceptance Result", f"- Session ID: {session_id}", f"- Acceptance: {acceptance}", f"- Manual session evidence: `{session_dir.as_posix()}`", "", "## Closed Gate 4 Conditions", "- UAT signed with real operators/supervisors", "- P1/P2 defects closed", "", "## Remaining Deferred Items", f"- Open P3: {open_counts['P3']}", f"- Open P4: {open_counts['P4']}", "- Deferred scope remains tracked in `docs/roadmap/05-wave2-backlog.md`.", "", "## Baseline", "- Accepted baseline remains the current MVP v1 scope without API expansion.", "- Stage 1-3 public routes remain unchanged.", ] return "\n".join(lines) + "\n" def finalize_manual_session( *, session_dir: Path, acceptance: str, dry_run: bool = False, ) -> dict[str, int]: required_files = { "session_protocol": session_dir / "session-protocol.md", "scenario_checklist": session_dir / "scenario-checklist.md", "defect_log": session_dir / "defect-log.csv", "signoff_sheet": session_dir / "signoff-sheet.md", "manifest": session_dir / "manifest.json", } missing = [name for name, path in required_files.items() if not path.exists()] attachments_dir = session_dir / "attachments" if not attachments_dir.exists(): missing.append("attachments") if missing: raise FileNotFoundError(f"Manual session bundle is incomplete: {missing}") validation_errors: list[str] = [] validation_errors.extend(validate_session_protocol(required_files["session_protocol"])) validation_errors.extend(validate_signoff_sheet(required_files["signoff_sheet"])) open_counts = parse_open_defect_counts(required_files["defect_log"]) if open_counts["P1"] != 0 or open_counts["P2"] != 0: validation_errors.append( f"Cannot close MVP pilot with open P1/P2 defects: P1={open_counts['P1']}, P2={open_counts['P2']}" ) if validation_errors: details = "\n".join(f"- {error}" for error in validation_errors) raise ValueError(f"Manual UAT package validation failed:\n{details}") if dry_run: return open_counts session_id = session_dir.name.replace("manual_", "", 1) gate4_content = GATE4_PATH.read_text(encoding="utf-8") updated_gate4 = apply_gate4_closure( gate4_content, session_id=session_id, session_dir=session_dir, acceptance=acceptance, open_counts=open_counts, ) GATE4_PATH.write_text(updated_gate4, encoding="utf-8") P1P2_PATH.write_text( build_p1p2_register( session_id=session_id, session_dir=session_dir, open_counts=open_counts, acceptance=acceptance, ), encoding="utf-8", ) ACCEPTED_RELEASE_PATH.write_text( build_acceptance_release_note( session_id=session_id, session_dir=session_dir, acceptance=acceptance, open_counts=open_counts, ), encoding="utf-8", ) return open_counts def main() -> None: parser = argparse.ArgumentParser(description="Validate a manual UAT bundle and close MVP Gate 4") parser.add_argument("--session-dir", required=True, help="Path to the prepared manual session directory") parser.add_argument( "--acceptance", choices=["accepted_for_pilot_completion", "accepted_with_conditions"], default="accepted_for_pilot_completion", help="Acceptance mode recorded in gate closure artifacts", ) parser.add_argument( "--dry-run", action="store_true", help="Validate the manual session bundle without updating gate/release documents", ) args = parser.parse_args() session_dir = Path(args.session_dir) if not session_dir.is_absolute(): session_dir = (ROOT / session_dir).resolve() counts = finalize_manual_session( session_dir=session_dir, acceptance=args.acceptance, dry_run=args.dry_run, ) prefix = "MVP pilot finalization dry-run passed:" if args.dry_run else "MVP pilot finalized:" print(f"{prefix} P1={counts['P1']} P2={counts['P2']} P3={counts['P3']} P4={counts['P4']}") if __name__ == "__main__": main()