@seanyao/roll 2.602.4 → 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/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 |
@@ -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 / manual-only tags
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
- tag = f" [{source}]" if source else ""
140
- return f"- {desc}{tag}"
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 _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)
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 _already_in_changelog(story_id, desc, changelog):
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
- 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
- )
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
- 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))
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
- draft = _build_draft(groups)
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()