@seanyao/roll 2.602.5 → 2.604.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 (34) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/bin/roll +500 -792
  3. package/lib/README.md +0 -1
  4. package/lib/changelog_audit.py +139 -145
  5. package/lib/changelog_generate.py +237 -30
  6. package/lib/consistency_check.py +409 -0
  7. package/lib/i18n/consistency.sh +8 -0
  8. package/lib/loop-fmt.py +2 -2
  9. package/lib/prices/snapshot-2026-05-22.json +1 -7
  10. package/lib/prices/snapshot-2026-05-23-deepseek.json +0 -2
  11. package/lib/prices/snapshot-2026-06-02-kimi.json +0 -1
  12. package/lib/prices_fetcher.py +312 -63
  13. package/lib/roll-loop-status.py +1 -1
  14. package/package.json +1 -1
  15. package/skills/roll-.changelog/SKILL.md +1 -1
  16. package/skills/roll-loop/SKILL.md +7 -23
  17. package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
  18. package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
  19. package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
  20. package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
  21. package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
  22. package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
  23. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  24. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  25. package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
  26. package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
  27. package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
  28. package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
  29. package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
  30. package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
  31. package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
  32. package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
  33. package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
  34. package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
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,155 +1,149 @@
1
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.
2
+ """Changelog audit module (US-CONSIST-002).
4
3
 
5
- Run before `release.sh` so missing entries surface BEFORE the AI rewrite
6
- gets a chance to silently drop them.
4
+ Checks that Done backlog stories are reflected in CHANGELOG.md.
5
+ Provides both coverage checking and gap reporting.
7
6
 
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
7
+ Usage as library:
8
+ from lib.changelog_audit import check_changelog_coverage
9
+ result = check_changelog_coverage(done_stories, changelog_path)
16
10
  """
11
+
17
12
  from __future__ import annotations
18
- import argparse, json, re, subprocess, sys
13
+
19
14
  from pathlib import Path
15
+ from typing import Any
20
16
 
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
17
 
76
- def _read_unreleased_section(changelog: Path) -> str:
77
- """Return the text of the ## Unreleased section (or empty string)."""
78
- if not changelog.exists():
18
+ def _read_changelog_text(changelog_path: Path) -> str:
19
+ """Read changelog text, returning empty string if file is missing."""
20
+ if not changelog_path.exists():
79
21
  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]"):
22
+ return changelog_path.read_text(encoding="utf-8")
23
+
24
+
25
+ def check_changelog_coverage(
26
+ done_stories: dict[str, list[str]],
27
+ changelog_path: Path,
28
+ ) -> dict[str, Any]:
29
+ """Check that Done stories appear in CHANGELOG.md.
30
+
31
+ Args:
32
+ done_stories: {feature_name: [story_id, ...]} from backlog
33
+ changelog_path: Path to CHANGELOG.md
34
+
35
+ Returns:
36
+ {"status": "pass"|"fail", "gaps": [descriptions...]}
37
+ """
38
+ if not changelog_path.exists():
39
+ return {"status": "pass", "gaps": []}
40
+
41
+ changelog_text = _read_changelog_text(changelog_path)
42
+ gaps: list[str] = []
43
+
44
+ for feature_name, story_ids in done_stories.items():
45
+ for story_id in story_ids:
46
+ if story_id not in changelog_text:
47
+ gaps.append(
48
+ f"Story '{story_id}' (feature '{feature_name}') is Done "
49
+ "but not referenced in CHANGELOG.md"
50
+ )
51
+
52
+ return {
53
+ "status": "pass" if not gaps else "fail",
54
+ "gaps": gaps,
55
+ }
56
+
57
+
58
+ def check_features_md_coverage(
59
+ done_features: dict[str, list[str]],
60
+ features_md_path: Path,
61
+ ) -> dict[str, Any]:
62
+ """Check that features with Done stories are listed in features.md.
63
+
64
+ Args:
65
+ done_features: {feature_name: [story_id, ...]} from backlog
66
+ features_md_path: Path to .roll/features.md
67
+
68
+ Returns:
69
+ {"status": "pass"|"fail", "gaps": [descriptions...]}
70
+ """
71
+ import re
72
+
73
+ if not features_md_path.exists():
74
+ return {"status": "pass", "gaps": []}
75
+
76
+ features_text = features_md_path.read_text(encoding="utf-8")
77
+ gaps: list[str] = []
78
+
79
+ for feature_name in done_features:
80
+ escaped = re.escape(feature_name)
81
+ if not re.search(r"(^|[\s/])" + escaped + r"([\s/).]|$)", features_text):
82
+ gaps.append(
83
+ f"Feature '{feature_name}' has Done stories but is missing "
84
+ "from features.md catalog"
85
+ )
86
+
87
+ return {
88
+ "status": "pass" if not gaps else "fail",
89
+ "gaps": gaps,
90
+ }
91
+
92
+
93
+ def check_guide_doc_coverage(
94
+ done_features: dict[str, list[str]],
95
+ guide_en_dir: Path,
96
+ ) -> dict[str, Any]:
97
+ """Check that features with Done stories have guide documentation.
98
+
99
+ Heuristic: checks whether a .md file in guide/en/ references the feature
100
+ name or its story IDs.
101
+
102
+ Args:
103
+ done_features: {feature_name: [story_id, ...]} from backlog
104
+ guide_en_dir: Path to guide/en/
105
+
106
+ Returns:
107
+ {"status": "pass"|"fail", "gaps": [descriptions...]}
108
+ """
109
+ if not guide_en_dir.exists() or not guide_en_dir.is_dir():
110
+ return {"status": "pass", "gaps": []}
111
+
112
+ # Collect all guide text
113
+ all_guide_text = ""
114
+ for md_file in sorted(guide_en_dir.glob("*.md")):
115
+ try:
116
+ all_guide_text += md_file.read_text(encoding="utf-8")
117
+ except (OSError, UnicodeDecodeError):
114
118
  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())
