lgtm-specs 0.0.4

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 (212) hide show
  1. package/.claude/settings.local.json +14 -0
  2. package/.gemini/README.md +8 -0
  3. package/.gemini/config.yaml +20 -0
  4. package/.gemini/styleguide.md +35 -0
  5. package/.github/workflows/README.md +5 -0
  6. package/.github/workflows/release.yml +52 -0
  7. package/.github/workflows/validate.yml +27 -0
  8. package/.prettierignore +4 -0
  9. package/.prettierrc +1 -0
  10. package/AGENTS.md +151 -0
  11. package/README.md +98 -0
  12. package/VERSION +1 -0
  13. package/agents/README.md +73 -0
  14. package/agents/modes/README.md +9 -0
  15. package/agents/modes/build.md +88 -0
  16. package/agents/modes/hack.md +76 -0
  17. package/agents/modes/review.md +79 -0
  18. package/agents/roles/builder.md +75 -0
  19. package/agents/roles/counsel.md +96 -0
  20. package/agents/roles/explorer.md +77 -0
  21. package/agents/roles/lead.md +76 -0
  22. package/agents/roles/librarian.md +63 -0
  23. package/agents/roles/planner.md +75 -0
  24. package/agents/roles/reviewer/BASE.md +9 -0
  25. package/agents/roles/reviewer/OUTPUT_FORMAT.md +4 -0
  26. package/agents/roles/reviewer/README.md +48 -0
  27. package/agents/roles/reviewer/lite.md +51 -0
  28. package/agents/roles/reviewer/logic.md +48 -0
  29. package/agents/roles/reviewer/performance.md +45 -0
  30. package/agents/roles/reviewer/plan.md +52 -0
  31. package/agents/roles/reviewer/quality.md +49 -0
  32. package/agents/roles/reviewer/security.md +47 -0
  33. package/agents/roles/reviewer/test.md +48 -0
  34. package/agents/templates/README.md +6 -0
  35. package/agents/templates/mode.md +33 -0
  36. package/agents/templates/role.md +73 -0
  37. package/contribute/README.md +24 -0
  38. package/contribute/add-agent.md +29 -0
  39. package/contribute/add-ci.md +31 -0
  40. package/contribute/add-constitution.md +17 -0
  41. package/contribute/add-law.md +20 -0
  42. package/contribute/add-policy.md +27 -0
  43. package/contribute/checklist.md +42 -0
  44. package/contribute/maintenance.md +19 -0
  45. package/contribute/update-models.md +47 -0
  46. package/docs/README.md +13 -0
  47. package/docs/adr/0001-knowledge-engineering-workflow.md +22 -0
  48. package/docs/adr/0002-rule-hierarchy.md +25 -0
  49. package/docs/adr/0003-atomic-knowledge-graph.md +21 -0
  50. package/docs/adr/0004-identification-schema.md +22 -0
  51. package/docs/adr/0005-agent-specialization.md +39 -0
  52. package/docs/adr/0006-git-workflow-integrity.md +34 -0
  53. package/docs/adr/0007-operating-modes-and-gates.md +54 -0
  54. package/docs/adr/0008-rules-vs-workflows-boundary.md +64 -0
  55. package/docs/adr/README.md +14 -0
  56. package/docs/agent_architecture.md +164 -0
  57. package/docs/context_lifecycle.md +228 -0
  58. package/docs/engineering_principles.md +128 -0
  59. package/docs/local_policies.md +59 -0
  60. package/docs/meta/collaborative_dynamics.md +142 -0
  61. package/docs/meta/domains/README.md +8 -0
  62. package/docs/meta/domains/bitcoin/01-units.md +21 -0
  63. package/docs/meta/domains/bitcoin/02-broadcast-cancellation.md +20 -0
  64. package/docs/meta/domains/bitcoin/03-fee-rates-rounding.md +21 -0
  65. package/docs/meta/domains/bitcoin/04-confirmations-reorgs.md +20 -0
  66. package/docs/meta/domains/bitcoin/05-address-gap-limit.md +16 -0
  67. package/docs/meta/domains/bitcoin/06-relay-policy.md +27 -0
  68. package/docs/meta/domains/bitcoin/README.md +12 -0
  69. package/docs/meta/domains/git/01-workflow.md +89 -0
  70. package/docs/meta/domains/git/02-commits.md +57 -0
  71. package/docs/meta/domains/git/03-collaboration.md +40 -0
  72. package/docs/meta/domains/git/04-integrity.md +26 -0
  73. package/docs/meta/domains/git/05-configuration.md +209 -0
  74. package/docs/meta/domains/git/06-advanced.md +130 -0
  75. package/docs/meta/domains/git/README.md +29 -0
  76. package/docs/meta/industry_best_practices.md +555 -0
  77. package/docs/meta/languages/README.md +8 -0
  78. package/docs/meta/languages/go/01-concurrency.md +37 -0
  79. package/docs/meta/languages/go/02-api-design.md +30 -0
  80. package/docs/meta/languages/go/03-resilience.md +27 -0
  81. package/docs/meta/languages/go/04-errors.md +27 -0
  82. package/docs/meta/languages/go/05-performance.md +18 -0
  83. package/docs/meta/languages/go/06-safety.md +18 -0
  84. package/docs/meta/languages/go/07-testing.md +44 -0
  85. package/docs/meta/languages/go/08-config-layout.md +23 -0
  86. package/docs/meta/languages/go/README.md +14 -0
  87. package/docs/meta/languages/typescript/01-strictness.md +19 -0
  88. package/docs/meta/languages/typescript/02-immutability.md +15 -0
  89. package/docs/meta/languages/typescript/03-async.md +18 -0
  90. package/docs/meta/languages/typescript/04-design.md +19 -0
  91. package/docs/meta/languages/typescript/05-control-flow.md +11 -0
  92. package/docs/meta/languages/typescript/README.md +11 -0
  93. package/docs/meta/workflow.md +68 -0
  94. package/docs/philosophy.md +36 -0
  95. package/integrate/README.md +459 -0
  96. package/integrate/versioning.md +41 -0
  97. package/models/README.md +68 -0
  98. package/models/registry.yaml +55 -0
  99. package/package.json +11 -0
  100. package/rules/README.md +57 -0
  101. package/rules/RULE-00000-EXAMPLE.md +29 -0
  102. package/rules/constitution/CONS-00001-srp.md +40 -0
  103. package/rules/constitution/CONS-00002-ocp.md +43 -0
  104. package/rules/constitution/CONS-00003-lsp.md +44 -0
  105. package/rules/constitution/CONS-00004-isp.md +46 -0
  106. package/rules/constitution/CONS-00005-dip.md +37 -0
  107. package/rules/constitution/CONS-00006-dry.md +45 -0
  108. package/rules/constitution/CONS-00007-demeter.md +35 -0
  109. package/rules/constitution/CONS-00008-composition.md +44 -0
  110. package/rules/constitution/CONS-00009-deep-modules.md +39 -0
  111. package/rules/constitution/CONS-00010-kiss.md +47 -0
  112. package/rules/constitution/CONS-00011-yagni.md +49 -0
  113. package/rules/constitution/CONS-00012-cognitive-limits.md +28 -0
  114. package/rules/constitution/CONS-00013-boy-scout.md +27 -0
  115. package/rules/constitution/CONS-00014-broken-windows.md +35 -0
  116. package/rules/constitution/CONS-00015-safety.md +46 -0
  117. package/rules/constitution/CONS-00016-cqs.md +39 -0
  118. package/rules/constitution/CONS-00017-postel.md +35 -0
  119. package/rules/constitution/CONS-00018-cap.md +35 -0
  120. package/rules/constitution/CONS-00019-fallacies.md +37 -0
  121. package/rules/constitution/CONS-00020-shift-left.md +28 -0
  122. package/rules/constitution/CONS-00021-congruence.md +28 -0
  123. package/rules/constitution/CONS-00022-orthogonality.md +40 -0
  124. package/rules/constitution/CONS-00023-determinism.md +38 -0
  125. package/rules/constitution/CONS-00024-security.md +42 -0
  126. package/rules/constitution/CONS-00025-efficiency.md +38 -0
  127. package/rules/constitution/CONS-00026-resilience.md +41 -0
  128. package/rules/constitution/CONS-00027-transparency.md +40 -0
  129. package/rules/constitution/CONS-00028-evolvability.md +36 -0
  130. package/rules/constitution/CONS-00029-operability.md +36 -0
  131. package/rules/constitution/CONS-00030-rework-cycle.md +27 -0
  132. package/rules/constitution/CONS-00031-checklist.md +28 -0
  133. package/rules/constitution/CONS-00032-documentation.md +39 -0
  134. package/rules/constitution/README.md +52 -0
  135. package/rules/laws/README.md +15 -0
  136. package/rules/laws/bitcoin/BTC-00001-amounts-as-satoshis.md +39 -0
  137. package/rules/laws/bitcoin/BTC-00002-broadcast-not-cancelable.md +36 -0
  138. package/rules/laws/bitcoin/BTC-00003-fee-rate-math-rounding.md +37 -0
  139. package/rules/laws/bitcoin/BTC-00004-confirmations-and-reorgs.md +40 -0
  140. package/rules/laws/bitcoin/BTC-00005-address-gap-limit.md +37 -0
  141. package/rules/laws/bitcoin/BTC-00006-relay-is-policy-dependent.md +36 -0
  142. package/rules/laws/bitcoin/BTC-00007-dust-policy.md +36 -0
  143. package/rules/laws/bitcoin/BTC-00008-min-relay-fee.md +36 -0
  144. package/rules/laws/bitcoin/BTC-00009-feefilter.md +36 -0
  145. package/rules/laws/bitcoin/README.md +29 -0
  146. package/rules/laws/default.md +30 -0
  147. package/rules/laws/git/GIT-00001-atomic-commit.md +29 -0
  148. package/rules/laws/git/GIT-00002-imperative-subject.md +27 -0
  149. package/rules/laws/git/GIT-00003-formatting-50-72.md +28 -0
  150. package/rules/laws/git/GIT-00004-trunk-based.md +28 -0
  151. package/rules/laws/git/GIT-00005-public-immutability.md +26 -0
  152. package/rules/laws/git/GIT-00006-signing.md +27 -0
  153. package/rules/laws/git/GIT-00007-reviewer-capital.md +26 -0
  154. package/rules/laws/git/GIT-00008-patch-series.md +28 -0
  155. package/rules/laws/git/GIT-00009-branch-naming.md +28 -0
  156. package/rules/laws/git/GIT-00010-pr-hygiene.md +51 -0
  157. package/rules/laws/git/GIT-00011-merge-method.md +35 -0
  158. package/rules/laws/git/GIT-00012-conflict-resolution.md +35 -0
  159. package/rules/laws/git/GIT-00013-ignore-standards.md +38 -0
  160. package/rules/laws/git/GIT-00014-lfs-large-binaries.md +37 -0
  161. package/rules/laws/git/GIT-00015-git-hooks.md +35 -0
  162. package/rules/laws/git/GIT-00016-branch-protection.md +34 -0
  163. package/rules/laws/git/GIT-00017-secrets-management.md +34 -0
  164. package/rules/laws/git/GIT-00018-ci-enforcement.md +33 -0
  165. package/rules/laws/git/GIT-00019-review-checklist.md +39 -0
  166. package/rules/laws/git/GIT-00020-issue-references.md +34 -0
  167. package/rules/laws/git/GIT-00021-partial-staging.md +38 -0
  168. package/rules/laws/git/GIT-00022-feature-flags.md +33 -0
  169. package/rules/laws/git/GIT-00023-breaking-changes.md +41 -0
  170. package/rules/laws/git/GIT-00024-dependency-management.md +44 -0
  171. package/rules/laws/git/GIT-00025-large-repository-optimization.md +54 -0
  172. package/rules/laws/git/README.md +31 -0
  173. package/rules/laws/go/GO-00001-actor-model.md +51 -0
  174. package/rules/laws/go/GO-00002-api-design.md +37 -0
  175. package/rules/laws/go/GO-00003-error-handling.md +43 -0
  176. package/rules/laws/go/GO-00004-context.md +45 -0
  177. package/rules/laws/go/GO-00005-performance.md +40 -0
  178. package/rules/laws/go/GO-00006-packages.md +29 -0
  179. package/rules/laws/go/GO-00007-circuit-breakers.md +43 -0
  180. package/rules/laws/go/GO-00008-safety.md +39 -0
  181. package/rules/laws/go/GO-00009-table-driven-test.md +48 -0
  182. package/rules/laws/go/GO-00010-escape-analysis.md +37 -0
  183. package/rules/laws/go/GO-00011-retry.md +45 -0
  184. package/rules/laws/go/GO-00012-rate-limiting.md +42 -0
  185. package/rules/laws/go/GO-00013-io-buffering.md +43 -0
  186. package/rules/laws/go/GO-00014-memory-layout.md +41 -0
  187. package/rules/laws/go/GO-00015-aaa-pattern.md +49 -0
  188. package/rules/laws/go/GO-00016-test-libraries.md +35 -0
  189. package/rules/laws/go/GO-00017-comments.md +37 -0
  190. package/rules/laws/go/GO-00018-test-isolation.md +38 -0
  191. package/rules/laws/go/GO-00019-test-comments.md +36 -0
  192. package/rules/laws/go/GO-00020-mocking.md +36 -0
  193. package/rules/laws/go/GO-00021-configuration.md +36 -0
  194. package/rules/laws/go/GO-00022-observability.md +34 -0
  195. package/rules/laws/go/GO-00023-dependency-management.md +28 -0
  196. package/rules/laws/go/GO-00024-project-layout.md +30 -0
  197. package/rules/laws/go/GO-00025-concurrency-patterns.md +39 -0
  198. package/rules/laws/go/README.md +45 -0
  199. package/rules/laws/typescript/README.md +14 -0
  200. package/rules/laws/typescript/TS-00001-no-any.md +39 -0
  201. package/rules/laws/typescript/TS-00002-immutability.md +36 -0
  202. package/rules/laws/typescript/TS-00003-async.md +35 -0
  203. package/rules/laws/typescript/TS-00004-strict-null.md +38 -0
  204. package/rules/laws/typescript/TS-00005-unions.md +35 -0
  205. package/rules/laws/typescript/TS-00006-interface.md +38 -0
  206. package/rules/laws/typescript/TS-00007-generics.md +38 -0
  207. package/rules/laws/typescript/TS-00008-modules.md +28 -0
  208. package/rules/policies/README.md +12 -0
  209. package/rules/policies/default.md +28 -0
  210. package/scripts/README.md +45 -0
  211. package/scripts/generate_release_notes.py +376 -0
  212. package/scripts/validate_specs.py +730 -0
