@event4u/agent-config 1.9.1 → 1.12.0

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 (77) hide show
  1. package/.agent-src/commands/agent-handoff.md +15 -0
  2. package/.agent-src/commands/chat-history-clear.md +98 -0
  3. package/.agent-src/commands/chat-history-resume.md +178 -0
  4. package/.agent-src/commands/chat-history.md +102 -0
  5. package/.agent-src/commands/compress.md +9 -9
  6. package/.agent-src/commands/copilot-agents-init.md +1 -1
  7. package/.agent-src/commands/fix-portability.md +2 -2
  8. package/.agent-src/commands/fix-pr-bot-comments.md +1 -1
  9. package/.agent-src/commands/fix-pr-developer-comments.md +1 -1
  10. package/.agent-src/commands/fix-references.md +2 -2
  11. package/.agent-src/commands/mode.md +5 -5
  12. package/.agent-src/commands/onboard.md +171 -0
  13. package/.agent-src/commands/roadmap-create.md +7 -2
  14. package/.agent-src/commands/roadmap-execute.md +2 -2
  15. package/.agent-src/commands/set-cost-profile.md +101 -0
  16. package/.agent-src/commands/sync-agent-settings.md +122 -0
  17. package/.agent-src/commands/sync-gitignore.md +104 -0
  18. package/.agent-src/commands/tests-execute.md +6 -6
  19. package/.agent-src/commands/upstream-contribute.md +5 -4
  20. package/.agent-src/contexts/augment-infrastructure.md +2 -2
  21. package/.agent-src/contexts/override-system.md +1 -1
  22. package/.agent-src/contexts/subagent-configuration.md +3 -3
  23. package/.agent-src/guidelines/agent-infra/layered-settings.md +48 -5
  24. package/.agent-src/rules/ask-when-uncertain.md +56 -3
  25. package/.agent-src/rules/augment-portability.md +52 -1
  26. package/.agent-src/rules/augment-source-of-truth.md +10 -10
  27. package/.agent-src/rules/chat-history.md +171 -0
  28. package/.agent-src/rules/docker-commands.md +5 -7
  29. package/.agent-src/rules/docs-sync.md +13 -9
  30. package/.agent-src/rules/improve-before-implement.md +2 -0
  31. package/.agent-src/rules/onboarding-gate.md +94 -0
  32. package/.agent-src/rules/package-ci-checks.md +6 -5
  33. package/.agent-src/rules/roadmap-progress-sync.md +24 -13
  34. package/.agent-src/rules/size-enforcement.md +1 -1
  35. package/.agent-src/rules/skill-quality.md +1 -1
  36. package/.agent-src/rules/think-before-action.md +1 -0
  37. package/.agent-src/scripts/update_roadmap_progress.py +26 -9
  38. package/.agent-src/skills/check-refs/SKILL.md +1 -1
  39. package/.agent-src/skills/command-routing/SKILL.md +1 -1
  40. package/.agent-src/skills/command-writing/SKILL.md +4 -3
  41. package/.agent-src/skills/file-editor/SKILL.md +2 -2
  42. package/.agent-src/skills/guideline-writing/SKILL.md +4 -3
  43. package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +2 -2
  44. package/.agent-src/skills/lint-skills/SKILL.md +1 -1
  45. package/.agent-src/skills/roadmap-management/SKILL.md +13 -10
  46. package/.agent-src/skills/rtk-output-filtering/SKILL.md +20 -30
  47. package/.agent-src/skills/rule-writing/SKILL.md +5 -5
  48. package/.agent-src/skills/terragrunt/SKILL.md +0 -8
  49. package/.agent-src/skills/upstream-contribute/SKILL.md +5 -4
  50. package/.agent-src/templates/agent-settings.md +86 -34
  51. package/.claude-plugin/marketplace.json +1 -1
  52. package/AGENTS.md +2 -2
  53. package/CHANGELOG.md +296 -0
  54. package/CONTRIBUTING.md +89 -40
  55. package/README.md +3 -3
  56. package/composer.json +2 -1
  57. package/config/agent-settings.template.yml +45 -6
  58. package/config/gitignore-block.txt +24 -0
  59. package/config/profiles/balanced.ini +5 -0
  60. package/config/profiles/full.ini +5 -0
  61. package/config/profiles/minimal.ini +5 -0
  62. package/docs/customization.md +30 -4
  63. package/docs/getting-started.md +52 -3
  64. package/docs/mcp.md +15 -4
  65. package/package.json +13 -2
  66. package/scripts/agent-config +155 -0
  67. package/scripts/chat_history.py +519 -0
  68. package/scripts/check_portability.py +151 -1
  69. package/scripts/install.py +55 -3
  70. package/scripts/install.sh +50 -21
  71. package/scripts/mcp_render.py +30 -16
  72. package/scripts/release.py +588 -0
  73. package/scripts/sync_agent_settings.py +211 -0
  74. package/scripts/sync_gitignore.py +226 -0
  75. package/templates/agent-config-wrapper.sh +47 -0
  76. package/.agent-src/commands/config-agent-settings.md +0 -126
  77. package/.agent-src/skills/eloquent/evals/last-run.json +0 -99
