@seanyao/roll 2026.529.5 → 2026.601.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +57 -25
  2. package/README.md +10 -7
  3. package/bin/roll +3952 -317
  4. package/conventions/config.yaml +7 -0
  5. package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
  6. package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
  7. package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
  8. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  9. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  10. package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
  11. package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
  12. package/lib/agent_usage/__init__.py +4 -0
  13. package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
  14. package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
  15. package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
  16. package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
  17. package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
  18. package/lib/agent_usage/gemini.py +127 -0
  19. package/lib/agent_usage/kimi.py +127 -0
  20. package/lib/agent_usage/openai.py +126 -0
  21. package/lib/agent_usage/qwen.py +128 -0
  22. package/lib/context_feed_budget.sh +194 -0
  23. package/lib/github_sync.py +876 -0
  24. package/lib/i18n/agent.sh +54 -0
  25. package/lib/i18n/init.sh +22 -0
  26. package/lib/i18n/peer.sh +7 -0
  27. package/lib/i18n/peer_help.sh +4 -0
  28. package/lib/i18n/skills_catalog.sh +30 -0
  29. package/lib/loop-exit-summary.py +393 -0
  30. package/lib/loop-fmt.py +93 -75
  31. package/lib/loop_pick_agent.py +241 -170
  32. package/lib/loop_result_eval.py +469 -0
  33. package/lib/model_prices.py +0 -10
  34. package/lib/roll-home.py +1 -28
  35. package/lib/roll-loop-status.py +330 -40
  36. package/lib/roll-onboard-render.py +378 -0
  37. package/lib/roll-peer.py +1 -1
  38. package/lib/roll-plan-validate.py +165 -0
  39. package/lib/roll_git.py +41 -0
  40. package/lib/slides/components/README.md +8 -2
  41. package/lib/slides/templates/introduction-v3.html +1 -6
  42. package/lib/slides-render.py +305 -15
  43. package/lib/slides-validate.py +195 -7
  44. package/package.json +1 -1
  45. package/skills/roll-.changelog/SKILL.md +67 -56
  46. package/skills/roll-brief/SKILL.md +1 -1
  47. package/skills/roll-build/SKILL.md +14 -12
  48. package/skills/roll-deck/SKILL.md +152 -0
  49. package/skills/roll-design/SKILL.md +13 -6
  50. package/skills/roll-doc/SKILL.md +269 -6
  51. package/skills/roll-fix/SKILL.md +15 -9
  52. package/skills/roll-loop/SKILL.md +9 -7
  53. package/skills/roll-notes/SKILL.md +1 -1
  54. package/skills/roll-onboard/SKILL.md +85 -0
  55. package/skills/roll-peer/SKILL.md +6 -5
  56. package/lib/agent_routes_lint.py +0 -203
  57. package/skills/roll-research/SKILL.md +0 -316
  58. package/skills/roll-research/references/schema.json +0 -166
  59. package/skills/roll-research/scripts/md_to_pdf.py +0 -289
@@ -47,6 +47,7 @@ from roll_render import (
47
47
  section_head, metric, metric_dur, metric_dollar, metric_tokens,
48
48
  day_band, cycle_row,
49
49
  )
50
+ from roll_git import git_remote_url as _git_remote_url
50
51
 
51
52
  # ════════════════════════════════════════════════════════════════════════════
52
53
  # Paths — must match bin/roll's _project_slug + _SHARED_ROOT defaults
@@ -90,34 +91,6 @@ def project_slug(path: Optional[str] = None) -> str:
90
91
  return f"{base}-{h}"
91
92
 
92
93
 
