@seanyao/roll 3.609.2 → 3.610.2

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 (103) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +5 -6
  3. package/dist/roll.mjs +16146 -16186
  4. package/package.json +3 -2
  5. package/skills/README.md +14 -1
  6. package/skills/docs/skill-authoring.md +66 -0
  7. package/skills/reports/skill-audit-summary.md +53 -0
  8. package/skills/roll-.changelog/SKILL.md +25 -443
  9. package/skills/roll-.changelog/references/full-contract.md +462 -0
  10. package/skills/roll-.clarify/SKILL.md +6 -4
  11. package/skills/roll-.dream/SKILL.md +26 -353
  12. package/skills/roll-.dream/references/full-contract.md +365 -0
  13. package/skills/roll-.echo/SKILL.md +6 -4
  14. package/skills/roll-.qa/SKILL.md +25 -236
  15. package/skills/roll-.qa/references/full-contract.md +256 -0
  16. package/skills/roll-.review/SKILL.md +6 -2
  17. package/skills/roll-brief/SKILL.md +6 -8
  18. package/skills/roll-build/SKILL.md +28 -864
  19. package/skills/roll-build/references/full-contract.md +883 -0
  20. package/skills/roll-debug/SKILL.md +26 -585
  21. package/skills/roll-debug/references/full-contract.md +607 -0
  22. package/skills/roll-design/SKILL.md +28 -903
  23. package/skills/roll-design/references/full-contract.md +923 -0
  24. package/skills/roll-doc/SKILL.md +25 -574
  25. package/skills/roll-doc/references/full-contract.md +594 -0
  26. package/skills/roll-doctor/SKILL.md +21 -2
  27. package/skills/roll-fix/SKILL.md +28 -621
  28. package/skills/roll-fix/references/full-contract.md +640 -0
  29. package/skills/roll-idea/SKILL.md +6 -2
  30. package/skills/roll-loop/SKILL.md +27 -543
  31. package/skills/roll-loop/references/full-contract.md +555 -0
  32. package/skills/roll-notes/SKILL.md +6 -2
  33. package/skills/roll-onboard/SKILL.md +6 -2
  34. package/skills/roll-peer/SKILL.md +27 -316
  35. package/skills/roll-peer/references/full-contract.md +329 -0
  36. package/skills/roll-propose/SKILL.md +6 -8
  37. package/skills/roll-review-pr/SKILL.md +6 -2
  38. package/skills/roll-sentinel/SKILL.md +26 -344
  39. package/skills/roll-sentinel/references/full-contract.md +363 -0
  40. package/skills/roll-spar/SKILL.md +27 -269
  41. package/skills/roll-spar/references/full-contract.md +288 -0
  42. package/skills/route-cases/skills.json +235 -0
  43. package/skills/scripts/audit-skills.mjs +272 -0
  44. package/skills/scripts/test-audit-skills.mjs +39 -0
  45. package/skills/tests/fixtures/skill-audit/block-skill/SKILL.md +12 -0
  46. package/skills/tests/fixtures/skill-audit/minimal-skill/SKILL.md +8 -0
  47. package/skills/tests/fixtures/skill-audit/quoted-skill/SKILL.md +10 -0
  48. package/skills/tests/fixtures/skill-audit/route-cases.json +21 -0
  49. package/skills/tests/fixtures/skill-audit/spoke-skill/SKILL.md +12 -0
  50. package/skills/tests/fixtures/skill-audit/spoke-skill/references/runbook.md +3 -0
  51. package/bin/roll +0 -15361
  52. package/lib/backfill-pi-usage.py +0 -243
  53. package/lib/changelog_audit.py +0 -149
  54. package/lib/changelog_generate.py +0 -470
  55. package/lib/consistency_check.py +0 -409
  56. package/lib/context_feed_budget.sh +0 -194
  57. package/lib/github_sync.py +0 -876
  58. package/lib/i18n/slides.sh +0 -3
  59. package/lib/i18n/slides_build.sh +0 -38
  60. package/lib/i18n/slides_delete.sh +0 -19
  61. package/lib/i18n/slides_list.sh +0 -14
  62. package/lib/i18n/slides_logs.sh +0 -12
  63. package/lib/i18n/slides_new.sh +0 -15
  64. package/lib/i18n/slides_preview.sh +0 -14
  65. package/lib/i18n/slides_templates.sh +0 -7
  66. package/lib/i18n.sh +0 -211
  67. package/lib/loop-exit-summary.py +0 -393
  68. package/lib/loop-fmt.py +0 -589
  69. package/lib/loop_pick_agent.py +0 -316
  70. package/lib/loop_result_eval.py +0 -469
  71. package/lib/loop_unstick.py +0 -180
  72. package/lib/model_prices.py +0 -194
  73. package/lib/prices_fetcher.py +0 -534
  74. package/lib/roll-backlog.py +0 -225
  75. package/lib/roll-brief.py +0 -286
  76. package/lib/roll-help.py +0 -158
  77. package/lib/roll-home.py +0 -556
  78. package/lib/roll-init.py +0 -156
  79. package/lib/roll-loop-status.py +0 -1691
  80. package/lib/roll-loop-story.py +0 -191
  81. package/lib/roll-peer.py +0 -252
  82. package/lib/roll-setup.py +0 -102
  83. package/lib/roll-status.py +0 -367
  84. package/lib/roll_git.py +0 -41
  85. package/lib/roll_render.py +0 -414
  86. package/lib/slides/components/README.md +0 -123
  87. package/lib/slides/components/cards-2.html +0 -9
  88. package/lib/slides/components/cards-3.html +0 -9
  89. package/lib/slides/components/cards-4.html +0 -9
  90. package/lib/slides/components/compare.html +0 -22
  91. package/lib/slides/components/highlight.html +0 -9
  92. package/lib/slides/components/pipeline.html +0 -12
  93. package/lib/slides/components/plain.html +0 -7
  94. package/lib/slides/components/quote.html +0 -4
  95. package/lib/slides/components/timeline.html +0 -9
  96. package/lib/slides/templates/introduction-v3.html +0 -571
  97. package/lib/slides/templates/pitch.html +0 -0
  98. package/lib/slides-render.py +0 -778
  99. package/lib/slides-validate.py +0 -357
  100. package/lib/test_quality_gate.py +0 -143
  101. package/skills/roll-deck/SKILL.md +0 -296
  102. /package/skills/roll-debug/{injectable-bb.js → assets/injectable-bb.js} +0 -0
  103. /package/skills/roll-design/{ENGINEERING_CHECKLIST.md → references/engineering-checklist.md} +0 -0
