@seanyao/roll 0.5.0 → 2.602.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 (181) hide show
  1. package/CHANGELOG.md +736 -0
  2. package/LICENSE +21 -0
  3. package/README.md +65 -165
  4. package/bin/dream-test-quality-scan +110 -0
  5. package/bin/roll +15030 -814
  6. package/conventions/config.yaml +17 -1
  7. package/conventions/global/AGENTS.md +146 -100
  8. package/conventions/global/CLAUDE.md +1 -21
  9. package/conventions/global/GEMINI.md +8 -22
  10. package/conventions/global/project_rules.md +9 -0
  11. package/conventions/templates/backend-service/AGENTS.md +30 -81
  12. package/conventions/templates/backend-service/GEMINI.md +3 -3
  13. package/conventions/templates/backend-service/project_rules.md +16 -0
  14. package/conventions/templates/cli/AGENTS.md +31 -58
  15. package/conventions/templates/cli/CLAUDE.md +3 -5
  16. package/conventions/templates/cli/GEMINI.md +3 -3
  17. package/conventions/templates/cli/project_rules.md +16 -0
  18. package/conventions/templates/frontend-only/AGENTS.md +29 -64
  19. package/conventions/templates/frontend-only/GEMINI.md +3 -3
  20. package/conventions/templates/frontend-only/project_rules.md +14 -0
  21. package/conventions/templates/fullstack/AGENTS.md +31 -79
  22. package/conventions/templates/fullstack/CLAUDE.md +1 -1
  23. package/conventions/templates/fullstack/GEMINI.md +3 -3
  24. package/conventions/templates/fullstack/project_rules.md +15 -0
  25. package/lib/README.md +42 -0
  26. package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
  27. package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
  28. package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
  29. package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
  30. package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
  31. package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
  32. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  33. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  34. package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
  35. package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
  36. package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
  37. package/lib/agent_usage/README.md +49 -0
  38. package/lib/agent_usage/__init__.py +108 -0
  39. package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
  40. package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
  41. package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
  42. package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
  43. package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
  44. package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
  45. package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
  46. package/lib/agent_usage/gemini.py +127 -0
  47. package/lib/agent_usage/kimi.py +278 -0
  48. package/lib/agent_usage/kimi_emit.py +123 -0
  49. package/lib/agent_usage/openai.py +126 -0
  50. package/lib/agent_usage/pi.py +200 -0
  51. package/lib/agent_usage/pi_emit.py +135 -0
  52. package/lib/agent_usage/qwen.py +128 -0
  53. package/lib/backfill-pi-usage.py +243 -0
  54. package/lib/changelog_audit.py +155 -0
  55. package/lib/changelog_generate.py +263 -0
  56. package/lib/context_feed_budget.sh +194 -0
  57. package/lib/github_sync.py +876 -0
  58. package/lib/i18n/README.md +54 -0
  59. package/lib/i18n/agent.sh +75 -0
  60. package/lib/i18n/alert.sh +20 -0
  61. package/lib/i18n/backlog.sh +96 -0
  62. package/lib/i18n/brief.sh +5 -0
  63. package/lib/i18n/changelog.sh +5 -0
  64. package/lib/i18n/ci.sh +15 -0
  65. package/lib/i18n/debug.sh +0 -0
  66. package/lib/i18n/doctor.sh +44 -0
  67. package/lib/i18n/dream.sh +0 -0
  68. package/lib/i18n/init.sh +91 -0
  69. package/lib/i18n/lang.sh +10 -0
  70. package/lib/i18n/loop.sh +140 -0
  71. package/lib/i18n/migrate.sh +74 -0
  72. package/lib/i18n/offboard.sh +31 -0
  73. package/lib/i18n/onboard.sh +0 -0
  74. package/lib/i18n/peer.sh +41 -0
  75. package/lib/i18n/peer_help.sh +25 -0
  76. package/lib/i18n/peer_reset.sh +7 -0
  77. package/lib/i18n/peer_status.sh +5 -0
  78. package/lib/i18n/prices.sh +3 -0
  79. package/lib/i18n/prices_refresh.sh +17 -0
  80. package/lib/i18n/prices_show.sh +7 -0
  81. package/lib/i18n/propose.sh +0 -0
  82. package/lib/i18n/release.sh +0 -0
  83. package/lib/i18n/research.sh +0 -0
  84. package/lib/i18n/review_pr.sh +0 -0
  85. package/lib/i18n/sentinel.sh +0 -0
  86. package/lib/i18n/setup.sh +3 -0
  87. package/lib/i18n/shared.sh +157 -0
  88. package/lib/i18n/skills/roll-brief.sh +47 -0
  89. package/lib/i18n/skills/roll-build.sh +97 -0
  90. package/lib/i18n/skills/roll-design.sh +18 -0
  91. package/lib/i18n/skills/roll-fix.sh +53 -0
  92. package/lib/i18n/skills/roll-loop.sh +28 -0
  93. package/lib/i18n/skills/roll-onboard.sh +33 -0
  94. package/lib/i18n/skills_catalog.sh +30 -0
  95. package/lib/i18n/slides.sh +3 -0
  96. package/lib/i18n/slides_build.sh +38 -0
  97. package/lib/i18n/slides_delete.sh +19 -0
  98. package/lib/i18n/slides_list.sh +14 -0
  99. package/lib/i18n/slides_logs.sh +12 -0
  100. package/lib/i18n/slides_new.sh +15 -0
  101. package/lib/i18n/slides_preview.sh +14 -0
  102. package/lib/i18n/slides_templates.sh +7 -0
  103. package/lib/i18n/status.sh +21 -0
  104. package/lib/i18n/update.sh +24 -0
  105. package/lib/i18n.sh +211 -0
  106. package/lib/loop-exit-summary.py +393 -0
  107. package/lib/loop-fmt.py +589 -0
  108. package/lib/loop_pick_agent.py +316 -0
  109. package/lib/loop_result_eval.py +469 -0
  110. package/lib/loop_unstick.py +180 -0
  111. package/lib/model_prices.py +194 -0
  112. package/lib/prices/README.md +35 -0
  113. package/lib/prices/snapshot-2026-05-22.json +22 -0
  114. package/lib/prices/snapshot-2026-05-23-deepseek.json +15 -0
  115. package/lib/prices/snapshot-2026-05-23-kimi.json +15 -0
  116. package/lib/prices_fetcher.py +285 -0
  117. package/lib/roll-backlog.py +225 -0
  118. package/lib/roll-brief.py +286 -0
  119. package/lib/roll-help.py +158 -0
  120. package/lib/roll-home.py +556 -0
  121. package/lib/roll-init.py +156 -0
  122. package/lib/roll-loop-status.py +1683 -0
  123. package/lib/roll-loop-story.py +191 -0
  124. package/lib/roll-onboard-render.py +378 -0
  125. package/lib/roll-peer.py +252 -0
  126. package/lib/roll-plan-validate.py +386 -0
  127. package/lib/roll-setup.py +102 -0
  128. package/lib/roll-status.py +367 -0
  129. package/lib/roll_git.py +41 -0
  130. package/lib/roll_render.py +414 -0
  131. package/lib/slides/components/README.md +123 -0
  132. package/lib/slides/components/cards-2.html +9 -0
  133. package/lib/slides/components/cards-3.html +9 -0
  134. package/lib/slides/components/cards-4.html +9 -0
  135. package/lib/slides/components/compare.html +22 -0
  136. package/lib/slides/components/highlight.html +9 -0
  137. package/lib/slides/components/pipeline.html +12 -0
  138. package/lib/slides/components/plain.html +7 -0
  139. package/lib/slides/components/quote.html +4 -0
  140. package/lib/slides/components/timeline.html +9 -0
  141. package/lib/slides/templates/introduction-v3.html +571 -0
  142. package/lib/slides/templates/pitch.html +0 -0
  143. package/lib/slides-render.py +778 -0
  144. package/lib/slides-validate.py +357 -0
  145. package/lib/test_quality_gate.py +143 -0
  146. package/package.json +8 -7
  147. package/skills/roll-.changelog/SKILL.md +406 -33
  148. package/skills/roll-.clarify/SKILL.md +5 -2
  149. package/skills/roll-.dream/SKILL.md +374 -0
  150. package/skills/roll-.echo/SKILL.md +5 -2
  151. package/skills/roll-.qa/SKILL.md +57 -3
  152. package/skills/roll-.review/SKILL.md +42 -3
  153. package/skills/roll-brief/SKILL.md +209 -0
  154. package/skills/roll-build/SKILL.md +308 -63
  155. package/skills/roll-debug/SKILL.md +341 -162
  156. package/skills/roll-debug/injectable-bb.js +263 -0
  157. package/skills/roll-deck/SKILL.md +296 -0
  158. package/skills/roll-design/ENGINEERING_CHECKLIST.md +1 -1
  159. package/skills/roll-design/SKILL.md +733 -94
  160. package/skills/roll-doc/SKILL.md +595 -0
  161. package/skills/roll-doctor/SKILL.md +192 -0
  162. package/skills/roll-fix/SKILL.md +149 -32
  163. package/skills/{roll-jot → roll-idea}/SKILL.md +18 -10
  164. package/skills/roll-loop/SKILL.md +579 -0
  165. package/skills/roll-notes/SKILL.md +103 -0
  166. package/skills/roll-onboard/SKILL.md +234 -0
  167. package/skills/roll-peer/SKILL.md +336 -0
  168. package/skills/roll-propose/SKILL.md +157 -0
  169. package/skills/roll-review-pr/SKILL.md +58 -0
  170. package/skills/roll-sentinel/SKILL.md +11 -2
  171. package/skills/roll-spar/SKILL.md +8 -6
  172. package/template/.github/workflows/ci.yml +5 -2
  173. package/template/AGENTS.md +20 -74
  174. package/skills/roll-research/SKILL.md +0 -307
  175. package/skills/roll-research/references/schema.json +0 -162
  176. package/skills/roll-research/scripts/md_to_pdf.py +0 -289
  177. package/tools/roll-fetch/SKILL.md +0 -182
  178. package/tools/roll-fetch/package.json +0 -15
  179. package/tools/roll-fetch/smart-web-fetch.js +0 -558
  180. package/tools/roll-probe/SKILL.md +0 -84
  181. /package/template/{BACKLOG.md → .roll/backlog.md} +0 -0