119
+
120
+ # Also read practices/ subdirectory
121
+ practices_dir = guide_en_dir / "practices"
122
+ if practices_dir.exists() and practices_dir.is_dir():
123
+ for md_file in sorted(practices_dir.glob("*.md")):
124
+ try:
125
+ all_guide_text += md_file.read_text(encoding="utf-8")
126
+ except (OSError, UnicodeDecodeError):
127
+ continue
128
+
129
+ gaps: list[str] = []
130
+ for feature_name, story_ids in done_features.items():
131
+ found = False
132
+ # Check if feature name appears in guide text
133
+ if feature_name.lower() in all_guide_text.lower():
134
+ found = True
135
+ # Check if any story ID appears
136
+ for sid in story_ids:
137
+ if sid in all_guide_text:
138
+ found = True
139
+ break
140
+ if not found:
141
+ gaps.append(
142
+ f"Feature '{feature_name}' has Done stories but no guide "
143
+ "documentation found in guide/en/"
144
+ )
145
+
146
+ return {
147
+ "status": "pass" if not gaps else "fail",
148
+ "gaps": gaps,
149
+ }
@@ -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,158 @@ 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 _uncarded_to_entries(uncarded: list[tuple[str, str]]) -> list[tuple[str, str, str, str]]:
327
+ """FIX-179: convert uncarded merged PRs (pr_num, title) into draft entries
328
+ (id, desc, source, category) so they fold into the categorized draft as
329
+ normal bullets. The old behaviour emitted a separate '⚠️ 待确认 … 请确认'
330
+ block — a maintainer prompt that must NEVER reach the published CHANGELOG
331
+ (it leaked into v2.603.1). The "lacked a card" notice now goes to stderr.
332
+
333
+ Uses the PR title as the description (stripped of conventional-commit
334
+ prefixes and the leading id); the story/fix id when present, else PR#<n>.
335
+ """
336
+ entries: list[tuple[str, str, str, str]] = []
337
+ for pr_num, title in uncarded:
338
+ t = re.sub(
339
+ r"^\s*(Fix|tcr|docs|chore|feat|refactor|perf|test|Story\s+\d+)\s*[::]\s*",
340
+ "", title, flags=re.I,
341
+ )
342
+ idm = re.search(r"\b(US-[A-Z]+-\d+|FIX-\d+|REFACTOR-\d+)\b", title)
343
+ sid = idm.group(1) if idm else f"PR#{pr_num}"
344
+ if idm:
345
+ t = re.sub(r"\b" + re.escape(idm.group(1)) + r"\b\s*[::]?\s*", "", t).strip()
346
+ cleaned = _clean_description(t) or t.strip()
347
+ cat = _detect_category(cleaned)
348
+ src = "loop" if re.search(r"US-AUTO|US-LOOP|FIX-|REFACTOR-", sid) else ""
349
+ entries.append((sid, cleaned, src, cat))
350
+ return entries
351
+
352
+
193
353
  # ─── Main ────────────────────────────────────────────────────────────────────
194
354
 
355
+
195
356
  def main() -> int:
