#!/usr/bin/env python3 """Cycle retrospective logger for the Timmy dev loop. Called after each cycle completes (success or failure). Appends a structured entry to .loop/retro/cycles.jsonl. EPOCH NOTATION (turnover system): Each cycle carries a symbolic epoch tag alongside the raw integer: ⟳WW.D:NNN ⟳ turnover glyph — marks epoch-aware cycles WW ISO week-of-year (01–53) D ISO weekday (1=Mon … 7=Sun) NNN daily cycle counter, zero-padded, resets at midnight UTC Example: ⟳12.3:042 — Week 12, Wednesday, 42nd cycle of the day. The raw `cycle` integer is preserved for backward compatibility. The `epoch` field carries the symbolic notation. SUCCESS DEFINITION: A cycle is only "success" if BOTH conditions are met: 1. The hermes process exited cleanly (exit code 0) 2. Main is green (smoke test passes on main after merge) A cycle that merges a PR but leaves main red is a FAILURE. The --main-green flag records the smoke test result. Usage: python3 scripts/cycle_retro.py --cycle 42 --success --main-green --issue 85 \ --type bug --duration 480 --tests-passed 1450 --tests-added 3 \ --files-changed 2 --lines-added 45 --lines-removed 12 \ --kimi-panes 2 --pr 155 python3 scripts/cycle_retro.py --cycle 43 --failure --issue 90 \ --type feature --duration 1200 --reason "tox failed: 3 errors" python3 scripts/cycle_retro.py --cycle 44 --success --no-main-green \ --reason "PR merged but tests fail on main" """ from __future__ import annotations import argparse import json import re import subprocess import sys from datetime import datetime, timezone from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl" SUMMARY_FILE = REPO_ROOT / ".loop" / "retro" / "summary.json" EPOCH_COUNTER_FILE = REPO_ROOT / ".loop" / "retro" / ".epoch_counter" # How many recent entries to include in rolling summary SUMMARY_WINDOW = 50 # ── Epoch turnover ──────────────────────────────────────────────────────── def _epoch_tag(now: datetime | None = None) -> tuple[str, dict]: """Generate the symbolic epoch tag and advance the daily counter. Returns (epoch_string, epoch_parts) where epoch_parts is a dict with week, weekday, daily_n for structured storage. The daily counter persists in .epoch_counter as a two-line file: line 1: ISO date (YYYY-MM-DD) of the current epoch day line 2: integer count When the date rolls over, the counter resets to 1. """ if now is None: now = datetime.now(timezone.utc) iso_cal = now.isocalendar() # (year, week, weekday) week = iso_cal[1] weekday = iso_cal[2] today_str = now.strftime("%Y-%m-%d") # Read / reset daily counter daily_n = 1 EPOCH_COUNTER_FILE.parent.mkdir(parents=True, exist_ok=True) if EPOCH_COUNTER_FILE.exists(): try: lines = EPOCH_COUNTER_FILE.read_text().strip().splitlines() if len(lines) == 2 and lines[0] == today_str: daily_n = int(lines[1]) + 1 except (ValueError, IndexError): pass # corrupt file — reset # Persist EPOCH_COUNTER_FILE.write_text(f"{today_str}\n{daily_n}\n") tag = f"\u27f3{week:02d}.{weekday}:{daily_n:03d}" parts = {"week": week, "weekday": weekday, "daily_n": daily_n} return tag, parts BRANCH_ISSUE_RE = re.compile(r"issue-(\d+)") def _detect_issue_from_branch() -> int | None: """Try to extract an issue number from the current git branch name. Matches branch patterns like ``kimi/issue-492`` or ``fix/issue-17``. Returns ``None`` when not on a matching branch or git is unavailable. """ try: branch = subprocess.check_output( ["git", "rev-parse", "--abbrev-ref", "HEAD"], stderr=subprocess.DEVNULL, text=True, ).strip() except (subprocess.CalledProcessError, FileNotFoundError): return None m = BRANCH_ISSUE_RE.search(branch) return int(m.group(1)) if m else None def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description="Log a cycle retrospective") p.add_argument("--cycle", type=int, required=True) p.add_argument("--issue", type=int, default=None) p.add_argument("--type", choices=["bug", "feature", "refactor", "philosophy", "unknown"], default="unknown") outcome = p.add_mutually_exclusive_group(required=True) outcome.add_argument("--success", action="store_true") outcome.add_argument("--failure", action="store_true") p.add_argument("--duration", type=int, default=0, help="Cycle time in seconds") p.add_argument("--tests-passed", type=int, default=0) p.add_argument("--tests-added", type=int, default=0) p.add_argument("--files-changed", type=int, default=0) p.add_argument("--lines-added", type=int, default=0) p.add_argument("--lines-removed", type=int, default=0) p.add_argument("--kimi-panes", type=int, default=0) p.add_argument("--pr", type=int, default=None, help="PR number if merged") p.add_argument("--reason", type=str, default="", help="Failure reason") p.add_argument("--notes", type=str, default="", help="Free-form observations") p.add_argument("--main-green", action="store_true", default=False, help="Smoke test passed on main after this cycle") p.add_argument("--no-main-green", dest="main_green", action="store_false", help="Smoke test failed or was not run") return p.parse_args() def update_summary() -> None: """Compute rolling summary statistics from recent cycles.""" if not RETRO_FILE.exists(): return entries = [] for line in RETRO_FILE.read_text().strip().splitlines(): try: entries.append(json.loads(line)) except json.JSONDecodeError: continue recent = entries[-SUMMARY_WINDOW:] if not recent: return # Only count entries with real measured data for rates. # Backfilled entries lack main_green/hermes_clean fields — exclude them. measured = [e for e in recent if "main_green" in e] successes = [e for e in measured if e.get("success")] failures = [e for e in measured if not e.get("success")] main_green_count = sum(1 for e in measured if e.get("main_green")) hermes_clean_count = sum(1 for e in measured if e.get("hermes_clean")) durations = [e["duration"] for e in recent if e.get("duration", 0) > 0] # Per-type stats (only from measured entries for rates) type_stats: dict[str, dict] = {} for e in recent: t = e.get("type", "unknown") if t not in type_stats: type_stats[t] = {"count": 0, "measured": 0, "success": 0, "total_duration": 0} type_stats[t]["count"] += 1 type_stats[t]["total_duration"] += e.get("duration", 0) if "main_green" in e: type_stats[t]["measured"] += 1 if e.get("success"): type_stats[t]["success"] += 1 for t, stats in type_stats.items(): if stats["measured"] > 0: stats["success_rate"] = round(stats["success"] / stats["measured"], 2) else: stats["success_rate"] = -1 if stats["count"] > 0: stats["avg_duration"] = round(stats["total_duration"] / stats["count"]) # Quarantine candidates (failed 2+ times) issue_failures: dict[int, int] = {} for e in recent: if not e.get("success") and e.get("issue"): issue_failures[e["issue"]] = issue_failures.get(e["issue"], 0) + 1 quarantine_candidates = {k: v for k, v in issue_failures.items() if v >= 2} # Epoch turnover stats — cycles per week/day from epoch-tagged entries epoch_entries = [e for e in recent if e.get("epoch")] by_week: dict[int, int] = {} by_weekday: dict[int, int] = {} for e in epoch_entries: w = e.get("epoch_week") d = e.get("epoch_weekday") if w is not None: by_week[w] = by_week.get(w, 0) + 1 if d is not None: by_weekday[d] = by_weekday.get(d, 0) + 1 # Current epoch — latest entry's epoch tag current_epoch = epoch_entries[-1].get("epoch", "") if epoch_entries else "" # Weekday names for display weekday_glyphs = {1: "Mon", 2: "Tue", 3: "Wed", 4: "Thu", 5: "Fri", 6: "Sat", 7: "Sun"} by_weekday_named = {weekday_glyphs.get(k, str(k)): v for k, v in sorted(by_weekday.items())} summary = { "updated_at": datetime.now(timezone.utc).isoformat(), "current_epoch": current_epoch, "window": len(recent), "measured_cycles": len(measured), "total_cycles": len(entries), "success_rate": round(len(successes) / len(measured), 2) if measured else -1, "main_green_rate": round(main_green_count / len(measured), 2) if measured else -1, "hermes_clean_rate": round(hermes_clean_count / len(measured), 2) if measured else -1, "avg_duration_seconds": round(sum(durations) / len(durations)) if durations else 0, "total_lines_added": sum(e.get("lines_added", 0) for e in recent), "total_lines_removed": sum(e.get("lines_removed", 0) for e in recent), "total_prs_merged": sum(1 for e in recent if e.get("pr")), "by_type": type_stats, "by_week": dict(sorted(by_week.items())), "by_weekday": by_weekday_named, "quarantine_candidates": quarantine_candidates, "recent_failures": [ {"cycle": e["cycle"], "epoch": e.get("epoch", ""), "issue": e.get("issue"), "reason": e.get("reason", "")} for e in failures[-5:] ], } SUMMARY_FILE.write_text(json.dumps(summary, indent=2) + "\n") def main() -> None: args = parse_args() # Auto-detect issue from branch name when not explicitly provided if args.issue is None: detected = _detect_issue_from_branch() if detected is not None: args.issue = detected print(f"[retro] Auto-detected issue #{detected} from branch name") # Reject idle cycles — no issue and no duration means nothing happened if not args.issue and args.duration == 0: print(f"[retro] Cycle {args.cycle} skipped — idle (no issue, no duration)") return # A cycle is only truly successful if hermes exited clean AND main is green truly_success = args.success and args.main_green # Generate epoch turnover tag now = datetime.now(timezone.utc) epoch_tag, epoch_parts = _epoch_tag(now) entry = { "timestamp": now.isoformat(), "cycle": args.cycle, "epoch": epoch_tag, "epoch_week": epoch_parts["week"], "epoch_weekday": epoch_parts["weekday"], "epoch_daily_n": epoch_parts["daily_n"], "issue": args.issue, "type": args.type, "success": truly_success, "hermes_clean": args.success, "main_green": args.main_green, "duration": args.duration, "tests_passed": args.tests_passed, "tests_added": args.tests_added, "files_changed": args.files_changed, "lines_added": args.lines_added, "lines_removed": args.lines_removed, "kimi_panes": args.kimi_panes, "pr": args.pr, "reason": args.reason if (args.failure or not args.main_green) else "", "notes": args.notes, } RETRO_FILE.parent.mkdir(parents=True, exist_ok=True) with open(RETRO_FILE, "a") as f: f.write(json.dumps(entry) + "\n") update_summary() status = "✓ SUCCESS" if args.success else "✗ FAILURE" print(f"[retro] {epoch_tag} Cycle {args.cycle} {status}", end="") if args.issue: print(f" (#{args.issue} {args.type})", end="") if args.duration: print(f" — {args.duration}s", end="") if args.failure and args.reason: print(f" — {args.reason}", end="") print() if __name__ == "__main__": main()