@@ -0,0 +1,28 @@
1
+ # Module Organization (TS-00008)
2
+
3
+ **Source**: [The Law of TypeScript](../../../docs/meta/languages/typescript/04-design.md#3-module-organization)
4
+ **Tags**: #structural #typescript #architecture
5
+ **Related**: [Clean Packages](../go/GO-00006-packages.md)
6
+
7
+ ## Definition
8
+
9
+ Organize code by Feature/Domain, not by technical type.
10
+
11
+ ## Requirements
12
+
13
+ 1. **Feature Folders**: Group related components, styles, and tests in one folder.
14
+ 2. **No `types/`**: Keep types co-located with the code that uses them.
15
+ 3. **Barrels**: Use `index.ts` to export public API of a module.
16
+
17
+ ## Anti-Patterns
18
+
19
+ - **Technical Split**: `src/components`, `src/utils`, `src/types` (MVC style).
20
+ - **Deep Imports**: Importing `module/internal/helper` instead of `module`.
21
+
22
+ ## Examples
23
+
24
+ **Bad:**
25
+ `src/types/User.ts`, `src/controllers/UserController.ts`
26
+
27
+ **Good:**
28
+ `src/features/user/types.ts`, `src/features/user/controller.ts`
@@ -0,0 +1,12 @@
1
+ # Project Policies
2
+
3
+ This directory contains repository-specific configuration and style rules.
4
+
5
+ ## Usage
6
+
7
+ - **Default**: `default.md` is used if no other policy matches.
8
+ - **Custom**: Add `[project]-policy.md` for specific repos.
9
+
10
+ ## Index
11
+
12
+ - [Default Policy](default.md)
@@ -0,0 +1,28 @@
1
+ # Default Policy (POL-00000)
2
+
3
+ **Source**: [Industry Best Practices](../../docs/meta/industry_best_practices.md#1-solid-principles)
4
+ **Tags**: #operational #policy #default
5
+ **Related**: [KISS](../constitution/CONS-00010-kiss.md)
6
+
7
+ ## Definition
8
+
9
+ Baseline defaults for any project without specific configuration.
10
+
11
+ ## Requirements
12
+
13
+ 1. **Format**: Use standard language formatters (`gofmt`, `prettier`).
14
+ 2. **Lint**: Use standard linters (`golangci-lint`, `eslint`).
15
+ 3. **Naming**: Idiomatic casing (camelCase for JS, snake_case for Python).
16
+
17
+ ## Anti-Patterns
18
+
19
+ - **Custom Style**: Arguing about whitespace.
20
+ - **No CI**: Merging without checks.
21
+
22
+ ## Examples
23
+
24
+ **Bad:**
25
+ Manually formatting JSON.
26
+
27
+ **Good:**
28
+ `bun run format`
@@ -0,0 +1,45 @@
1
+ # Scripts
2
+
3
+ ## Index
4
+
5
+ - `validate_specs.py`: Repository integrity checks (rules structure, indexing, capability registry).
6
+ - `generate_release_notes.py`: Categorized release notes for GitHub releases.
7
+
8
+ ## Validation
9
+
10
+ Run validation:
11
+
12
+ ```bash
13
+ python3 scripts/validate_specs.py
14
+ ```
15
+
16
+ Optional: validate external Markdown links (network-dependent):
17
+
18
+ ```bash
19
+ python3 scripts/validate_specs.py --check-urls
20
+ ```
21
+
22
+ Tune timeout if needed:
23
+
24
+ ```bash
25
+ python3 scripts/validate_specs.py --check-urls --url-timeout 12
26
+ ```
27
+
28
+ Ignore a specific external URL (repeatable):
29
+
30
+ ```bash
31
+ python3 scripts/validate_specs.py --check-urls --url-ignore "https://example.com/badge.svg"
32
+ ```
33
+
34
+ Run the full local check (validation + formatting):
35
+
36
+ ```bash
37
+ bun run check
38
+ ```
39
+
40
+ If the repo ever needs a temporary warnings baseline during a migration:
41
+
42
+ ```bash
43
+ python3 scripts/validate_specs.py --write-baseline scripts/validate_baseline.txt
44
+ python3 scripts/validate_specs.py --baseline scripts/validate_baseline.txt
45
+ ```
@@ -0,0 +1,376 @@
1
+ """Generate GitHub release notes for a semver tag.
2
+
3
+ This script is intended to run in CI on tag push.
4
+
5
+ Output goals:
6
+ - Summary paragraph.
7
+ - Highlights grouped by impacted repo area (Knowledge, Agents, Modes, Integration, Models, etc.).
8
+ - Keep it scannable; omit long commit/PR listings.
9
+
10
+ Inputs:
11
+ - Tag name like "v0.0.2" (from GITHUB_REF_NAME).
12
+ - Git history with tags available (checkout fetch-depth 0).
13
+
14
+ Auth:
15
+ - If GITHUB_TOKEN is set, the script fetches PR metadata and file lists via GitHub API.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import re
24
+ import subprocess
25
+ import sys
26
+ import urllib.parse
27
+ import urllib.request
28
+ from dataclasses import dataclass
29
+
30
+
31
+ TYPE_RE = re.compile(r"^(?P<type>[a-z]+)(\((?P<scope>[^)]+)\))?:\s+(?P<rest>.+)$")
32
+ MERGE_PR_RE = re.compile(r"^Merge pull request #(?P<num>\d+)\b")
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class PR:
37
+ number: int
38
+ title: str
39
+ url: str
40
+ type: str
41
+ keyword: str
42
+ files: tuple[str, ...] = ()
43
+
44
+
45
+ def sh(*args: str) -> str:
46
+ out = subprocess.check_output(list(args), stderr=subprocess.STDOUT)
47
+ return out.decode("utf-8").strip()
48
+
49
+
50
+ def git_tags() -> list[str]:
51
+ txt = sh("git", "tag", "-l", "v*", "--sort=-version:refname")
52
+ return [line.strip() for line in txt.splitlines() if line.strip()]
53
+
54
+
55
+ def previous_tag(current: str) -> str | None:
56
+ for t in git_tags():
57
+ if t != current:
58
+ return t
59
+ return None
60
+
61
+
62
+ def merged_pr_merges(prev: str | None, cur: str) -> list[tuple[int, str]]:
63
+ """Return (pr_number, merge_commit_sha) pairs."""
64
+
65
+ rev_range = f"{prev}..{cur}" if prev else cur
66
+ txt = sh(
67
+ "git",
68
+ "log",
69
+ "--merges",
70
+ "--first-parent",
71
+ "--pretty=%H %s",
72
+ rev_range,
73
+ )
74
+
75
+ out: list[tuple[int, str]] = []
76
+ seen: set[int] = set()
77
+ for line in txt.splitlines():
78
+ line = line.strip()
79
+ if not line:
80
+ continue
81
+ sha, subj = line.split(" ", 1)
82
+ m = MERGE_PR_RE.match(subj.strip())
83
+ if not m:
84
+ continue
85
+ n = int(m.group("num"))
86
+ if n in seen:
87
+ continue
88
+ seen.add(n)
89
+ out.append((n, sha))
90
+ return out
91
+
92
+
93
+ def merge_pr_title_from_commit(sha: str) -> str | None:
94
+ """Extract PR title from a GitHub merge commit body."""
95
+
96
+ body = sh("git", "log", "-1", "--format=%b", sha)
97
+ for line in body.splitlines():
98
+ t = line.strip()
99
+ if t:
100
+ return t
101
+ return None
102
+
103
+
104
+ def files_from_commit(sha: str) -> tuple[str, ...]:
105
+ # Merge commits require `-m` to surface per-parent file lists.
106
+ txt = sh("git", "diff-tree", "-m", "--no-commit-id", "--name-only", "-r", sha)
107
+ files = [line.strip() for line in txt.splitlines() if line.strip()]
108
+ return tuple(sorted({f for f in files if f}))
109
+
110
+
111
+ def local_pr(repo: str, num: int, sha: str) -> PR:
112
+ url = f"https://github.com/{repo}/pull/{num}"
113
+ title = merge_pr_title_from_commit(sha) or f"PR #{num}"
114
+ return PR(
115
+ number=num,
116
+ title=title,
117
+ url=url,
118
+ type=infer_type_from_title(title),
119
+ keyword=infer_keyword_from_title(title),
120
+ files=files_from_commit(sha),
121
+ )
122
+
123
+
124
+ def infer_type_from_title(title: str) -> str:
125
+ m = TYPE_RE.match(title.strip())
126
+ if not m:
127
+ return "other"
128
+ t = m.group("type")
129
+ return t.lower()
130
+
131
+
132
+ def infer_keyword_from_title(title: str) -> str:
133
+ m = TYPE_RE.match(title.strip())
134
+ if not m:
135
+ return "other"
136
+ scope = m.group("scope")
137
+ t = m.group("type")
138
+ return (scope or t or "other").lower()
139
+
140
+
141
+ def strip_type_prefix(title: str) -> str:
142
+ m = TYPE_RE.match(title.strip())
143
+ return m.group("rest") if m else title.strip()
144
+
145
+
146
+ def github_api_get_json(url: str, token: str | None) -> dict:
147
+ req = urllib.request.Request(url)
148
+ req.add_header("User-Agent", "lgtm-specs-release-notes")
149
+ req.add_header("Accept", "application/vnd.github+json")
150
+ if token:
151
+ req.add_header("Authorization", f"Bearer {token}")
152
+ with urllib.request.urlopen(req, timeout=30) as resp:
153
+ return json.loads(resp.read().decode("utf-8"))
154
+
155
+
156
+ def fetch_pr_files(repo: str, num: int, token: str | None) -> tuple[str, ...]:
157
+ if not token:
158
+ return ()
159
+
160
+ files: list[str] = []
161
+ page = 1
162
+ per_page = 100
163
+ while True:
164
+ api = f"https://api.github.com/repos/{repo}/pulls/{num}/files?per_page={per_page}&page={page}"
165
+ data = github_api_get_json(api, token)
166
+ if not isinstance(data, list):
167
+ break
168
+ batch = [str(item.get("filename")) for item in data if isinstance(item, dict) and item.get("filename")]
169
+ files.extend(batch)
170
+ if len(batch) < per_page:
171
+ break
172
+ page += 1
173
+
174
+ # Stable ordering.
175
+ return tuple(sorted({f for f in files if f}))
176
+
177
+
178
+ def fetch_pr(repo: str, num: int, token: str | None) -> PR:
179
+ api = f"https://api.github.com/repos/{repo}/pulls/{num}"
180
+ data = github_api_get_json(api, token)
181
+ title = str(data.get("title") or f"PR #{num}")
182
+ url = str(data.get("html_url") or f"https://github.com/{repo}/pull/{num}")
183
+ return PR(
184
+ number=num,
185
+ title=title,
186
+ url=url,
187
+ type=infer_type_from_title(title),
188
+ keyword=infer_keyword_from_title(title),
189
+ files=fetch_pr_files(repo, num, token),
190
+ )
191
+
192
+
193
+ def pr_areas(pr: PR) -> set[str]:
194
+ # Repo-area classification for highlights.
195
+ areas: set[str] = set()
196
+
197
+ for f in pr.files:
198
+ if f.startswith("rules/") or f.startswith("docs/meta/"):
199
+ areas.add("Knowledge")
200
+ if f.startswith("agents/modes/"):
201
+ areas.add("Modes")
202
+ elif f.startswith("agents/roles/"):
203
+ areas.add("Agents")
204
+ elif f.startswith("agents/"):
205
+ areas.add("Agents")
206
+ if f.startswith("integrate/"):
207
+ areas.add("Integration")
208
+ if f.startswith("models/"):
209
+ areas.add("Models")
210
+ if f.startswith(".github/workflows/"):
211
+ areas.add("CI")
212
+ if f.startswith("scripts/"):
213
+ areas.add("Tooling")
214
+ if f.startswith("contribute/"):
215
+ areas.add("Contributing")
216
+
217
+ if not areas:
218
+ # Fallback: infer from conventional scope/type.
219
+ kw = (pr.keyword or pr.type or "").lower()
220
+ if kw in ("agents", "review", "reviewers"):
221
+ areas.add("Agents")
222
+ elif kw in ("integrate", "integration"):
223
+ areas.add("Integration")
224
+ elif kw in ("models", "model"):
225
+ areas.add("Models")
226
+ elif kw in ("ci", "release"):
227
+ areas.add("CI")
228
+ elif kw in ("docs",):
229
+ areas.add("Knowledge")
230
+ else:
231
+ areas.add("Other")
232
+
233
+ return areas
234
+
235
+
236
+ def keyword_search_url(repo: str, keyword: str) -> str:
237
+ # Search merged PR titles for a keyword or conventional prefix.
238
+ conventional = {
239
+ "feat",
240
+ "fix",
241
+ "docs",
242
+ "ci",
243
+ "chore",
244
+ "refactor",
245
+ "test",
246
+ "perf",
247
+ "build",
248
+ "style",
249
+ }
250
+ needle = f"{keyword}:" if keyword in conventional else keyword
251
+ q = f"is:pr is:merged in:title \"{needle}\""
252
+ return f"https://github.com/{repo}/pulls?q={urllib.parse.quote(q)}"
253
+
254
+
255
+ def pr_link(pr: PR) -> str:
256
+ return f"[#${pr.number}]({pr.url})".replace("#$", "#")
257
+
258
+
259
+ def summary_paragraph(tag: str, prev: str | None, prs: list[PR], areas_sorted: list[str]) -> str:
260
+ n = len(prs)
261
+ prev_txt = prev if prev else "the previous release"
262
+ if not areas_sorted or areas_sorted == ["Other"]:
263
+ return f"{tag} includes {n} merged PR(s) since {prev_txt}."
264
+
265
+ top = areas_sorted[:3]
266
+ if len(top) == 1:
267
+ focus = top[0]
268
+ elif len(top) == 2:
269
+ focus = f"{top[0]} and {top[1]}"
270
+ else:
271
+ focus = f"{top[0]}, {top[1]}, and {top[2]}"
272
+
273
+ return f"{tag} includes {n} merged PR(s) since {prev_txt}, focusing on {focus}."
274
+
275
+
276
+ def compare_url(repo: str, prev: str | None, cur: str) -> str:
277
+ if not prev:
278
+ return f"https://github.com/{repo}/commits/{cur}"
279
+ return f"https://github.com/{repo}/compare/{prev}...{cur}"
280
+
281
+
282
+ def main() -> int:
283
+ ap = argparse.ArgumentParser(add_help=True)
284
+ ap.add_argument("--tag", type=str, default=os.environ.get("GITHUB_REF_NAME"))
285
+ ap.add_argument("--repo", type=str, default=os.environ.get("GITHUB_REPOSITORY"))
286
+ ap.add_argument("--output", type=str, required=True)
287
+ args = ap.parse_args()
288
+
289
+ tag = (args.tag or "").strip()
290
+ repo = (args.repo or "").strip()
291
+ if not tag:
292
+ print("ERROR: missing --tag (or GITHUB_REF_NAME)", file=sys.stderr)
293
+ return 2
294
+ if not repo:
295
+ print("ERROR: missing --repo (or GITHUB_REPOSITORY)", file=sys.stderr)
296
+ return 2
297
+
298
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
299
+ prev = previous_tag(tag)
300
+ pr_merges = merged_pr_merges(prev, tag)
301
+
302
+ prs: list[PR] = []
303
+ for n, sha in pr_merges:
304
+ if token:
305
+ try:
306
+ prs.append(fetch_pr(repo, n, token))
307
+ continue
308
+ except Exception:
309
+ pass
310
+ prs.append(local_pr(repo, n, sha))
311
+
312
+ # Group by impacted area.
313
+ by_area: dict[str, list[PR]] = {}
314
+ for pr in prs:
315
+ for area in pr_areas(pr):
316
+ by_area.setdefault(area, []).append(pr)
317
+
318
+ area_order = [
319
+ "Knowledge",
320
+ "Agents",
321
+ "Modes",
322
+ "Integration",
323
+ "Models",
324
+ "CI",
325
+ "Tooling",
326
+ "Contributing",
327
+ "Other",
328
+ ]
329
+
330
+ areas_sorted = [a for a in area_order if a in by_area]
331
+
332
+ lines: list[str] = []
333
+ lines.append(f"# {tag}")
334
+ lines.append("")
335
+ lines.append("## Summary")
336
+ lines.append("")
337
+ lines.append(summary_paragraph(tag, prev, prs, [a for a in areas_sorted if a != "Other"]))
338
+ lines.append("")
339
+
340
+ if prs:
341
+ lines.append("## Highlights")
342
+ lines.append("")
343
+
344
+ # Show up to 6 areas. If everything is "Other", show it anyway.
345
+ areas_to_show = [a for a in areas_sorted if a != "Other"]
346
+ if not areas_to_show and "Other" in areas_sorted:
347
+ areas_to_show = ["Other"]
348
+
349
+ for area in areas_to_show[:6]:
350
+ items = by_area.get(area, [])
351
+ if not items:
352
+ continue
353
+
354
+ # Prefer up to 2 representative PR titles to describe the area.
355
+ reps = items[:2]
356
+ titles = "; ".join(strip_type_prefix(p.title) for p in reps)
357
+ links = ", ".join(pr_link(p) for p in items[:4])
358
+ if len(items) > 4:
359
+ links = f"{links}, ..."
360
+
361
+ lines.append(f"- {area}: {titles} ({links})")
362
+
363
+ lines.append("")
364
+
365
+ lines.append(f"**Full Changelog**: {compare_url(repo, prev, tag)}")
366
+ lines.append("")
367
+
368
+ out = "\n".join(lines)
369
+ with open(args.output, "w", encoding="utf-8") as f:
370
+ f.write(out)
371
+
372
+ return 0
373
+
374
+
375
+ if __name__ == "__main__":
376
+ raise SystemExit(main())