196
357
  ap = argparse.ArgumentParser(
197
358
  description="Generate a draft ## Unreleased section from backlog ✅ Done stories."
@@ -211,9 +372,23 @@ def main() -> int:
211
372
 
212
373
  rows = _read_done_stories(backlog)
213
374
 
375
+ # FIX-177: only draft stories that are actually UNRELEASED. The backlog holds
376
+ # every ✅ Done story ever (500+), most already shipped in past versions; the
377
+ # CHANGELOG only carries recent versions, so filtering by "already in
378
+ # CHANGELOG text" let hundreds of long-released stories leak into the draft.
379
+ # Release-aware rule: a story is unreleased iff its id is referenced by a
380
+ # commit merged since the last release tag (git log <tag>..HEAD). Old stories
381
+ # never appear there and are correctly excluded. Falls back to the
382
+ # CHANGELOG-text filter when git/tag are unavailable (e.g. test sandboxes).
383
+ since_tag_log = _commit_log_since_last_release()
384
+
214
385
  filtered: list[tuple[str, str, str, str]] = []
215
386
  for story_id, desc, source in rows:
216
- if _already_in_changelog(story_id, desc, changelog):
387
+ if since_tag_log is not None:
388
+ # Release-aware: skip stories not named in any post-release commit.
389
+ if not story_id or story_id not in since_tag_log:
390
+ continue
391
+ elif _already_in_changelog(story_id, desc, changelog):
217
392
  continue
218
393
  if _is_internal(desc):
219
394
  continue
@@ -223,31 +398,63 @@ def main() -> int:
223
398
  cat = _detect_category(cleaned)
224
399
  filtered.append((story_id, cleaned, source, cat))
225
400
 
401
+ # ── US-CL-007: gap detection ───────────────────────────────────────────
402
+ uncarded: list[tuple[str, str]] = []
403
+ tag = _latest_release_tag()
404
+ if tag and _gh_available():
405
+ merged_prs = _merged_prs_since_tag(tag)
406
+ done_story_ids = {sid for sid, _desc, _src in rows}
407
+ changelog_text = changelog.read_text(encoding="utf-8") if changelog.exists() else ""
408
+ for pr_num, pr_title, commit_msg in merged_prs:
409
+ if _pr_in_done_rows(pr_num, backlog):
410
+ continue
411
+ if _pr_is_covered(pr_num, pr_title, commit_msg, done_story_ids, changelog_text):
412
+ continue
413
+ uncarded.append((pr_num, pr_title))
414
+
226
415
  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
- )
416
+ payload = {
417
+ "stories_found": len(rows),
418
+ "stories_drafted": len(filtered),
419
+ "draft": [
420
+ {"id": sid, "desc": d, "category": c, "source": s}
421
+ for sid, d, s, c in filtered
422
+ ],
423
+ "uncarded_merged": [
424
+ {"pr": num, "title": title}
425
+ for num, title in uncarded
426
+ ],
427
+ }
428
+ json.dump(payload, sys.stdout, indent=2, ensure_ascii=False)
240
429
  print()
241
430
  return 0
242
431
 
243
- if not filtered:
432
+ if not filtered and not uncarded:
244
433
  print("# No new ✅ Done stories found for CHANGELOG.")
245
434
  return 0
246
435
 
436
+ # FIX-179: fold uncarded merged PRs INTO the categorized draft (complete
437
+ # coverage) rather than a separate '请确认' warning block — that maintainer
438
+ # prompt must never reach the published CHANGELOG. The "lacked a card"
439
+ # notice goes to STDERR so the human still sees what to back-fill.
440
+ all_entries: list[tuple[str, str, str, str]] = list(filtered) + _uncarded_to_entries(uncarded)
441
+ if uncarded:
442
+ pr_list = " ".join(f"#{p}" for p, _t in uncarded)
443
+ print(
444
+ f"note: {len(uncarded)} 个 merged PR 未建卡,已按 PR 标题并入草稿,建议补卡: {pr_list}",
445
+ file=sys.stderr,
446
+ )
447
+
448
+ # FIX-178: style-lint warnings go to STDERR so the stdout draft stays clean
449
+ # (no inline `# lint:` markers in the deliverable). The human still sees them.
450
+ for story_id, desc, source, _cat in all_entries:
451
+ viols = _lint_bullet(_format_bullet(desc, source, story_id))
452
+ if viols:
453
+ print(f"lint: {story_id or '?'}: {', '.join(viols)}", file=sys.stderr)
454
+
247
455
  groups: dict[str, list[tuple[str, str, str]]] = {}
248
- for story_id, desc, source, cat in filtered:
456
+ for story_id, desc, source, cat in all_entries:
249
457
  groups.setdefault(cat, []).append((story_id, desc, source))
250
-
251
458
  draft = _build_draft(groups)
252
459
 
253
460
  if args.write: