@trac3er/oh-my-god 2.0.0 → 2.0.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 (243) hide show
  1. package/.claude-plugin/marketplace.json +8 -8
  2. package/.claude-plugin/plugin.json +5 -4
  3. package/.claude-plugin/scripts/uninstall.sh +74 -3
  4. package/.claude-plugin/scripts/update.sh +78 -3
  5. package/.coveragerc +26 -0
  6. package/.mcp.json +4 -4
  7. package/CHANGELOG.md +14 -0
  8. package/CODE_OF_CONDUCT.md +27 -0
  9. package/CONTRIBUTING.md +62 -0
  10. package/OMG-setup.sh +1201 -355
  11. package/README.md +77 -56
  12. package/SECURITY.md +25 -0
  13. package/agents/__init__.py +1 -0
  14. package/agents/model_roles.py +196 -0
  15. package/agents/omg-architect-mode.md +3 -5
  16. package/agents/omg-backend-engineer.md +3 -5
  17. package/agents/omg-database-engineer.md +3 -5
  18. package/agents/omg-frontend-designer.md +4 -5
  19. package/agents/omg-implement-mode.md +4 -5
  20. package/agents/omg-infra-engineer.md +3 -5
  21. package/agents/omg-research-mode.md +4 -6
  22. package/agents/omg-security-auditor.md +3 -5
  23. package/agents/omg-testing-engineer.md +3 -5
  24. package/build/lib/yaml.py +321 -0
  25. package/commands/OMG:ai-commit.md +101 -14
  26. package/commands/OMG:arch.md +302 -19
  27. package/commands/OMG:ccg.md +12 -7
  28. package/commands/OMG:compat.md +25 -17
  29. package/commands/OMG:cost.md +173 -13
  30. package/commands/OMG:crazy.md +1 -1
  31. package/commands/OMG:create-agent.md +170 -20
  32. package/commands/OMG:deps.md +235 -17
  33. package/commands/OMG:domain-init.md +1 -1
  34. package/commands/OMG:escalate.md +41 -12
  35. package/commands/OMG:health-check.md +37 -13
  36. package/commands/OMG:init.md +122 -14
  37. package/commands/OMG:project-init.md +1 -1
  38. package/commands/OMG:session-branch.md +76 -9
  39. package/commands/OMG:session-fork.md +42 -5
  40. package/commands/OMG:session-merge.md +124 -8
  41. package/commands/OMG:setup.md +69 -12
  42. package/commands/OMG:stats.md +215 -14
  43. package/commands/OMG:teams.md +19 -10
  44. package/config/lsp_languages.yaml +8 -0
  45. package/hooks/__init__.py +0 -0
  46. package/hooks/_agent_registry.py +423 -0
  47. package/hooks/_analytics.py +291 -0
  48. package/hooks/_budget.py +31 -0
  49. package/hooks/_common.py +569 -0
  50. package/hooks/_compression_optimizer.py +119 -0
  51. package/hooks/_cost_ledger.py +176 -0
  52. package/hooks/_learnings.py +126 -0
  53. package/hooks/_memory.py +103 -0
  54. package/hooks/_protected_context.py +150 -0
  55. package/hooks/_token_counter.py +221 -0
  56. package/hooks/branch_manager.py +236 -0
  57. package/hooks/budget_governor.py +232 -0
  58. package/hooks/circuit-breaker.py +270 -0
  59. package/hooks/compression_feedback.py +254 -0
  60. package/hooks/config-guard.py +216 -0
  61. package/hooks/context_pressure.py +53 -0
  62. package/hooks/credential_store.py +1020 -0
  63. package/hooks/fetch-rate-limits.py +212 -0
  64. package/hooks/firewall.py +48 -0
  65. package/hooks/hashline-formatter-bridge.py +224 -0
  66. package/hooks/hashline-injector.py +273 -0
  67. package/hooks/hashline-validator.py +216 -0
  68. package/hooks/idle-detector.py +95 -0
  69. package/hooks/intentgate-keyword-detector.py +188 -0
  70. package/hooks/magic-keyword-router.py +195 -0
  71. package/hooks/policy_engine.py +505 -0
  72. package/hooks/post-tool-failure.py +19 -0
  73. package/hooks/post-write.py +219 -0
  74. package/hooks/post_write.py +46 -0
  75. package/hooks/pre-compact.py +398 -0
  76. package/hooks/pre-tool-inject.py +98 -0
  77. package/hooks/prompt-enhancer.py +672 -0
  78. package/hooks/quality-runner.py +191 -0
  79. package/hooks/query.py +512 -0
  80. package/hooks/secret-guard.py +61 -0
  81. package/hooks/secret_audit.py +144 -0
  82. package/hooks/session-end-capture.py +137 -0
  83. package/hooks/session-start.py +277 -0
  84. package/hooks/setup_wizard.py +582 -0
  85. package/hooks/shadow_manager.py +297 -0
  86. package/hooks/state_migration.py +225 -0
  87. package/hooks/stop-gate.py +7 -0
  88. package/hooks/stop_dispatcher.py +945 -0
  89. package/hooks/test-validator.py +361 -0
  90. package/hooks/test_generator_hook.py +123 -0
  91. package/hooks/todo-state-tracker.py +114 -0
  92. package/hooks/tool-ledger.py +149 -0
  93. package/hooks/trust_review.py +585 -0
  94. package/hud/omg-hud.mjs +31 -1
  95. package/lab/__init__.py +1 -0
  96. package/lab/pipeline.py +75 -0
  97. package/lab/policies.py +52 -0
  98. package/package.json +7 -18
  99. package/plugins/README.md +33 -61
  100. package/plugins/advanced/commands/OMG:deep-plan.md +3 -3
  101. package/plugins/advanced/commands/OMG:learn.md +1 -1
  102. package/plugins/advanced/commands/OMG:security-review.md +3 -3
  103. package/plugins/advanced/commands/OMG:ship.md +1 -1
  104. package/plugins/advanced/plugin.json +1 -1
  105. package/plugins/core/plugin.json +8 -3
  106. package/plugins/dephealth/__init__.py +0 -0
  107. package/plugins/dephealth/cve_scanner.py +188 -0
  108. package/plugins/dephealth/license_checker.py +135 -0
  109. package/plugins/dephealth/manifest_detector.py +423 -0
  110. package/plugins/dephealth/vuln_analyzer.py +169 -0
  111. package/plugins/testgen/__init__.py +0 -0
  112. package/plugins/testgen/codamosa_engine.py +402 -0
  113. package/plugins/testgen/edge_case_synthesizer.py +184 -0
  114. package/plugins/testgen/framework_detector.py +271 -0
  115. package/plugins/testgen/skeleton_generator.py +219 -0
  116. package/plugins/viz/__init__.py +0 -0
  117. package/plugins/viz/ast_parser.py +139 -0
  118. package/plugins/viz/diagram_generator.py +192 -0
  119. package/plugins/viz/graph_builder.py +444 -0
  120. package/plugins/viz/native_parsers.py +259 -0
  121. package/plugins/viz/regex_parser.py +112 -0
  122. package/pyproject.toml +81 -0
  123. package/rules/contextual/write-verify.md +2 -2
  124. package/rules/core/00-truth.md +1 -1
  125. package/rules/core/01-surgical.md +1 -1
  126. package/rules/core/02-circuit-breaker.md +2 -2
  127. package/rules/core/03-ensemble.md +3 -3
  128. package/rules/core/04-testing.md +3 -3
  129. package/runtime/__init__.py +32 -0
  130. package/runtime/adapters/__init__.py +13 -0
  131. package/runtime/adapters/claude.py +60 -0
  132. package/runtime/adapters/gpt.py +53 -0
  133. package/runtime/adapters/local.py +53 -0
  134. package/runtime/adoption.py +212 -0
  135. package/runtime/business_workflow.py +220 -0
  136. package/runtime/cli_provider.py +85 -0
  137. package/runtime/compat.py +1299 -0
  138. package/runtime/custom_agent_loader.py +366 -0
  139. package/runtime/dispatcher.py +47 -0
  140. package/runtime/ecosystem.py +371 -0
  141. package/runtime/legacy_compat.py +7 -0
  142. package/runtime/mcp_config_writers.py +115 -0
  143. package/runtime/mcp_lifecycle.py +153 -0
  144. package/runtime/mcp_memory_server.py +135 -0
  145. package/runtime/memory_parsers/__init__.py +0 -0
  146. package/runtime/memory_parsers/chatgpt_parser.py +257 -0
  147. package/runtime/memory_parsers/claude_import.py +107 -0
  148. package/runtime/memory_parsers/export.py +97 -0
  149. package/runtime/memory_parsers/gemini_import.py +91 -0
  150. package/runtime/memory_parsers/kimi_import.py +91 -0
  151. package/runtime/memory_store.py +215 -0
  152. package/runtime/omc_compat.py +7 -0
  153. package/runtime/providers/__init__.py +0 -0
  154. package/runtime/providers/codex_provider.py +112 -0
  155. package/runtime/providers/gemini_provider.py +128 -0
  156. package/runtime/providers/kimi_provider.py +151 -0
  157. package/runtime/providers/opencode_provider.py +144 -0
  158. package/runtime/subagent_dispatcher.py +362 -0
  159. package/runtime/team_router.py +1167 -0
  160. package/runtime/tmux_session_manager.py +169 -0
  161. package/scripts/check-omg-compat-contract-snapshot.py +137 -0
  162. package/scripts/check-omg-contract-snapshot.py +12 -0
  163. package/scripts/check-omg-public-ready.py +193 -0
  164. package/scripts/check-omg-standalone-clean.py +103 -0
  165. package/scripts/legacy_to_omg_migrate.py +29 -0
  166. package/scripts/migrate-legacy.py +464 -0
  167. package/scripts/omc_to_omg_migrate.py +12 -0
  168. package/scripts/omg.py +492 -0
  169. package/scripts/settings-merge.py +283 -0
  170. package/scripts/verify-standalone.sh +8 -4
  171. package/settings.json +126 -29
  172. package/templates/profile.yaml +1 -1
  173. package/tools/__init__.py +2 -0
  174. package/tools/browser_consent.py +289 -0
  175. package/tools/browser_stealth.py +481 -0
  176. package/tools/browser_tool.py +448 -0
  177. package/tools/changelog_generator.py +347 -0
  178. package/tools/commit_splitter.py +746 -0
  179. package/tools/config_discovery.py +151 -0
  180. package/tools/config_merger.py +449 -0
  181. package/tools/dashboard_generator.py +300 -0
  182. package/tools/git_inspector.py +298 -0
  183. package/tools/lsp_client.py +275 -0
  184. package/tools/lsp_discovery.py +231 -0
  185. package/tools/lsp_operations.py +392 -0
  186. package/tools/pr_generator.py +404 -0
  187. package/tools/python_repl.py +656 -0
  188. package/tools/python_sandbox.py +609 -0
  189. package/tools/search_providers/__init__.py +77 -0
  190. package/tools/search_providers/brave.py +115 -0
  191. package/tools/search_providers/exa.py +116 -0
  192. package/tools/search_providers/jina.py +104 -0
  193. package/tools/search_providers/perplexity.py +139 -0
  194. package/tools/search_providers/synthetic.py +74 -0
  195. package/tools/session_snapshot.py +736 -0
  196. package/tools/ssh_manager.py +912 -0
  197. package/tools/theme_engine.py +294 -0
  198. package/tools/theme_selector.py +137 -0
  199. package/tools/web_search.py +622 -0
  200. package/yaml.py +321 -0
  201. package/.claude-plugin/scripts/install.sh +0 -9
  202. package/bun.lock +0 -23
  203. package/bunfig.toml +0 -3
  204. package/hooks/_budget.ts +0 -1
  205. package/hooks/_common.ts +0 -63
  206. package/hooks/circuit-breaker.ts +0 -101
  207. package/hooks/config-guard.ts +0 -4
  208. package/hooks/firewall.ts +0 -20
  209. package/hooks/policy_engine.ts +0 -156
  210. package/hooks/post-tool-failure.ts +0 -22
  211. package/hooks/post-write.ts +0 -4
  212. package/hooks/pre-tool-inject.ts +0 -4
  213. package/hooks/prompt-enhancer.ts +0 -46
  214. package/hooks/quality-runner.ts +0 -24
  215. package/hooks/secret-guard.ts +0 -4
  216. package/hooks/session-end-capture.ts +0 -19
  217. package/hooks/session-start.ts +0 -19
  218. package/hooks/shadow_manager.ts +0 -81
  219. package/hooks/stop-gate.ts +0 -22
  220. package/hooks/stop_dispatcher.ts +0 -147
  221. package/hooks/test-generator-hook.ts +0 -4
  222. package/hooks/tool-ledger.ts +0 -27
  223. package/hooks/trust_review.ts +0 -175
  224. package/lab/pipeline.ts +0 -75
  225. package/lab/policies.ts +0 -68
  226. package/runtime/common.ts +0 -111
  227. package/runtime/compat.ts +0 -174
  228. package/runtime/dispatcher.ts +0 -25
  229. package/runtime/ecosystem.ts +0 -186
  230. package/runtime/provider_bootstrap.ts +0 -99
  231. package/runtime/provider_smoke.ts +0 -34
  232. package/runtime/release_readiness.ts +0 -186
  233. package/runtime/team_router.ts +0 -144
  234. package/scripts/check-omg-compat-contract-snapshot.ts +0 -20
  235. package/scripts/check-omg-standalone-clean.ts +0 -12
  236. package/scripts/check-runtime-clean.ts +0 -94
  237. package/scripts/omg.ts +0 -352
  238. package/scripts/settings-merge.ts +0 -93
  239. package/tools/commit_splitter.ts +0 -23
  240. package/tools/git_inspector.ts +0 -18
  241. package/tools/session_snapshot.ts +0 -47
  242. package/trac3er-oh-my-god-2.0.0.tgz +0 -0
  243. package/tsconfig.json +0 -15