93
- def _git_remote_url(repo_path: str) -> Optional[str]:
94
- """Return the normalized remote URL for a git repo, or None."""
95
- try:
96
- url = subprocess.check_output(
97
- ["git", "-C", repo_path, "remote", "get-url", "origin"],
98
- stderr=subprocess.DEVNULL, text=True
99
- ).strip()
100
- if url:
101
- return url
102
- except Exception:
103
- pass
104
- # Fallback: first available remote
105
- try:
106
- remotes = subprocess.check_output(
107
- ["git", "-C", repo_path, "remote"],
108
- stderr=subprocess.DEVNULL, text=True
109
- ).strip().splitlines()
110
- if remotes:
111
- url = subprocess.check_output(
112
- ["git", "-C", repo_path, "remote", "get-url", remotes[0]],
113
- stderr=subprocess.DEVNULL, text=True
114
- ).strip()
115
- if url:
116
- return url
117
- except Exception:
118
- pass
119
- return None
120
-
121
94
  def shared_root() -> Path:
122
95
  return Path(os.environ.get("ROLL_SHARED_ROOT") or os.path.expanduser("~/.shared/roll"))
123
96
 
@@ -189,7 +162,11 @@ def _resolve_project_path(slug: str) -> Optional[Path]:
189
162
 
190
163
 
191
164
  def _loop_runtime_dir_py(slug: str) -> Optional[Path]:
192
- """Mirror bin/roll's _loop_runtime_dir: return <project>/.roll/loop."""
165
+ """Mirror bin/roll's _loop_runtime_dir: return <project>/.roll/loop.
166
+ Honors ROLL_PROJECT_RUNTIME_DIR env override (test sandbox)."""
167
+ env_rt = os.environ.get("ROLL_PROJECT_RUNTIME_DIR", "").strip()
168
+ if env_rt:
169
+ return Path(env_rt)
193
170
  proj = _resolve_project_path(slug)
194
171
  if proj is None:
195
172
  return None
@@ -680,6 +657,10 @@ def load_runs(slug: str) -> Dict[str, Dict[str, Any]]:
680
657
  if not path.exists():
681
658
  return {}
682
659
  base = slug.split("-")[0] # 'Roll-a43d1b' → 'Roll'
660
+ # FIX-144: old-slug runs (e.g. path-based slug before git-remote-based
661
+ # migration) share the same project path but have a different slug.
662
+ # Resolve once and compare paths to salvage those runs.
663
+ proj_path = _resolve_project_path(slug)
683
664
  out: Dict[str, Dict[str, Any]] = {}
684
665
  with path.open(errors="ignore") as f:
685
666
  for line in f:
@@ -689,7 +670,12 @@ def load_runs(slug: str) -> Dict[str, Dict[str, Any]]:
689
670
  continue
690
671
  p = r.get("project", "")
691
672
  if p != slug and p != base and not p.startswith(f"{slug}-cycle-"):
692
- continue
673
+ # String match failed — try path match for old-slug salvage.
674
+ if proj_path is None:
675
+ continue
676
+ other_proj = _resolve_project_path(p)
677
+ if other_proj is None or other_proj != proj_path:
678
+ continue
693
679
  rid = r.get("run_id", "")
694
680
  if rid:
695
681
  out[rid] = r
