@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,157 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ roll-help — render the `roll --help` page.
4
+
5
+ Compact wordmark + grouped commands (AUTONOMY / PROJECT / MACHINE) + examples.
6
+
7
+ Usage:
8
+ python3 lib/roll-help.py # live
9
+ python3 lib/roll-help.py --no-color
10
+ python3 lib/roll-help.py --demo # same as live, no fixture needed
11
+ """
12
+
13
+ from __future__ import annotations
14
+ import argparse, os, re, sys
15
+ from pathlib import Path
16
+
17
+ _LIB_DIR = os.path.dirname(os.path.realpath(__file__))
18
+ if _LIB_DIR not in sys.path:
19
+ sys.path.insert(0, _LIB_DIR)
20
+ import roll_render
21
+ from roll_render import COLS, c, row, section_head, strw, pad
22
+
23
+ # ════════════════════════════════════════════════════════════════════════════
24
+ # Version
25
+ # ════════════════════════════════════════════════════════════════════════════
26
+ def _roll_version() -> str:
27
+ roll_bin = Path(_LIB_DIR).parent / "bin" / "roll"
28
+ if roll_bin.exists():
29
+ for line in roll_bin.open(errors="ignore"):
30
+ m = re.match(r'^VERSION="([^"]+)"', line)
31
+ if m:
32
+ return m.group(1)
33
+ return "—"
34
+
35
+ # ════════════════════════════════════════════════════════════════════════════
36
+ # Command table
37
+ # ════════════════════════════════════════════════════════════════════════════
38
+ # (name, args_hint, en_desc, zh_desc, star)
39
+ AUTONOMY = [
40
+ ("loop", "<on|off|now|status|…>", "manage the autonomous BACKLOG executor", "管理自主执行循环", True),
41
+ ("brief", "", "show latest owner brief", "查看最新简报", True),
42
+ ("backlog", "[block|defer|…]", "view and manage pending tasks", "查看和管理待处理任务", True),
43
+ ("peer", "", "cross-agent negotiation & review", "跨 Agent 协商对审", False),
44
+ ("alert", "", "view and clear loop alerts", "查看 / 清除 loop 告警", False),
45
+ ]
46
+
47
+ PROJECT = [
48
+ ("init", "", "create AGENTS.md + BACKLOG.md + docs/", "初始化项目工作流文件", False),
49
+ ("status", "", "show current state and drift", "显示当前状态和漂移项", False),
50
+ ("agent", "[use <name>]", "per-project agent selection", "切换项目 agent", False),
51
+ ("ci", "[--wait]", "show or wait for current commit's CI status", "查看 / 等待 CI 状态", False),
52
+ ("release", "", "run the release script (human-only)", "执行发版脚本(仅人工)", False),
53
+ ("review-pr", "<number>", "AI-powered code review for a PR", "AI 代码评审", False),
54
+ ]
55
+
56
+ MACHINE = [
57
+ ("setup", "[-f]", "first-time install or re-sync", "首次安装或重新同步", False),
58
+ ("update", "", "upgrade to latest + re-sync", "升级到最新版并重新同步", False),
59
+ ("version", "", "print installed roll version", "显示已安装版本", False),
60
+ ]
61
+
62
+ EXAMPLES = [
63
+ ("roll loop on", "启用自主执行循环"),
64
+ ("roll brief", "查看最新简报"),
65
+ ("roll backlog defer US-DOC '过早引入'", "推迟一类任务"),
66
+ ("roll agent use kimi", "切换当前项目到 kimi"),
67
+ ]
68
+
69
+ # ════════════════════════════════════════════════════════════════════════════
70
+ # Render
71
+ # ════════════════════════════════════════════════════════════════════════════
72
+ def _hr() -> None:
73
+ print(c("faint", "─" * COLS))
74
+
75
+ def _cmd_block(entries: list) -> None:
76
+ """Render a command group — two lines per command (EN + ZH)."""
77
+ for name, args, en_desc, zh_desc, star in entries:
78
+ star_mark = c("amber", " ★") if star else " "
79
+ en_line = (
80
+ " " +
81
+ c("blue", name, bold=True) +
82
+ star_mark +
83
+ " " +
84
+ (c("dim", args + " ") if args else " ") +
85
+ c("fg", en_desc)
86
+ )
87
+ zh_line = " " + " " * (strw(name) + 2 + 2) + c("dim", zh_desc)
88
+ print(en_line)
89
+ print(zh_line)
90
+
91
+ def render(version: str) -> None:
92
+ # ── Wordmark ──────────────────────────────────────────────────────────────
93
+ print()
94
+ left = (" " + c("fg", "roll", bold=True) + c("muted", " · ") +
95
+ c("dim", "autonomous delivery for software teams"))
96
+ right = c("yellow", f"v{version}") + " "
97
+ print(row(left, right))
98
+ print(" " + c("dim", "自主交付,人只做三件事:提需求、审核、发版"))
99
+ print()
100
+ print(" " + c("dim", "usage ") + c("fg", "roll") + c("dim", " <command> [options]"))
101
+ print()
102
+ _hr()
103
+ print()
104
+
105
+ # ── AUTONOMY ──────────────────────────────────────────────────────────────
106
+ section_head("AUTONOMY", "日常使用", "★ = most used")
107
+ print()
108
+ _cmd_block(AUTONOMY)
109
+ print()
110
+ _hr()
111
+ print()
112
+
113
+ # ── PROJECT ───────────────────────────────────────────────────────────────
114
+ section_head("PROJECT", "项目内", "per-repo setup and CI")
115
+ print()
116
+ _cmd_block(PROJECT)
117
+ print()
118
+ _hr()
119
+ print()
120
+
121
+ # ── MACHINE ───────────────────────────────────────────────────────────────
122
+ section_head("MACHINE", "全局", "install, upgrade, version")
123
+ print()
124
+ _cmd_block(MACHINE)
125
+ print()
126
+ _hr()
127
+ print()
128
+
129
+ # ── Examples ──────────────────────────────────────────────────────────────
130
+ print(" " + c("muted", "examples"))
131
+ print()
132
+ for cmd, zh in EXAMPLES:
133
+ print(" " + c("blue", cmd) + " " + c("dim", zh))
134
+ print()
135
+ print(" " + c("dim", "docs: ") + c("blue", "github.com/seanyao/Roll") +
136
+ c("muted", " · ") +
137
+ c("dim", "issues: ") + c("blue", "github.com/seanyao/Roll/issues"))
138
+ print()
139
+
140
+ # ════════════════════════════════════════════════════════════════════════════
141
+ # Entry
142
+ # ════════════════════════════════════════════════════════════════════════════
143
+ def main() -> None:
144
+ ap = argparse.ArgumentParser(add_help=False)
145
+ ap.add_argument("--demo", action="store_true")
146
+ ap.add_argument("--no-color", dest="no_color", action="store_true")
147
+ ap.add_argument("--en", action="store_true")
148
+ ap.add_argument("--zh", action="store_true")
149
+ args, _ = ap.parse_known_args()
150
+
151
+ if args.no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
152
+ roll_render.USE_COLOR = False
153
+
154
+ render(_roll_version())
155
+
156
+ if __name__ == "__main__":
157
+ main()
@@ -0,0 +1,493 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ roll-home — render the `roll` bare-command home dashboard.
4
+
5
+ One-screen overview: current loop state, three autonomous layers, four
6
+ defenses, delivery pipeline, current-focus DoD, and items needing human
7
+ attention. Reads all state files per-project (slug = basename-md5_6).
8
+
9
+ Usage:
10
+ python3 lib/roll-home.py # live data
11
+ python3 lib/roll-home.py --no-color
12
+ python3 lib/roll-home.py --en | --zh # collapse bilingual rows
13
+ python3 lib/roll-home.py --demo # render with fixture data
14
+ """
15
+
16
+ from __future__ import annotations
17
+ import argparse, hashlib, os, re, subprocess, sys, time
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+ from typing import Any, Dict, Optional, Tuple
21
+
22
+ os.environ.setdefault("TZ", "Asia/Shanghai")
23
+ time.tzset()
24
+
25
+ _LIB_DIR = os.path.dirname(os.path.realpath(__file__))
26
+ if _LIB_DIR not in sys.path:
27
+ sys.path.insert(0, _LIB_DIR)
28
+ import roll_render
29
+ from roll_render import COLS, c, row, section_head, strw, pad
30
+
31
+ # ════════════════════════════════════════════════════════════════════════════
32
+ # Paths
33
+ # ════════════════════════════════════════════════════════════════════════════
34
+ def _project_slug(path: Optional[str] = None) -> str:
35
+ path = os.path.realpath(path or os.getcwd())
36
+ try:
37
+ common = subprocess.check_output(
38
+ ["git", "-C", path, "rev-parse", "--git-common-dir"],
39
+ stderr=subprocess.DEVNULL, text=True,
40
+ ).strip()
41
+ if common.endswith("/.git"):
42
+ path = common[:-5]
43
+ except Exception:
44
+ pass
45
+ base = re.sub(r"[^A-Za-z0-9]+", "-", os.path.basename(path)).strip("-")
46
+ h = hashlib.md5(path.encode()).hexdigest()[:6]
47
+ return f"{base}-{h}"
48
+
49
+ def _shared_root() -> Path:
50
+ return Path(os.environ.get("ROLL_SHARED_ROOT") or os.path.expanduser("~/.shared/roll"))
51
+
52
+ def _roll_pkg_dir() -> Path:
53
+ pkg = os.environ.get("ROLL_PKG_DIR")
54
+ return Path(pkg) if pkg else Path(_LIB_DIR).parent
55
+
56
+ # ════════════════════════════════════════════════════════════════════════════
57
+ # Loaders
58
+ # ════════════════════════════════════════════════════════════════════════════
59
+ def _load_yaml_flat(path: Path) -> Dict[str, str]:
60
+ out: Dict[str, str] = {}
61
+ if not path.exists():
62
+ return out
63
+ for line in path.open(errors="ignore"):
64
+ m = re.match(r"^([\w_]+):\s*(.*?)\s*$", line)
65
+ if m:
66
+ out[m.group(1)] = m.group(2).strip().strip('"').strip("'")
67
+ return out
68
+
69
+ def _load_config() -> Dict[str, str]:
70
+ for p in [
71
+ os.path.expanduser("~/.roll/config.yaml"),
72
+ os.path.join(os.getcwd(), ".roll.yaml"),
73
+ ]:
74
+ d = _load_yaml_flat(Path(p))
75
+ if d:
76
+ return d
77
+ return {}
78
+
79
+ def _git_info() -> Tuple[str, str]:
80
+ try:
81
+ branch = subprocess.check_output(
82
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
83
+ stderr=subprocess.DEVNULL, text=True,
84
+ ).strip()
85
+ except Exception:
86
+ branch = "—"
87
+ try:
88
+ dirty = bool(subprocess.check_output(
89
+ ["git", "status", "--porcelain"],
90
+ stderr=subprocess.DEVNULL, text=True,
91
+ ).strip())
92
+ status = "dirty" if dirty else "✓"
93
+ except Exception:
94
+ status = "—"
95
+ return branch, status
96
+
97
+ def _roll_version() -> str:
98
+ roll_bin = _roll_pkg_dir() / "bin" / "roll"
99
+ if roll_bin.exists():
100
+ for line in roll_bin.open(errors="ignore"):
101
+ m = re.match(r'^VERSION="([^"]+)"', line)
102
+ if m:
103
+ return m.group(1)
104
+ return "—"
105
+
106
+ def _launchd_svc_state(service: str, slug: str) -> str:
107
+ label = f"com.roll.{service}.{slug}"
108
+ plist = Path(os.path.expanduser("~/Library/LaunchAgents")) / f"{label}.plist"
109
+ if not plist.exists():
110
+ return "not-installed"
111
+ try:
112
+ out = subprocess.check_output(
113
+ ["launchctl", "list", label], stderr=subprocess.DEVNULL, text=True,
114
+ )
115
+ return "enabled" if out.strip() else "installed-off"
116
+ except Exception:
117
+ return "installed-off"
118
+
119
+ def _dream_last_hours() -> Optional[int]:
120
+ log = _shared_root() / "dream" / "log.md"
121
+ if not log.exists():
122
+ return None
123
+ try:
124
+ return int((time.time() - log.stat().st_mtime) / 3600)
125
+ except Exception:
126
+ return None
127
+
128
+ def _peer_last() -> Optional[Tuple[str, int]]:
129
+ peer_dir = _shared_root() / "peer"
130
+ if not peer_dir.exists():
131
+ return None
132
+ logs = sorted(peer_dir.glob("*.log"))
133
+ if not logs:
134
+ return None
135
+ latest = logs[-1]
136
+ try:
137
+ days = int((time.time() - latest.stat().st_mtime) / 86400)
138
+ for line in latest.read_text(errors="ignore").splitlines():
139
+ m = re.search(r"\b(AGREE|REFINE|OBJECT|ESCALATE)\b", line)
140
+ if m:
141
+ return (m.group(1), days)
142
+ return ("—", days)
143
+ except Exception:
144
+ return None
145
+
146
+ def _backlog_counts() -> Tuple[int, int, int, str, str, str, int]:
147
+ """(ideas, todo, in_progress, id, title, link, refactor_pending)."""
148
+ bl = Path("BACKLOG.md")
149
+ if not bl.exists():
150
+ return (0, 0, 0, "", "", "", 0)
151
+ ideas = todo = in_prog = refactors = 0
152
+ ip_id = ip_title = ip_link = ""
153
+ for line in bl.read_text(errors="ignore").splitlines():
154
+ if "| 📋 Todo |" in line:
155
+ if re.match(r"^\|\s*IDEA-", line):
156
+ ideas += 1
157
+ elif re.match(r"^\| REFACTOR-", line):
158
+ refactors += 1
159
+ else:
160
+ todo += 1
161
+ elif "| 🔨 In Progress |" in line:
162
+ in_prog += 1
163
+ if not ip_id:
164
+ m = re.search(r"(US|FIX|REFACTOR)-[A-Z]*-?\d+", line)
165
+ if m:
166
+ ip_id = m.group(0)
167
+ parts = [p.strip() for p in line.split("|")]
168
+ if len(parts) >= 4:
169
+ ip_title = parts[2][:60]
170
+ m2 = re.search(r"docs/features/[^\)]+", line)
171
+ if m2:
172
+ ip_link = m2.group(0)
173
+ return (ideas, todo, in_prog, ip_id, ip_title, ip_link, refactors)
174
+
175
+ def _alert_count(slug: str) -> int:
176
+ af = _shared_root() / "loop" / f"ALERT-{slug}.md"
177
+ if not af.exists():
178
+ return 0
179
+ return sum(1 for l in af.read_text(errors="ignore").splitlines() if l.startswith("# ALERT"))
180
+
181
+ def _proposal_count() -> int:
182
+ p = Path("PROPOSALS.md")
183
+ if not p.exists():
184
+ return 0
185
+ return sum(1 for l in p.read_text(errors="ignore").splitlines() if l.startswith("## PROPOSAL"))
186
+
187
+ def _release_ready() -> bool:
188
+ briefs_dir = Path("docs/briefs")
189
+ if not briefs_dir.exists():
190
+ return False
191
+ try:
192
+ tag = subprocess.check_output(
193
+ ["git", "describe", "--tags", "--abbrev=0"],
194
+ stderr=subprocess.DEVNULL, text=True,
195
+ ).strip()
196
+ log = subprocess.check_output(
197
+ ["git", "log", f"{tag}..HEAD", "--pretty=format:%s"],
198
+ stderr=subprocess.DEVNULL, text=True,
199
+ )
200
+ if not any(
201
+ l for l in log.splitlines()
202
+ if l and not re.match(r"^(docs|chore)(\([^)]*\))?:", l)
203
+ ):
204
+ return False
205
+ briefs = sorted(briefs_dir.glob("*.md"))
206
+ if not briefs:
207
+ return False
208
+ return bool(re.search(r"✅ 可发版|Release ready", briefs[-1].read_text(errors="ignore")))
209
+ except Exception:
210
+ return False
211
+
212
+ def _tcr_last_min() -> Optional[int]:
213
+ try:
214
+ ts = subprocess.check_output(
215
+ ["git", "log", "--grep=^tcr:", "-1", "--format=%ct"],
216
+ stderr=subprocess.DEVNULL, text=True,
217
+ ).strip()
218
+ return int((time.time() - int(ts)) / 60) if ts else None
219
+ except Exception:
220
+ return None
221
+
222
+ def _ac_completion(feature_link: str) -> Tuple[int, int]:
223
+ if not feature_link:
224
+ return (0, 0)
225
+ path_str, _, anchor = feature_link.partition("#")
226
+ if not path_str or not Path(path_str).exists():
227
+ return (0, 0)
228
+ text = Path(path_str).read_text(errors="ignore")
229
+ in_sec = done = total = 0
230
+ for line in text.splitlines():
231
+ if f'id="{anchor}"' in line:
232
+ in_sec = 1
233
+ continue
234
+ if in_sec and re.match(r"^## ", line):
235
+ break
236
+ if in_sec:
237
+ if re.search(r"\[x\]", line, re.IGNORECASE):
238
+ done += 1
239
+ total += 1
240
+ elif "[ ]" in line:
241
+ total += 1
242
+ return (done, total)
243
+
244
+ # ════════════════════════════════════════════════════════════════════════════
245
+ # Demo fixture
246
+ # ════════════════════════════════════════════════════════════════════════════
247
+ def _demo_data() -> Dict[str, Any]:
248
+ return dict(
249
+ project_name="myapp", version="2026.518.3",
250
+ agent="claude", git_branch="main", git_status="✓",
251
+ timestamp="06:38",
252
+ state={"status": "idle", "current_item": ""},
253
+ loop_state="enabled", loop_minute=38,
254
+ loop_active_start=10, loop_active_end=18,
255
+ dream_state="enabled", dream_hour=3, dream_minute=12,
256
+ dream_last_hours=4, refactor_pending=4,
257
+ peer_last=("AGREE", 1), tcr_last_min=4,
258
+ ideas=2, todo=14, in_progress=1,
259
+ in_prog_id="US-VIEW-002", in_prog_title="roll 裸命令打出一屏总览",
260
+ in_prog_link="", ac_done=0, ac_total=9,
261
+ alerts=0, proposals=0, release_ready=False,
262
+ )
263
+
264
+ # ════════════════════════════════════════════════════════════════════════════
265
+ # Render helpers
266
+ # ════════════════════════════════════════════════════════════════════════════
267
+ def _hr() -> None:
268
+ print(c("faint", "─" * COLS))
269
+
270
+ def _svc_badge(state: str, paused: bool = False) -> Tuple[str, str]:
271
+ if paused:
272
+ return (c("amber", "⏸"), c("amber", "paused "))
273
+ if state == "enabled":
274
+ return (c("green", "●"), c("green", "enabled "))
275
+ if state == "installed-off":
276
+ return (c("amber", "⚠"), c("amber", "off "))
277
+ return (c("red", "○"), c("red", "missing "))
278
+
279
+ # ════════════════════════════════════════════════════════════════════════════
280
+ # Main renderer
281
+ # ════════════════════════════════════════════════════════════════════════════
282
+ def render(d: Dict[str, Any]) -> None:
283
+ state = d.get("state", {})
284
+ status = state.get("status", "idle")
285
+ in_prog = d.get("in_progress", 0)
286
+ tcr_min = d.get("tcr_last_min")
287
+
288
+ # ── Identity ─────────────────────────────────────────────────────────────
289
+ print()
290
+ left = (" " + c("fg", "roll", bold=True) + c("muted", " · ") +
291
+ c("yellow", f"Roll v{d['version']}"))
292
+ git_col = "green" if d["git_status"] == "✓" else "amber"
293
+ right = (c("dim", f"agent {d['agent']}") + c("muted", " · ") +
294
+ c(git_col, f"git {d['git_status']}") + c("muted", " · ") +
295
+ c("dim", d["git_branch"]) + c("muted", " · ") +
296
+ c("dim", d["timestamp"]) + " ")
297
+ print(row(left, right))
298
+ print()
299
+
300
+ # ── Eyebrow ──────────────────────────────────────────────────────────────
301
+ if status == "running":
302
+ sid = state.get("current_item", "—")
303
+ print(" " + c("purple", "⏵", bold=True) + " " +
304
+ c("fg", "now working ") + c("blue", sid, bold=True))
305
+ elif status == "paused":
306
+ print(" " + c("amber", "⏸ paused") + c("dim", " · run: ") + c("blue", "roll loop resume"))
307
+ else:
308
+ lm = d.get("loop_minute", 0)
309
+ print(" " + c("muted", "●") + " " + c("dim", f"next :{lm:02d}") + c("muted", " · ") + c("dim", "idle"))
310
+ print()
311
+ _hr()
312
+ print()
313
+
314
+ # ── THREE LAYERS ─────────────────────────────────────────────────────────
315
+ section_head("THREE LAYERS", "三层自治", "loop · dream · peer")
316
+ print()
317
+
318
+ lbl_w = 8 # "Loop " / "Dream " / "Peer "
319
+ st_w = 9 # "enabled " / "off " / "missing "
320
+
321
+ # Loop
322
+ dot, word = _svc_badge(d["loop_state"], status == "paused")
323
+ loop_sched = c("dim", f"every :{d['loop_minute']:02d}") + c("muted", " ") + c("dim", f"{d['loop_active_start']:02d}:00–{d['loop_active_end']:02d}:00")
324
+ if in_prog:
325
+ loop_detail = c("dim", " now: ") + c("purple", "⏵", bold=True) + " " + c("blue", d.get("in_prog_id", ""))
326
+ elif tcr_min is not None:
327
+ loop_detail = c("dim", f" last tcr {tcr_min}min ago")
328
+ else:
329
+ loop_detail = ""
330
+ print(" " + dot + " " + c("fg", pad("Loop", lbl_w), bold=True) + word + loop_sched + loop_detail)
331
+
332
+ # Dream
333
+ d_dot, d_word = _svc_badge(d["dream_state"])
334
+ dream_sched = c("dim", f"{d['dream_hour']:02d}:{d['dream_minute']:02d}")
335
+ dlh = d.get("dream_last_hours")
336
+ last_scan = c("dim", f" last scan {dlh}h ago") if dlh is not None else c("dim", " no scan yet")
337
+ rp = d.get("refactor_pending", 0)
338
+ dream_detail = last_scan + c("muted", " · ") + c("dim", f"{rp} REFACTOR queued")
339
+ print(" " + d_dot + " " + c("fg", pad("Dream", lbl_w), bold=True) + d_word + dream_sched + dream_detail)
340
+
341
+ # Peer
342
+ pl = d.get("peer_last")
343
+ if pl:
344
+ res, days = pl
345
+ peer_detail = c("dim", f" last {res} {days}d ago")
346
+ else:
347
+ peer_detail = c("dim", " last —")
348
+ print(" " + c("green", "●") + " " + c("fg", pad("Peer", lbl_w), bold=True) +
349
+ c("green", pad("ready ", st_w)) + c("dim", "on complexity=large") + peer_detail)
350
+ print()
351
+ _hr()
352
+ print()
353
+
354
+ # ── FOUR DEFENSES ────────────────────────────────────────────────────────
355
+ section_head("FOUR DEFENSES", "四道防线", "tcr · review · spar · sentinel")
356
+ print()
357
+ tcr_chip = (c("green", "✓ TCR") + c("dim", f" {tcr_min}min")) if tcr_min is not None else c("red", "○ TCR")
358
+ print(" " + tcr_chip +
359
+ " " + c("green", "● Auto Review") +
360
+ " " + c("muted", "○ Spar") +
361
+ " " + c("muted", "○ Sentinel"))
362
+ print()
363
+ _hr()
364
+ print()
365
+
366
+ # ── PIPELINE ─────────────────────────────────────────────────────────────
367
+ section_head("PIPELINE", "交付流水线", "idea → backlog → build → verify → release")
368
+ print()
369
+ ideas = d.get("ideas", 0)
370
+ todo = d.get("todo", 0)
371
+ idea_s = c("blue", str(ideas)) if ideas else c("dim", "0")
372
+ todo_s = c("blue", str(todo)) if todo else c("dim", "0")
373
+ build_s = (c("purple", f"▲{in_prog}", bold=True) + " " + c("muted", "🔨")) if in_prog else c("dim", "0")
374
+ rr = d.get("release_ready", False)
375
+ release_s = c("green", "ready") if rr else c("muted", "—")
376
+ print(" " +
377
+ c("dim", "Ideas ") + idea_s + c("muted", " ▸ ") +
378
+ c("dim", "Backlog ") + todo_s + c("muted", " ▸ ") +
379
+ c("dim", "Build ") + build_s + c("muted", " ▸ ") +
380
+ c("dim", "Verify ") + c("muted", "—") + c("muted", " ▸ ") +
381
+ c("dim", "Release ") + release_s)
382
+ print()
383
+ _hr()
384
+ print()
385
+
386
+ # ── CURRENT FOCUS · DoD ──────────────────────────────────────────────────
387
+ if in_prog:
388
+ section_head("CURRENT FOCUS · DoD", "当前焦点", "build > 0")
389
+ print()
390
+ print(" " + c("purple", "🔨", bold=True) + " " +
391
+ c("blue", d.get("in_prog_id", ""), bold=True) +
392
+ c("muted", " ") + c("dim", d.get("in_prog_title", "")))
393
+ print()
394
+ ac_done = d.get("ac_done", 0)
395
+ ac_total = d.get("ac_total", 0)
396
+ ac_chip = (c("green", "[✓ AC]") if ac_total > 0 and ac_done == ac_total
397
+ else c("amber", f"[○ AC {ac_done}/{ac_total}]"))
398
+ tcr_chip2 = c("green", "[✓ TCR]") if tcr_min is not None else c("muted", "[○ TCR]")
399
+ chips = [ac_chip, c("muted", "[○ CI]"), tcr_chip2, c("muted", "[○ Peer]")]
400
+ chips2 = [c("muted", "[○ Coverage]"), c("muted", "[○ Docs]"), c("muted", "[○ Spar]"), c("muted", "[○ Branch]")]
401
+ print(" " + " ".join(chips))
402
+ print(" " + " ".join(chips2))
403
+ print()
404
+ _hr()
405
+ print()
406
+
407
+ # ── NEED YOU ─────────────────────────────────────────────────────────────
408
+ section_head("NEED YOU", "需要你介入", "alerts · proposals · release")
409
+ print()
410
+ alerts = d.get("alerts", 0)
411
+ proposals = d.get("proposals", 0)
412
+ if not alerts and not proposals and not rr:
413
+ print(" " + c("green", "✓") + " " + c("dim", "AI 自驱中 — 无需介入"))
414
+ else:
415
+ if alerts:
416
+ print(" " + c("red", "⚠") + " " + c("red", f"{alerts} ALERT", bold=True) +
417
+ c("dim", " run: ") + c("blue", "roll alert"))
418
+ if proposals:
419
+ print(" " + c("amber", "▤") + " " + c("amber", f"{proposals} PROPOSAL", bold=True) +
420
+ c("dim", " see: ") + c("blue", "PROPOSALS.md"))
421
+ if rr:
422
+ print(" " + c("green", "✓") + " " + c("green", "Release ready", bold=True) +
423
+ c("dim", " run: ") + c("blue", "roll release"))
424
+ print()
425
+ _hr()
426
+ print()
427
+
428
+ # ── Quick-nav ─────────────────────────────────────────────────────────────
429
+ nav = ["roll loop", "roll backlog", "roll brief", "roll status", "roll peer", "roll --help"]
430
+ print(" " + c("muted", " · ").join(c("blue", cmd) for cmd in nav))
431
+ print()
432
+
433
+ # ════════════════════════════════════════════════════════════════════════════
434
+ # Entry
435
+ # ════════════════════════════════════════════════════════════════════════════
436
+ def main() -> None:
437
+ ap = argparse.ArgumentParser(add_help=False)
438
+ ap.add_argument("--demo", action="store_true")
439
+ ap.add_argument("--no-color", dest="no_color", action="store_true")
440
+ ap.add_argument("--en", action="store_true")
441
+ ap.add_argument("--zh", action="store_true")
442
+ args, _ = ap.parse_known_args()
443
+
444
+ if args.no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
445
+ roll_render.USE_COLOR = False
446
+
447
+ if args.demo:
448
+ d = _demo_data()
449
+ else:
450
+ slug = _project_slug()
451
+ config = _load_config()
452
+ state = _load_yaml_flat(_shared_root() / "loop" / f"state-{slug}.yaml")
453
+ bra, gs = _git_info()
454
+ ideas, todo, in_prog, ip_id, ip_title, ip_link, refactor_pending = _backlog_counts()
455
+ ac_done, ac_total = _ac_completion(ip_link) if in_prog else (0, 0)
456
+
457
+ def _ci(k: str, default: int) -> int:
458
+ try:
459
+ return int(config.get(k) or default)
460
+ except Exception:
461
+ return default
462
+
463
+ d = dict(
464
+ project_name = os.path.basename(os.getcwd()),
465
+ version = _roll_version(),
466
+ agent = config.get("primary_agent") or "claude",
467
+ git_branch = bra,
468
+ git_status = gs,
469
+ timestamp = datetime.now().strftime("%H:%M"),
470
+ state = state,
471
+ loop_state = _launchd_svc_state("loop", slug),
472
+ loop_minute = _ci("loop_minute", 38),
473
+ loop_active_start = _ci("loop_active_start", 10),
474
+ loop_active_end = _ci("loop_active_end", 18),
475
+ dream_state = _launchd_svc_state("dream", slug),
476
+ dream_hour = _ci("loop_dream_hour", 3),
477
+ dream_minute = _ci("loop_dream_minute", 12),
478
+ dream_last_hours = _dream_last_hours(),
479
+ refactor_pending = refactor_pending,
480
+ peer_last = _peer_last(),
481
+ tcr_last_min = _tcr_last_min(),
482
+ ideas=ideas, todo=todo, in_progress=in_prog,
483
+ in_prog_id=ip_id, in_prog_title=ip_title, in_prog_link=ip_link,
484
+ ac_done=ac_done, ac_total=ac_total,
485
+ alerts = _alert_count(slug),
486
+ proposals = _proposal_count(),
487
+ release_ready = _release_ready(),
488
+ )
489
+
490
+ render(d)
491
+
492
+ if __name__ == "__main__":
493
+ main()