@@ -0,0 +1,393 @@
1
+ #!/usr/bin/env python3
2
+ """Render a one-cycle exit summary block for the loop's .command window (US-LOOP-040).
3
+
4
+ When a `roll loop` cycle ends, the macOS `.command` Terminal window that was
5
+ attached to the tmux session is left showing only a `press enter to close`
6
+ prompt. The full Cycle Phase Breakdown / runs.jsonl data already exists on disk
7
+ but the user has to scroll back through tmux scrollback or open
8
+ ``~/.shared/roll/loop/cron-<slug>.log`` to see what the cycle did.
9
+
10
+ This helper renders a compact ``─── Cycle <CYCLE_ID> Summary ───`` block to
11
+ stdout, consumed by the ``.command`` shell *before* the `press enter` prompt.
12
+ It is a pure read-side view: it never writes new files and never mutates loop
13
+ state.
14
+
15
+ Five signals (per the US-LOOP-040 issue):
16
+
17
+ 1. result — runs.jsonl latest row's ``status`` + ``built[]`` + ``tcr_count``
18
+ (idle cycle → ``idle: no story picked``)
19
+ 2. ci — newest ``ci`` event outcome from events.ndjson tail
20
+ (``ok``→green, ``red``→red, ``heal-attempting`` passthrough,
21
+ no event → ``ci: n/a``)
22
+ 3. todo — count of ``📋 Todo`` lines in .roll/backlog.md
23
+ 4. phases — runs.jsonl ``phases`` map, top 5 by duration desc
24
+ 5. alerts — raw failure / alert text placeholder
25
+
26
+ US-LOOP-041 layers failure / alert *highlighting* on top of the US-LOOP-040
27
+ renderer. The relevant signal lines are flagged with a severity prefix and
28
+ ANSI colour:
29
+
30
+ * RED + ``✗`` — runs.jsonl ``status`` is ``failed`` / ``aborted``; the latest
31
+ ``ci`` outcome is ``red``; or the events tail has a ``cycle_end`` whose
32
+ outcome is not ``ok`` / ``idle``.
33
+ * YELLOW + ``⚠`` — latest ``ci`` outcome is ``heal-attempting``; an
34
+ ``ALERT-<slug>.md`` exists and is non-empty; or ``tcr_count == 0`` while
35
+ ``built[]`` is non-empty (suspected zero-diff).
36
+ * default colour, no prefix — a fully green cycle (built/idle + ci green +
37
+ no alert).
38
+
39
+ ANSI escapes are only emitted when stdout is a TTY (``sys.stdout.isatty()``)
40
+ and ``NO_COLOR`` is unset (see https://no-color.org). Pipes, redirects and
41
+ test captures get plain text — no escape codes are written.
42
+
43
+ Data-source priority: runs.jsonl latest matching row > events.ndjson tail >
44
+ fall back to the cron log's last 30 lines. Any missing source degrades
45
+ silently — the renderer never errors and never blocks `press enter`.
46
+
47
+ When no usable data exists at all (idle/aborted early-exit, runs.jsonl not yet
48
+ flushed) it prints a single
49
+ ``(summary unavailable — see log: <cron-log>)`` line instead.
50
+
51
+ Invocation::
52
+
53
+ python3 loop-exit-summary.py \
54
+ --runs <runs.jsonl> \
55
+ --events <events.ndjson> \
56
+ --backlog <.roll/backlog.md> \
57
+ --cron-log <cron-<slug>.log> \
58
+ --alert <ALERT-<slug>.md> \
59
+ [--cycle-id <id>] [--color {auto,always,never}]
60
+
61
+ All paths are optional; a missing / unreadable file is treated as absent.
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import argparse
67
+ import json
68
+ import os
69
+ import sys
70
+ from typing import Any, Dict, List, Optional
71
+
72
+
73
+ def _read_last_json_line(path: Optional[str], cycle_id: str = "") -> Optional[Dict[str, Any]]:
74
+ """Return the last well-formed JSON object from a .jsonl file.
75
+
76
+ When ``cycle_id`` is given, prefer the last row whose ``cycle_id`` matches;
77
+ otherwise fall back to the last parseable row. Returns None when the file
78
+ is absent, empty, or has no parseable rows.
79
+ """
80
+ if not path or not os.path.isfile(path):
81
+ return None
82
+ last: Optional[Dict[str, Any]] = None
83
+ matched: Optional[Dict[str, Any]] = None
84
+ try:
85
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
86
+ for line in fh:
87
+ line = line.strip()
88
+ if not line:
89
+ continue
90
+ try:
91
+ obj = json.loads(line)
92
+ except (ValueError, TypeError):
93
+ continue
94
+ if not isinstance(obj, dict):
95
+ continue
96
+ last = obj
97
+ if cycle_id and obj.get("cycle_id") == cycle_id:
98
+ matched = obj
99
+ except OSError:
100
+ return None
101
+ return matched if matched is not None else last
102
+
103
+
104
+ def _read_json_lines(path: Optional[str]) -> List[Dict[str, Any]]:
105
+ """Return all well-formed JSON objects from a .ndjson file (in order)."""
106
+ out: List[Dict[str, Any]] = []
107
+ if not path or not os.path.isfile(path):
108
+ return out
109
+ try:
110
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
111
+ for line in fh:
112
+ line = line.strip()
113
+ if not line:
114
+ continue
115
+ try:
116
+ obj = json.loads(line)
117
+ except (ValueError, TypeError):
118
+ continue
119
+ if isinstance(obj, dict):
120
+ out.append(obj)
121
+ except OSError:
122
+ return out
123
+ return out
124
+
125
+
126
+ def _latest_ci_outcome(events: List[Dict[str, Any]]) -> Optional[str]:
127
+ """Newest ``ci`` event outcome from an events stream, or None."""
128
+ for ev in reversed(events):
129
+ if ev.get("stage") == "ci":
130
+ outcome = ev.get("outcome")
131
+ if outcome:
132
+ return str(outcome)
133
+ return None
134
+
135
+
136
+ def _latest_cycle_end_outcome(events: List[Dict[str, Any]]) -> Optional[str]:
137
+ """Newest ``cycle_end`` event outcome from an events stream, or None."""
138
+ for ev in reversed(events):
139
+ if ev.get("stage") == "cycle_end":
140
+ outcome = ev.get("outcome")
141
+ if outcome:
142
+ return str(outcome)
143
+ return None
144
+
145
+
146
+ def _alert_active(path: Optional[str]) -> bool:
147
+ """True when an ALERT-<slug>.md file exists and has non-whitespace content."""
148
+ if not path or not os.path.isfile(path):
149
+ return False
150
+ try:
151
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
152
+ return bool(fh.read().strip())
153
+ except OSError:
154
+ return False
155
+
156
+
157
+ # ── ANSI colouring (US-LOOP-041) ─────────────────────────────────────────────
158
+ # Severity ranks: 0 = none/green, 1 = warn (yellow), 2 = fail (red).
159
+ _SEV_NONE = 0
160
+ _SEV_WARN = 1
161
+ _SEV_FAIL = 2
162
+
163
+ _ANSI = {_SEV_WARN: "\033[33m", _SEV_FAIL: "\033[31m"}
164
+ _ANSI_RESET = "\033[0m"
165
+ _PREFIX = {_SEV_NONE: "", _SEV_WARN: "⚠ ", _SEV_FAIL: "✗ "}
166
+
167
+
168
+ def _color_enabled(mode: str) -> bool:
169
+ """Decide whether ANSI escapes should be emitted.
170
+
171
+ ``always`` forces colour, ``never`` forces plain text, ``auto`` (default)
172
+ honours NO_COLOR (https://no-color.org) and only colours a real TTY.
173
+ """
174
+ if mode == "always":
175
+ return True
176
+ if mode == "never":
177
+ return False
178
+ if os.environ.get("NO_COLOR") is not None:
179
+ return False
180
+ try:
181
+ return bool(sys.stdout.isatty())
182
+ except (ValueError, AttributeError):
183
+ return False
184
+
185
+
186
+ def _decorate(text: str, sev: int, color: bool) -> str:
187
+ """Apply severity prefix + (optional) ANSI colour to a single line.
188
+
189
+ The leading indentation is preserved; the prefix and colour wrap only the
190
+ non-indented payload so columns still line up.
191
+ """
192
+ if sev == _SEV_NONE:
193
+ return text
194
+ stripped = text.lstrip(" ")
195
+ indent = text[: len(text) - len(stripped)]
196
+ payload = _PREFIX[sev] + stripped
197
+ if color:
198
+ payload = _ANSI[sev] + payload + _ANSI_RESET
199
+ return indent + payload
200
+
201
+
202
+ def _count_todo(path: Optional[str]) -> Optional[int]:
203
+ """Count lines bearing the 📋 Todo marker in backlog.md. None if absent."""
204
+ if not path or not os.path.isfile(path):
205
+ return None
206
+ count = 0
207
+ try:
208
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
209
+ for line in fh:
210
+ if "📋" in line and "Todo" in line:
211
+ count += 1
212
+ except OSError:
213
+ return None
214
+ return count
215
+
216
+
217
+ def _tail_lines(path: Optional[str], n: int) -> List[str]:
218
+ """Last ``n`` non-empty lines of a text file, or [] when absent."""
219
+ if not path or not os.path.isfile(path):
220
+ return []
221
+ try:
222
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
223
+ lines = [ln.rstrip("\n") for ln in fh]
224
+ except OSError:
225
+ return []
226
+ return lines[-n:]
227
+
228
+
229
+ def _fmt_ci(outcome: Optional[str]) -> str:
230
+ if outcome is None:
231
+ return "ci: n/a"
232
+ mapping = {"ok": "green", "green": "green", "red": "red",
233
+ "heal-attempting": "heal-attempting"}
234
+ return "ci: " + mapping.get(outcome, outcome)
235
+
236
+
237
+ def _fmt_result(row: Dict[str, Any]) -> str:
238
+ status = row.get("status", "")
239
+ built = row.get("built") or []
240
+ tcr = row.get("tcr_count", 0)
241
+ if status == "idle" or (not built and status in ("", "idle")):
242
+ return "idle: no story picked"
243
+ if built:
244
+ built_str = " ".join(str(b) for b in built)
245
+ return "built: {0} · tcr commits: {1}".format(built_str, tcr)
246
+ # non-idle terminal with no built[] (failed/aborted/blocked/orphan)
247
+ return "{0} · tcr commits: {1}".format(status or "unknown", tcr)
248
+
249
+
250
+ def _fmt_phases(phases: Dict[str, Any], limit: int = 5) -> List[str]:
251
+ rows: List[tuple] = []
252
+ for name, dur in phases.items():
253
+ try:
254
+ rows.append((int(dur), str(name)))
255
+ except (ValueError, TypeError):
256
+ continue
257
+ rows.sort(key=lambda r: (-r[0], r[1]))
258
+ out = []
259
+ for dur, name in rows[:limit]:
260
+ out.append(" {0:<22} {1:>5}s".format(name, dur))
261
+ return out
262
+
263
+
264
+ def _result_severity(row: Dict[str, Any]) -> int:
265
+ """Severity for the result line (US-LOOP-041)."""
266
+ status = str(row.get("status", ""))
267
+ if status in ("failed", "aborted"):
268
+ return _SEV_FAIL
269
+ built = row.get("built") or []
270
+ tcr = row.get("tcr_count", 0)
271
+ if built and tcr == 0: # suspected zero-diff: built something but no commit
272
+ return _SEV_WARN
273
+ return _SEV_NONE
274
+
275
+
276
+ def _ci_severity(outcome: Optional[str]) -> int:
277
+ """Severity for the ci line (US-LOOP-041)."""
278
+ if outcome == "red":
279
+ return _SEV_FAIL
280
+ if outcome == "heal-attempting":
281
+ return _SEV_WARN
282
+ return _SEV_NONE
283
+
284
+
285
+ def render(runs: Optional[str], events: Optional[str], backlog: Optional[str],
286
+ cron_log: Optional[str], cycle_id: str = "",
287
+ alert: Optional[str] = None, color: bool = False) -> str:
288
+ """Build the summary block as a string.
289
+
290
+ ``color`` toggles ANSI escapes; severity prefixes (``✗`` / ``⚠``) are
291
+ always applied to flagged lines regardless of ``color`` (US-LOOP-041).
292
+ """
293
+ row = _read_last_json_line(runs, cycle_id)
294
+ ev_list = _read_json_lines(events)
295
+ ci_outcome = _latest_ci_outcome(ev_list)
296
+ cycle_end_outcome = _latest_cycle_end_outcome(ev_list)
297
+ alert_on = _alert_active(alert)
298
+ todo = _count_todo(backlog)
299
+
300
+ # Source priority: a usable runs.jsonl row is the primary feed. With no
301
+ # row AND no events, fall back to the cron log's tail; if even that is
302
+ # empty, emit the single "unavailable" placeholder line.
303
+ have_primary = row is not None
304
+ have_events = bool(ev_list)
305
+
306
+ cid = cycle_id or (row.get("cycle_id") if row else "") or "unknown"
307
+ lines: List[str] = []
308
+ title = "─── Cycle {0} Summary ───".format(cid)
309
+
310
+ if not have_primary and not have_events:
311
+ tail = _tail_lines(cron_log, 30)
312
+ if not tail:
313
+ log_hint = cron_log or "~/.shared/roll/loop/cron-<slug>.log"
314
+ return "(summary unavailable — see log: {0})".format(log_hint)
315
+ # Degraded view: header + raw cron tail so the user still sees output.
316
+ lines.append(title)
317
+ lines.append(" (runs.jsonl + events unavailable — showing cron log tail)")
318
+ for ln in tail:
319
+ lines.append(" " + ln)
320
+ return "\n".join(lines)
321
+
322
+ lines.append(title)
323
+
324
+ # cycle_end fail severity applies to the result line when it's worse than
325
+ # what the runs.jsonl status alone implies.
326
+ cycle_end_sev = _SEV_NONE
327
+ if cycle_end_outcome is not None and cycle_end_outcome not in ("ok", "idle"):
328
+ cycle_end_sev = _SEV_FAIL
329
+
330
+ # 1. result
331
+ if row is not None:
332
+ result_sev = max(_result_severity(row), cycle_end_sev)
333
+ lines.append(_decorate(" " + _fmt_result(row), result_sev, color))
334
+ else:
335
+ lines.append(_decorate(" result: n/a", cycle_end_sev, color))
336
+
337
+ # 2. ci
338
+ lines.append(_decorate(" " + _fmt_ci(ci_outcome),
339
+ _ci_severity(ci_outcome), color))
340
+
341
+ # 3. todo
342
+ if todo is not None:
343
+ lines.append(" todo remaining: {0}".format(todo))
344
+ else:
345
+ lines.append(" todo remaining: n/a")
346
+
347
+ # 4. phases (top 5 by duration desc)
348
+ phases = (row.get("phases") if row else None) or {}
349
+ if isinstance(phases, dict) and phases:
350
+ phase_rows = _fmt_phases(phases)
351
+ if phase_rows:
352
+ lines.append(" phases (top 5 by time):")
353
+ lines.extend(phase_rows)
354
+
355
+ # 5. alerts / failure highlight (US-LOOP-041)
356
+ alerts = (row.get("alerts") if row else None) or []
357
+ if isinstance(alerts, list) and alerts:
358
+ lines.append(_decorate(" alerts:", _SEV_FAIL, color))
359
+ for a in alerts:
360
+ lines.append(_decorate(" " + str(a), _SEV_FAIL, color))
361
+ if alert_on:
362
+ lines.append(_decorate(" alert: ALERT file active — see log",
363
+ _SEV_WARN, color))
364
+
365
+ return "\n".join(lines)
366
+
367
+
368
+ def main(argv: Optional[List[str]] = None) -> int:
369
+ parser = argparse.ArgumentParser(
370
+ description="Render a loop cycle exit summary block.")
371
+ parser.add_argument("--runs", default=None, help="path to runs.jsonl")
372
+ parser.add_argument("--events", default=None, help="path to events.ndjson")
373
+ parser.add_argument("--backlog", default=None, help="path to .roll/backlog.md")
374
+ parser.add_argument("--cron-log", default=None, help="path to cron-<slug>.log")
375
+ parser.add_argument("--cycle-id", default="", help="cycle id to prefer")
376
+ parser.add_argument("--alert", default=None, help="path to ALERT-<slug>.md")
377
+ parser.add_argument("--color", choices=("auto", "always", "never"),
378
+ default="auto", help="ANSI colour mode (default: auto)")
379
+ args = parser.parse_args(argv)
380
+
381
+ try:
382
+ color = _color_enabled(args.color)
383
+ out = render(args.runs, args.events, args.backlog,
384
+ args.cron_log, args.cycle_id,
385
+ alert=args.alert, color=color)
386
+ except Exception: # noqa: BLE001 — silent fallback per AC: never error
387
+ return 0
388
+ sys.stdout.write(out + "\n")
389
+ return 0
390
+
391
+
392
+ if __name__ == "__main__":
393
+ sys.exit(main())