@@ -748,8 +734,10 @@ def merge_runs_into_cycles(cycles: List[Dict[str, Any]], runs: Dict[str, Dict[st
748
734
  if r.get("duration_sec"):
749
735
  cap = int((ts - start).total_seconds())
750
736
  cy["duration_s"] = min(r["duration_sec"], cap) if cap > 0 else r["duration_sec"]
751
- # Outcome: runs.jsonl wins when events stream was vacuous.
752
- if cy.get("outcome") in ("unknown", "running") and r.get("status"):
737
+ # Outcome: runs.jsonl wins when events stream was vacuous or
738
+ # misleading (idle/failed emitted by _loop_event even though the
739
+ # agent completed work and _runs_append recorded built).
740
+ if cy.get("outcome") in ("unknown", "running", "idle", "failed") and r.get("status"):
753
741
  cy["outcome"] = {"built": "done", "interrupted": "fail"}.get(r["status"], r["status"])
754
742
  if not cy.get("story") and r["built"]:
755
743
  cy["story"] = r["built"][0]
@@ -861,6 +849,154 @@ def _self_score_summary_line(notes_dir = None, window: int = 14) -> str:
861
849
  return f"self-score: mean {mean:.1f} / min {minv} / redo {redo} (last {window})"
862
850
 
863
851
 
852
+ # ─────────────────────────────────────────────────────────────────────────────
853
+ # US-EVAL-003: result-eval trend view (window aggregation).
854
+ #
855
+ # Distinct from self-score (above): self-score is the agent's *subjective*
856
+ # review of a skill run; result_eval is the *objective* per-cycle result score
857
+ # computed in US-EVAL-002 and stored in each runs.jsonl record under the
858
+ # `result_eval` block ({version, score 1..10, dims{dim: 0..1|"unknown"}}).
859
+ #
860
+ # We aggregate the most recent N records that carry a result_eval: the mean and
861
+ # minimum cycle score, a per-dimension hit-rate (share of records scoring 1.0 on
862
+ # that dimension, ignoring "unknown"), and a trend arrow comparing the newer
863
+ # half's mean against the older half's. Records without result_eval (older
864
+ # schema) are simply skipped — never an error (backward compat).
865
+
866
+ # Dimension order for the eval view. Imported from the rubric so the two stay
867
+ # in sync; falls back to a literal list if the scorer module isn't importable
868
+ # (e.g. a stripped-down test env).
869
+ def _eval_dim_names() -> List[str]:
870
+ try:
871
+ import importlib.util
872
+ path = Path(__file__).resolve().parent / "loop_result_eval.py"
873
+ spec = importlib.util.spec_from_file_location("loop_result_eval", path)
874
+ mod = importlib.util.module_from_spec(spec)
875
+ spec.loader.exec_module(mod) # type: ignore[union-attr]
876
+ return [name for name, _ in mod.DIMENSIONS]
877
+ except Exception:
878
+ return ["outcome", "correctness", "scope_fidelity",
879
+ "quality", "efficiency", "cleanliness"]
880
+
881
+ UNKNOWN = "unknown"
882
+
883
+
884
+ def _eval_records(records: List[Dict[str, Any]], window: int) -> List[Dict[str, Any]]:
885
+ """The most recent `window` records (oldest→newest) that carry a usable
886
+ result_eval block. Records sorted by `ts`; absence of result_eval skips."""
887
+ rows: List[Dict[str, Any]] = []
888
+ ordered = sorted((r or {} for r in records), key=lambda r: r.get("ts", ""))
889
+ for r in ordered:
890
+ ev = r.get("result_eval")
891
+ if isinstance(ev, dict) and isinstance(ev.get("score"), (int, float)):
892
+ rows.append(ev)
893
+ return rows[-window:] if window > 0 else rows
894
+
895
+
896
+ def aggregate_eval(records: List[Dict[str, Any]], window: int = 14) -> Dict[str, Any]:
897
+ """Aggregate result_eval over the last `window` scored records.
898
+
899
+ Returns a dict: {n, mean, min, trend ('up'|'down'|'flat'|None), dims:
900
+ {dim: hit_rate float 0..1 | None}}. n is the count of scored records used.
901
+ """
902
+ rows = _eval_records(records, window)
903
+ n = len(rows)
904
+ scores = [float(ev["score"]) for ev in rows]
905
+ out: Dict[str, Any] = {"n": n, "mean": None, "min": None,
906
+ "trend": None, "dims": {}}
907
+ if n == 0:
908
+ return out
909
+ out["mean"] = sum(scores) / n
910
+ out["min"] = min(scores)
911
+ # Per-dimension hit-rate: share of records that scored a perfect 1.0 on the
912
+ # dimension, counting only records where the dim is known (not "unknown").
913
+ for dim in _eval_dim_names():
914
+ known = 0
915
+ hits = 0
916
+ for ev in rows:
917
+ v = (ev.get("dims") or {}).get(dim, UNKNOWN)
918
+ if v == UNKNOWN or v is None:
919
+ continue
920
+ known += 1
921
+ if float(v) >= 1.0:
922
+ hits += 1
923
+ out["dims"][dim] = (hits / known) if known else None
924
+ # Trend: compare the newer half's mean against the older half's. Needs at
925
+ # least 2 records to split; <0.3 score delta is "flat".
926
+ if n >= 2:
927
+ half = n // 2
928
+ older = scores[:half] or scores[:1]
929
+ newer = scores[half:]
930
+ delta = (sum(newer) / len(newer)) - (sum(older) / len(older))
931
+ if delta > 0.3:
932
+ out["trend"] = "up"
933
+ elif delta < -0.3:
934
+ out["trend"] = "down"
935
+ else:
936
+ out["trend"] = "flat"
937
+ return out
938
+
939
+
940
+ _TREND_ARROW = {"up": "↑", "down": "↓", "flat": "→"}
941
+
942
+
943
+ # Single-line dashboard summary, mirroring `_self_score_summary_line`'s form.
944
+ # Returns "" when there are no scored records, "result-eval: (n/a) need 3 ..."
945
+ # when the sample is below 3 (same threshold/idiom as self-score), else
946
+ # "result-eval: mean 7.4↑ / min 5 / outcome 80% scope 60% ... (last N)".
947
+ def _result_eval_summary_line(records: List[Dict[str, Any]], window: int = 14) -> str:
948
+ agg = aggregate_eval(records or [], window)
949
+ n = agg["n"]
950
+ if n == 0:
951
+ return ""
952
+ if n < 3:
953
+ return f"result-eval: (n/a) — {n} sample(s), need 3 (last {window})"
954
+ arrow = _TREND_ARROW.get(agg["trend"] or "flat", "")
955
+ # Short per-dimension labels for a compact line.
956
+ short = {"outcome": "out", "correctness": "ci", "scope_fidelity": "scope",
957
+ "quality": "qual", "efficiency": "eff", "cleanliness": "clean"}
958
+ dim_bits = []
959
+ for dim, rate in agg["dims"].items():
960
+ if rate is None:
961
+ continue
962
+ dim_bits.append(f"{short.get(dim, dim)} {round(rate * 100)}%")
963
+ dims_str = (" / " + " ".join(dim_bits)) if dim_bits else ""
964
+ return (f"result-eval: mean {agg['mean']:.1f}{arrow} / min {int(agg['min'])}"
965
+ f"{dims_str} (last {window})")
966
+
967
+
968
+ # Multi-line view for `roll loop eval [N]`. Pretty-prints the aggregation as a
969
+ # small dashboard block (no color dependency beyond the shared renderer).
970
+ def format_eval_view(records: List[Dict[str, Any]], window: int = 14) -> str:
971
+ agg = aggregate_eval(records or [], window)
972
+ n = agg["n"]
973
+ lines: List[str] = []
974
+ lines.append(f"Loop result-eval — last {window} cycles")
975
+ lines.append(f"循环结果评分 — 最近 {window} 轮")
976
+ lines.append("")
977
+ if n == 0:
978
+ lines.append("no scored cycles yet (need result_eval in runs.jsonl)")
979
+ lines.append("尚无评分 cycle(runs.jsonl 需含 result_eval)")
980
+ return "\n".join(lines)
981
+ if n < 3:
982
+ lines.append(f"(n/a) — {n} sample(s), need 3")
983
+ lines.append(f"(n/a) — 样本 {n} 个,至少需要 3 个")
984
+ return "\n".join(lines)
985
+ arrow = _TREND_ARROW.get(agg["trend"] or "flat", "")
986
+ lines.append(f" mean {agg['mean']:.1f} / 10 {arrow}")
987
+ lines.append(f" min {int(agg['min'])} / 10")
988
+ lines.append(f" n {n}")
989
+ lines.append("")
990
+ lines.append(" dimension hit-rate / 各维度命中率")
991
+ for dim in _eval_dim_names():
992
+ rate = agg["dims"].get(dim)
993
+ if rate is None:
994
+ lines.append(f" {dim:<16} n/a")
995
+ else:
996
+ lines.append(f" {dim:<16} {round(rate * 100)}%")
997
+ return "\n".join(lines)
998
+
999
+
864
1000
  # US-AGENT-010: per-agent hit-rate summary for the ROLLUP block.
865
1001
  # Aggregates the last `window_cycles` runs.jsonl records grouped by `agent`.
866
1002
  # Returns a single-line string like
@@ -1049,6 +1185,19 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
1049
1185
  # ZH eyebrow row is left-aligned only — mirroring the EN right side
1050
1186
  # would duplicate signal without adding info.
1051
1187
  print(eb_zh)
1188
+
1189
+ # US-LOOP-036: daily service (dream/brief) next-fire lines, read straight
1190
+ # from the launchd plist so they reflect the latest `roll config <svc>-time`
1191
+ # reload rather than a stale yaml-derived guess.
1192
+ for _svc in ("dream", "brief"):
1193
+ _sl = _daily_schedule_line(_svc, now=now)
1194
+ if _sl:
1195
+ print(" " + c("dim", _sl))
1196
+ # FIX-151: dedicated loop (pr/ci/alert) last-tick age
1197
+ for _loop in ("pr", "ci", "alert"):
1198
+ _tl = _tick_age_line(_loop, now=now)
1199
+ if _tl:
1200
+ print(" " + c("dim", _tl))
1052
1201
  print()
1053
1202
 
1054
1203
  print(c("faint", "─" * COLS))
@@ -1148,6 +1297,16 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
1148
1297
  if _skill_line:
1149
1298
  print(" " + c("dim", _skill_line))
1150
1299
 
1300
+ # US-EVAL-003: per-cycle result-eval trend (single line), distinct from the
1301
+ # self-score line above (subjective skill review vs objective cycle result).
1302
+ try:
1303
+ _eval_records_in = list(runs.values()) if isinstance(runs, dict) else list(runs or [])
1304
+ _eval_line = _result_eval_summary_line(_eval_records_in)
1305
+ except Exception:
1306
+ _eval_line = ""
1307
+ if _eval_line:
1308
+ print(" " + c("dim", _eval_line))
1309
+
1151
1310
  print()
1152
1311
  print(c("faint", "─" * COLS))
1153
1312
  print()
@@ -1230,21 +1389,96 @@ def _read_schedule_spec(project_root: Optional[Path] = None) -> Tuple[int, int]:
1230
1389
  return (60, h)
1231
1390
 
1232
1391
 
1233
- def _read_plist_loop_minute() -> int:
1234
- """FIX-063: read actual loop Minute from launchd plist (truth source).
1235
- Falls back to 48 only when plist missing/unparseable.
1392
+ def _read_daily_plist_schedule(svc: str) -> Optional[Dict[str, Any]]:
1393
+ """US-LOOP-036: read the actual fire schedule of a daily service (dream/brief)
1394
+ from its launchd plist the truth source after a `roll config <svc>-time`
1395
+ reload. Returns one of:
1396
+
1397
+ {"mode": "calendar", "hour": H, "minute": M} array-style StartCalendarInterval
1398
+ {"mode": "interval"} legacy StartInterval=86400
1399
+
1400
+ or None when the plist is missing/unparseable. Distinguishing the two modes
1401
+ lets the caller pick the right _compute_next_fire branch.
1236
1402
  """
1237
1403
  import re as _re
1238
1404
  slug = project_slug()
1239
- plist = Path(os.path.expanduser("~/Library/LaunchAgents")) / f"com.roll.loop.{slug}.plist"
1405
+ # Honor _LAUNCHD_DIR (the same sandbox override bin/roll exports) so tests —
1406
+ # and any non-default install dir — read the plist the writer just wrote.
1407
+ ladir = os.environ.get("_LAUNCHD_DIR") or os.path.expanduser("~/Library/LaunchAgents")
1408
+ plist = Path(ladir) / f"com.roll.{svc}.{slug}.plist"
1240
1409
  if not plist.exists():
1241
- return 48
1410
+ return None
1242
1411
  try:
1243
1412
  text = plist.read_text(errors="ignore")
1244
1413
  except Exception:
1245
- return 48
1246
- m = _re.search(r"<key>Minute</key>\s*<integer>(\d+)</integer>", text)
1247
- return int(m.group(1)) if m else 48
1414
+ return None
1415
+ if "StartCalendarInterval" in text:
1416
+ h = _re.search(r"<key>Hour</key>\s*<integer>(\d+)</integer>", text)
1417
+ m = _re.search(r"<key>Minute</key>\s*<integer>(\d+)</integer>", text)
1418
+ if h:
1419
+ return {"mode": "calendar", "hour": int(h.group(1)),
1420
+ "minute": int(m.group(1)) if m else 0}
1421
+ if "StartInterval" in text:
1422
+ return {"mode": "interval"}
1423
+ return None
1424
+
1425
+
1426
+ def _daily_schedule_line(svc: str, now: Optional[datetime] = None) -> Optional[str]:
1427
+ """US-LOOP-036: one-line `<svc>: HH:MM (next fire in Xh Ym)` for the status
1428
+ dashboard. Calendar mode shows the configured wall-clock time and projects
1429
+ the next fire; interval (legacy) mode has no HH:MM anchor so it just labels
1430
+ the daily-interval mode. Returns None when the service has no plist.
1431
+ """
1432
+ sched = _read_daily_plist_schedule(svc)
1433
+ if sched is None:
1434
+ return None
1435
+ base = now or datetime.now().astimezone()
1436
+ if sched["mode"] == "calendar":
1437
+ hh, mm = sched["hour"], sched["minute"]
1438
+ nxt = _compute_next_fire(hour=hh, minute=mm, now=base)
1439
+ line = f"{svc}: {hh:02d}:{mm:02d}"
1440
+ if nxt is not None:
1441
+ delta = int(nxt - base.timestamp())
1442
+ h, m = divmod(max(delta, 0) // 60, 60)
1443
+ line += f" (next fire in {h}h {m}m)"
1444
+ return line
1445
+ return f"{svc}: daily (legacy interval)"
1446
+
1447
+
1448
+ def _tick_age_line(loop_type: str, now: Optional[datetime] = None) -> Optional[str]:
1449
+ """FIX-151: read the last tick for a dedicated loop (pr/ci/alert) and return
1450
+ a human-readable age line, or None if no tick file exists."""
1451
+ slug = project_slug()
1452
+ rt_dir = _loop_runtime_dir_py(slug)
1453
+ if rt_dir is not None:
1454
+ tick_file = rt_dir / f"{loop_type}-tick.jsonl"
1455
+ else:
1456
+ tick_file = shared_root() / "loop" / f"{loop_type}-tick-{slug}.jsonl"
1457
+ if not tick_file.exists():
1458
+ return None
1459
+ try:
1460
+ last_line = tick_file.read_text().strip().splitlines()[-1]
1461
+ except (IndexError, OSError):
1462
+ return None
1463
+ # Extract ts field from JSONL
1464
+ m = re.search(r'"ts":"([^"]+)"', last_line)
1465
+ if not m:
1466
+ return None
1467
+ ts_str = m.group(1)
1468
+ try:
1469
+ # Parse ISO 8601 UTC timestamp
1470
+ tick_dt = datetime.strptime(ts_str, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
1471
+ except ValueError:
1472
+ return None
1473
+ base = now or datetime.now(timezone.utc)
1474
+ age_sec = int((base - tick_dt).total_seconds())
1475
+ if age_sec < 60:
1476
+ age_str = f"{age_sec}s"
1477
+ elif age_sec < 3600:
1478
+ age_str = f"{age_sec // 60}m"
1479
+ else:
1480
+ age_str = f"{age_sec // 3600}h"
1481
+ return f"{loop_type}: tick {age_str} ago"
1248
1482
 
1249
1483
 
1250
1484
  def _detect_install_state() -> str:
@@ -1281,6 +1515,46 @@ def _detect_install_state() -> str:
1281
1515
  return "stale"
1282
1516
 
1283
1517
 
1518
+ def _compute_next_fire(
1519
+ *,
1520
+ hour: Optional[int] = None,
1521
+ minute: int = 0,
1522
+ last_fire: Optional[float] = None,
1523
+ now: Optional[datetime] = None,
1524
+ ) -> Optional[float]:
1525
+ """US-LOOP-036: compute the next fire epoch for a daily (dream/brief) service.
1526
+
1527
+ Two modes mirror the plist schedule_xml that _write_launchd_plist renders:
1528
+
1529
+ StartCalendarInterval mode (hour is not None):
1530
+ Fire at the next HH:MM wall-clock instant. If today's HH:MM has not yet
1531
+ passed (relative to `now`), it is today; otherwise tomorrow. Day/month/
1532
+ year roll-over (including leap-year Feb 29 → Mar 1) is handled by adding
1533
+ a timedelta to a normalized datetime, so we never construct an invalid
1534
+ date by hand.
1535
+
1536
+ StartInterval=86400 legacy mode (hour is None):
1537
+ Fire at `last_fire` + 24h. Returns None when last_fire is unknown — the
1538
+ caller then has no anchor to project from.
1539
+
1540
+ Returns a POSIX epoch (float seconds) or None. `now` defaults to the current
1541
+ local time; tests pass a fixed `now` for determinism.
1542
+ """
1543
+ if hour is None:
1544
+ # Legacy StartInterval=86400 — project from the last fire.
1545
+ if last_fire is None:
1546
+ return None
1547
+ return float(last_fire) + 86400.0
1548
+
1549
+ base = (now or datetime.now().astimezone())
1550
+ # Anchor to today's HH:MM. timedelta arithmetic handles all calendar
1551
+ # roll-over (month/year boundaries, leap days) without manual date math.
1552
+ candidate = base.replace(hour=hour, minute=minute, second=0, microsecond=0)
1553
+ if candidate <= base:
1554
+ candidate = candidate + timedelta(days=1)
1555
+ return candidate.timestamp()
1556
+
1557
+
1284
1558
  def _next_cron_hint(state: Dict[str, str], zh: bool = False) -> str:
1285
1559
  """US-LOOP-013: compute next cron fire time from schedule spec.
1286
1560
 
@@ -1359,8 +1633,24 @@ def main(argv=None):
1359
1633
  p.add_argument("--no-color", action="store_true", help="strip ANSI (also honors NO_COLOR=1)")
1360
1634
  p.add_argument("--en", action="store_true", help="EN rows only")
1361
1635
  p.add_argument("--zh", action="store_true", help="ZH rows only")
1636
+ p.add_argument("--eval", nargs="?", type=int, const=14, default=None,
1637
+ metavar="N",
1638
+ help="result-eval trend view over the last N scored cycles "
1639
+ "(default 14); prints mean/min/per-dim hit-rate and exits")
1362
1640
  args = p.parse_args(argv)
1363
1641
 
1642
+ # US-EVAL-003: `roll loop eval [N]` — result-eval trend view, then exit.
1643
+ if args.eval is not None:
1644
+ window = args.eval if args.eval and args.eval > 0 else 14
1645
+ if os.environ.get("ROLL_RENDER_FIXTURE"):
1646
+ records: List[Dict[str, Any]] = []
1647
+ else:
1648
+ _slug = project_slug()
1649
+ _runs = load_runs(_slug)
1650
+ records = list(_runs.values())
1651
+ print(format_eval_view(records, window=window))
1652
+ return
1653
+
1364
1654
  roll_render.USE_COLOR = (not args.no_color
1365
1655
  and not os.environ.get("NO_COLOR")
1366
1656
  and (sys.stdout.isatty() or os.environ.get("FORCE_COLOR")))