@@ -1,470 +0,0 @@
1
- #!/usr/bin/env python3
2
- """US-CL-006+007: 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
- Also detects merged PRs since the last release tag that lack a corresponding
7
- Done story or CHANGELOG entry (gap detection).
8
-
9
- Usage:
10
- python3 lib/changelog_generate.py # output draft to stdout
11
- python3 lib/changelog_generate.py --write # append to CHANGELOG.md
12
- python3 lib/changelog_generate.py --json # machine-readable
13
- """
14
- from __future__ import annotations
15
- import argparse
16
- import json
17
- import re
18
- import subprocess
19
- import sys
20
- from pathlib import Path
21
-
22
- # ─── Filters ─────────────────────────────────────────────────────────────────
23
- # Skip patterns: test infrastructure, internal contracts, dev-only changes.
24
- # These mirror the filter rules in $roll-.changelog SKILL.md Section 3.
25
- SKIP_PATTERNS = [
26
- re.compile(r"test\s+infrastructure|bats\s|fixture|teardown|isolation|CI\s+时序", re.I),
27
- re.compile(r"SKILL\.md|prompt\s+内部|schema\s+|contract\s+test|enum\s+强制", re.I),
28
- re.compile(r"内部重构|提取函数|变量改名|目录调整|死代码|消重", re.I),
29
- re.compile(r"发版脚本自身|release\.sh\s+逻辑|TCR\s+节奏|pre-commit|钩子", re.I),
30
- re.compile(r"仅开发者|只开发者|维护者可见|内部可见", re.I),
31
- ]
32
-
33
- # Category detection (best-effort keyword matching)
34
- CATEGORIES = [
35
- ("新功能", [r"新增", r"添加", r"支持", r"新命令", r"新功能", r"引入", r"上线"]),
36
- ("稳定性", [r"修复", r"崩溃", r"卡死", r"误报", r"泄漏", r"竞争", r"并发", r"死锁", r"幽灵"]),
37
- ("可见性", [r"显示", r"dashboard", r"状态", r"可见", r"查看", r"实时", r"弹窗", r"日志"]),
38
- ("自动化流水线", [r"PR\s", r"合并", r"auto-merge", r"loop\s", r"调度", r"launchd", r"定时"]),
39
- ("工程和测试", [r"测试", r"CI\s", r"重构", r"提取", r"优化", r"提速", r"并行"]),
40
- ]
41
-
42
- CATEGORY_ORDER = ["新功能", "稳定性", "可见性", "自动化流水线", "工程和测试", "其他"]
43
-
44
- # Changelog lint rules (inline copy of _changelog_lint_bullet for portability).
45
- LINT_BACKTICK_ID = re.compile(r"`[^`]*(_|\(\))[^`]*`")
46
- LINT_FILE_SUFFIX = re.compile(r"\.(md|sh|yml|ts|bats)([^A-Za-z0-9]|$)")
47
- LINT_INTERNAL_WORD = re.compile(r"(Phase|Step)\s+[0-9]+|Helper|Schema|Fixture|Refactor")
48
- LINT_PATH_FRAG = re.compile(r"(^|[^A-Za-z0-9_])(\.roll|docs|bin|tests|scripts)/")
49
-
50
- # ─── Helpers ─────────────────────────────────────────────────────────────────
51
-
52
-
53
- def _read_done_stories(backlog_path: Path) -> list[tuple[str, str, str]]:
54
- """Extract ✅ Done rows from backlog table.
55
-
56
- Returns list of (story_id, description, source_tag).
57
- """
58
- rows: list[tuple[str, str, str]] = []
59
- text = backlog_path.read_text(encoding="utf-8")
60
- for line in text.splitlines():
61
- if not line.startswith("|") or line.count("|") < 4:
62
- continue
63
- if "✅ Done" not in line:
64
- continue
65
- parts = line.split("|")
66
- if len(parts) < 4:
67
- continue
68
- # story id from first column
69
- id_m = re.search(r"\[([A-Z]+-[A-Z0-9-]+-\d+|FIX-\d+|REFACTOR-\d+)\]", parts[1])
70
- story_id = id_m.group(1) if id_m else ""
71
- desc = parts[2].strip()
72
- if not desc or desc.lower() == "description":
73
- continue
74
- # source tag: loop-executed stories get [loop]
75
- source = "loop" if re.search(r"US-AUTO|US-LOOP|FIX-|REFACTOR-", story_id) else ""
76
- rows.append((story_id, desc, source))
77
- return rows
78
-
79
-
80
- def _is_internal(desc: str) -> bool:
81
- for pat in SKIP_PATTERNS:
82
- if pat.search(desc):
83
- return True
84
- return False
85
-
86
-
87
- def _clean_description(desc: str) -> str:
88
- # Remove depends-on tags
89
- desc = re.sub(r"`?depends-on:[^`|]+`?", "", desc)
90
- # Remove markdown links — keep link text only
91
- desc = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", desc)
92
- # Collapse whitespace
93
- desc = re.sub(r"\s+", " ", desc).strip()
94
- # Remove leading em-dash / hyphen noise
95
- desc = re.sub(r"^[—\-]\s*", "", desc)
96
- return desc
97
-
98
-
99
- def _detect_category(desc: str) -> str:
100
- for cat, patterns in CATEGORIES:
101
- for pat in patterns:
102
- if re.search(pat, desc, re.I):
103
- return cat
104
- return "其他"
105
-
106
-
107
- def _already_in_changelog(story_id: str, desc: str, changelog_path: Path) -> bool:
108
- if not changelog_path.exists():
109
- return False
110
- text = changelog_path.read_text(encoding="utf-8")
111
- if story_id in text:
112
- return True
113
- # Also check by description text (cleaned, first 20 chars) to catch
114
- # entries that don't carry the story ID.
115
- desc_stub = desc[:20].strip()
116
- if desc_stub and desc_stub in text:
117
- return True
118
- return False
119
-
120
-
121
- def _lint_bullet(bullet: str) -> list[str]:
122
- """Return list of violation tags (empty = clean)."""
123
- viols: list[str] = []
124
- stripped = re.sub(r"`[^`]*`", "", bullet)
125
- if LINT_BACKTICK_ID.search(bullet):
126
- viols.append("backtick-identifier")
127
- if LINT_FILE_SUFFIX.search(stripped):
128
- viols.append("file-suffix")
129
- if LINT_INTERNAL_WORD.search(bullet):
130
- viols.append("internal-word")
131
- # length: visible chars > 50
132
- vis_len = len(stripped.strip())
133
- if vis_len > 50:
134
- viols.append("over-length")
135
- if LINT_PATH_FRAG.search(stripped):
136
- viols.append("path-fragment")
137
- return viols
138
-
139
-
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}"
154
-
155
-
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.
159
- lines = ["## Unreleased", ""]
160
- for cat in CATEGORY_ORDER:
161
- if cat not in groups:
162
- continue
163
- lines.append(f"### {cat}")
164
- lines.append("")
165
- for story_id, desc, source in groups[cat]:
166
- lines.append(_format_bullet(desc, source, story_id))
167
- lines.append("")
168
- return "\n".join(lines).rstrip() + "\n"
169
-
170
-
171
- def _write_to_changelog(draft: str, changelog_path: Path) -> None:
172
- """Merge draft into CHANGELOG.md, avoiding duplicates."""
173
- if changelog_path.exists():
174
- text = changelog_path.read_text(encoding="utf-8")
175
- else:
176
- text = "# Changelog\n\n"
177
-
178
- if "## Unreleased" not in text:
179
- # Insert right after the title
180
- text = text.rstrip("\n") + "\n\n" + draft
181
- else:
182
- # Extract existing Unreleased section, merge new bullets
183
- # Pattern: from ## Unreleased up to next ## or EOF
184
- m = re.search(r"^(## Unreleased\s*\n)(.*?)(?=\n## |\Z)", text, re.MULTILINE | re.DOTALL)
185
- if not m:
186
- text = text.rstrip("\n") + "\n\n" + draft
187
- else:
188
- existing = m.group(2)
189
- new_lines = draft.splitlines()[2:] # drop "## Unreleased" and blank line
190
- merged = existing.rstrip("\n") + "\n"
191
- for line in new_lines:
192
- # Skip category headers already present
193
- if line.startswith("### ") and line in existing:
194
- continue
195
- # Skip bullets already present
196
- bullet_core = re.sub(r"\s*\[loop\]\s*$", "", line).strip()
197
- if bullet_core and bullet_core not in existing:
198
- merged += line + "\n"
199
- text = text[: m.start()] + "## Unreleased\n" + merged + text[m.end() :]
200
-
201
- changelog_path.write_text(text, encoding="utf-8")
202
-
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
-
353
- # ─── Main ────────────────────────────────────────────────────────────────────
354
-
355
-
356
- def main() -> int:
357
- ap = argparse.ArgumentParser(
358
- description="Generate a draft ## Unreleased section from backlog ✅ Done stories."
359
- )
360
- ap.add_argument("--backlog", default=".roll/backlog.md", help="Path to backlog file")
361
- ap.add_argument("--changelog", default="CHANGELOG.md", help="Path to CHANGELOG.md")
362
- ap.add_argument("--write", action="store_true", help="Write draft to CHANGELOG.md")
363
- ap.add_argument("--json", action="store_true", help="Machine-readable output")
364
- args = ap.parse_args()
365
-
366
- backlog = Path(args.backlog)
367
- changelog = Path(args.changelog)
368
-
369
- if not backlog.exists():
370
- print("Error: backlog file not found", file=sys.stderr)
371
- return 1
372
-
373
- rows = _read_done_stories(backlog)
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
-
385
- filtered: list[tuple[str, str, str, str]] = []
386
- for story_id, desc, source in rows:
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):
392
- continue
393
- if _is_internal(desc):
394
- continue
395
- cleaned = _clean_description(desc)
396
- if not cleaned:
397
- continue
398
- cat = _detect_category(cleaned)
399
- filtered.append((story_id, cleaned, source, cat))
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
-
415
- if args.json:
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)
429
- print()
430
- return 0
431
-
432
- if not filtered and not uncarded:
433
- print("# No new ✅ Done stories found for CHANGELOG.")
434
- return 0
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
-
455
- groups: dict[str, list[tuple[str, str, str]]] = {}
456
- for story_id, desc, source, cat in all_entries:
457
- groups.setdefault(cat, []).append((story_id, desc, source))
458
- draft = _build_draft(groups)
459
-
460
- if args.write:
461
- _write_to_changelog(draft, changelog)
462
- print(f"Updated {changelog}")
463
- else:
464
- print(draft, end="")
465
-
466
- return 0
467
-
468
-
469
- if __name__ == "__main__":
470
- sys.exit(main())