@@ -0,0 +1,588 @@
1
+ #!/usr/bin/env python3
2
+ """End-to-end release automation for `event4u/agent-config`.
3
+
4
+ Invoked via `task release`. The bump level (major/minor/patch) is
5
+ auto-detected from Conventional Commits since the last tag; pass
6
+ `--as {major,minor,patch}` to force, or `--version X.Y.Z` to pin.
7
+
8
+ Pipeline:
9
+ 1. Preflight — on main, clean tree, origin in sync, gh available,
10
+ target tag doesn't exist yet.
11
+ 2. Plan — compute new version, parse Conventional Commits
12
+ since the last tag, render CHANGELOG section.
13
+ 3. Confirm — show preview, ask once (skippable with --yes).
14
+ 4. Branch + bump — create `release/X.Y.Z`, update package.json,
15
+ .claude-plugin/marketplace.json, CHANGELOG.md.
16
+ 5. Commit + push — `release: X.Y.Z`, push branch, open PR.
17
+ 6. Wait for CI — `gh pr checks --watch` (skippable with --no-wait).
18
+ 7. Merge — `gh pr merge --merge --delete-branch`.
19
+ 8. Tag main — fast-forward main, tag the merge commit,
20
+ push the tag (this triggers publish-npm.yml).
21
+ 9. GitHub Release — `gh release create X.Y.Z --notes <changelog>`.
22
+
23
+ Idempotency is intentionally limited: this script mutates git state, so
24
+ re-running after a partial failure needs a clean tree. Each step prints
25
+ what it's about to do before doing it, so a crash leaves a recoverable
26
+ trail.
27
+
28
+ Stdlib-only (Python 3.10+). No third-party runtime dependencies.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import json
35
+ import re
36
+ import subprocess
37
+ import sys
38
+ import time
39
+ from dataclasses import dataclass
40
+ from datetime import date as _date
41
+ from pathlib import Path
42
+
43
+ REPO_ROOT = Path(__file__).resolve().parent.parent
44
+ PACKAGE_JSON = REPO_ROOT / "package.json"
45
+ MARKETPLACE_JSON = REPO_ROOT / ".claude-plugin" / "marketplace.json"
46
+ CHANGELOG = REPO_ROOT / "CHANGELOG.md"
47
+ MAIN_BRANCH = "main"
48
+ REMOTE = "origin"
49
+ REPO_SLUG = "event4u-app/agent-config"
50
+
51
+ # Conventional Commit types and how they map into CHANGELOG sections.
52
+ # Order in this tuple determines order in the rendered entry.
53
+ SECTIONS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
54
+ ("Features", "minor", ("feat",)),
55
+ ("Bug Fixes", "patch", ("fix",)),
56
+ ("Performance", "patch", ("perf",)),
57
+ ("Reverts", "patch", ("revert",)),
58
+ ("Documentation", None, ("docs",)),
59
+ ("Refactoring", None, ("refactor",)),
60
+ ("Tests", None, ("test",)),
61
+ ("Build", None, ("build",)),
62
+ ("CI", None, ("ci",)),
63
+ ("Chores", None, ("chore",)),
64
+ )
65
+ BREAKING_RE = re.compile(r"^([a-z]+)(\([^)]+\))?!:")
66
+ CONVENTIONAL_RE = re.compile(
67
+ r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<bang>!)?: (?P<subject>.+)$"
68
+ )
69
+
70
+
71
+ # ─── dataclasses ──────────────────────────────────────────────────────────────
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Commit:
76
+ sha: str
77
+ type: str
78
+ scope: str | None
79
+ subject: str
80
+ breaking: bool
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class Plan:
85
+ current: str
86
+ target: str
87
+ bump: str # "major" | "minor" | "patch"
88
+ commits: list[Commit]
89
+ last_tag: str | None
90
+ changelog_body: str # rendered body (without the heading)
91
+ changelog_entry: str # full entry including heading, for CHANGELOG.md
92
+
93
+
94
+ # ─── utilities ────────────────────────────────────────────────────────────────
95
+
96
+
97
+ def die(msg: str, code: int = 2) -> None:
98
+ print(f"error: {msg}", file=sys.stderr)
99
+ sys.exit(code)
100
+
101
+
102
+ def run(
103
+ *args: str,
104
+ check: bool = True,
105
+ capture: bool = False,
106
+ cwd: Path | None = None,
107
+ ) -> subprocess.CompletedProcess[str]:
108
+ """Thin subprocess wrapper with sane defaults.
109
+
110
+ When ``check`` and ``capture`` are both True and the command fails,
111
+ Python's default behaviour swallows stderr — callers only see a
112
+ ``CalledProcessError`` with no hint of what went wrong. We catch that
113
+ path and die with the actual stderr so release preflight failures are
114
+ diagnosable without re-running with a debugger.
115
+ """
116
+ try:
117
+ return subprocess.run(
118
+ list(args),
119
+ check=check,
120
+ cwd=cwd or REPO_ROOT,
121
+ text=True,
122
+ capture_output=capture,
123
+ )
124
+ except subprocess.CalledProcessError as err:
125
+ if capture:
126
+ cmd = " ".join(args)
127
+ out = (err.stderr or err.stdout or "").strip()
128
+ die(f"command failed ({err.returncode}): {cmd}\n{out}")
129
+ raise
130
+
131
+
132
+ def git(*args: str, capture: bool = False) -> str:
133
+ r = run("git", *args, capture=capture)
134
+ return r.stdout.strip() if capture else ""
135
+
136
+
137
+ def gh(*args: str, capture: bool = False, check: bool = True) -> str:
138
+ r = run("gh", *args, capture=capture, check=check)
139
+ return r.stdout.strip() if capture else ""
140
+
141
+
142
+ def watch_pr_checks() -> None:
143
+ """Watch PR checks and tolerate the 'no checks' case.
144
+
145
+ ``gh pr checks --watch`` exits 1 both on real failures and when no
146
+ checks are reported at all (no workflow triggered, no required
147
+ checks configured in branch protection). The latter must not block
148
+ the release — we warn and continue. Real failures still die.
149
+
150
+ A short grace period gives GitHub time to register workflow runs
151
+ on a freshly-pushed branch.
152
+ """
153
+ time.sleep(5)
154
+ proc = subprocess.run(
155
+ ["gh", "pr", "checks", "--watch"],
156
+ cwd=REPO_ROOT,
157
+ text=True,
158
+ capture_output=True,
159
+ )
160
+ output = ((proc.stdout or "") + (proc.stderr or "")).strip()
161
+ if proc.returncode == 0:
162
+ if output:
163
+ print(output)
164
+ return
165
+ if "no checks reported" in output.lower():
166
+ print(f"⚠️ {output}")
167
+ print(
168
+ " Continuing without check validation — configure required "
169
+ "checks in branch protection to enforce this gate."
170
+ )
171
+ return
172
+ if output:
173
+ print(output, file=sys.stderr)
174
+ die(f"PR checks failed (exit {proc.returncode})")
175
+
176
+
177
+ def have(bin: str) -> bool:
178
+ return (
179
+ subprocess.run(
180
+ ["which", bin], capture_output=True, text=True
181
+ ).returncode
182
+ == 0
183
+ )
184
+
185
+
186
+ # ─── version math ─────────────────────────────────────────────────────────────
187
+
188
+
189
+ SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
190
+
191
+
192
+ def parse_version(s: str) -> tuple[int, int, int]:
193
+ m = SEMVER_RE.match(s.strip())
194
+ if not m:
195
+ die(f"not a bare semver (X.Y.Z): {s!r}")
196
+ return int(m.group(1)), int(m.group(2)), int(m.group(3))
197
+
198
+
199
+ def bump_version(current: str, kind: str) -> str:
200
+ major, minor, patch = parse_version(current)
201
+ if kind == "major":
202
+ return f"{major + 1}.0.0"
203
+ if kind == "minor":
204
+ return f"{major}.{minor + 1}.0"
205
+ if kind == "patch":
206
+ return f"{major}.{minor}.{patch + 1}"
207
+ die(f"unknown bump kind: {kind}")
208
+ return "" # unreachable
209
+
210
+
211
+ # ─── commit parsing + changelog rendering ────────────────────────────────────
212
+
213
+
214
+ def commits_since(tag: str | None) -> list[Commit]:
215
+ """Return non-merge commits after `tag` (or all of history if tag is None)."""
216
+ rev = f"{tag}..HEAD" if tag else "HEAD"
217
+ raw = git("log", rev, "--no-merges", "--format=%H%x1f%s", capture=True)
218
+ out: list[Commit] = []
219
+ for line in raw.splitlines():
220
+ if "\x1f" not in line:
221
+ continue
222
+ sha, subject = line.split("\x1f", 1)
223
+ m = CONVENTIONAL_RE.match(subject)
224
+ if not m:
225
+ out.append(Commit(sha, "other", None, subject, False))
226
+ continue
227
+ breaking = bool(m.group("bang")) or "BREAKING CHANGE" in subject
228
+ out.append(
229
+ Commit(
230
+ sha=sha,
231
+ type=m.group("type"),
232
+ scope=m.group("scope"),
233
+ subject=m.group("subject"),
234
+ breaking=breaking,
235
+ )
236
+ )
237
+ return out
238
+
239
+
240
+ def infer_bump(commits: list[Commit]) -> str:
241
+ """Derive the semver bump from commit types (for preview only)."""
242
+ if any(c.breaking for c in commits):
243
+ return "major"
244
+ for _label, level, types in SECTIONS:
245
+ if level == "minor" and any(c.type in types for c in commits):
246
+ return "minor"
247
+ return "patch"
248
+
249
+
250
+ def latest_tag() -> str | None:
251
+ r = run(
252
+ "git", "describe", "--tags", "--abbrev=0", "--match", "[0-9]*.[0-9]*.[0-9]*",
253
+ check=False, capture=True,
254
+ )
255
+ tag = r.stdout.strip()
256
+ return tag or None
257
+
258
+
259
+ def render_changelog_entry(
260
+ version: str,
261
+ prev: str | None,
262
+ commits: list[Commit],
263
+ today: str,
264
+ ) -> tuple[str, str]:
265
+ """Return (heading-aware full entry, body-only for GitHub Release notes)."""
266
+ if prev:
267
+ heading = (
268
+ f"## [{version}](https://github.com/{REPO_SLUG}/compare/"
269
+ f"{prev}...{version}) ({today})"
270
+ )
271
+ else:
272
+ heading = f"## {version} ({today})"
273
+
274
+ # Group by section; commits of unknown type drop into "Other".
275
+ grouped: dict[str, list[Commit]] = {label: [] for label, _, _ in SECTIONS}
276
+ grouped["BREAKING CHANGES"] = []
277
+ other: list[Commit] = []
278
+ for c in commits:
279
+ if c.breaking:
280
+ grouped["BREAKING CHANGES"].append(c)
281
+ continue
282
+ placed = False
283
+ for label, _level, types in SECTIONS:
284
+ if c.type in types:
285
+ grouped[label].append(c)
286
+ placed = True
287
+ break
288
+ if not placed:
289
+ other.append(c)
290
+
291
+ body_lines: list[str] = []
292
+ ordered_labels = ["BREAKING CHANGES"] + [label for label, _, _ in SECTIONS]
293
+ for label in ordered_labels:
294
+ bucket = grouped.get(label) or []
295
+ if not bucket:
296
+ continue
297
+ body_lines.append("")
298
+ body_lines.append(f"### {label}")
299
+ body_lines.append("")
300
+ for c in bucket:
301
+ body_lines.append(_changelog_line(c))
302
+ if other:
303
+ body_lines.append("")
304
+ body_lines.append("### Other")
305
+ body_lines.append("")
306
+ for c in other:
307
+ body_lines.append(_changelog_line(c))
308
+
309
+ body = "\n".join(body_lines).lstrip("\n")
310
+ full = heading + "\n\n" + body + "\n"
311
+ return full, body
312
+
313
+
314
+ def _changelog_line(c: Commit) -> str:
315
+ scope = f"**{c.scope}:** " if c.scope else ""
316
+ short = c.sha[:7]
317
+ link = f"https://github.com/{REPO_SLUG}/commit/{c.sha}"
318
+ return f"* {scope}{c.subject} ([{short}]({link}))"
319
+
320
+
321
+ def prepend_changelog(path: Path, entry: str) -> None:
322
+ """Insert `entry` directly above the most recent `## [` heading."""
323
+ text = path.read_text(encoding="utf-8")
324
+ marker_re = re.compile(r"^## \[?\d+\.\d+\.\d+", re.MULTILINE)
325
+ m = marker_re.search(text)
326
+ if not m:
327
+ # No prior release heading — append after the introduction.
328
+ path.write_text(text.rstrip() + "\n\n" + entry, encoding="utf-8")
329
+ return
330
+ before = text[: m.start()]
331
+ after = text[m.start() :]
332
+ path.write_text(before + entry + "\n" + after, encoding="utf-8")
333
+
334
+
335
+
336
+ # ─── file mutations ───────────────────────────────────────────────────────────
337
+
338
+
339
+ def set_package_version(path: Path, version: str) -> None:
340
+ """Update the top-level `version` field; preserve 4-space indentation."""
341
+ data = json.loads(path.read_text(encoding="utf-8"))
342
+ data["version"] = version
343
+ path.write_text(json.dumps(data, indent=4) + "\n", encoding="utf-8")
344
+
345
+
346
+ def set_marketplace_version(path: Path, version: str) -> None:
347
+ """Update `metadata.version`; preserve 2-space indentation + UTF-8."""
348
+ data = json.loads(path.read_text(encoding="utf-8"))
349
+ data.setdefault("metadata", {})["version"] = version
350
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
351
+
352
+
353
+ # ─── preflight ────────────────────────────────────────────────────────────────
354
+
355
+
356
+ def preflight(target: str) -> None:
357
+ """Fail fast on conditions that would break the release mid-flight."""
358
+ for b in ("git", "gh"):
359
+ if not have(b):
360
+ die(f"{b!r} not found on PATH")
361
+
362
+ # Probe the active token directly via an authenticated API call. `gh auth
363
+ # status` returns non-zero if *any* account in the keyring is broken, even
364
+ # when the active one is fine — so we'd rather ask "does the token the
365
+ # release will actually use work?" than parse multi-account status output.
366
+ r = run("gh", "api", "user", "--jq", ".login", check=False, capture=True)
367
+ if r.returncode != 0:
368
+ die("gh is not authenticated; run `gh auth login` first")
369
+
370
+ branch = git("rev-parse", "--abbrev-ref", "HEAD", capture=True)
371
+ if branch != MAIN_BRANCH:
372
+ die(f"release must run from {MAIN_BRANCH!r}, currently on {branch!r}")
373
+
374
+ porcelain = git("status", "--porcelain", capture=True)
375
+ if porcelain:
376
+ die("working tree is not clean; commit or stash first")
377
+
378
+ # --force lets the remote's tag positions win over stale local tags.
379
+ # The release consumes the remote view as source of truth, and we're
380
+ # about to create a new tag anyway — local drift (e.g. from renamed
381
+ # release-please tags) should not block the fetch.
382
+ run("git", "fetch", REMOTE, "--tags", "--prune", "--force", capture=True)
383
+ local = git("rev-parse", "HEAD", capture=True)
384
+ remote = git("rev-parse", f"{REMOTE}/{MAIN_BRANCH}", capture=True)
385
+ if local != remote:
386
+ die(
387
+ f"local {MAIN_BRANCH} is not in sync with {REMOTE}/{MAIN_BRANCH}; "
388
+ "pull or push first"
389
+ )
390
+
391
+ tags = git("tag", "-l", target, capture=True).splitlines()
392
+ if target in tags:
393
+ die(f"tag {target!r} already exists; nothing to release")
394
+
395
+
396
+ # ─── plan ─────────────────────────────────────────────────────────────────────
397
+
398
+
399
+ def print_preview(plan: Plan) -> None:
400
+ print()
401
+ print("═" * 72)
402
+ print(f" Release preview — {plan.current} → {plan.target} ({plan.bump})")
403
+ print("═" * 72)
404
+ print()
405
+ print(f"Previous tag: {plan.last_tag or '(none)'}")
406
+ print(f"New tag: {plan.target}")
407
+ print(f"Commits: {len(plan.commits)} since {plan.last_tag or 'start'}")
408
+ detected = infer_bump(plan.commits) if plan.commits else "patch"
409
+ if detected != plan.bump:
410
+ print(
411
+ f"NOTE: commits suggest a {detected!r} bump, "
412
+ f"you picked {plan.bump!r}"
413
+ )
414
+ print()
415
+ print("Files to change:")
416
+ print(f" · {PACKAGE_JSON.relative_to(REPO_ROOT)}")
417
+ print(f" · {MARKETPLACE_JSON.relative_to(REPO_ROOT)}")
418
+ print(f" · {CHANGELOG.relative_to(REPO_ROOT)}")
419
+ print()
420
+ print("Changelog section:")
421
+ print("─" * 72)
422
+ print(plan.changelog_entry.rstrip())
423
+ print("─" * 72)
424
+ print()
425
+
426
+
427
+ def confirm(prompt: str) -> bool:
428
+ ans = input(f"{prompt} [y/N] ").strip().lower()
429
+ return ans in {"y", "yes"}
430
+
431
+
432
+ # ─── orchestration ────────────────────────────────────────────────────────────
433
+
434
+
435
+ def _step(n: int, total: int, msg: str) -> None:
436
+ print(f"[{n}/{total}] {msg}")
437
+
438
+
439
+ def execute(plan: Plan, *, wait_for_checks: bool, dry_run: bool) -> None:
440
+ branch = f"release/{plan.target}"
441
+ total = 9
442
+
443
+ if dry_run:
444
+ print("(dry-run) no git/gh mutations will be performed.")
445
+ return
446
+
447
+ _step(1, total, f"Create branch {branch}")
448
+ run("git", "checkout", "-b", branch)
449
+
450
+ _step(2, total, "Bump package.json + marketplace.json, prepend CHANGELOG")
451
+ set_package_version(PACKAGE_JSON, plan.target)
452
+ set_marketplace_version(MARKETPLACE_JSON, plan.target)
453
+ prepend_changelog(CHANGELOG, plan.changelog_entry)
454
+
455
+ _step(3, total, f"Commit `release: {plan.target}`")
456
+ run("git", "add", str(PACKAGE_JSON), str(MARKETPLACE_JSON), str(CHANGELOG))
457
+ run("git", "commit", "-m", f"release: {plan.target}")
458
+
459
+ _step(4, total, f"Push {branch} to {REMOTE}")
460
+ run("git", "push", "-u", REMOTE, branch)
461
+
462
+ _step(5, total, "Open pull request")
463
+ pr_body = (
464
+ f"Release {plan.target}.\n\n"
465
+ f"{plan.changelog_body}\n\n"
466
+ "Created by `scripts/release.py`."
467
+ )
468
+ run(
469
+ "gh", "pr", "create",
470
+ "--base", MAIN_BRANCH,
471
+ "--head", branch,
472
+ "--title", f"release: {plan.target}",
473
+ "--body", pr_body,
474
+ )
475
+
476
+ if wait_for_checks:
477
+ _step(6, total, "Wait for PR checks")
478
+ watch_pr_checks()
479
+ else:
480
+ _step(6, total, "Skip waiting for checks (--no-wait)")
481
+
482
+ _step(7, total, "Merge pull request (merge commit) and delete branch")
483
+ run("gh", "pr", "merge", "--merge", "--delete-branch")
484
+
485
+ _step(8, total, f"Fast-forward {MAIN_BRANCH}, tag merge commit, push tag")
486
+ run("git", "checkout", MAIN_BRANCH)
487
+ run("git", "pull", "--ff-only", REMOTE, MAIN_BRANCH)
488
+ run("git", "tag", plan.target)
489
+ run("git", "push", REMOTE, plan.target)
490
+
491
+ _step(9, total, "Create GitHub Release (triggers publish-npm on the tag)")
492
+ notes = plan.changelog_body or f"Release {plan.target}"
493
+ run(
494
+ "gh", "release", "create", plan.target,
495
+ "--title", plan.target,
496
+ "--notes", notes,
497
+ )
498
+
499
+ print()
500
+ print(f"✅ Released {plan.target}")
501
+ print(f" https://github.com/{REPO_SLUG}/releases/tag/{plan.target}")
502
+ print(" npm publish runs asynchronously via publish-npm.yml on the tag.")
503
+
504
+
505
+ # ─── entrypoint ───────────────────────────────────────────────────────────────
506
+
507
+
508
+ def _parse_args(argv: list[str]) -> argparse.Namespace:
509
+ p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
510
+ p.add_argument(
511
+ "--as",
512
+ dest="bump_override",
513
+ choices=("major", "minor", "patch"),
514
+ default=None,
515
+ help=(
516
+ "Force a specific bump level. Default is auto-detect from "
517
+ "Conventional Commits since the last tag."
518
+ ),
519
+ )
520
+ p.add_argument(
521
+ "--version",
522
+ dest="explicit",
523
+ default=None,
524
+ help="Use an explicit X.Y.Z instead of the auto-detected bump.",
525
+ )
526
+ p.add_argument(
527
+ "--yes", "-y", action="store_true",
528
+ help="Skip the confirmation prompt.",
529
+ )
530
+ p.add_argument(
531
+ "--dry-run", action="store_true",
532
+ help="Print the plan and exit without touching git/gh.",
533
+ )
534
+ p.add_argument(
535
+ "--no-wait", action="store_true",
536
+ help="Merge immediately without waiting for PR checks to pass.",
537
+ )
538
+ return p.parse_args(argv)
539
+
540
+
541
+ def resolve_bump(override: str | None, commits: list[Commit]) -> str:
542
+ """Override wins; otherwise auto-detect from commits (or 'patch' if empty)."""
543
+ if override:
544
+ return override
545
+ return infer_bump(commits)
546
+
547
+
548
+ def main(argv: list[str] | None = None) -> int:
549
+ args = _parse_args(list(sys.argv[1:] if argv is None else argv))
550
+
551
+ current = json.loads(PACKAGE_JSON.read_text(encoding="utf-8"))["version"]
552
+ parse_version(current)
553
+
554
+ prev = latest_tag()
555
+ commits = commits_since(prev)
556
+ bump = resolve_bump(args.bump_override, commits)
557
+ target = args.explicit or bump_version(current, bump)
558
+ parse_version(target)
559
+
560
+ if not args.dry_run:
561
+ preflight(target)
562
+
563
+ today = _date.today().isoformat()
564
+ full, body = render_changelog_entry(target, prev, commits, today)
565
+ plan = Plan(
566
+ current=current,
567
+ target=target,
568
+ bump=bump,
569
+ commits=commits,
570
+ last_tag=prev,
571
+ changelog_body=body,
572
+ changelog_entry=full,
573
+ )
574
+ print_preview(plan)
575
+
576
+ if args.dry_run:
577
+ return 0
578
+
579
+ if not args.yes and not confirm(f"Proceed with release {plan.target}?"):
580
+ print("aborted.")
581
+ return 1
582
+
583
+ execute(plan, wait_for_checks=not args.no_wait, dry_run=False)
584
+ return 0
585
+
586
+
587
+ if __name__ == "__main__":
588
+ raise SystemExit(main())