@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,243 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ backfill-pi-usage — one-time, idempotent recovery of pi/deepseek token+cost
4
+ into an existing loop events file.
5
+
6
+ Why this exists
7
+ ---------------
8
+ Before US-LOOP-026 the loop ran pi via ``pi -p`` (text mode), which prints no
9
+ usage. loop-fmt's old passthrough still appended a ``stage=="usage"`` event on
10
+ every retry attempt, each with ``model=="pi"`` and null tokens — so a single
11
+ cycle accumulated up to ~180 empty usage events. The dashboard SUMS token
12
+ fields across same-label usage events; with all-null tokens the SUM was 0
13
+ (harmless), but it means every affected cycle shows ``—/—``.
14
+
15
+ pi persists every session to ``~/.pi/agent/sessions/<enc-cwd>/<ts>_<uuid>.jsonl``
16
+ with real per-message usage. This script recovers that, and rewrites the events
17
+ file so each affected cycle is left with **exactly one** authoritative usage
18
+ event (real tokens, cost frozen in native CNY) — collapsing the N null events to
19
+ avoid the dashboard ×N inflation.
20
+
21
+ Safety / idempotency
22
+ --------------------
23
+ - Backs up the events file to ``<file>.bak-<UTC>`` first; aborts if backup fails.
24
+ - Only touches labels whose usage events are all pi-vendor (``model`` in
25
+ {"pi", "deepseek-v4-pro"}) AND carry null tokens AND match a pi session.
26
+ claude cycles, already-real cycles, and unmatched-null cycles are passed
27
+ through untouched.
28
+ - Re-runnable: once a label has a real-token usage event it is no longer a
29
+ candidate, so a second run is a no-op.
30
+ - FIX-065 tripwire: refuses to rewrite a production ``~/.shared/roll`` events
31
+ file from a test context (BATS / temp cwd) unless HOME itself is sandboxed.
32
+
33
+ Usage
34
+ -----
35
+ python3 lib/backfill-pi-usage.py --slug roll-ecf079
36
+ python3 lib/backfill-pi-usage.py --events /path/to/events.ndjson --dry-run
37
+ """
38
+
39
+ import argparse
40
+ import importlib.util
41
+ import json
42
+ import os
43
+ import shutil
44
+ import sys
45
+ from datetime import datetime, timezone
46
+
47
+ _THIS_DIR = os.path.dirname(os.path.abspath(__file__))
48
+
49
+ PI_VENDOR_MODELS = ("pi", "deepseek-v4-pro")
50
+
51
+
52
+ def _load_pi_emit():
53
+ spec = importlib.util.spec_from_file_location(
54
+ "pi_emit", os.path.join(_THIS_DIR, "agent_usage", "pi_emit.py")
55
+ )
56
+ m = importlib.util.module_from_spec(spec)
57
+ spec.loader.exec_module(m)
58
+ return m
59
+
60
+
61
+ def _default_events_path(slug, shared=None):
62
+ base = shared or os.environ.get("LOOP_SHARED_ROOT") \
63
+ or os.path.expanduser("~/.shared/roll")
64
+ return os.path.join(base, "loop", "events-%s.ndjson" % slug)
65
+
66
+
67
+ def _is_test_context():
68
+ return bool(os.environ.get("BATS_TEST_FILENAME")) or _cwd_is_temp()
69
+
70
+
71
+ def _cwd_is_temp():
72
+ p = os.environ.get("PWD") or os.getcwd()
73
+ return any(seg in p for seg in ("/tmp/", "/private/tmp/", "/var/folders/", "/tmp."))
74
+
75
+
76
+ def _home_is_sandbox():
77
+ home = os.environ.get("HOME") or ""
78
+ return any(seg in home for seg in ("/tmp/", "/private/tmp/", "/var/folders/", "/tmp."))
79
+
80
+
81
+ def _tripwire(evfile):
82
+ """FIX-065: refuse a prod write from a test context."""
83
+ home = os.environ.get("HOME") or ""
84
+ if not home or _home_is_sandbox():
85
+ return
86
+ prod = os.path.join(home, ".shared", "roll") + os.sep
87
+ if os.path.abspath(evfile).startswith(os.path.abspath(prod)) and _is_test_context():
88
+ raise SystemExit(
89
+ "[FIX-065] refusing to rewrite prod events file from test context: %s" % evfile
90
+ )
91
+
92
+
93
+ def _scan(lines):
94
+ """Parse lines → (events_or_None list, per-label usage summary).
95
+
96
+ Returns (parsed, labels) where parsed is a list of (raw_line, obj_or_None)
97
+ preserving order, and labels maps label → {"pi": bool, "real": bool}.
98
+ """
99
+ parsed = []
100
+ labels = {}
101
+ for raw in lines:
102
+ obj = None
103
+ try:
104
+ obj = json.loads(raw)
105
+ except (ValueError, TypeError):
106
+ obj = None
107
+ parsed.append((raw, obj))
108
+ if not obj or obj.get("stage") != "usage":
109
+ continue
110
+ lab = obj.get("label")
111
+ d = obj.get("detail") or {}
112
+ rec = labels.setdefault(lab, {"pi": False, "real": False})
113
+ if d.get("model") in PI_VENDOR_MODELS:
114
+ rec["pi"] = True
115
+ if d.get("input_tokens"):
116
+ rec["real"] = True
117
+ return parsed, labels
118
+
119
+
120
+ def backfill(evfile, slug=None, shared=None, base_dir=None, dry_run=False):
121
+ """Rewrite evfile so each recoverable pi cycle nets one real usage event.
122
+
123
+ Returns a stats dict.
124
+ """
125
+ _tripwire(evfile)
126
+ pi_emit = _load_pi_emit()
127
+
128
+ with open(evfile) as f:
129
+ lines = f.readlines()
130
+
131
+ parsed, labels = _scan(lines)
132
+
133
+ # Candidate = pi-vendor, all-null, and a session match yields real usage.
134
+ candidates = [l for l, r in labels.items() if r["pi"] and not r["real"]]
135
+ replacement = {} # label -> detail payload
136
+ matched, unmatched = [], []
137
+ for lab in candidates:
138
+ cwd = os.path.join(
139
+ (shared or os.environ.get("LOOP_SHARED_ROOT")
140
+ or os.path.expanduser("~/.shared/roll")),
141
+ "worktrees", "%s-cycle-%s" % (slug, lab),
142
+ )
143
+ ev = pi_emit.build_event(cwd=cwd, cycle_id=lab, slug=slug, base_dir=base_dir)
144
+ if ev is None:
145
+ unmatched.append(lab)
146
+ continue
147
+ replacement[lab] = ev["detail"]
148
+ matched.append(lab)
149
+
150
+ if dry_run or not matched:
151
+ # Nothing recoverable to rewrite → no backup, no write (keeps re-runs
152
+ # a true no-op instead of spawning empty .bak files).
153
+ return {
154
+ "candidates": len(candidates),
155
+ "matched": len(matched),
156
+ "unmatched": len(unmatched),
157
+ "matched_labels": sorted(matched),
158
+ "unmatched_labels": sorted(unmatched),
159
+ "written": False,
160
+ }
161
+
162
+ # Backup before any write; abort the whole run if backup fails.
163
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
164
+ bak = "%s.bak-%s" % (evfile, stamp)
165
+ shutil.copy2(evfile, bak)
166
+
167
+ # Stream rewrite: for an affected label, the FIRST usage line becomes the
168
+ # real event (original ts preserved so it stays in its day bucket), every
169
+ # subsequent same-label usage line is dropped → exactly one per label.
170
+ emitted = set()
171
+ out = []
172
+ for raw, obj in parsed:
173
+ if not obj or obj.get("stage") != "usage":
174
+ out.append(raw)
175
+ continue
176
+ lab = obj.get("label")
177
+ if lab not in replacement:
178
+ out.append(raw) # claude / already-real / unmatched-null: untouched
179
+ continue
180
+ if lab in emitted:
181
+ continue # collapse the remaining null duplicates away
182
+ new_ev = {
183
+ "ts": obj.get("ts"),
184
+ "stage": "usage",
185
+ "label": lab,
186
+ "detail": replacement[lab],
187
+ "outcome": "ok",
188
+ }
189
+ out.append(json.dumps(new_ev) + "\n")
190
+ emitted.add(lab)
191
+
192
+ tmp = evfile + ".tmp-%s" % stamp
193
+ with open(tmp, "w") as f:
194
+ f.writelines(out)
195
+ os.replace(tmp, evfile)
196
+
197
+ return {
198
+ "candidates": len(candidates),
199
+ "matched": len(matched),
200
+ "unmatched": len(unmatched),
201
+ "matched_labels": sorted(matched),
202
+ "unmatched_labels": sorted(unmatched),
203
+ "backup": bak,
204
+ "written": True,
205
+ }
206
+
207
+
208
+ def main(argv=None):
209
+ ap = argparse.ArgumentParser(description="backfill pi/deepseek usage into events file")
210
+ ap.add_argument("--slug", help="project slug (resolves default events path + session cwd)")
211
+ ap.add_argument("--events", help="explicit events file path (overrides --slug default)")
212
+ ap.add_argument("--shared", help="shared root (default ~/.shared/roll)")
213
+ ap.add_argument("--base-dir", help="pi sessions root override (tests)")
214
+ ap.add_argument("--dry-run", action="store_true", help="report only, write nothing")
215
+ args = ap.parse_args(argv)
216
+
217
+ evfile = args.events or _default_events_path(args.slug, args.shared)
218
+ if not os.path.isfile(evfile):
219
+ print("[backfill] no events file: %s" % evfile, file=sys.stderr)
220
+ return 1
221
+ if not args.slug:
222
+ # slug is needed to reconstruct session cwd; derive from filename.
223
+ base = os.path.basename(evfile)
224
+ if base.startswith("events-") and base.endswith(".ndjson"):
225
+ args.slug = base[len("events-"):-len(".ndjson")]
226
+
227
+ stats = backfill(
228
+ evfile, slug=args.slug, shared=args.shared,
229
+ base_dir=args.base_dir, dry_run=args.dry_run,
230
+ )
231
+ mode = "DRY-RUN" if args.dry_run else "WROTE"
232
+ print("[backfill] %s %s" % (mode, evfile))
233
+ print(" candidates=%d matched=%d unmatched=%d"
234
+ % (stats["candidates"], stats["matched"], stats["unmatched"]))
235
+ if stats.get("backup"):
236
+ print(" backup=%s" % stats["backup"])
237
+ if stats["unmatched_labels"]:
238
+ print(" unmatched (left as null): %s" % ", ".join(stats["unmatched_labels"]))
239
+ return 0
240
+
241
+
242
+ if __name__ == "__main__":
243
+ sys.exit(main())
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env python3
2
+ """FIX-113: changelog audit — list PRs merged to main since the latest
3
+ release tag that don't appear in CHANGELOG.md's ## Unreleased section.
4
+
5
+ Run before `release.sh` so missing entries surface BEFORE the AI rewrite
6
+ gets a chance to silently drop them.
7
+
8
+ Usage:
9
+ python3 lib/changelog_audit.py # report missing
10
+ python3 lib/changelog_audit.py --since v2026.520.1
11
+ python3 lib/changelog_audit.py --json # machine-readable
12
+
13
+ Exit 0 always (read-only audit). Output:
14
+ - "audit ok" + no missing list when CHANGELOG covers every merged PR
15
+ - "audit found N PR(s) without a CHANGELOG entry:" + list otherwise
16
+ """
17
+ from __future__ import annotations
18
+ import argparse, json, re, subprocess, sys
19
+ from pathlib import Path
20
+
21
+ PR_RE = re.compile(r"\(#(\d+)\)")
22
+ SQUASH_RE = re.compile(r"^([a-z0-9]{7,40})\s+(.*?)\s*\(#(\d+)\)\s*$")
23
+
24
+ # Heuristic: PR titles whose first token is one of these tags are usually
25
+ # user-visible (need CHANGELOG entry). Tags like "chore:", "docs:" still
26
+ # warrant a docs section entry; left to user judgement.
27
+ USER_VISIBLE_PATTERNS = (
28
+ re.compile(r"\b(US-[A-Z0-9-]+-\d+)\b"),
29
+ re.compile(r"\b(FIX-\d+)\b"),
30
+ re.compile(r"\b(REFACTOR-\d+)\b"),
31
+ )
32
+
33
+ def _latest_tag() -> str:
34
+ """Return the most recent v* tag, or empty string if none."""
35
+ try:
36
+ out = subprocess.check_output(
37
+ ["git", "tag", "--list", "v*", "--sort=-creatordate"],
38
+ text=True, stderr=subprocess.DEVNULL,
39
+ ).strip()
40
+ for line in out.splitlines():
41
+ if line:
42
+ return line
43
+ except Exception:
44
+ pass
45
+ return ""
46
+
47
+ def _merged_prs_since(since: str):
48
+ """Return list of (sha, subject, pr_number) for first-parent merges
49
+ on main between <since> and HEAD."""
50
+ cmd = ["git", "log", "--first-parent", "--oneline"]
51
+ if since:
52
+ cmd.append(f"{since}..HEAD")
53
+ try:
54
+ out = subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL)
55
+ except Exception:
56
+ return []
57
+ rows = []
58
+ for line in out.splitlines():
59
+ m = SQUASH_RE.match(line)
60
+ if not m:
61
+ # Older "Merge pull request #N from <branch>" format
62
+ m2 = re.match(r"^([a-f0-9]{7,40})\s+Merge pull request #(\d+) from .*$", line)
63
+ if m2:
64
+ rows.append((m2.group(1), line.split(None, 1)[1], int(m2.group(2))))
65
+ continue
66
+ # Also look for parenthesized #N anywhere in subject
67
+ m3 = re.match(r"^([a-f0-9]{7,40})\s+(.*)$", line)
68
+ if m3:
69
+ pr_match = PR_RE.search(m3.group(2))
70
+ if pr_match:
71
+ rows.append((m3.group(1), m3.group(2), int(pr_match.group(1))))
72
+ continue
73
+ rows.append((m.group(1), m.group(2), int(m.group(3))))
74
+ return rows
75
+
76
+ def _read_unreleased_section(changelog: Path) -> str:
77
+ """Return the text of the ## Unreleased section (or empty string)."""
78
+ if not changelog.exists():
79
+ return ""
80
+ text = changelog.read_text(errors="ignore")
81
+ m = re.search(r"^## Unreleased\s*\n(.*?)(?=^## |\Z)", text, re.MULTILINE | re.DOTALL)
82
+ return m.group(1) if m else ""
83
+
84
+ def _is_in_changelog(subject: str, unreleased_text: str) -> bool:
85
+ """A PR is considered covered if any story id from its subject appears in
86
+ the Unreleased section text."""
87
+ for pat in USER_VISIBLE_PATTERNS:
88
+ for m in pat.finditer(subject):
89
+ sid = m.group(1)
90
+ if sid in unreleased_text:
91
+ return True
92
+ # Fallback: PR number explicit mention
93
+ pr_m = PR_RE.search(subject)
94
+ if pr_m and f"#{pr_m.group(1)}" in unreleased_text:
95
+ return True
96
+ return False
97
+
98
+ def main():
99
+ ap = argparse.ArgumentParser()
100
+ ap.add_argument("--since", default="", help="Compare against this tag (default: latest v* tag)")
101
+ ap.add_argument("--changelog", default="CHANGELOG.md")
102
+ ap.add_argument("--json", action="store_true", help="Machine-readable output")
103
+ args = ap.parse_args()
104
+
105
+ since = args.since or _latest_tag()
106
+ prs = _merged_prs_since(since)
107
+ cl = _read_unreleased_section(Path(args.changelog))
108
+
109
+ missing = []
110
+ skipped_internal = []
111
+ for sha, subject, pr_n in prs:
112
+ # Skip merges that are themselves releases
113
+ if subject.startswith("[release]") or subject.startswith("[ release]"):
114
+ continue
115
+ if _is_in_changelog(subject, cl):
116
+ continue
117
+ # Heuristic: subjects that don't contain a story id and start with
118
+ # internal-only tags (chore: backlog ..., chore: rebase, etc.) are
119
+ # marked as "internal", less likely to need user-facing entry.
120
+ is_user_visible = any(p.search(subject) for p in USER_VISIBLE_PATTERNS)
121
+ if is_user_visible:
122
+ missing.append({"pr": pr_n, "sha": sha, "subject": subject})
123
+ else:
124
+ skipped_internal.append({"pr": pr_n, "sha": sha, "subject": subject})
125
+
126
+ if args.json:
127
+ json.dump({
128
+ "since": since,
129
+ "total_prs": len(prs),
130
+ "missing_user_visible": missing,
131
+ "skipped_internal": skipped_internal,
132
+ }, sys.stdout, indent=2, ensure_ascii=False)
133
+ print()
134
+ return 0
135
+
136
+ print(f"changelog audit since={since or '(no tag)'} scanned {len(prs)} PR(s)")
137
+ print()
138
+ if not missing:
139
+ print(f" ✓ audit ok — every user-visible PR is mentioned in CHANGELOG.md Unreleased")
140
+ if skipped_internal:
141
+ print(f" · {len(skipped_internal)} internal/infra PR(s) skipped from audit")
142
+ return 0
143
+
144
+ print(f" ⚠ {len(missing)} user-visible PR(s) without a CHANGELOG entry:")
145
+ for m in missing:
146
+ print(f" #{m['pr']} {m['subject']}")
147
+ print()
148
+ print(" Add bullets under '## Unreleased' in CHANGELOG.md before release,")
149
+ print(" or confirm these PRs are intentionally undocumented (rare).")
150
+ if skipped_internal:
151
+ print(f" ({len(skipped_internal)} internal PR(s) skipped from audit)")
152
+ return 0
153
+
154
+ if __name__ == "__main__":
155
+ sys.exit(main())
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env python3
2
+ """US-CL-006: changelog generate — deterministic draft generator.
3
+
4
+ Extracts ✅ Done stories from .roll/backlog.md, filters internal entries,
5
+ applies mechanical lint, and produces a draft ## Unreleased section.
6
+
7
+ Usage:
8
+ python3 lib/changelog_generate.py # output draft to stdout
9
+ python3 lib/changelog_generate.py --write # append to CHANGELOG.md
10
+ python3 lib/changelog_generate.py --json # machine-readable
11
+ """
12
+ from __future__ import annotations
13
+ import argparse
14
+ import json
15
+ import re
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ # ─── Filters ─────────────────────────────────────────────────────────────────
20
+ # Skip patterns: test infrastructure, internal contracts, dev-only changes.
21
+ # These mirror the filter rules in $roll-.changelog SKILL.md Section 3.
22
+ SKIP_PATTERNS = [
23
+ re.compile(r"test\s+infrastructure|bats\s|fixture|teardown|isolation|CI\s+时序", re.I),
24
+ re.compile(r"SKILL\.md|prompt\s+内部|schema\s+|contract\s+test|enum\s+强制", re.I),
25
+ re.compile(r"内部重构|提取函数|变量改名|目录调整|死代码|消重", re.I),
26
+ re.compile(r"发版脚本自身|release\.sh\s+逻辑|TCR\s+节奏|pre-commit|钩子", re.I),
27
+ re.compile(r"仅开发者|只开发者|维护者可见|内部可见", re.I),
28
+ ]
29
+
30
+ # Category detection (best-effort keyword matching)
31
+ CATEGORIES = [
32
+ ("新功能", [r"新增", r"添加", r"支持", r"新命令", r"新功能", r"引入", r"上线"]),
33
+ ("稳定性", [r"修复", r"崩溃", r"卡死", r"误报", r"泄漏", r"竞争", r"并发", r"死锁", r"幽灵"]),
34
+ ("可见性", [r"显示", r"dashboard", r"状态", r"可见", r"查看", r"实时", r"弹窗", r"日志"]),
35
+ ("自动化流水线", [r"PR\s", r"合并", r"auto-merge", r"loop\s", r"调度", r"launchd", r"定时"]),
36
+ ("工程和测试", [r"测试", r"CI\s", r"重构", r"提取", r"优化", r"提速", r"并行"]),
37
+ ]
38
+
39
+ CATEGORY_ORDER = ["新功能", "稳定性", "可见性", "自动化流水线", "工程和测试", "其他"]
40
+
41
+ # Changelog lint rules (inline copy of _changelog_lint_bullet for portability).
42
+ LINT_BACKTICK_ID = re.compile(r"`[^`]*(_|\(\))[^`]*`")
43
+ LINT_FILE_SUFFIX = re.compile(r"\.(md|sh|yml|ts|bats)([^A-Za-z0-9]|$)")
44
+ LINT_INTERNAL_WORD = re.compile(r"(Phase|Step)\s+[0-9]+|Helper|Schema|Fixture|Refactor")
45
+ LINT_PATH_FRAG = re.compile(r"(^|[^A-Za-z0-9_])(\.roll|docs|bin|tests|scripts)/")
46
+
47
+
48
+ # ─── Helpers ─────────────────────────────────────────────────────────────────
49
+
50
+ def _read_done_stories(backlog_path: Path) -> list[tuple[str, str, str]]:
51
+ """Extract ✅ Done rows from backlog table.
52
+
53
+ Returns list of (story_id, description, source_tag).
54
+ """
55
+ rows: list[tuple[str, str, str]] = []
56
+ text = backlog_path.read_text(encoding="utf-8")
57
+ for line in text.splitlines():
58
+ if not line.startswith("|") or line.count("|") < 4:
59
+ continue
60
+ if "✅ Done" not in line:
61
+ continue
62
+ parts = line.split("|")
63
+ if len(parts) < 4:
64
+ continue
65
+ # story id from first column
66
+ id_m = re.search(r"\[([A-Z]+-[A-Z0-9-]+-\d+|FIX-\d+|REFACTOR-\d+)\]", parts[1])
67
+ story_id = id_m.group(1) if id_m else ""
68
+ desc = parts[2].strip()
69
+ if not desc or desc.lower() == "description":
70
+ continue
71
+ # source tag: loop-executed stories get [loop]
72
+ source = "loop" if re.search(r"US-AUTO|US-LOOP|FIX-|REFACTOR-", story_id) else ""
73
+ rows.append((story_id, desc, source))
74
+ return rows
75
+
76
+
77
+ def _is_internal(desc: str) -> bool:
78
+ for pat in SKIP_PATTERNS:
79
+ if pat.search(desc):
80
+ return True
81
+ return False
82
+
83
+
84
+ def _clean_description(desc: str) -> str:
85
+ # Remove depends-on / manual-only tags
86
+ desc = re.sub(r"`?depends-on:[^`|]+`?", "", desc)
87
+ desc = re.sub(r"`?manual-only:[^`|]+`?", "", desc)
88
+ # Remove markdown links — keep link text only
89
+ desc = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", desc)
90
+ # Collapse whitespace
91
+ desc = re.sub(r"\s+", " ", desc).strip()
92
+ # Remove leading em-dash / hyphen noise
93
+ desc = re.sub(r"^[—\-]\s*", "", desc)
94
+ return desc
95
+
96
+
97
+ def _detect_category(desc: str) -> str:
98
+ for cat, patterns in CATEGORIES:
99
+ for pat in patterns:
100
+ if re.search(pat, desc, re.I):
101
+ return cat
102
+ return "其他"
103
+
104
+
105
+ def _already_in_changelog(story_id: str, desc: str, changelog_path: Path) -> bool:
106
+ if not changelog_path.exists():
107
+ return False
108
+ text = changelog_path.read_text(encoding="utf-8")
109
+ if story_id in text:
110
+ return True
111
+ # Also check by description text (cleaned, first 20 chars) to catch
112
+ # entries that don't carry the story ID.
113
+ desc_stub = desc[:20].strip()
114
+ if desc_stub and desc_stub in text:
115
+ return True
116
+ return False
117
+
118
+
119
+ def _lint_bullet(bullet: str) -> list[str]:
120
+ """Return list of violation tags (empty = clean)."""
121
+ viols: list[str] = []
122
+ stripped = re.sub(r"`[^`]*`", "", bullet)
123
+ if LINT_BACKTICK_ID.search(bullet):
124
+ viols.append("backtick-identifier")
125
+ if LINT_FILE_SUFFIX.search(stripped):
126
+ viols.append("file-suffix")
127
+ if LINT_INTERNAL_WORD.search(bullet):
128
+ viols.append("internal-word")
129
+ # length: visible chars > 50
130
+ vis_len = len(stripped.strip())
131
+ if vis_len > 50:
132
+ viols.append("over-length")
133
+ if LINT_PATH_FRAG.search(stripped):
134
+ viols.append("path-fragment")
135
+ return viols
136
+
137
+
138
+ def _format_bullet(desc: str, source: str) -> str:
139
+ tag = f" [{source}]" if source else ""
140
+ return f"- {desc}{tag}"
141
+
142
+
143
+ def _build_draft(groups: dict[str, list[tuple[str, str, str]]]) -> str:
144
+ lines = ["## Unreleased", ""]
145
+ for cat in CATEGORY_ORDER:
146
+ if cat not in groups:
147
+ continue
148
+ lines.append(f"### {cat}")
149
+ lines.append("")
150
+ for _story_id, desc, source in groups[cat]:
151
+ bullet = _format_bullet(desc, source)
152
+ viols = _lint_bullet(bullet)
153
+ if viols:
154
+ bullet += f" # lint: {', '.join(viols)}"
155
+ lines.append(bullet)
156
+ lines.append("")
157
+ return "\n".join(lines).rstrip() + "\n"
158
+
159
+
160
+ def _write_to_changelog(draft: str, changelog_path: Path) -> None:
161
+ """Merge draft into CHANGELOG.md, avoiding duplicates."""
162
+ if changelog_path.exists():
163
+ text = changelog_path.read_text(encoding="utf-8")
164
+ else:
165
+ text = "# Changelog\n\n"
166
+
167
+ if "## Unreleased" not in text:
168
+ # Insert right after the title
169
+ text = text.rstrip("\n") + "\n\n" + draft
170
+ else:
171
+ # Extract existing Unreleased section, merge new bullets
172
+ # Pattern: from ## Unreleased up to next ## or EOF
173
+ m = re.search(r"^(## Unreleased\s*\n)(.*?)(?=\n## |\Z)", text, re.MULTILINE | re.DOTALL)
174
+ if not m:
175
+ text = text.rstrip("\n") + "\n\n" + draft
176
+ else:
177
+ existing = m.group(2)
178
+ new_lines = draft.splitlines()[2:] # drop "## Unreleased" and blank line
179
+ merged = existing.rstrip("\n") + "\n"
180
+ for line in new_lines:
181
+ # Skip category headers already present
182
+ if line.startswith("### ") and line in existing:
183
+ continue
184
+ # Skip bullets already present
185
+ bullet_core = re.sub(r"\s*\[loop\]\s*$", "", line).strip()
186
+ if bullet_core and bullet_core not in existing:
187
+ merged += line + "\n"
188
+ text = text[: m.start()] + "## Unreleased\n" + merged + text[m.end() :]
189
+
190
+ changelog_path.write_text(text, encoding="utf-8")
191
+
192
+
193
+ # ─── Main ────────────────────────────────────────────────────────────────────
194
+
195
+ def main() -> int:
196
+ ap = argparse.ArgumentParser(
197
+ description="Generate a draft ## Unreleased section from backlog ✅ Done stories."
198
+ )
199
+ ap.add_argument("--backlog", default=".roll/backlog.md", help="Path to backlog file")
200
+ ap.add_argument("--changelog", default="CHANGELOG.md", help="Path to CHANGELOG.md")
201
+ ap.add_argument("--write", action="store_true", help="Write draft to CHANGELOG.md")
202
+ ap.add_argument("--json", action="store_true", help="Machine-readable output")
203
+ args = ap.parse_args()
204
+
205
+ backlog = Path(args.backlog)
206
+ changelog = Path(args.changelog)
207
+
208
+ if not backlog.exists():
209
+ print("Error: backlog file not found", file=sys.stderr)
210
+ return 1
211
+
212
+ rows = _read_done_stories(backlog)
213
+
214
+ filtered: list[tuple[str, str, str, str]] = []
215
+ for story_id, desc, source in rows:
216
+ if _already_in_changelog(story_id, desc, changelog):
217
+ continue
218
+ if _is_internal(desc):
219
+ continue
220
+ cleaned = _clean_description(desc)
221
+ if not cleaned:
222
+ continue
223
+ cat = _detect_category(cleaned)
224
+ filtered.append((story_id, cleaned, source, cat))
225
+
226
+ if args.json:
227
+ json.dump(
228
+ {
229
+ "stories_found": len(rows),
230
+ "stories_drafted": len(filtered),
231
+ "draft": [
232
+ {"id": sid, "desc": d, "category": c, "source": s}
233
+ for sid, d, s, c in filtered
234
+ ],
235
+ },
236
+ sys.stdout,
237
+ indent=2,
238
+ ensure_ascii=False,
239
+ )
240
+ print()
241
+ return 0
242
+
243
+ if not filtered:
244
+ print("# No new ✅ Done stories found for CHANGELOG.")
245
+ return 0
246
+
247
+ groups: dict[str, list[tuple[str, str, str]]] = {}
248
+ for story_id, desc, source, cat in filtered:
249
+ groups.setdefault(cat, []).append((story_id, desc, source))
250
+
251
+ draft = _build_draft(groups)
252
+
253
+ if args.write:
254
+ _write_to_changelog(draft, changelog)
255
+ print(f"Updated {changelog}")
256
+ else:
257
+ print(draft, end="")
258
+
259
+ return 0
260
+
261
+
262
+ if __name__ == "__main__":
263
+ sys.exit(main())