@seanyao/roll 2026.518.2 → 2026.518.4

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.
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ roll-backlog — v2 terminal view for `roll backlog`.
4
+
5
+ Parses BACKLOG.md and renders items grouped by type:
6
+ Bug Fixes (red) · User Stories (blue) · Refactors (amber) · Ideas (dim)
7
+
8
+ In-progress items get a ⏵ purple marker.
9
+ Blocked and Deferred items appear in their own sections below.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ import sys
16
+ from typing import List, NamedTuple, Optional
17
+
18
+ _LIB_DIR = os.path.dirname(os.path.realpath(__file__))
19
+ if _LIB_DIR not in sys.path:
20
+ sys.path.insert(0, _LIB_DIR)
21
+ import roll_render as rr
22
+ from roll_render import c, pad, row, trunc, strw, COLS
23
+
24
+ # ════════════════════════════════════════════════════════════════════════════
25
+ # BACKLOG parsing
26
+ # ════════════════════════════════════════════════════════════════════════════
27
+
28
+ class Item(NamedTuple):
29
+ id: str
30
+ desc: str
31
+ status: str # raw status cell content
32
+ reason: str # extracted reason from Blocked/Deferred status
33
+
34
+
35
+ _ID_RE = re.compile(r"\[([^\]]+)\]\([^)]+\)") # [US-XXX](link)
36
+ _REASON_RE = re.compile(r"\[([^\]]+)\]") # [reason text]
37
+
38
+
39
+ def _parse_id(cell: str) -> str:
40
+ m = _ID_RE.search(cell)
41
+ if m:
42
+ return m.group(1)
43
+ return cell.strip()
44
+
45
+
46
+ def _parse_reason(status_cell: str) -> str:
47
+ # Skip the leading emoji word, extract first [...] block
48
+ m = _REASON_RE.search(status_cell)
49
+ return m.group(1) if m else ""
50
+
51
+
52
+ def parse_backlog(path: str) -> List[Item]:
53
+ items: List[Item] = []
54
+ with open(path, encoding="utf-8") as f:
55
+ for line in f:
56
+ line = line.rstrip("\n")
57
+ if not line.startswith("|"):
58
+ continue
59
+ parts = [p.strip() for p in line.split("|")]
60
+ if len(parts) < 4:
61
+ continue
62
+ id_cell = parts[1]
63
+ desc_cell = parts[2]
64
+ status_cell = parts[3] if len(parts) > 3 else ""
65
+ item_id = _parse_id(id_cell)
66
+ if not re.match(r"(US|FIX|REFACTOR|IDEA)-", item_id):
67
+ continue
68
+ reason = _parse_reason(status_cell) if ("Blocked" in status_cell or "Deferred" in status_cell) else ""
69
+ items.append(Item(item_id, desc_cell, status_cell, reason))
70
+ return items
71
+
72
+
73
+ def classify(items: List[Item]):
74
+ todo_fix: List[Item] = []
75
+ todo_us: List[Item] = []
76
+ todo_ref: List[Item] = []
77
+ todo_idea: List[Item] = []
78
+ in_progress: List[Item] = []
79
+ blocked: List[Item] = []
80
+ deferred: List[Item] = []
81
+
82
+ for it in items:
83
+ st = it.status
84
+ if "In Progress" in st:
85
+ in_progress.append(it)
86
+ elif "Blocked" in st:
87
+ blocked.append(it)
88
+ elif "Deferred" in st:
89
+ deferred.append(it)
90
+ elif "Todo" in st:
91
+ if it.id.startswith("FIX-"):
92
+ todo_fix.append(it)
93
+ elif it.id.startswith("US-"):
94
+ todo_us.append(it)
95
+ elif it.id.startswith("REFACTOR-"):
96
+ todo_ref.append(it)
97
+ elif it.id.startswith("IDEA-"):
98
+ todo_idea.append(it)
99
+
100
+ return todo_fix, todo_us, todo_ref, todo_idea, in_progress, blocked, deferred
101
+
102
+
103
+ # ════════════════════════════════════════════════════════════════════════════
104
+ # Rendering
105
+ # ════════════════════════════════════════════════════════════════════════════
106
+
107
+ _MAX_DESC = 62
108
+
109
+ BG_RUN = "\033[48;2;40;20;70m" # faint purple bg for in-progress row
110
+
111
+
112
+ def _render_item_row(it: Item, color: str, *, glyph: str = " ", bg: str = "") -> None:
113
+ """Print one item line: glyph · ID · description."""
114
+ id_str = c(color, pad(it.id, 16))
115
+ desc = trunc(it.desc, _MAX_DESC)
116
+ desc_str = c(color, desc) if color != "dim" else c("dim", desc)
117
+ line = f" {glyph} {id_str} {desc_str}"
118
+ if bg and rr.USE_COLOR:
119
+ print(bg + line + rr.RESET)
120
+ else:
121
+ print(line)
122
+
123
+
124
+ def _render_group(title_en: str, title_zh: str, color: str, items: List[Item]) -> None:
125
+ if not items:
126
+ return
127
+ n = len(items)
128
+ header = c(color, f" {title_en}", bold=True) + c("muted", " · ") + c("dim", title_zh) + c("muted", f" ({n})")
129
+ print(header)
130
+ for it in items:
131
+ _render_item_row(it, color)
132
+ print()
133
+
134
+
135
+ def _render_in_progress(items: List[Item]) -> None:
136
+ for it in items:
137
+ glyph = c("purple", "⏵")
138
+ id_str = c("purple", pad(it.id, 16), bold=True)
139
+ desc = trunc(it.desc, _MAX_DESC)
140
+ desc_str = c("purple", desc)
141
+ line = f" {glyph} {id_str} {desc_str}"
142
+ if rr.USE_COLOR:
143
+ print(BG_RUN + line + rr.RESET)
144
+ else:
145
+ print(f" ⏵ {it.id} {it.desc}")
146
+
147
+
148
+ def render(path: str) -> None:
149
+ items = parse_backlog(path)
150
+ todo_fix, todo_us, todo_ref, todo_idea, in_progress, blocked, deferred = classify(items)
151
+
152
+ todo_total = len(todo_fix) + len(todo_us) + len(todo_ref) + len(todo_idea)
153
+ blocked_count = len(blocked)
154
+ deferred_count = len(deferred)
155
+
156
+ # ── Header ──────────────────────────────────────────────────────────────
157
+ print()
158
+ pending_total = todo_total + len(in_progress)
159
+ tags = c("fg", f"{pending_total} Pending", bold=True)
160
+ if blocked_count:
161
+ tags += c("muted", " · ") + c("amber", f"{blocked_count} Blocked")
162
+ if deferred_count:
163
+ tags += c("muted", " · ") + c("dim", f"{deferred_count} Deferred")
164
+ header_left = " " + c("pink", "BACKLOG", bold=True) + c("muted", " · ") + c("dim", "待处理任务")
165
+ print(row(header_left, " " + tags))
166
+ print()
167
+
168
+ # ── In-progress (shown first, above groups) ──────────────────────────────
169
+ if in_progress:
170
+ _render_in_progress(in_progress)
171
+ print()
172
+
173
+ # ── Todo groups in priority order ────────────────────────────────────────
174
+ _render_group("Bug Fixes", "缺陷修复", "red", todo_fix)
175
+ _render_group("User Stories", "用户故事", "blue", todo_us)
176
+ _render_group("Refactors", "重构", "amber", todo_ref)
177
+ _render_group("Ideas", "创意", "dim", todo_idea)
178
+
179
+ if todo_total == 0 and len(in_progress) == 0:
180
+ print(c("green", " ✓ Nothing pending — backlog is clear 暂无待处理任务"))
181
+ print()
182
+
183
+ # ── Blocked ──────────────────────────────────────────────────────────────
184
+ if blocked:
185
+ print(c("amber", " Blocked", bold=True) + c("muted", " · ") + c("dim", "已阻塞") + c("muted", f" ({blocked_count})"))
186
+ for it in blocked:
187
+ id_str = c("amber", pad(it.id, 16))
188
+ desc = trunc(it.desc, 50)
189
+ reason_str = c("muted", f" ({it.reason})") if it.reason else ""
190
+ print(f" 🔒 {id_str} {c('dim', desc)}{reason_str}")
191
+ print()
192
+
193
+ # ── Deferred ─────────────────────────────────────────────────────────────
194
+ if deferred:
195
+ print(c("dim", f" Deferred · 已推迟 ({deferred_count})"))
196
+ for it in deferred:
197
+ id_str = c("dim", pad(it.id, 16))
198
+ desc = trunc(it.desc, 50)
199
+ reason_str = c("muted", f" ({it.reason})") if it.reason else ""
200
+ print(f" ⏸ {id_str} {c('dim', desc)}{reason_str}")
201
+ print()
202
+
203
+ # ── Footer ───────────────────────────────────────────────────────────────
204
+ print(c("muted", " ") + c("dim", "triage: ") + c("blue", "roll backlog block/defer/unblock <pattern> [reason]"))
205
+ print()
206
+
207
+
208
+ # ════════════════════════════════════════════════════════════════════════════
209
+ # Entry
210
+ # ════════════════════════════════════════════════════════════════════════════
211
+
212
+ def main() -> None:
213
+ args = sys.argv[1:]
214
+ demo = "--demo" in args
215
+ no_color = "--no-color" in args or not sys.stdout.isatty() or os.getenv("NO_COLOR")
216
+ rr.USE_COLOR = not no_color
217
+
218
+ backlog = "BACKLOG.md"
219
+ if not demo and not os.path.isfile(backlog):
220
+ print(f"Error: {backlog} not found — run 'roll init' first", file=sys.stderr)
221
+ sys.exit(1)
222
+
223
+ if demo:
224
+ _write_demo(backlog)
225
+ try:
226
+ render(backlog)
227
+ finally:
228
+ os.unlink(backlog)
229
+ else:
230
+ render(backlog)
231
+
232
+
233
+ def _write_demo(path: str) -> None:
234
+ with open(path, "w") as f:
235
+ f.write("""# Project Backlog
236
+
237
+ ## 🐛 Bug Fixes
238
+ | ID | Description | Status |
239
+ |----|-------------|--------|
240
+ | FIX-042 | Fix outer runner tmux kill matching wrong session | 🔨 In Progress |
241
+ | FIX-043 | Handle stale state in loop now command | 📋 Todo |
242
+
243
+ ## Epic: Autonomous Evolution
244
+ ### Feature: autonomous-evolution
245
+ | Story | Description | Status |
246
+ |-------|-------------|--------|
247
+ | [US-AUTO-042](docs/features/autonomous-evolution.md) | Loop cost telemetry — write model and token data per cycle | 📋 Todo |
248
+
249
+ ## ♻️ Refactor
250
+ | ID | Description | Status |
251
+ |----|-------------|--------|
252
+ | REFACTOR-010 | Simplify CI test parallelism strategy | 🔒 Blocked [waiting on CI infra] |
253
+
254
+ ## 💡 Ideas
255
+ | ID | Description | Status |
256
+ |----|-------------|--------|
257
+ | IDEA-025 | Dashboard cost from list-price tokens | ⏸ Deferred [design pending] |
258
+ """)
259
+
260
+
261
+ if __name__ == "__main__":
262
+ main()
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ roll-brief — v2 terminal view for `roll brief`.
4
+
5
+ Parses the latest docs/briefs/<date>.md and renders it as three sections:
6
+ SUMMARY — eyebrow + shipped/watch/decide counts
7
+ HIGHLIGHTS — completed story list
8
+ DECIDE — action-required items with D1/D2/... numbering
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ import sys
15
+ import time
16
+ from typing import Dict, List, Tuple
17
+
18
+ _LIB_DIR = os.path.dirname(os.path.realpath(__file__))
19
+ if _LIB_DIR not in sys.path:
20
+ sys.path.insert(0, _LIB_DIR)
21
+ import roll_render as rr
22
+ from roll_render import c, pad, row, trunc, strw, COLS
23
+
24
+ # ════════════════════════════════════════════════════════════════════════════
25
+ # Brief parsing — section-based
26
+ # ════════════════════════════════════════════════════════════════════════════
27
+
28
+ def _age_str(mtime: float) -> str:
29
+ age_s = int(time.time() - mtime)
30
+ if age_s < 3600:
31
+ return f"{age_s // 60}m ago"
32
+ if age_s < 86400:
33
+ return f"{age_s // 3600}h ago"
34
+ return f"{age_s // 86400}d ago"
35
+
36
+
37
+ def _split_sections(lines: List[str]) -> Dict[str, List[str]]:
38
+ """Split markdown into {section_heading: [body_lines], ...}.
39
+ Key '' holds lines before the first section header.
40
+ Key '@title' holds the # title line.
41
+ """
42
+ sections: Dict[str, List[str]] = {"": []}
43
+ current = ""
44
+ for line in lines:
45
+ if line.startswith("# "):
46
+ sections["@title"] = [line]
47
+ elif line.startswith("## "):
48
+ current = line[3:].strip()
49
+ sections[current] = []
50
+ else:
51
+ sections[current].append(line)
52
+ return sections
53
+
54
+
55
+ def _parse_table(body: List[str]) -> List[Tuple[str, str]]:
56
+ """Extract (id, desc) pairs from a markdown table body."""
57
+ rows = []
58
+ for line in body:
59
+ if not line.strip().startswith("|"):
60
+ continue
61
+ parts = [p.strip() for p in line.strip().split("|")]
62
+ if len(parts) < 3:
63
+ continue
64
+ cell0 = parts[1]
65
+ cell1 = parts[2] if len(parts) > 2 else ""
66
+ # skip separator rows like |---|---| and header rows
67
+ if re.match(r"[-:]+$", cell0.replace(" ", "")):
68
+ continue
69
+ if cell0.lower() in ("编号", "story", "id", ""):
70
+ continue
71
+ rows.append((cell0, cell1))
72
+ return rows
73
+
74
+
75
+ def _parse_numbered_list(body: List[str]) -> List[str]:
76
+ """Extract items from a numbered markdown list."""
77
+ items = []
78
+ for line in body:
79
+ m = re.match(r"^\d+\.\s+(.*)", line.rstrip())
80
+ if m:
81
+ text = m.group(1)
82
+ text = re.sub(r"\*\*(.*?)\*\*", r"\1", text)
83
+ items.append(text.strip())
84
+ return items
85
+
86
+
87
+ class Brief:
88
+ def __init__(self) -> None:
89
+ self.path: str = ""
90
+ self.mtime: float = 0.0
91
+ self.title_date: str = ""
92
+ self.coverage: str = ""
93
+ self.shipped: List[Tuple[str, str]] = []
94
+ self.in_progress: List[Tuple[str, str]] = []
95
+ self.pending_count: int = 0
96
+ self.decide: List[str] = []
97
+ self.alert_count: int = 0
98
+
99
+
100
+ def parse_brief(path: str) -> Brief:
101
+ b = Brief()
102
+ b.path = path
103
+ b.mtime = os.path.getmtime(path)
104
+
105
+ with open(path, encoding="utf-8") as f:
106
+ raw = f.read()
107
+
108
+ lines = raw.splitlines()
109
+ sections = _split_sections(lines)
110
+
111
+ # Title date
112
+ title_lines = sections.get("@title", [])
113
+ if title_lines:
114
+ m = re.search(r"(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})", title_lines[0])
115
+ if m:
116
+ b.title_date = m.group(1)
117
+
118
+ # Coverage from preamble blockquote
119
+ for line in sections.get("", []):
120
+ if line.startswith("> ") and "覆盖" in line:
121
+ b.coverage = line[2:].strip()
122
+
123
+ # 已完成
124
+ for key, body in sections.items():
125
+ if key.startswith("已完成"):
126
+ b.shipped = _parse_table(body)
127
+ break
128
+
129
+ # 进行中
130
+ for key, body in sections.items():
131
+ if key.startswith("进行中"):
132
+ b.in_progress = _parse_table(body)
133
+ break
134
+
135
+ # Pending count from section header
136
+ for key in sections:
137
+ m = re.search(r"待处理.*?(\d+)", key)
138
+ if m:
139
+ b.pending_count = int(m.group(1))
140
+
141
+ # 需人工介入
142
+ for key, body in sections.items():
143
+ if key.startswith("需人工介入"):
144
+ b.decide = _parse_numbered_list(body)
145
+ break
146
+
147
+ # Footer alert count
148
+ for line in lines:
149
+ if line.startswith("*状态") or ("告警" in line and line.startswith("*")):
150
+ m = re.search(r"告警\s*(\d+)", line)
151
+ if m:
152
+ b.alert_count = int(m.group(1))
153
+ break
154
+
155
+ return b
156
+
157
+
158
+ # ════════════════════════════════════════════════════════════════════════════
159
+ # Rendering
160
+ # ════════════════════════════════════════════════════════════════════════════
161
+
162
+ _MAX_DESC = 60
163
+
164
+
165
+ def _render_eyebrow(b: Brief) -> None:
166
+ brief_file = os.path.basename(b.path)
167
+ age = _age_str(b.mtime)
168
+ left = " " + c("pink", "BRIEF", bold=True) + c("muted", " · ") + c("dim", "简报")
169
+ right = c("dim", brief_file) + c("muted", " · ") + c("amber", age)
170
+ print(row(left, " " + right))
171
+ if b.title_date:
172
+ cov = (c("muted", " · ") + c("dim", b.coverage)) if b.coverage else ""
173
+ print(" " + c("dim", b.title_date) + cov)
174
+ print()
175
+
176
+
177
+ def _render_summary(b: Brief) -> None:
178
+ n_shipped = len(b.shipped)
179
+ n_watch = len(b.in_progress)
180
+ n_decide = len(b.decide)
181
+
182
+ tags = c("green", f"✓ {n_shipped} Shipped") + c("muted", " · ")
183
+ tags += (c("amber", f"! {n_watch} Watch") if n_watch else c("dim", f"! {n_watch} Watch"))
184
+ tags += c("muted", " · ")
185
+ tags += (c("amber", f"⚠ {n_decide} Decide") if n_decide else c("dim", f"⚠ {n_decide} Decide"))
186
+
187
+ left = " " + c("fg", "SUMMARY", bold=True) + c("muted", " · ") + c("dim", "摘要")
188
+ print(row(left, " " + tags))
189
+ print()
190
+
191
+ print(" " + c("green", "✓") + c("muted", " ") +
192
+ c("fg", f"{n_shipped} Shipped", bold=True) +
193
+ c("dim", f" · 已完成 {n_shipped} 项"))
194
+
195
+ if n_watch:
196
+ print(" " + c("amber", "!") + c("muted", " ") +
197
+ c("amber", f"{n_watch} Watch", bold=True) +
198
+ c("dim", f" · 进行中 {n_watch} 项"))
199
+ else:
200
+ print(" " + c("dim", "!") + c("muted", " ") + c("dim", f"{n_watch} Watch"))
201
+
202
+ if n_decide:
203
+ print(" " + c("amber", "⚠") + c("muted", " ") +
204
+ c("amber", f"{n_decide} Decide", bold=True) +
205
+ c("dim", f" · 需人工介入 {n_decide} 项"))
206
+ else:
207
+ print(" " + c("dim", "⚠") + c("muted", " ") + c("dim", f"{n_decide} Decide"))
208
+
209
+ if b.pending_count:
210
+ print(" " + c("dim", f"· {b.pending_count} 项待处理"))
211
+
212
+ print()
213
+
214
+
215
+ def _render_highlights(b: Brief) -> None:
216
+ if not b.shipped:
217
+ return
218
+ n = len(b.shipped)
219
+ header = (c("blue", " HIGHLIGHTS", bold=True) +
220
+ c("muted", " · ") + c("dim", "已完成") + c("muted", f" ({n})"))
221
+ print(header)
222
+ for (id_cell, desc) in b.shipped:
223
+ desc_t = trunc(desc, _MAX_DESC)
224
+ print(" " + c("muted", "— ") + c("blue", pad(id_cell, 18)) + " " + c("dim", desc_t))
225
+ print()
226
+
227
+
228
+ def _render_decide(b: Brief) -> None:
229
+ if not b.decide:
230
+ return
231
+ n = len(b.decide)
232
+ header = (c("amber", " DECIDE", bold=True) +
233
+ c("muted", " · ") + c("dim", "需人工介入") + c("muted", f" ({n})"))
234
+ print(header)
235
+ print()
236
+ for idx, item in enumerate(b.decide, start=1):
237
+ label = c("amber", f" D{idx}", bold=True)
238
+ body = trunc(item, COLS - 8)
239
+ print(f"{label} {c('fg', body)}")
240
+ print()
241
+
242
+
243
+ def _render_footer() -> None:
244
+ parts = [
245
+ c("dim", "next: ") + c("blue", "roll loop now"),
246
+ c("dim", "regen: ") + c("blue", "roll brief --regen"),
247
+ c("dim", "alerts: ") + c("blue", "roll alert"),
248
+ ]
249
+ print(" " + c("muted", " · ").join(parts))
250
+ print()
251
+
252
+
253
+ def render(path: str) -> None:
254
+ b = parse_brief(path)
255
+ print()
256
+ _render_eyebrow(b)
257
+ _render_summary(b)
258
+ _render_highlights(b)
259
+ _render_decide(b)
260
+ _render_footer()
261
+
262
+
263
+ # ════════════════════════════════════════════════════════════════════════════
264
+ # Entry
265
+ # ════════════════════════════════════════════════════════════════════════════
266
+
267
+ def main() -> None:
268
+ args = sys.argv[1:]
269
+ no_color = "--no-color" in args or not sys.stdout.isatty() or os.getenv("NO_COLOR")
270
+ rr.USE_COLOR = not no_color
271
+
272
+ briefs_dir = "docs/briefs"
273
+ briefs = sorted(
274
+ f for f in os.listdir(briefs_dir) if f.endswith(".md")
275
+ ) if os.path.isdir(briefs_dir) else []
276
+
277
+ if not briefs:
278
+ print("No brief yet — run 'roll brief --regen' 暂无简报", file=sys.stderr)
279
+ sys.exit(1)
280
+
281
+ latest = os.path.join(briefs_dir, briefs[-1])
282
+ render(latest)
283
+
284
+
285
+ if __name__ == "__main__":
286
+ main()