@seanyao/roll 0.5.0 → 2.602.1

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 +717 -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 +14897 -815
  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 +186 -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 +14 -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 +727 -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 +578 -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,556 @@
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
+ ROLL_RENDER_FIXTURE=1 python3 lib/roll-home.py # render with fixture data (test only)
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
+ from roll_git import git_remote_url as _git_remote_url
31
+
32
+ # ════════════════════════════════════════════════════════════════════════════
33
+ # Paths
34
+ # ════════════════════════════════════════════════════════════════════════════
35
+ def _project_slug(path: Optional[str] = None) -> str:
36
+ # US-LOOP-006: cycle wrapper exports ROLL_MAIN_SLUG — honour it (parity with
37
+ # bin/roll _project_slug and lib/roll-loop-status.py project_slug).
38
+ env_slug = os.environ.get("ROLL_MAIN_SLUG", "").strip()
39
+ if env_slug:
40
+ return env_slug
41
+
42
+ path = os.path.realpath(path or os.getcwd())
43
+ try:
44
+ common = subprocess.check_output(
45
+ ["git", "-C", path, "rev-parse", "--git-common-dir"],
46
+ stderr=subprocess.DEVNULL, text=True,
47
+ ).strip()
48
+ if common.endswith("/.git"):
49
+ path = common[:-5]
50
+ except Exception:
51
+ pass
52
+
53
+ # US-OBS-010: derive slug from git remote URL for stable cross-machine
54
+ # identity. Mirror normalization in bin/roll + lib/roll-loop-status.py
55
+ # so all three callers agree on the slug. Without this, `roll` home dash
56
+ # looks up plists at the old path-based slug while `roll loop status`
57
+ # looks them up at the new remote-based slug — dashboards diverge and
58
+ # the home page falsely reports the loop as "missing".
59
+ remote_url = _git_remote_url(path)
60
+ if remote_url:
61
+ remote_url = remote_url.rstrip("/")
62
+ if remote_url.endswith(".git"):
63
+ remote_url = remote_url[:-4]
64
+ m = re.match(r"^git@([^:]+):(.+)$", remote_url)
65
+ if m:
66
+ remote_url = f"https://{m.group(1)}/{m.group(2)}"
67
+ remote_url = remote_url.lower()
68
+ base = re.sub(r"[^A-Za-z0-9]+", "-", os.path.basename(remote_url)).strip("-")
69
+ h = hashlib.md5(remote_url.encode()).hexdigest()[:6]
70
+ return f"{base}-{h}"
71
+
72
+ # Fallback: path-based (pre-US-OBS-010 behaviour) when no remote configured.
73
+ base = re.sub(r"[^A-Za-z0-9]+", "-", os.path.basename(path)).strip("-")
74
+ h = hashlib.md5(path.encode()).hexdigest()[:6]
75
+ return f"{base}-{h}"
76
+
77
+ def _shared_root() -> Path:
78
+ return Path(os.environ.get("ROLL_SHARED_ROOT") or os.path.expanduser("~/.shared/roll"))
79
+
80
+ def _roll_pkg_dir() -> Path:
81
+ pkg = os.environ.get("ROLL_PKG_DIR")
82
+ return Path(pkg) if pkg else Path(_LIB_DIR).parent
83
+
84
+ # ════════════════════════════════════════════════════════════════════════════
85
+ # Loaders
86
+ # ════════════════════════════════════════════════════════════════════════════
87
+ def _load_yaml_flat(path: Path) -> Dict[str, str]:
88
+ out: Dict[str, str] = {}
89
+ if not path.exists():
90
+ return out
91
+ for line in path.open(errors="ignore"):
92
+ m = re.match(r"^([\w_]+):\s*(.*?)\s*$", line)
93
+ if m:
94
+ out[m.group(1)] = m.group(2).strip().strip('"').strip("'")
95
+ return out
96
+
97
+ def _load_config() -> Dict[str, str]:
98
+ for p in [
99
+ os.path.expanduser("~/.roll/config.yaml"),
100
+ os.path.join(os.getcwd(), ".roll.yaml"),
101
+ ]:
102
+ d = _load_yaml_flat(Path(p))
103
+ if d:
104
+ return d
105
+ return {}
106
+
107
+
108
+ def _resolve_project_agent(config: Dict[str, str]) -> str:
109
+ """FIX-117: home banner agent label must honor project-level override
110
+ in `.roll/local.yaml` (set by `roll agent use`), not just the global
111
+ `~/.roll/config.yaml#primary_agent`. Mirror bin/roll _project_agent
112
+ precedence: local.yaml > .roll.yaml > config.primary_agent > 'claude'."""
113
+ for path in (
114
+ Path(".roll/local.yaml"),
115
+ Path(".roll.yaml"),
116
+ ):
117
+ if path.exists():
118
+ local = _load_yaml_flat(path)
119
+ if local.get("agent"):
120
+ return local["agent"]
121
+ return config.get("primary_agent") or "claude"
122
+
123
+ def _git_info() -> Tuple[str, str]:
124
+ try:
125
+ branch = subprocess.check_output(
126
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
127
+ stderr=subprocess.DEVNULL, text=True,
128
+ ).strip()
129
+ except Exception:
130
+ branch = "—"
131
+ try:
132
+ dirty = bool(subprocess.check_output(
133
+ ["git", "status", "--porcelain"],
134
+ stderr=subprocess.DEVNULL, text=True,
135
+ ).strip())
136
+ status = "dirty" if dirty else "✓"
137
+ except Exception:
138
+ status = "—"
139
+ return branch, status
140
+
141
+ def _roll_version() -> str:
142
+ roll_bin = _roll_pkg_dir() / "bin" / "roll"
143
+ if roll_bin.exists():
144
+ for line in roll_bin.open(errors="ignore"):
145
+ m = re.match(r'^VERSION="([^"]+)"', line)
146
+ if m:
147
+ return m.group(1)
148
+ return "—"
149
+
150
+ def _launchd_svc_state(service: str, slug: str) -> str:
151
+ label = f"com.roll.{service}.{slug}"
152
+ plist = Path(os.path.expanduser("~/Library/LaunchAgents")) / f"{label}.plist"
153
+ if not plist.exists():
154
+ return "not-installed"
155
+ try:
156
+ out = subprocess.check_output(
157
+ ["launchctl", "list", label], stderr=subprocess.DEVNULL, text=True,
158
+ )
159
+ return "enabled" if out.strip() else "installed-off"
160
+ except Exception:
161
+ return "installed-off"
162
+
163
+ def _read_plist_schedule(service: str, slug: str) -> Optional[Dict[str, int]]:
164
+ """FIX-063: read actual Minute/Hour from launchd plist (truth source).
165
+ Returns {'minute': N, 'hour': N|None} or None if plist missing.
166
+ Dashboard must reflect what launchd actually fires, not a hardcoded default.
167
+ """
168
+ label = f"com.roll.{service}.{slug}"
169
+ plist = Path(os.path.expanduser("~/Library/LaunchAgents")) / f"{label}.plist"
170
+ if not plist.exists():
171
+ return None
172
+ try:
173
+ text = plist.read_text(errors="ignore")
174
+ except Exception:
175
+ return None
176
+ # Parse <key>Minute</key><integer>N</integer> (and Hour)
177
+ m = re.search(r"<key>Minute</key>\s*<integer>(\d+)</integer>", text)
178
+ h = re.search(r"<key>Hour</key>\s*<integer>(\d+)</integer>", text)
179
+ if not m:
180
+ return None
181
+ return {"minute": int(m.group(1)), "hour": int(h.group(1)) if h else None}
182
+
183
+ def _dream_last_hours() -> Optional[int]:
184
+ log = _shared_root() / "dream" / "log.md"
185
+ if not log.exists():
186
+ return None
187
+ try:
188
+ return int((time.time() - log.stat().st_mtime) / 3600)
189
+ except Exception:
190
+ return None
191
+
192
+ def _peer_last() -> Optional[Tuple[str, int]]:
193
+ peer_dir = _shared_root() / "peer"
194
+ if not peer_dir.exists():
195
+ return None
196
+ logs = sorted(peer_dir.glob("*.log"))
197
+ if not logs:
198
+ return None
199
+ latest = logs[-1]
200
+ try:
201
+ days = int((time.time() - latest.stat().st_mtime) / 86400)
202
+ for line in latest.read_text(errors="ignore").splitlines():
203
+ m = re.search(r"\b(AGREE|REFINE|OBJECT|ESCALATE)\b", line)
204
+ if m:
205
+ return (m.group(1), days)
206
+ return ("—", days)
207
+ except Exception:
208
+ return None
209
+
210
+ def _backlog_counts() -> Tuple[int, int, int, str, str, str, int]:
211
+ """(ideas, todo, in_progress, id, title, link, refactor_pending)."""
212
+ bl = Path(".roll/backlog.md")
213
+ if not bl.exists():
214
+ return (0, 0, 0, "", "", "", 0)
215
+ ideas = todo = in_prog = refactors = 0
216
+ ip_id = ip_title = ip_link = ""
217
+ for line in bl.read_text(errors="ignore").splitlines():
218
+ if "| 📋 Todo |" in line:
219
+ if re.match(r"^\|\s*IDEA-", line):
220
+ ideas += 1
221
+ elif re.match(r"^\| REFACTOR-", line):
222
+ refactors += 1
223
+ else:
224
+ todo += 1
225
+ elif "| 🔨 In Progress |" in line:
226
+ in_prog += 1
227
+ if not ip_id:
228
+ m = re.search(r"(US|FIX|REFACTOR)-[A-Z]*-?\d+", line)
229
+ if m:
230
+ ip_id = m.group(0)
231
+ parts = [p.strip() for p in line.split("|")]
232
+ if len(parts) >= 4:
233
+ ip_title = parts[2][:60]
234
+ m2 = re.search(r".roll/features/[^\)]+", line)
235
+ if m2:
236
+ ip_link = m2.group(0)
237
+ return (ideas, todo, in_prog, ip_id, ip_title, ip_link, refactors)
238
+
239
+ def _alert_count(slug: str) -> int:
240
+ af = _shared_root() / "loop" / f"ALERT-{slug}.md"
241
+ if not af.exists():
242
+ return 0
243
+ return sum(1 for l in af.read_text(errors="ignore").splitlines() if l.startswith("# ALERT"))
244
+
245
+ def _proposal_count() -> int:
246
+ p = Path(".roll/proposals.md")
247
+ if not p.exists():
248
+ return 0
249
+ return sum(1 for l in p.read_text(errors="ignore").splitlines() if l.startswith("## PROPOSAL"))
250
+
251
+ def _release_ready() -> bool:
252
+ briefs_dir = Path(".roll/briefs")
253
+ if not briefs_dir.exists():
254
+ return False
255
+ try:
256
+ tag = subprocess.check_output(
257
+ ["git", "describe", "--tags", "--abbrev=0"],
258
+ stderr=subprocess.DEVNULL, text=True,
259
+ ).strip()
260
+ log = subprocess.check_output(
261
+ ["git", "log", f"{tag}..HEAD", "--pretty=format:%s"],
262
+ stderr=subprocess.DEVNULL, text=True,
263
+ )
264
+ if not any(
265
+ l for l in log.splitlines()
266
+ if l and not re.match(r"^(docs|chore)(\([^)]*\))?:", l)
267
+ ):
268
+ return False
269
+ briefs = sorted(briefs_dir.glob("*.md"))
270
+ if not briefs:
271
+ return False
272
+ return bool(re.search(r"✅ 可发版|Release ready", briefs[-1].read_text(errors="ignore")))
273
+ except Exception:
274
+ return False
275
+
276
+ def _tcr_last_min() -> Optional[int]:
277
+ try:
278
+ ts = subprocess.check_output(
279
+ ["git", "log", "--grep=^tcr:", "-1", "--format=%ct"],
280
+ stderr=subprocess.DEVNULL, text=True,
281
+ ).strip()
282
+ return int((time.time() - int(ts)) / 60) if ts else None
283
+ except Exception:
284
+ return None
285
+
286
+ def _ac_completion(feature_link: str) -> Tuple[int, int]:
287
+ if not feature_link:
288
+ return (0, 0)
289
+ path_str, _, anchor = feature_link.partition("#")
290
+ if not path_str or not Path(path_str).exists():
291
+ return (0, 0)
292
+ text = Path(path_str).read_text(errors="ignore")
293
+ in_sec = done = total = 0
294
+ for line in text.splitlines():
295
+ if f'id="{anchor}"' in line:
296
+ in_sec = 1
297
+ continue
298
+ if in_sec and re.match(r"^## ", line):
299
+ break
300
+ if in_sec:
301
+ if re.search(r"\[x\]", line, re.IGNORECASE):
302
+ done += 1
303
+ total += 1
304
+ elif "[ ]" in line:
305
+ total += 1
306
+ return (done, total)
307
+
308
+ # ════════════════════════════════════════════════════════════════════════════
309
+ # Fixture data (test-only; opt in via ROLL_RENDER_FIXTURE=1)
310
+ # ════════════════════════════════════════════════════════════════════════════
311
+ def _fixture_data() -> Dict[str, Any]:
312
+ return dict(
313
+ project_name="myapp", version="2026.518.3",
314
+ agent="claude", git_branch="main", git_status="✓",
315
+ timestamp="06:38",
316
+ state={"status": "idle", "current_item": ""},
317
+ loop_state="enabled", loop_minute=38,
318
+ loop_active_start=10, loop_active_end=18,
319
+ dream_state="enabled", dream_hour=3, dream_minute=12,
320
+ dream_last_hours=4, refactor_pending=4,
321
+ peer_last=("AGREE", 1), tcr_last_min=4,
322
+ ideas=2, todo=14, in_progress=1,
323
+ in_prog_id="US-VIEW-002", in_prog_title="roll 裸命令打出一屏总览",
324
+ in_prog_link="", ac_done=0, ac_total=9,
325
+ alerts=0, proposals=0, release_ready=False,
326
+ )
327
+
328
+ # ════════════════════════════════════════════════════════════════════════════
329
+ # Render helpers
330
+ # ════════════════════════════════════════════════════════════════════════════
331
+ def _hr() -> None:
332
+ print(c("faint", "─" * COLS))
333
+
334
+ def _svc_badge(state: str, paused: bool = False) -> Tuple[str, str]:
335
+ if paused:
336
+ return (c("amber", "⏸"), c("amber", "paused "))
337
+ if state == "enabled":
338
+ return (c("green", "●"), c("green", "enabled "))
339
+ if state == "installed-off":
340
+ return (c("amber", "⚠"), c("amber", "off "))
341
+ return (c("red", "○"), c("red", "missing "))
342
+
343
+ # ════════════════════════════════════════════════════════════════════════════
344
+ # Main renderer
345
+ # ════════════════════════════════════════════════════════════════════════════
346
+ def render(d: Dict[str, Any]) -> None:
347
+ state = d.get("state", {})
348
+ status = state.get("status", "idle")
349
+ in_prog = d.get("in_progress", 0)
350
+ tcr_min = d.get("tcr_last_min")
351
+
352
+ # ── Identity ─────────────────────────────────────────────────────────────
353
+ print()
354
+ left = (" " + c("fg", "roll", bold=True) + c("muted", " · ") +
355
+ c("yellow", f"Roll v{d['version']}"))
356
+ git_col = "green" if d["git_status"] == "✓" else "amber"
357
+ right = (c("dim", f"agent {d['agent']}") + c("muted", " · ") +
358
+ c(git_col, f"git {d['git_status']}") + c("muted", " · ") +
359
+ c("dim", d["git_branch"]) + c("muted", " · ") +
360
+ c("dim", d["timestamp"]) + " ")
361
+ print(row(left, right))
362
+ print()
363
+
364
+ # ── Eyebrow ──────────────────────────────────────────────────────────────
365
+ if status == "running":
366
+ sid = state.get("current_item", "—")
367
+ print(" " + c("purple", "⏵", bold=True) + " " +
368
+ c("fg", "now working ") + c("blue", sid, bold=True))
369
+ elif status == "paused":
370
+ print(" " + c("amber", "⏸ paused") + c("dim", " · run: ") + c("blue", "roll loop resume"))
371
+ else:
372
+ lm = d.get("loop_minute", 0)
373
+ print(" " + c("muted", "●") + " " + c("dim", f"next :{lm:02d}") + c("muted", " · ") + c("dim", "idle"))
374
+ print()
375
+ _hr()
376
+ print()
377
+
378
+ # ── THREE LAYERS ─────────────────────────────────────────────────────────
379
+ section_head("THREE LAYERS", "三层自治", "loop · dream · peer")
380
+ print()
381
+
382
+ lbl_w = 8 # "Loop " / "Dream " / "Peer "
383
+ st_w = 9 # "enabled " / "off " / "missing "
384
+
385
+ # Loop
386
+ dot, word = _svc_badge(d["loop_state"], status == "paused")
387
+ 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")
388
+ if in_prog:
389
+ loop_detail = c("dim", " now: ") + c("purple", "⏵", bold=True) + " " + c("blue", d.get("in_prog_id", ""))
390
+ elif tcr_min is not None:
391
+ loop_detail = c("dim", f" last tcr {tcr_min}min ago")
392
+ else:
393
+ loop_detail = ""
394
+ print(" " + dot + " " + c("fg", pad("Loop", lbl_w), bold=True) + word + loop_sched + loop_detail)
395
+
396
+ # Dream
397
+ d_dot, d_word = _svc_badge(d["dream_state"])
398
+ dream_sched = c("dim", f"{d['dream_hour']:02d}:{d['dream_minute']:02d}")
399
+ dlh = d.get("dream_last_hours")
400
+ last_scan = c("dim", f" last scan {dlh}h ago") if dlh is not None else c("dim", " no scan yet")
401
+ rp = d.get("refactor_pending", 0)
402
+ dream_detail = last_scan + c("muted", " · ") + c("dim", f"{rp} REFACTOR queued")
403
+ print(" " + d_dot + " " + c("fg", pad("Dream", lbl_w), bold=True) + d_word + dream_sched + dream_detail)
404
+
405
+ # Peer
406
+ pl = d.get("peer_last")
407
+ if pl:
408
+ res, days = pl
409
+ peer_detail = c("dim", f" last {res} {days}d ago")
410
+ else:
411
+ peer_detail = c("dim", " last —")
412
+ print(" " + c("green", "●") + " " + c("fg", pad("Peer", lbl_w), bold=True) +
413
+ c("green", pad("ready ", st_w)) + c("dim", "on complexity=large") + peer_detail)
414
+ print()
415
+ _hr()
416
+ print()
417
+
418
+ # ── FOUR DEFENSES ────────────────────────────────────────────────────────
419
+ section_head("FOUR DEFENSES", "四道防线", "tcr · review · spar · sentinel")
420
+ print()
421
+ tcr_chip = (c("green", "✓ TCR") + c("dim", f" {tcr_min}min")) if tcr_min is not None else c("red", "○ TCR")
422
+ print(" " + tcr_chip +
423
+ " " + c("green", "● Auto Review") +
424
+ " " + c("muted", "○ Spar") +
425
+ " " + c("muted", "○ Sentinel"))
426
+ print()
427
+ _hr()
428
+ print()
429
+
430
+ # ── PIPELINE ─────────────────────────────────────────────────────────────
431
+ section_head("PIPELINE", "交付流水线", "idea → backlog → build → verify → release")
432
+ print()
433
+ ideas = d.get("ideas", 0)
434
+ todo = d.get("todo", 0)
435
+ idea_s = c("blue", str(ideas)) if ideas else c("dim", "0")
436
+ todo_s = c("blue", str(todo)) if todo else c("dim", "0")
437
+ build_s = (c("purple", f"▲{in_prog}", bold=True) + " " + c("muted", "🔨")) if in_prog else c("dim", "0")
438
+ rr = d.get("release_ready", False)
439
+ release_s = c("green", "ready") if rr else c("muted", "—")
440
+ print(" " +
441
+ c("dim", "Ideas ") + idea_s + c("muted", " ▸ ") +
442
+ c("dim", "Backlog ") + todo_s + c("muted", " ▸ ") +
443
+ c("dim", "Build ") + build_s + c("muted", " ▸ ") +
444
+ c("dim", "Verify ") + c("muted", "—") + c("muted", " ▸ ") +
445
+ c("dim", "Release ") + release_s)
446
+ print()
447
+ _hr()
448
+ print()
449
+
450
+ # ── CURRENT FOCUS · DoD ──────────────────────────────────────────────────
451
+ if in_prog:
452
+ section_head("CURRENT FOCUS · DoD", "当前焦点", "build > 0")
453
+ print()
454
+ print(" " + c("purple", "🔨", bold=True) + " " +
455
+ c("blue", d.get("in_prog_id", ""), bold=True) +
456
+ c("muted", " ") + c("dim", d.get("in_prog_title", "")))
457
+ print()
458
+ ac_done = d.get("ac_done", 0)
459
+ ac_total = d.get("ac_total", 0)
460
+ ac_chip = (c("green", "[✓ AC]") if ac_total > 0 and ac_done == ac_total
461
+ else c("amber", f"[○ AC {ac_done}/{ac_total}]"))
462
+ tcr_chip2 = c("green", "[✓ TCR]") if tcr_min is not None else c("muted", "[○ TCR]")
463
+ chips = [ac_chip, c("muted", "[○ CI]"), tcr_chip2, c("muted", "[○ Peer]")]
464
+ chips2 = [c("muted", "[○ Coverage]"), c("muted", "[○ Docs]"), c("muted", "[○ Spar]"), c("muted", "[○ Branch]")]
465
+ print(" " + " ".join(chips))
466
+ print(" " + " ".join(chips2))
467
+ print()
468
+ _hr()
469
+ print()
470
+
471
+ # ── NEED YOU ─────────────────────────────────────────────────────────────
472
+ section_head("NEED YOU", "需要你介入", "alerts · proposals · release")
473
+ print()
474
+ alerts = d.get("alerts", 0)
475
+ proposals = d.get("proposals", 0)
476
+ if not alerts and not proposals and not rr:
477
+ print(" " + c("green", "✓") + " " + c("dim", "AI 自驱中 — 无需介入"))
478
+ else:
479
+ if alerts:
480
+ print(" " + c("red", "⚠") + " " + c("red", f"{alerts} ALERT", bold=True) +
481
+ c("dim", " run: ") + c("blue", "roll alert"))
482
+ if proposals:
483
+ print(" " + c("amber", "▤") + " " + c("amber", f"{proposals} PROPOSAL", bold=True) +
484
+ c("dim", " see: ") + c("blue", ".roll/proposals.md"))
485
+ if rr:
486
+ print(" " + c("green", "✓") + " " + c("green", "Release ready", bold=True) +
487
+ c("dim", " run: ") + c("blue", "roll release"))
488
+ print()
489
+ _hr()
490
+ print()
491
+
492
+ # ── Quick-nav ─────────────────────────────────────────────────────────────
493
+ nav = ["roll loop", "roll backlog", "roll brief", "roll status", "roll peer", "roll --help"]
494
+ print(" " + c("muted", " · ").join(c("blue", cmd) for cmd in nav))
495
+ print()
496
+
497
+ # ════════════════════════════════════════════════════════════════════════════
498
+ # Entry
499
+ # ════════════════════════════════════════════════════════════════════════════
500
+ def main() -> None:
501
+ ap = argparse.ArgumentParser(add_help=False)
502
+ ap.add_argument("--no-color", dest="no_color", action="store_true")
503
+ ap.add_argument("--en", action="store_true")
504
+ ap.add_argument("--zh", action="store_true")
505
+ args, _ = ap.parse_known_args()
506
+
507
+ if args.no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
508
+ roll_render.USE_COLOR = False
509
+
510
+ if os.environ.get("ROLL_RENDER_FIXTURE"):
511
+ d = _fixture_data()
512
+ else:
513
+ slug = _project_slug()
514
+ config = _load_config()
515
+ state = _load_yaml_flat(_shared_root() / "loop" / f"state-{slug}.yaml")
516
+ bra, gs = _git_info()
517
+ ideas, todo, in_prog, ip_id, ip_title, ip_link, refactor_pending = _backlog_counts()
518
+ ac_done, ac_total = _ac_completion(ip_link) if in_prog else (0, 0)
519
+
520
+ def _ci(k: str, default: int) -> int:
521
+ try:
522
+ return int(config.get(k) or default)
523
+ except Exception:
524
+ return default
525
+
526
+ d = dict(
527
+ project_name = os.path.basename(os.getcwd()),
528
+ version = _roll_version(),
529
+ agent = _resolve_project_agent(config),
530
+ git_branch = bra,
531
+ git_status = gs,
532
+ timestamp = datetime.now().strftime("%H:%M"),
533
+ state = state,
534
+ loop_state = _launchd_svc_state("loop", slug),
535
+ loop_minute = (_read_plist_schedule("loop", slug) or {}).get("minute") or _ci("loop_minute", 38),
536
+ loop_active_start = _ci("loop_active_start", 10),
537
+ loop_active_end = _ci("loop_active_end", 18),
538
+ dream_state = _launchd_svc_state("dream", slug),
539
+ dream_hour = (_read_plist_schedule("dream", slug) or {}).get("hour") or _ci("loop_dream_hour", 3),
540
+ dream_minute = (_read_plist_schedule("dream", slug) or {}).get("minute") or _ci("loop_dream_minute", 12),
541
+ dream_last_hours = _dream_last_hours(),
542
+ refactor_pending = refactor_pending,
543
+ peer_last = _peer_last(),
544
+ tcr_last_min = _tcr_last_min(),
545
+ ideas=ideas, todo=todo, in_progress=in_prog,
546
+ in_prog_id=ip_id, in_prog_title=ip_title, in_prog_link=ip_link,
547
+ ac_done=ac_done, ac_total=ac_total,
548
+ alerts = _alert_count(slug),
549
+ proposals = _proposal_count(),
550
+ release_ready = _release_ready(),
551
+ )
552
+
553
+ render(d)
554
+
555
+ if __name__ == "__main__":
556
+ main()