@@ -0,0 +1,347 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Changelog Generator for OMG
4
+
5
+ Parses conventional commits from git log and generates/updates CHANGELOG.md
6
+ in Keep-a-Changelog format.
7
+
8
+ Feature flag: OMG_CHANGELOG_ENABLED (default: False)
9
+ """
10
+
11
+ import os
12
+ import re
13
+ import sys
14
+ from datetime import date
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ # Lazy imports
18
+ _git_inspector = None
19
+ _get_feature_flag = None
20
+
21
+
22
+ def _ensure_imports():
23
+ """Lazy import git_inspector and feature flag helper."""
24
+ global _git_inspector, _get_feature_flag
25
+ if _git_inspector is None:
26
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
27
+ from tools import git_inspector as _gi
28
+ from hooks._common import get_feature_flag as _gff
29
+ _git_inspector = _gi
30
+ _get_feature_flag = _gff
31
+
32
+
33
+ def _is_enabled() -> bool:
34
+ """Check if changelog feature is enabled."""
35
+ _ensure_imports()
36
+ return _get_feature_flag("GIT_WORKFLOW", default=False)
37
+
38
+
39
+ # Supported conventional commit types
40
+ CONVENTIONAL_TYPES = frozenset({
41
+ "feat", "fix", "docs", "style", "refactor",
42
+ "test", "chore", "perf", "ci", "build", "sec",
43
+ })
44
+
45
+ # Regex for conventional commit: type(scope): description OR type: description
46
+ _CONVENTIONAL_RE = re.compile(
47
+ r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?!?:\s*(?P<description>.+)$"
48
+ )
49
+
50
+ # Changelog section groupings
51
+ _TYPE_TO_SECTION = {
52
+ "feat": "Added",
53
+ "fix": "Fixed",
54
+ "refactor": "Changed",
55
+ "perf": "Changed",
56
+ "docs": "Changed",
57
+ "style": "Changed",
58
+ "build": "Changed",
59
+ "ci": "Changed",
60
+ "chore": "Other",
61
+ "test": "Other",
62
+ "sec": "Security",
63
+ }
64
+
65
+ _SECTION_ORDER = ["Added", "Fixed", "Changed", "Deprecated", "Removed", "Security", "Other"]
66
+
67
+
68
+ def parse_commit_log(cwd: str = ".") -> List[Dict[str, Any]]:
69
+ """Parse git log for conventional commits.
70
+
71
+ Args:
72
+ cwd: Working directory (default: current directory)
73
+
74
+ Returns:
75
+ List of dicts with keys: type, scope, description, hash, author, date, breaking
76
+ Returns empty list if OMG_CHANGELOG_ENABLED is False or no conventional commits found.
77
+ """
78
+ if not _is_enabled():
79
+ return []
80
+
81
+ _ensure_imports()
82
+ raw_commits = _git_inspector.git_log(cwd, n=100)
83
+
84
+ if not raw_commits:
85
+ return []
86
+
87
+ parsed = []
88
+ for commit in raw_commits:
89
+ subject = commit.get("subject", "").strip()
90
+ if not subject:
91
+ continue
92
+
93
+ match = _CONVENTIONAL_RE.match(subject)
94
+ if not match:
95
+ continue
96
+
97
+ commit_type = match.group("type").lower()
98
+ if commit_type not in CONVENTIONAL_TYPES:
99
+ continue
100
+
101
+ # Detect breaking changes
102
+ breaking = "BREAKING CHANGE" in subject or "!" in subject.split(":")[0]
103
+
104
+ parsed.append({
105
+ "type": commit_type,
106
+ "scope": match.group("scope") or "",
107
+ "description": match.group("description").strip(),
108
+ "hash": commit.get("hash", "")[:7],
109
+ "author": commit.get("author", ""),
110
+ "date": commit.get("date", ""),
111
+ "breaking": breaking,
112
+ })
113
+
114
+ return parsed
115
+
116
+
117
+ def generate_changelog_entry(
118
+ commits: List[Dict[str, Any]],
119
+ version: str = "Unreleased",
120
+ ) -> str:
121
+ """Format a Keep-a-Changelog section from parsed commits.
122
+
123
+ Args:
124
+ commits: List of parsed commit dicts from parse_commit_log()
125
+ version: Version label (default: "Unreleased")
126
+
127
+ Returns:
128
+ Formatted changelog section string.
129
+ Returns empty string if commits list is empty.
130
+ """
131
+ if not commits:
132
+ return ""
133
+
134
+ today = date.today().isoformat()
135
+ header = f"## [{version}] - {today}"
136
+
137
+ # Group commits by section
138
+ sections: Dict[str, List[str]] = {s: [] for s in _SECTION_ORDER}
139
+
140
+ for commit in commits:
141
+ section = _TYPE_TO_SECTION.get(commit["type"], "Other")
142
+ scope = commit.get("scope", "")
143
+ description = commit["description"]
144
+ short_hash = commit.get("hash", "")
145
+
146
+ if scope:
147
+ entry = f"- **{scope}**: {description}"
148
+ else:
149
+ entry = f"- {description}"
150
+
151
+ if short_hash:
152
+ entry += f" (#{short_hash})"
153
+
154
+ if commit.get("breaking"):
155
+ entry += " **[BREAKING]**"
156
+
157
+ sections[section].append(entry)
158
+
159
+ lines = [header, ""]
160
+
161
+ for section_name in _SECTION_ORDER:
162
+ entries = sections[section_name]
163
+ if not entries:
164
+ continue
165
+ lines.append(f"### {section_name}")
166
+ lines.extend(entries)
167
+ lines.append("")
168
+
169
+ # Strip trailing blank line
170
+ while lines and lines[-1] == "":
171
+ lines.pop()
172
+
173
+ return "\n".join(lines)
174
+
175
+
176
+ def update_changelog(cwd: str = ".", version: str = None) -> bool:
177
+ """Parse commits and prepend a new entry to CHANGELOG.md.
178
+
179
+ Reads existing CHANGELOG.md (or creates a new one). Inserts the new
180
+ entry after the top-level `# Changelog` header without overwriting
181
+ existing sections.
182
+
183
+ Args:
184
+ cwd: Working directory (default: current directory)
185
+ version: Version label (default: "Unreleased")
186
+
187
+ Returns:
188
+ True on success, False on failure or if no commits to add.
189
+ """
190
+ commits = parse_commit_log(cwd)
191
+ if not commits:
192
+ return False
193
+
194
+ entry = generate_changelog_entry(commits, version=version or "Unreleased")
195
+ if not entry:
196
+ return False
197
+
198
+ changelog_path = os.path.join(cwd, "CHANGELOG.md")
199
+
200
+ try:
201
+ if os.path.exists(changelog_path):
202
+ with open(changelog_path, "r", encoding="utf-8") as f:
203
+ existing = f.read()
204
+ else:
205
+ existing = "# Changelog\n\nAll notable changes to this project will be documented here.\n"
206
+
207
+ # Find insertion point: after the first `# Changelog` header line
208
+ lines = existing.splitlines(keepends=True)
209
+ insert_idx = 0
210
+ for i, line in enumerate(lines):
211
+ if line.startswith("# "):
212
+ insert_idx = i + 1
213
+ # Skip blank lines immediately after the header
214
+ while insert_idx < len(lines) and lines[insert_idx].strip() == "":
215
+ insert_idx += 1
216
+ break
217
+
218
+ new_block = entry + "\n\n"
219
+ lines.insert(insert_idx, new_block)
220
+ new_content = "".join(lines)
221
+
222
+ with open(changelog_path, "w", encoding="utf-8") as f:
223
+ f.write(new_content)
224
+
225
+ return True
226
+
227
+ except OSError:
228
+ return False
229
+
230
+
231
+ def _dry_run(cwd: str = ".", version: str = None) -> str:
232
+ """Return the changelog entry that would be written, without modifying any file."""
233
+ commits = parse_commit_log(cwd)
234
+ if not commits:
235
+ return "[OMG Changelog] No conventional commits found (or feature flag disabled)."
236
+ return generate_changelog_entry(commits, version=version or "Unreleased")
237
+
238
+
239
+ def main():
240
+ """CLI entry point."""
241
+ import argparse
242
+
243
+ parser = argparse.ArgumentParser(
244
+ description="OMG Changelog Generator — parses conventional commits and updates CHANGELOG.md"
245
+ )
246
+ parser.add_argument("--cwd", default=".", help="Working directory (default: .)")
247
+ parser.add_argument("--version", default=None, help="Version label (default: Unreleased)")
248
+ parser.add_argument(
249
+ "--dry-run",
250
+ action="store_true",
251
+ help="Print the changelog entry without writing to file",
252
+ )
253
+ args = parser.parse_args()
254
+
255
+ if args.dry_run:
256
+ print(_dry_run(cwd=args.cwd, version=args.version))
257
+ return
258
+
259
+ success = update_changelog(cwd=args.cwd, version=args.version)
260
+ if success:
261
+ print("[OMG Changelog] CHANGELOG.md updated successfully.")
262
+ else:
263
+ print("[OMG Changelog] No changes written (no commits or feature flag disabled).")
264
+ sys.exit(1)
265
+
266
+
267
+ _SYNTH_TYPE_TO_SECTION = {
268
+ "feat": "Features",
269
+ "fix": "Bug Fixes",
270
+ }
271
+
272
+ _SYNTH_SECTION_ORDER = ["Features", "Bug Fixes", "Breaking Changes", "Other"]
273
+
274
+
275
+ def synthesize_changelog(commits: List[Dict[str, Any]]) -> str:
276
+ """Public API: group raw commit dicts by type into markdown sections.
277
+
278
+ Accepts dicts with ``message`` (str), optional ``hash`` and ``files``.
279
+ """
280
+ if not commits:
281
+ return ""
282
+
283
+ sections: Dict[str, List[str]] = {s: [] for s in _SYNTH_SECTION_ORDER}
284
+
285
+ for commit in commits:
286
+ message = commit.get("message", "").strip()
287
+ if not message:
288
+ continue
289
+
290
+ is_breaking = message.startswith("BREAKING CHANGE")
291
+ if not is_breaking:
292
+ colon_idx = message.find(":")
293
+ if colon_idx > 0 and "!" in message[:colon_idx]:
294
+ is_breaking = True
295
+
296
+ if is_breaking:
297
+ sections["Breaking Changes"].append(f"- {message}")
298
+ continue
299
+
300
+ match = _CONVENTIONAL_RE.match(message)
301
+ if match:
302
+ commit_type = match.group("type").lower()
303
+ section = _SYNTH_TYPE_TO_SECTION.get(commit_type, "Other")
304
+ sections[section].append(f"- {message}")
305
+ else:
306
+ sections["Other"].append(f"- {message}")
307
+
308
+ lines: List[str] = ["## Changes", ""]
309
+
310
+ for section_name in _SYNTH_SECTION_ORDER:
311
+ entries = sections[section_name]
312
+ if not entries:
313
+ continue
314
+ lines.append(f"### {section_name}")
315
+ lines.extend(entries)
316
+ lines.append("")
317
+
318
+ while lines and lines[-1] == "":
319
+ lines.pop()
320
+
321
+ if len(lines) <= 1:
322
+ return ""
323
+
324
+ return "\n".join(lines)
325
+
326
+
327
+ def write_changelog(
328
+ commits: List[Dict[str, Any]],
329
+ output_path: Optional[str] = None,
330
+ ) -> str:
331
+ """Synthesize changelog; write to *output_path* when given, else return string."""
332
+ content = synthesize_changelog(commits)
333
+ if not content:
334
+ return ""
335
+
336
+ if output_path is not None:
337
+ parent = os.path.dirname(output_path)
338
+ if parent:
339
+ os.makedirs(parent, exist_ok=True)
340
+ with open(output_path, "w", encoding="utf-8") as f:
341
+ f.write(content)
342
+
343
+ return content
344
+
345
+
346
+ if __name__ == "__main__":
347
+ main()