@seanyao/roll 2.602.5 → 2.603.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.
- package/CHANGELOG.md +32 -0
- package/bin/roll +473 -92
- package/lib/README.md +0 -1
- package/lib/__pycache__/changelog_generate.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
- package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
- package/lib/changelog_generate.py +221 -32
- package/lib/loop-fmt.py +2 -2
- package/lib/prices_fetcher.py +331 -63
- package/package.json +1 -1
- package/skills/roll-.changelog/SKILL.md +1 -1
- package/skills/roll-loop/SKILL.md +7 -23
- package/lib/changelog_audit.py +0 -155
package/lib/README.md
CHANGED
|
@@ -24,7 +24,6 @@ Python scripts and shell libraries that `bin/roll` delegates to for rendering-he
|
|
|
24
24
|
| `loop-fmt.py` | Loop log formatter (ANSI-strip, timestamp alignment) |
|
|
25
25
|
| `loop_unstick.py` | Diagnostic: detects and unsticks hung loop state |
|
|
26
26
|
| `backfill-pi-usage.py` | Backfills pi/deepseek token and cost data into existing cycle records |
|
|
27
|
-
| `changelog_audit.py` | Audits CHANGELOG.md against backlog entries |
|
|
28
27
|
| `i18n.sh` | Shell wrapper that delegates i18n string lookups to `lib/i18n/` |
|
|
29
28
|
| `slides-render.py` | Renders `.deck.md` → HTML slides |
|
|
30
29
|
| `slides-validate.py` | Validates deck file syntax and asset references |
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""US-CL-006: changelog generate — deterministic draft generator.
|
|
2
|
+
"""US-CL-006+007: changelog generate — deterministic draft generator.
|
|
3
3
|
|
|
4
4
|
Extracts ✅ Done stories from .roll/backlog.md, filters internal entries,
|
|
5
5
|
applies mechanical lint, and produces a draft ## Unreleased section.
|
|
6
|
+
Also detects merged PRs since the last release tag that lack a corresponding
|
|
7
|
+
Done story or CHANGELOG entry (gap detection).
|
|
6
8
|
|
|
7
9
|
Usage:
|
|
8
10
|
python3 lib/changelog_generate.py # output draft to stdout
|
|
@@ -13,6 +15,7 @@ from __future__ import annotations
|
|
|
13
15
|
import argparse
|
|
14
16
|
import json
|
|
15
17
|
import re
|
|
18
|
+
import subprocess
|
|
16
19
|
import sys
|
|
17
20
|
from pathlib import Path
|
|
18
21
|
|
|
@@ -44,9 +47,9 @@ LINT_FILE_SUFFIX = re.compile(r"\.(md|sh|yml|ts|bats)([^A-Za-z0-9]|$)")
|
|
|
44
47
|
LINT_INTERNAL_WORD = re.compile(r"(Phase|Step)\s+[0-9]+|Helper|Schema|Fixture|Refactor")
|
|
45
48
|
LINT_PATH_FRAG = re.compile(r"(^|[^A-Za-z0-9_])(\.roll|docs|bin|tests|scripts)/")
|
|
46
49
|
|
|
47
|
-
|
|
48
50
|
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
49
51
|
|
|
52
|
+
|
|
50
53
|
def _read_done_stories(backlog_path: Path) -> list[tuple[str, str, str]]:
|
|
51
54
|
"""Extract ✅ Done rows from backlog table.
|
|
52
55
|
|
|
@@ -82,9 +85,8 @@ def _is_internal(desc: str) -> bool:
|
|
|
82
85
|
|
|
83
86
|
|
|
84
87
|
def _clean_description(desc: str) -> str:
|
|
85
|
-
# Remove depends-on
|
|
88
|
+
# Remove depends-on tags
|
|
86
89
|
desc = re.sub(r"`?depends-on:[^`|]+`?", "", desc)
|
|
87
|
-
desc = re.sub(r"`?manual-only:[^`|]+`?", "", desc)
|
|
88
90
|
# Remove markdown links — keep link text only
|
|
89
91
|
desc = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", desc)
|
|
90
92
|
# Collapse whitespace
|
|
@@ -135,24 +137,33 @@ def _lint_bullet(bullet: str) -> list[str]:
|
|
|
135
137
|
return viols
|
|
136
138
|
|
|
137
139
|
|
|
138
|
-
def _format_bullet(desc: str, source: str) -> str:
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
def _format_bullet(desc: str, source: str, story_id: str = "") -> str:
|
|
141
|
+
"""Render one clean changelog bullet (deterministic & idempotent, FIX-178):
|
|
142
|
+
|
|
143
|
+
- <description>(<ID>) `[source]`
|
|
144
|
+
|
|
145
|
+
The story id is appended at the END (never spliced into the sentence) so it
|
|
146
|
+
is always traceable / audit-matchable without ever mangling the prose. The
|
|
147
|
+
bold-headline polish in the project's voice is applied by a separate AI pass
|
|
148
|
+
(configured agent) on top of this raw bullet — deterministic prose splitting
|
|
149
|
+
on punctuation mangles parentheses/arrows, so it is intentionally avoided.
|
|
150
|
+
"""
|
|
151
|
+
tag = f" `[{source}]`" if source else ""
|
|
152
|
+
idref = f"({story_id})" if story_id and story_id not in desc else ""
|
|
153
|
+
return f"- {desc}{idref}{tag}"
|
|
141
154
|
|
|
142
155
|
|
|
143
156
|
def _build_draft(groups: dict[str, list[tuple[str, str, str]]]) -> str:
|
|
157
|
+
# FIX-178: emit clean styled bullets only — lint markers are a separate
|
|
158
|
+
# concern (stderr summary in main), never inlined into the deliverable.
|
|
144
159
|
lines = ["## Unreleased", ""]
|
|
145
160
|
for cat in CATEGORY_ORDER:
|
|
146
161
|
if cat not in groups:
|
|
147
162
|
continue
|
|
148
163
|
lines.append(f"### {cat}")
|
|
149
164
|
lines.append("")
|
|
150
|
-
for
|
|
151
|
-
|
|
152
|
-
viols = _lint_bullet(bullet)
|
|
153
|
-
if viols:
|
|
154
|
-
bullet += f" # lint: {', '.join(viols)}"
|
|
155
|
-
lines.append(bullet)
|
|
165
|
+
for story_id, desc, source in groups[cat]:
|
|
166
|
+
lines.append(_format_bullet(desc, source, story_id))
|
|
156
167
|
lines.append("")
|
|
157
168
|
return "\n".join(lines).rstrip() + "\n"
|
|
158
169
|
|
|
@@ -190,8 +201,146 @@ def _write_to_changelog(draft: str, changelog_path: Path) -> None:
|
|
|
190
201
|
changelog_path.write_text(text, encoding="utf-8")
|
|
191
202
|
|
|
192
203
|
|
|
204
|
+
# ─── US-CL-007: merged PR gap detection ──────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _latest_release_tag() -> str | None:
|
|
208
|
+
"""Find the latest v* tag using git."""
|
|
209
|
+
try:
|
|
210
|
+
result = subprocess.run(
|
|
211
|
+
["git", "describe", "--tags", "--abbrev=0", "--match", "v*"],
|
|
212
|
+
capture_output=True, text=True, check=True
|
|
213
|
+
)
|
|
214
|
+
return result.stdout.strip()
|
|
215
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _commit_log_since_last_release() -> str | None:
|
|
220
|
+
"""Concatenated commit subjects since the last release tag (FIX-177).
|
|
221
|
+
|
|
222
|
+
Used for release-aware unreleased detection: a ✅ Done story is unreleased
|
|
223
|
+
iff its id appears here. Returns None when there is no release tag or git is
|
|
224
|
+
unavailable, so the caller falls back to the CHANGELOG-text dedup.
|
|
225
|
+
"""
|
|
226
|
+
tag = _latest_release_tag()
|
|
227
|
+
if not tag:
|
|
228
|
+
return None
|
|
229
|
+
try:
|
|
230
|
+
result = subprocess.run(
|
|
231
|
+
["git", "log", f"{tag}..HEAD", "--pretty=format:%s"],
|
|
232
|
+
capture_output=True, text=True, check=True
|
|
233
|
+
)
|
|
234
|
+
return result.stdout
|
|
235
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _gh_available() -> bool:
|
|
240
|
+
"""Check whether the gh CLI is installed and on PATH."""
|
|
241
|
+
try:
|
|
242
|
+
result = subprocess.run(
|
|
243
|
+
["gh", "--version"],
|
|
244
|
+
capture_output=True, text=True, timeout=5
|
|
245
|
+
)
|
|
246
|
+
return result.returncode == 0
|
|
247
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _merged_prs_since_tag(tag: str) -> list[tuple[str, str, str]]:
|
|
252
|
+
"""Return list of (pr_number, title, commit_msg) for PRs merged since tag.
|
|
253
|
+
|
|
254
|
+
PR numbers are extracted from commit messages (e.g. ``(#123)``).
|
|
255
|
+
Titles are enriched via ``gh pr view`` when available.
|
|
256
|
+
"""
|
|
257
|
+
try:
|
|
258
|
+
result = subprocess.run(
|
|
259
|
+
["git", "log", f"{tag}..HEAD", "--pretty=format:%H %s"],
|
|
260
|
+
capture_output=True, text=True, check=True
|
|
261
|
+
)
|
|
262
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
263
|
+
return []
|
|
264
|
+
|
|
265
|
+
prs: list[tuple[str, str, str]] = []
|
|
266
|
+
seen: set[str] = set()
|
|
267
|
+
for line in result.stdout.strip().splitlines():
|
|
268
|
+
if not line:
|
|
269
|
+
continue
|
|
270
|
+
parts = line.split(" ", 1)
|
|
271
|
+
if len(parts) < 2:
|
|
272
|
+
continue
|
|
273
|
+
_commit_hash, subject = parts
|
|
274
|
+
m = re.search(r"\(#(\d+)\)", subject)
|
|
275
|
+
if not m:
|
|
276
|
+
continue
|
|
277
|
+
pr_num = m.group(1)
|
|
278
|
+
if pr_num in seen:
|
|
279
|
+
continue
|
|
280
|
+
seen.add(pr_num)
|
|
281
|
+
|
|
282
|
+
title = subject
|
|
283
|
+
if _gh_available():
|
|
284
|
+
try:
|
|
285
|
+
gh_result = subprocess.run(
|
|
286
|
+
["gh", "pr", "view", pr_num, "--json", "title"],
|
|
287
|
+
capture_output=True, text=True, timeout=10
|
|
288
|
+
)
|
|
289
|
+
if gh_result.returncode == 0:
|
|
290
|
+
gh_data = json.loads(gh_result.stdout)
|
|
291
|
+
title = gh_data.get("title", subject)
|
|
292
|
+
except (json.JSONDecodeError, subprocess.TimeoutExpired):
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
prs.append((pr_num, title, subject))
|
|
296
|
+
return prs
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _pr_in_done_rows(pr_number: str, backlog_path: Path) -> bool:
|
|
300
|
+
"""Check whether the PR number appears in any ✅ Done row of the backlog."""
|
|
301
|
+
text = backlog_path.read_text(encoding="utf-8")
|
|
302
|
+
for line in text.splitlines():
|
|
303
|
+
if "✅ Done" in line and f"#{pr_number}" in line:
|
|
304
|
+
return True
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _pr_is_covered(
|
|
309
|
+
pr_number: str,
|
|
310
|
+
pr_title: str,
|
|
311
|
+
commit_msg: str,
|
|
312
|
+
done_story_ids: set[str],
|
|
313
|
+
changelog_text: str,
|
|
314
|
+
) -> bool:
|
|
315
|
+
"""Check if a merged PR is already represented in backlog or changelog."""
|
|
316
|
+
# By PR number in CHANGELOG
|
|
317
|
+
if f"#{pr_number}" in changelog_text:
|
|
318
|
+
return True
|
|
319
|
+
# By story ID appearing in PR title / commit message
|
|
320
|
+
for story_id in done_story_ids:
|
|
321
|
+
if story_id and (story_id in pr_title or story_id in commit_msg):
|
|
322
|
+
return True
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _build_uncarded_block(uncarded: list[tuple[str, str]]) -> str:
|
|
327
|
+
lines = [
|
|
328
|
+
"",
|
|
329
|
+
"### ⚠️ 待确认(merged 但未入 backlog)",
|
|
330
|
+
"",
|
|
331
|
+
"> 以下 PR 已合入主干,但在 backlog 中没有对应的 ✅ Done story,也未出现在 CHANGELOG 中。",
|
|
332
|
+
"> 请确认是否需要在 Unreleased 中补充条目。",
|
|
333
|
+
"",
|
|
334
|
+
]
|
|
335
|
+
for pr_num, title in uncarded:
|
|
336
|
+
lines.append(f"- PR #{pr_num}: {title}")
|
|
337
|
+
lines.append("")
|
|
338
|
+
return "\n".join(lines)
|
|
339
|
+
|
|
340
|
+
|
|
193
341
|
# ─── Main ────────────────────────────────────────────────────────────────────
|
|
194
342
|
|
|
343
|
+
|
|
195
344
|
def main() -> int:
|
|
196
345
|
ap = argparse.ArgumentParser(
|
|
197
346
|
description="Generate a draft ## Unreleased section from backlog ✅ Done stories."
|
|
@@ -211,9 +360,23 @@ def main() -> int:
|
|
|
211
360
|
|
|
212
361
|
rows = _read_done_stories(backlog)
|
|
213
362
|
|
|
363
|
+
# FIX-177: only draft stories that are actually UNRELEASED. The backlog holds
|
|
364
|
+
# every ✅ Done story ever (500+), most already shipped in past versions; the
|
|
365
|
+
# CHANGELOG only carries recent versions, so filtering by "already in
|
|
366
|
+
# CHANGELOG text" let hundreds of long-released stories leak into the draft.
|
|
367
|
+
# Release-aware rule: a story is unreleased iff its id is referenced by a
|
|
368
|
+
# commit merged since the last release tag (git log <tag>..HEAD). Old stories
|
|
369
|
+
# never appear there and are correctly excluded. Falls back to the
|
|
370
|
+
# CHANGELOG-text filter when git/tag are unavailable (e.g. test sandboxes).
|
|
371
|
+
since_tag_log = _commit_log_since_last_release()
|
|
372
|
+
|
|
214
373
|
filtered: list[tuple[str, str, str, str]] = []
|
|
215
374
|
for story_id, desc, source in rows:
|
|
216
|
-
if
|
|
375
|
+
if since_tag_log is not None:
|
|
376
|
+
# Release-aware: skip stories not named in any post-release commit.
|
|
377
|
+
if not story_id or story_id not in since_tag_log:
|
|
378
|
+
continue
|
|
379
|
+
elif _already_in_changelog(story_id, desc, changelog):
|
|
217
380
|
continue
|
|
218
381
|
if _is_internal(desc):
|
|
219
382
|
continue
|
|
@@ -223,32 +386,58 @@ def main() -> int:
|
|
|
223
386
|
cat = _detect_category(cleaned)
|
|
224
387
|
filtered.append((story_id, cleaned, source, cat))
|
|
225
388
|
|
|
389
|
+
# ── US-CL-007: gap detection ───────────────────────────────────────────
|
|
390
|
+
uncarded: list[tuple[str, str]] = []
|
|
391
|
+
tag = _latest_release_tag()
|
|
392
|
+
if tag and _gh_available():
|
|
393
|
+
merged_prs = _merged_prs_since_tag(tag)
|
|
394
|
+
done_story_ids = {sid for sid, _desc, _src in rows}
|
|
395
|
+
changelog_text = changelog.read_text(encoding="utf-8") if changelog.exists() else ""
|
|
396
|
+
for pr_num, pr_title, commit_msg in merged_prs:
|
|
397
|
+
if _pr_in_done_rows(pr_num, backlog):
|
|
398
|
+
continue
|
|
399
|
+
if _pr_is_covered(pr_num, pr_title, commit_msg, done_story_ids, changelog_text):
|
|
400
|
+
continue
|
|
401
|
+
uncarded.append((pr_num, pr_title))
|
|
402
|
+
|
|
226
403
|
if args.json:
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
"
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
)
|
|
404
|
+
payload = {
|
|
405
|
+
"stories_found": len(rows),
|
|
406
|
+
"stories_drafted": len(filtered),
|
|
407
|
+
"draft": [
|
|
408
|
+
{"id": sid, "desc": d, "category": c, "source": s}
|
|
409
|
+
for sid, d, s, c in filtered
|
|
410
|
+
],
|
|
411
|
+
"uncarded_merged": [
|
|
412
|
+
{"pr": num, "title": title}
|
|
413
|
+
for num, title in uncarded
|
|
414
|
+
],
|
|
415
|
+
}
|
|
416
|
+
json.dump(payload, sys.stdout, indent=2, ensure_ascii=False)
|
|
240
417
|
print()
|
|
241
418
|
return 0
|
|
242
419
|
|
|
243
|
-
if not filtered:
|
|
420
|
+
if not filtered and not uncarded:
|
|
244
421
|
print("# No new ✅ Done stories found for CHANGELOG.")
|
|
245
422
|
return 0
|
|
246
423
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
424
|
+
# FIX-178: style-lint warnings go to STDERR so the stdout draft stays clean
|
|
425
|
+
# (no inline `# lint:` markers in the deliverable). The human still sees them.
|
|
426
|
+
for story_id, desc, source, _cat in filtered:
|
|
427
|
+
viols = _lint_bullet(_format_bullet(desc, source, story_id))
|
|
428
|
+
if viols:
|
|
429
|
+
print(f"lint: {story_id or '?'}: {', '.join(viols)}", file=sys.stderr)
|
|
430
|
+
|
|
431
|
+
if filtered:
|
|
432
|
+
groups: dict[str, list[tuple[str, str, str]]] = {}
|
|
433
|
+
for story_id, desc, source, cat in filtered:
|
|
434
|
+
groups.setdefault(cat, []).append((story_id, desc, source))
|
|
435
|
+
draft = _build_draft(groups)
|
|
436
|
+
else:
|
|
437
|
+
draft = ""
|
|
250
438
|
|
|
251
|
-
|
|
439
|
+
if uncarded:
|
|
440
|
+
draft += _build_uncarded_block(uncarded)
|
|
252
441
|
|
|
253
442
|
if args.write:
|
|
254
443
|
_write_to_changelog(draft, changelog)
|
package/lib/loop-fmt.py
CHANGED
|
@@ -85,7 +85,7 @@ SUPPRESS_TOOLS = {"Read", "Glob", "Grep", "ReadMcpResourceTool", "ListMcpResourc
|
|
|
85
85
|
"TaskUpdate", "TaskOutput", "TaskStop"}
|
|
86
86
|
|
|
87
87
|
def now_hms():
|
|
88
|
-
return datetime.now(timezone.utc).strftime("%H:%M:%S")
|
|
88
|
+
return datetime.now(timezone.utc).astimezone().strftime("%H:%M:%S")
|
|
89
89
|
|
|
90
90
|
def trunc(s, n=60):
|
|
91
91
|
s = str(s).replace("\n", " ").strip()
|
|
@@ -561,7 +561,7 @@ def _passthrough_main(agent):
|
|
|
561
561
|
accumulated.append(line.rstrip())
|
|
562
562
|
# Timestamp prefix so tmux shows activity (even if agent output has
|
|
563
563
|
# no timestamps of its own).
|
|
564
|
-
ts = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
|
564
|
+
ts = datetime.now(timezone.utc).astimezone().strftime("%H:%M:%S")
|
|
565
565
|
out = f"{DARK_GRAY}{ts}{RESET} {line.rstrip()}"
|
|
566
566
|
sys.stdout.write(out + "\n")
|
|
567
567
|
sys.stdout.flush()
|