@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,102 @@
1
+ #!/usr/bin/env python3
2
+ """roll-setup — v2 terminal view for `roll setup`.
3
+
4
+ Reads a single JSON document from stdin describing the real outcomes of
5
+ `bin/roll cmd_setup`'s step sequence (detect platform / install skills /
6
+ sync conventions / etc). Renders the v2 UI preserving the visual style
7
+ of US-VIEW-007 while reflecting actual results.
8
+
9
+ Input schema matches `lib/roll-init.py` (see that file for details).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import sys
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
22
+ from roll_render import c, row, COLS
23
+
24
+
25
+ def _divider(char: str = "─") -> None:
26
+ print(c("dim", char * min(COLS, 80)))
27
+
28
+
29
+ def _step_icon(status: str) -> str:
30
+ if status == "ok":
31
+ return c("green", "✓")
32
+ if status == "skip":
33
+ return c("amber", "↷")
34
+ if status == "forced":
35
+ return c("blue", "~")
36
+ if status == "fail":
37
+ return c("red", "✗", bold=True)
38
+ return c("dim", "·")
39
+
40
+
41
+ def render(payload: dict) -> None:
42
+ header_label = payload.get("header_label", "SETUP")
43
+ subtitle = payload.get("subtitle", "初始化")
44
+ right_text = payload.get("project_path") or payload.get("right", "")
45
+
46
+ left = " " + c("blue", header_label, bold=True) + c("dim", " · ") + c("dim", subtitle)
47
+ right = c("dim", right_text) + " " if right_text else ""
48
+ print(row(left, right))
49
+ _divider()
50
+ print()
51
+
52
+ for step in payload.get("steps", []):
53
+ num = c("dim", f" {step.get('num', 0)}.")
54
+ icon = _step_icon(step.get("status", "ok"))
55
+ label = step.get("label", "")
56
+ print(f"{num} {icon} {label}")
57
+ note = step.get("note") or step.get("error")
58
+ if note:
59
+ tone = "red" if step.get("status") == "fail" else "dim"
60
+ print(" " + c(tone, str(note)))
61
+
62
+ print()
63
+ _divider()
64
+
65
+ footer = payload.get("footer") or {}
66
+ f_status = footer.get("status", "ok")
67
+ f_label = footer.get("label", "Setup complete")
68
+ icon_color = "green" if f_status == "ok" else "red"
69
+ msg = c(icon_color, f_label)
70
+ next_hint = footer.get("hint")
71
+ if next_hint:
72
+ print(f" {msg} — {next_hint}")
73
+ else:
74
+ print(f" {msg}")
75
+ _divider("═")
76
+
77
+
78
+ def _read_payload() -> dict:
79
+ raw = sys.stdin.read()
80
+ if not raw.strip():
81
+ sys.stderr.write("roll-setup.py: expected JSON on stdin\n")
82
+ sys.exit(1)
83
+ try:
84
+ return json.loads(raw)
85
+ except json.JSONDecodeError as exc:
86
+ sys.stderr.write(f"roll-setup.py: invalid JSON on stdin: {exc}\n")
87
+ sys.exit(1)
88
+
89
+
90
+ def main() -> None:
91
+ ap = argparse.ArgumentParser(add_help=False)
92
+ ap.add_argument("--no-color", dest="no_color", action="store_true")
93
+ args, _ = ap.parse_known_args()
94
+
95
+ if args.no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
96
+ roll_render.USE_COLOR = False
97
+
98
+ render(_read_payload())
99
+
100
+
101
+ if __name__ == "__main__":
102
+ main()
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ roll-status — render the `roll status` page.
4
+
5
+ One-screen sync health: global conventions, AI clients table (with drift fix hints),
6
+ project templates, and this-project metrics.
7
+
8
+ Usage:
9
+ python3 lib/roll-status.py # live data
10
+ python3 lib/roll-status.py --no-color
11
+ ROLL_RENDER_FIXTURE=1 python3 lib/roll-status.py # render with fixture data (test only)
12
+ """
13
+
14
+ from __future__ import annotations
15
+ import argparse, os, re, subprocess, sys
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional, Tuple
18
+
19
+ _LIB_DIR = os.path.dirname(os.path.realpath(__file__))
20
+ if _LIB_DIR not in sys.path:
21
+ sys.path.insert(0, _LIB_DIR)
22
+ import roll_render
23
+ from roll_render import COLS, c, row, section_head, strw, pad
24
+
25
+ # ════════════════════════════════════════════════════════════════════════════
26
+ # Paths
27
+ # ════════════════════════════════════════════════════════════════════════════
28
+ def _roll_home() -> Path:
29
+ return Path(os.environ.get("ROLL_HOME") or os.path.expanduser("~/.roll"))
30
+
31
+ def _global_dir() -> Path:
32
+ return _roll_home() / "conventions" / "global"
33
+
34
+ def _templates_dir() -> Path:
35
+ return _roll_home() / "conventions" / "templates"
36
+
37
+ def _config_path() -> Path:
38
+ return _roll_home() / "config.yaml"
39
+
40
+ def _shared_root() -> Path:
41
+ return Path(os.environ.get("ROLL_SHARED_ROOT") or os.path.expanduser("~/.shared/roll"))
42
+
43
+ def _project_slug() -> str:
44
+ path = os.path.realpath(os.getcwd())
45
+ try:
46
+ common = subprocess.check_output(
47
+ ["git", "-C", path, "rev-parse", "--git-common-dir"],
48
+ stderr=subprocess.DEVNULL, text=True,
49
+ ).strip()
50
+ if common.endswith("/.git"):
51
+ path = common[:-5]
52
+ except Exception:
53
+ pass
54
+ import hashlib
55
+ base = re.sub(r"[^A-Za-z0-9]+", "-", os.path.basename(path)).strip("-")
56
+ h = hashlib.md5(path.encode()).hexdigest()[:6]
57
+ return f"{base}-{h}"
58
+
59
+ # ════════════════════════════════════════════════════════════════════════════
60
+ # Data loaders
61
+ # ════════════════════════════════════════════════════════════════════════════
62
+ CONVENTION_FILES = ["AGENTS.md", "CLAUDE.md", "GEMINI.md", ".cursor-rules", "project_rules.md"]
63
+ TEMPLATES = ["fullstack", "frontend-only", "backend-service", "cli"]
64
+
65
+ def _global_conventions() -> List[Tuple[str, bool]]:
66
+ gd = _global_dir()
67
+ return [(f, (gd / f).exists()) for f in CONVENTION_FILES]
68
+
69
+ def _parse_ai_entries() -> List[Dict[str, str]]:
70
+ cfg = _config_path()
71
+ if not cfg.exists():
72
+ return []
73
+ entries = []
74
+ for line in cfg.read_text(errors="ignore").splitlines():
75
+ m = re.match(r"^ai_[a-z]+:\s*(.+)", line)
76
+ if not m:
77
+ continue
78
+ val = m.group(1).strip().replace("~", str(Path.home()))
79
+ parts = val.split("|")
80
+ if len(parts) < 3:
81
+ continue
82
+ ai_dir, cfg_file, src_file = parts[0].strip(), parts[1].strip(), parts[2].strip()
83
+ name = os.path.basename(ai_dir).lstrip(".")
84
+ if name in ("workspace", "agent"):
85
+ name = os.path.basename(os.path.dirname(ai_dir)).lstrip(".")
86
+ entries.append({"name": name, "ai_dir": ai_dir, "cfg_file": cfg_file, "src_file": src_file})
87
+ return entries
88
+
89
+ def _ai_sync_status(entry: Dict[str, str]) -> str:
90
+ ai_dir = Path(entry["ai_dir"])
91
+ cfg_file = ai_dir / entry["cfg_file"]
92
+ roll_md = ai_dir / "roll.md"
93
+ src = _global_dir() / entry["src_file"]
94
+
95
+ if not cfg_file.exists():
96
+ return "missing"
97
+ if not roll_md.exists():
98
+ return "out-of-sync"
99
+ try:
100
+ if src.exists() and roll_md.read_bytes() != src.read_bytes():
101
+ return "out-of-sync"
102
+ except Exception:
103
+ return "out-of-sync"
104
+ try:
105
+ if "@roll.md" not in cfg_file.read_text(errors="ignore"):
106
+ return "out-of-sync"
107
+ except Exception:
108
+ return "out-of-sync"
109
+ return "sync"
110
+
111
+ def _ai_skill_count(entry: Dict[str, str]) -> int:
112
+ skills_dir = Path(entry["ai_dir"]) / "skills"
113
+ if not skills_dir.exists():
114
+ return 0
115
+ try:
116
+ return sum(1 for p in skills_dir.iterdir() if p.name.startswith("roll-") and (p.is_symlink() or p.is_dir()))
117
+ except Exception:
118
+ return 0
119
+
120
+ def _template_count(tpl: str) -> int:
121
+ d = _templates_dir() / tpl
122
+ if not d.exists():
123
+ return 0
124
+ try:
125
+ return sum(1 for p in d.rglob("*") if p.is_file())
126
+ except Exception:
127
+ return 0
128
+
129
+ def _skills_installed() -> int:
130
+ sd = _roll_home() / "skills"
131
+ if not sd.exists():
132
+ return 0
133
+ try:
134
+ return sum(1 for p in sd.iterdir() if p.is_dir())
135
+ except Exception:
136
+ return 0
137
+
138
+ def _launchd_state(service: str, slug: str) -> str:
139
+ label = f"com.roll.{service}.{slug}"
140
+ plist = Path(os.path.expanduser("~/Library/LaunchAgents")) / f"{label}.plist"
141
+ if not plist.exists():
142
+ return "not-installed"
143
+ try:
144
+ out = subprocess.check_output(
145
+ ["launchctl", "list", label], stderr=subprocess.DEVNULL, text=True,
146
+ )
147
+ return "enabled" if out.strip() else "installed-off"
148
+ except Exception:
149
+ return "installed-off"
150
+
151
+ # ════════════════════════════════════════════════════════════════════════════
152
+ # Fixture data (test-only; opt in via ROLL_RENDER_FIXTURE=1)
153
+ # ════════════════════════════════════════════════════════════════════════════
154
+ def _fixture_data() -> Dict[str, Any]:
155
+ return dict(
156
+ conventions=[
157
+ ("AGENTS.md", True), ("CLAUDE.md", True), ("GEMINI.md", False),
158
+ (".cursor-rules", True), ("project_rules.md", False),
159
+ ],
160
+ ai_clients=[
161
+ {"name": "claude", "cfg_file": "CLAUDE.md", "path": "~/.claude/CLAUDE.md", "sync": "sync", "skills": 12},
162
+ {"name": "cursor", "cfg_file": "AGENTS.md", "path": "~/.cursor/AGENTS.md", "sync": "out-of-sync", "skills": 12},
163
+ {"name": "agy", "cfg_file": "GEMINI.md", "path": "~/.gemini/GEMINI.md", "sync": "missing", "skills": 0},
164
+ ],
165
+ templates=[
166
+ ("fullstack", 14), ("frontend-only", 9), ("backend-service", 11), ("cli", 7),
167
+ ],
168
+ skills_installed=12,
169
+ project_has_agents=True,
170
+ project_has_backlog=True,
171
+ project_features_count=23,
172
+ loop_state="enabled",
173
+ dream_state="not-installed",
174
+ )
175
+
176
+ # ════════════════════════════════════════════════════════════════════════════
177
+ # Render helpers
178
+ # ════════════════════════════════════════════════════════════════════════════
179
+ def _hr() -> None:
180
+ print(c("faint", "─" * COLS))
181
+
182
+ def _render_health(d: Dict[str, Any]) -> None:
183
+ clients = d["ai_clients"]
184
+ synced = sum(1 for x in clients if x["sync"] == "sync")
185
+ total = len(clients)
186
+ skills = d["skills_installed"]
187
+ tpls = len([t for t in d["templates"] if t[1] > 0])
188
+
189
+ has_drift = synced < total
190
+ if has_drift:
191
+ dot = c("amber", "!")
192
+ word = c("amber", "drift", bold=True)
193
+ detail = (c("dim", f" {synced}/{total} AI clients in sync") + c("muted", " · ") +
194
+ c("dim", f"{skills} skills") + c("muted", " · ") +
195
+ c("dim", f"{tpls} templates"))
196
+ else:
197
+ dot = c("green", "●")
198
+ word = c("green", "healthy", bold=True)
199
+ detail = (c("dim", f" {synced}/{total} AI clients in sync") + c("muted", " · ") +
200
+ c("dim", f"{skills} skills mounted") + c("muted", " · ") +
201
+ c("dim", f"{tpls} templates present"))
202
+
203
+ print()
204
+ print(" " + dot + " " + word + detail)
205
+ print()
206
+ _hr()
207
+ print()
208
+
209
+ def _render_global_conventions(conventions: list) -> None:
210
+ section_head("GLOBAL CONVENTIONS", "全局约定", "~/.roll/conventions/global/")
211
+ print()
212
+ for fname, exists in conventions:
213
+ if exists:
214
+ print(" " + c("green", "+") + " " + c("fg", fname))
215
+ else:
216
+ print(" " + c("red", "−") + " " + c("dim", fname) + " " + c("red", "missing"))
217
+ print()
218
+ _hr()
219
+ print()
220
+
221
+ def _render_ai_clients(clients: list) -> None:
222
+ section_head("AI CLIENTS", "AI 客户端同步", "convention · path · sync · skills")
223
+ print()
224
+
225
+ # Header
226
+ hdr = (" " + pad(c("dim", "name"), 14) +
227
+ pad(c("dim", "convention"), 14) +
228
+ pad(c("dim", "sync"), 14) +
229
+ c("dim", "skills"))
230
+ print(hdr)
231
+ print(" " + c("faint", "─" * (COLS - 4)))
232
+
233
+ for cl in clients:
234
+ sync_s = cl["sync"]
235
+ name = cl["name"]
236
+ cfg = cl["cfg_file"]
237
+ path = cl.get("path", "")
238
+ sk = cl.get("skills", 0)
239
+
240
+ if sync_s == "sync":
241
+ sync_col = c("green", "✓ in sync")
242
+ name_col = c("fg", name)
243
+ elif sync_s == "out-of-sync":
244
+ sync_col = c("amber", "~ out of sync")
245
+ name_col = c("amber", name)
246
+ else:
247
+ sync_col = c("red", "− missing")
248
+ name_col = c("red", name)
249
+
250
+ row_line = (" " + pad(name_col, 14) +
251
+ pad(c("dim", cfg), 14) +
252
+ pad(sync_col, 14) +
253
+ c("dim", str(sk)))
254
+ print(row_line)
255
+
256
+ if sync_s in ("out-of-sync", "missing"):
257
+ hint = (" " + c("dim", "fix: ") +
258
+ c("blue", f"roll setup -f {name}"))
259
+ print(hint)
260
+
261
+ print()
262
+ _hr()
263
+ print()
264
+
265
+ def _render_templates(templates: list) -> None:
266
+ section_head("PROJECT TEMPLATES", "项目模板", "~/.roll/conventions/templates/")
267
+ print()
268
+ parts = []
269
+ for tpl, count in templates:
270
+ if count > 0:
271
+ parts.append(c("fg", tpl) + c("dim", f" {count}f"))
272
+ else:
273
+ parts.append(c("red", "−") + " " + c("dim", tpl + " missing"))
274
+ print(" " + c("muted", " · ").join(parts))
275
+ print()
276
+ _hr()
277
+ print()
278
+
279
+ def _render_this_project(d: Dict[str, Any]) -> None:
280
+ section_head("THIS PROJECT", "本项目", os.path.basename(os.getcwd()))
281
+ print()
282
+
283
+ def _file_row(label: str, exists: bool, detail: str = "") -> None:
284
+ if exists:
285
+ sym = c("green", "+")
286
+ lbl = c("fg", label)
287
+ else:
288
+ sym = c("red", "−")
289
+ lbl = c("dim", label) + " " + c("red", "missing")
290
+ line = " " + sym + " " + lbl
291
+ if detail and exists:
292
+ line += c("dim", f" {detail}")
293
+ print(line)
294
+
295
+ _file_row("AGENTS.md", d["project_has_agents"])
296
+ _file_row(".roll/backlog.md", d["project_has_backlog"])
297
+ _file_row(".roll/features/", d["project_features_count"] > 0,
298
+ f"{d['project_features_count']} feature docs")
299
+
300
+ # Loop & dream launchd
301
+ for svc, state_key in [("loop", "loop_state"), ("dream", "dream_state")]:
302
+ state = d.get(state_key, "not-installed")
303
+ if state == "enabled":
304
+ dot = c("green", "●")
305
+ word = c("green", f"{svc} · launchd enabled")
306
+ elif state == "installed-off":
307
+ dot = c("amber", "⚠")
308
+ word = c("amber", f"{svc} · launchd off")
309
+ else:
310
+ dot = c("red", "○")
311
+ word = c("dim", f"{svc} · launchd not installed")
312
+ print(" " + dot + " " + word)
313
+
314
+ print()
315
+
316
+ # ════════════════════════════════════════════════════════════════════════════
317
+ # Live data collection
318
+ # ════════════════════════════════════════════════════════════════════════════
319
+ def _live_data() -> Dict[str, Any]:
320
+ slug = _project_slug()
321
+ entries = _parse_ai_entries()
322
+ ai_clients = []
323
+ for e in entries:
324
+ ai_clients.append({
325
+ "name": e["name"],
326
+ "cfg_file": e["cfg_file"],
327
+ "path": str(Path(e["ai_dir"]) / e["cfg_file"]).replace(str(Path.home()), "~"),
328
+ "sync": _ai_sync_status(e),
329
+ "skills": _ai_skill_count(e),
330
+ })
331
+ templates = [(t, _template_count(t)) for t in TEMPLATES]
332
+ feat_dir = Path(".roll/features")
333
+ return dict(
334
+ conventions = _global_conventions(),
335
+ ai_clients = ai_clients,
336
+ templates = templates,
337
+ skills_installed = _skills_installed(),
338
+ project_has_agents = Path("AGENTS.md").exists(),
339
+ project_has_backlog = Path(".roll/backlog.md").exists(),
340
+ project_features_count = sum(1 for _ in feat_dir.glob("*.md")) if feat_dir.exists() else 0,
341
+ loop_state = _launchd_state("loop", slug),
342
+ dream_state = _launchd_state("dream", slug),
343
+ )
344
+
345
+ # ════════════════════════════════════════════════════════════════════════════
346
+ # Entry
347
+ # ════════════════════════════════════════════════════════════════════════════
348
+ def main() -> None:
349
+ ap = argparse.ArgumentParser(add_help=False)
350
+ ap.add_argument("--no-color", dest="no_color", action="store_true")
351
+ ap.add_argument("--en", action="store_true")
352
+ ap.add_argument("--zh", action="store_true")
353
+ args, _ = ap.parse_known_args()
354
+
355
+ if args.no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
356
+ roll_render.USE_COLOR = False
357
+
358
+ d = _fixture_data() if os.environ.get("ROLL_RENDER_FIXTURE") else _live_data()
359
+
360
+ _render_health(d)
361
+ _render_global_conventions(d["conventions"])
362
+ _render_ai_clients(d["ai_clients"])
363
+ _render_templates(d["templates"])
364
+ _render_this_project(d)
365
+
366
+ if __name__ == "__main__":
367
+ main()
@@ -0,0 +1,41 @@
1
+ """
2
+ roll_git — shared git helpers for roll CLI scripts.
3
+
4
+ Standalone entry scripts (roll-home.py, roll-loop-status.py, ...) insert
5
+ _LIB_DIR into sys.path and import these helpers, mirroring the roll_render
6
+ import pattern. Keeps git/subprocess plumbing out of the pure-rendering
7
+ roll_render module and gives slug derivation a single source of truth.
8
+ """
9
+
10
+ from __future__ import annotations
11
+ import subprocess
12
+ from typing import Optional
13
+
14
+
15
+ def git_remote_url(repo_path: str) -> Optional[str]:
16
+ """Return the remote URL for a git repo — origin first, then any — or None."""
17
+ try:
18
+ url = subprocess.check_output(
19
+ ["git", "-C", repo_path, "remote", "get-url", "origin"],
20
+ stderr=subprocess.DEVNULL, text=True,
21
+ ).strip()
22
+ if url:
23
+ return url
24
+ except Exception:
25
+ pass
26
+ # Fallback: first available remote
27
+ try:
28
+ remotes = subprocess.check_output(
29
+ ["git", "-C", repo_path, "remote"],
30
+ stderr=subprocess.DEVNULL, text=True,
31
+ ).strip().splitlines()
32
+ if remotes:
33
+ url = subprocess.check_output(
34
+ ["git", "-C", repo_path, "remote", "get-url", remotes[0]],
35
+ stderr=subprocess.DEVNULL, text=True,
36
+ ).strip()
37
+ if url:
38
+ return url
39
+ except Exception:
40
+ pass
41
+ return None