@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.
- package/CHANGELOG.md +57 -25
- package/README.md +10 -7
- package/bin/roll +3952 -317
- package/conventions/config.yaml +7 -0
- package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
- package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
- package/lib/agent_usage/__init__.py +4 -0
- package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
- package/lib/agent_usage/gemini.py +127 -0
- package/lib/agent_usage/kimi.py +127 -0
- package/lib/agent_usage/openai.py +126 -0
- package/lib/agent_usage/qwen.py +128 -0
- package/lib/context_feed_budget.sh +194 -0
- package/lib/github_sync.py +876 -0
- package/lib/i18n/agent.sh +54 -0
- package/lib/i18n/init.sh +22 -0
- package/lib/i18n/peer.sh +7 -0
- package/lib/i18n/peer_help.sh +4 -0
- package/lib/i18n/skills_catalog.sh +30 -0
- package/lib/loop-exit-summary.py +393 -0
- package/lib/loop-fmt.py +93 -75
- package/lib/loop_pick_agent.py +241 -170
- package/lib/loop_result_eval.py +469 -0
- package/lib/model_prices.py +0 -10
- package/lib/roll-home.py +1 -28
- package/lib/roll-loop-status.py +330 -40
- package/lib/roll-onboard-render.py +378 -0
- package/lib/roll-peer.py +1 -1
- package/lib/roll-plan-validate.py +165 -0
- package/lib/roll_git.py +41 -0
- package/lib/slides/components/README.md +8 -2
- package/lib/slides/templates/introduction-v3.html +1 -6
- package/lib/slides-render.py +305 -15
- package/lib/slides-validate.py +195 -7
- package/package.json +1 -1
- package/skills/roll-.changelog/SKILL.md +67 -56
- package/skills/roll-brief/SKILL.md +1 -1
- package/skills/roll-build/SKILL.md +14 -12
- package/skills/roll-deck/SKILL.md +152 -0
- package/skills/roll-design/SKILL.md +13 -6
- package/skills/roll-doc/SKILL.md +269 -6
- package/skills/roll-fix/SKILL.md +15 -9
- package/skills/roll-loop/SKILL.md +9 -7
- package/skills/roll-notes/SKILL.md +1 -1
- package/skills/roll-onboard/SKILL.md +85 -0
- package/skills/roll-peer/SKILL.md +6 -5
- package/lib/agent_routes_lint.py +0 -203
- package/skills/roll-research/SKILL.md +0 -316
- package/skills/roll-research/references/schema.json +0 -166
- package/skills/roll-research/scripts/md_to_pdf.py +0 -289
package/lib/roll-loop-status.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
1234
|
-
"""
|
|
1235
|
-
|
|
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
|
-
|
|
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
|
|
1410
|
+
return None
|
|
1242
1411
|
try:
|
|
1243
1412
|
text = plist.read_text(errors="ignore")
|
|
1244
1413
|
except Exception:
|
|
1245
|
-
return
|
|
1246
|
-
|
|
1247
|
-
|
|
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")))
|