@event4u/agent-config 5.8.0 → 5.9.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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Shared agent configuration \u2014 skills for AI coding tools (Claude Code, Augment, Cursor, Cline, Windsurf, Gemini CLI).",
9
- "version": "5.8.0",
9
+ "version": "5.9.0",
10
10
  "keywords": [
11
11
  "agent-config",
12
12
  "skills",
package/CHANGELOG.md CHANGED
@@ -811,6 +811,23 @@ our recommendation order, not its support status.
811
811
  > that forces a new era split (`# Era: 5.5.x`, etc.) — see
812
812
  > [`docs/contracts/CHANGELOG-conventions.md § Era splits`](docs/contracts/CHANGELOG-conventions.md).
813
813
 
814
+ ## [5.9.0](https://github.com/event4u-app/agent-config/compare/5.8.0...5.9.0) (2026-06-02)
815
+
816
+ ### Features
817
+
818
+ * **doctor:** runnable global-only report without a project lockfile ([4c4f20b](https://github.com/event4u-app/agent-config/commit/4c4f20b82eb472ff6b6d84ebac889832ce1329f0))
819
+ * **ci:** release-published drift gate (catch npm lagging main) ([7f4e125](https://github.com/event4u-app/agent-config/commit/7f4e125dd48d0233d33ce8cbb219731a0b501497))
820
+
821
+ ### Bug Fixes
822
+
823
+ * **release:** anchor --resume to package.json, auto-delete merged branch ([f2db736](https://github.com/event4u-app/agent-config/commit/f2db736c7259845fe204af1ffd293e7f5341a58d))
824
+
825
+ ### Chores
826
+
827
+ * **roadmap:** close + archive doctor-global-only-readiness ([3d3aaf7](https://github.com/event4u-app/agent-config/commit/3d3aaf739464b9e6613175f352b09a153bdbec90))
828
+
829
+ Tests: 5480 (+25 since 5.8.0)
830
+
814
831
  ## [5.8.0](https://github.com/event4u-app/agent-config/compare/5.7.0...5.8.0) (2026-06-02)
815
832
 
816
833
  ### Features
@@ -1,6 +1,6 @@
1
1
  # Discovery — Deprecation Report
2
2
 
3
- - Generated: `2026-06-02T02:48:57Z`
3
+ - Generated: `2026-06-02T05:57:08Z`
4
4
  - Deprecated artefacts: **0**
5
5
 
6
6
  _None. Tree is clean._
@@ -10004,7 +10004,7 @@
10004
10004
  "reason": "scaffold for new SKILL.md authoring"
10005
10005
  }
10006
10006
  ],
10007
- "generated_at": "2026-06-02T02:48:57Z",
10007
+ "generated_at": "2026-06-02T05:57:08Z",
10008
10008
  "packs": [
10009
10009
  {
10010
10010
  "artefact_count": 85,
@@ -1 +1 @@
1
- a943cc6a13b848ee35ec40fcc64ca9cf0823fffdf944d3d15afa54535ca72d74 discovery-manifest.json
1
+ 4b7e6f9a9278cd1129d4cc5d1e5901f0c18b8ef8d741ef2ab7cfc30e567eb0cb discovery-manifest.json
@@ -1,6 +1,6 @@
1
1
  # Discovery Manifest — Summary
2
2
 
3
- - Generated: `2026-06-02T02:48:57Z`
3
+ - Generated: `2026-06-02T05:57:08Z`
4
4
  - Scanner: `ca7acd2ec7af`
5
5
  - Artefacts: **453**
6
6
  - Unassigned: **0**
@@ -1,6 +1,6 @@
1
1
  # Discovery — Orphan Report
2
2
 
3
- - Generated: `2026-06-02T02:48:57Z`
3
+ - Generated: `2026-06-02T05:57:08Z`
4
4
  - Orphan artefacts: **0**
5
5
 
6
6
  > An orphan is an artefact whose declared pack has no other members.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "checksum": "sha256:b55ddc4224e45ec7627ece7c2fd6f043df8c7030efbe50df87573f0da4de4173",
3
- "generated_at": "2026-06-02T02:48:57Z",
3
+ "generated_at": "2026-06-02T05:57:08Z",
4
4
  "packs": [
5
5
  {
6
6
  "artefact_count": 85,
@@ -1,6 +1,6 @@
1
1
  # Discovery — Trust Report
2
2
 
3
- - Generated: `2026-06-02T02:48:57Z`
3
+ - Generated: `2026-06-02T05:57:08Z`
4
4
  - Workspaces tracked: **8**
5
5
  - Human-review-required artefacts: **2**
6
6
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "checksum": "sha256:b55ddc4224e45ec7627ece7c2fd6f043df8c7030efbe50df87573f0da4de4173",
3
- "generated_at": "2026-06-02T02:48:57Z",
3
+ "generated_at": "2026-06-02T05:57:08Z",
4
4
  "scanner_version": "ca7acd2ec7af",
5
5
  "workspaces": [
6
6
  {
@@ -9,7 +9,7 @@
9
9
  "homepage": "https://github.com/event4u-app/agent-config#readme",
10
10
  "name": "@event4u/agent-config",
11
11
  "repository": "https://github.com/event4u-app/agent-config",
12
- "version": "5.8.0"
12
+ "version": "5.9.0"
13
13
  },
14
14
  "registries": [
15
15
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
- "version": "5.8.0",
3
+ "version": "5.9.0",
4
4
  "description": "Universal AI Agent OS \u2014 audited skills, governance rules, commands, and templates for AI coding tools (Claude Code, Cursor, Windsurf, Copilot).",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -23,8 +23,16 @@ mcp-mode · mcp-beta-readiness · offline-readiness · python-runtime ·
23
23
  tier-usage-readiness · council-cli · unsupported-combos ·
24
24
  wizard-state.
25
25
  Each emits a structured ``{id, status, message, remedy}`` record with
26
- ``status`` ∈ ``ok`` / ``warn`` / ``fail`` (rendered ``✅`` / ``⚠️`` /
27
- ``❌``). ``--check <id>`` runs a single check.
26
+ ``status`` ∈ ``ok`` / ``warn`` / ``fail`` / ``skipped`` (rendered
27
+ ``✅`` / ``⚠️`` / ``❌`` / ``⏭️``). ``--check <id>`` runs a single check.
28
+
29
+ Checks are split by scope (:data:`GLOBAL_CHECK_IDS` vs
30
+ :data:`MANIFEST_REQUIRED_CHECK_IDS`) so an ADR-020 global-only consumer
31
+ (bridge marker present, no ``agents/installed-tools.lock``) gets a
32
+ green-capable report instead of a hard bail: the global checks run, the
33
+ manifest-required checks report ``skipped``, and ``bridge-drift`` returns
34
+ a scope-aware "drift not applicable" verdict. See :func:`main` for the
35
+ no-manifest branch and exit-code contract.
28
36
 
29
37
  Repair affordances: ``--repair wizard-state`` resets a malformed or
30
38
  orphaned ``state/wizard-state.json`` under the user-global root (the
@@ -455,6 +463,41 @@ CHECK_IDS = (
455
463
  "wizard-state",
456
464
  )
457
465
 
466
+ #: Checks that need only the project root (or no input at all) and run
467
+ #: regardless of whether a project lockfile exists. Under ADR-020
468
+ #: global-only (bridge marker present, no ``installed-tools.lock``) these
469
+ #: still produce a real verdict. ``scope`` lives here because
470
+ #: :func:`_check_scope` reads only ``project_root`` — the roadmap prose
471
+ #: that grouped it with the manifest checks predates this code reality
472
+ #: (AI council, claude-sonnet-4-5 + gpt-4o, design lens, 2026-06-02).
473
+ GLOBAL_CHECK_IDS: frozenset[str] = frozenset({
474
+ "scope",
475
+ "global-binary",
476
+ "mcp-mode",
477
+ "mcp-beta-readiness",
478
+ "offline-readiness",
479
+ "python-runtime",
480
+ "tier-usage-readiness",
481
+ "council-cli",
482
+ "wizard-state",
483
+ })
484
+
485
+ #: Checks that genuinely cannot run without the project manifest. Without
486
+ #: a lockfile they report ``skipped`` rather than a misleading verdict.
487
+ #: ``bridge-drift`` is deliberately **not** here: it is scope-aware —
488
+ #: a manifest-derived drift roll-up when the lockfile exists, and a
489
+ #: "drift not applicable (global-only consumer)" verdict when it does
490
+ #: not (computed in :func:`_check_bridge_drift_no_manifest`, keeping
491
+ #: :func:`_check_bridge_drift` a pure roll-up per the council's SRP point).
492
+ MANIFEST_REQUIRED_CHECK_IDS: frozenset[str] = frozenset({
493
+ "manifest-integrity",
494
+ "lockfile-freshness",
495
+ "unsupported-combos",
496
+ })
497
+
498
+ #: Project-root-relative path of the ADR-020 global-only consumer marker.
499
+ BRIDGE_MARKER_RELATIVE = "agents/.event4u-bridge.yml"
500
+
458
501
  #: Repair targets that ``--repair <id>`` accepts. Each id maps to a
459
502
  #: function in :func:`_run_repair` that resets the named artefact and
460
503
  #: returns an exit code. Additive set: introduce by adding a new id
@@ -476,7 +519,7 @@ MCP_BETA_GATES: tuple[tuple[str, str], ...] = (
476
519
 
477
520
  #: Visible status → glyph map. ``warn`` keeps a trailing space so the
478
521
  #: rendered output stays in a single visual column with the other glyphs.
479
- STATUS_SYMBOLS = {"ok": "✅", "warn": "⚠️ ", "fail": "❌"}
522
+ STATUS_SYMBOLS = {"ok": "✅", "warn": "⚠️ ", "fail": "❌", "skipped": "⏭️ "}
480
523
 
481
524
  #: Minimum Python interpreter the CLI targets. Bumped in lockstep with
482
525
  #: ``from __future__ import annotations`` + PEP-604 syntax usage.
@@ -1159,6 +1202,131 @@ def _run_checks(
1159
1202
  return out
1160
1203
 
1161
1204
 
1205
+ def _skipped_manifest_check(check_id: str) -> dict[str, Any]:
1206
+ """A ``skipped`` verdict for a manifest-required check with no lockfile.
1207
+
1208
+ Explicit machine-readable record (not omitted, not ``null``) so the
1209
+ ``--json`` ``checks`` array keeps a stable shape for a global-only
1210
+ consumer — a council convergence point (2026-06-02).
1211
+ """
1212
+ return {
1213
+ "id": check_id, "status": "skipped",
1214
+ "message": "requires a project lockfile (agents/installed-tools.lock)",
1215
+ "remedy": "run `agent-config init` to create a project lockfile, "
1216
+ "then re-run this check",
1217
+ }
1218
+
1219
+
1220
+ def _check_bridge_drift_no_manifest(bridge_present: bool) -> dict[str, Any]:
1221
+ """Scope-aware ``bridge-drift`` verdict when no project manifest exists.
1222
+
1223
+ Kept out of :func:`_check_bridge_drift` (which stays a pure
1224
+ manifest-derived roll-up) per the council's single-responsibility
1225
+ point. A global-only consumer has no distributed tools to drift, so
1226
+ a bridge-present repo reports ``ok`` ("not applicable"); a repo with
1227
+ neither lockfile nor bridge marker reports ``skipped``.
1228
+ """
1229
+ if bridge_present:
1230
+ return {
1231
+ "id": "bridge-drift", "status": "ok",
1232
+ "message": "no project lockfile → distributed-tool drift not "
1233
+ "applicable (global-only consumer)",
1234
+ "remedy": "",
1235
+ }
1236
+ return {
1237
+ "id": "bridge-drift", "status": "skipped",
1238
+ "message": "no project lockfile and no bridge marker → drift check "
1239
+ "not applicable",
1240
+ "remedy": "run `agent-config init` (project install) or "
1241
+ "`agent-config refresh --project` (global-only consumer)",
1242
+ }
1243
+
1244
+
1245
+ def _run_checks_no_manifest(
1246
+ project_root: Path,
1247
+ bridge_present: bool,
1248
+ only: str | None = None,
1249
+ ) -> list[dict[str, Any]]:
1250
+ """Run the registry with no project manifest available.
1251
+
1252
+ Mirrors :func:`_run_checks` and preserves :data:`CHECK_IDS` order.
1253
+ Global checks (:data:`GLOBAL_CHECK_IDS`) run unchanged; manifest-required
1254
+ checks (:data:`MANIFEST_REQUIRED_CHECK_IDS`) report ``skipped``;
1255
+ ``bridge-drift`` gets the scope-aware no-manifest verdict.
1256
+ """
1257
+ runners: dict[str, Any] = {
1258
+ "scope": lambda: _check_scope(project_root),
1259
+ "global-binary": lambda: _check_global_binary(project_root),
1260
+ "manifest-integrity": lambda: _skipped_manifest_check("manifest-integrity"),
1261
+ "lockfile-freshness": lambda: _skipped_manifest_check("lockfile-freshness"),
1262
+ "bridge-drift": lambda: _check_bridge_drift_no_manifest(bridge_present),
1263
+ "mcp-mode": lambda: _check_mcp_mode(project_root),
1264
+ "mcp-beta-readiness": lambda: _check_mcp_beta_readiness(project_root),
1265
+ "offline-readiness": lambda: _check_offline_readiness(),
1266
+ "python-runtime": lambda: _check_python_runtime(),
1267
+ "tier-usage-readiness": lambda: _check_tier_usage_readiness(project_root),
1268
+ "council-cli": lambda: _check_council_cli(project_root),
1269
+ "unsupported-combos": lambda: _skipped_manifest_check("unsupported-combos"),
1270
+ "wizard-state": _check_wizard_state,
1271
+ }
1272
+ out: list[dict[str, Any]] = []
1273
+ for cid in CHECK_IDS:
1274
+ if only is not None and cid != only:
1275
+ continue
1276
+ out.append(runners[cid]())
1277
+ return out
1278
+
1279
+
1280
+ def _run_no_manifest(
1281
+ opts: argparse.Namespace,
1282
+ project_root: Path,
1283
+ origin: str,
1284
+ bridge_present: bool,
1285
+ ) -> int:
1286
+ """Handle the no-project-lockfile path without the old hard bail.
1287
+
1288
+ Exit-code contract (council-defined, 2026-06-02):
1289
+
1290
+ * ``0`` — bare report for a recognised global-only consumer
1291
+ (bridge marker present), or a single global ``--check`` that passed.
1292
+ * ``1`` — a runnable health check requested via ``--check`` failed.
1293
+ * ``2`` — a requested ``--check`` cannot run (manifest-required check
1294
+ with no lockfile, or ``bridge-drift`` with neither lockfile nor
1295
+ bridge marker), or a bare report in an **uninitialised** repo
1296
+ (neither lockfile nor bridge marker) — preserves the spirit of the
1297
+ pre-existing "run init" signal while still printing a real report.
1298
+ """
1299
+ checks = _run_checks_no_manifest(project_root, bridge_present, only=opts.check)
1300
+ fail_check = any(c["status"] == "fail" for c in checks)
1301
+ skipped_requested = opts.check is not None and any(
1302
+ c["id"] == opts.check and c["status"] == "skipped" for c in checks
1303
+ )
1304
+
1305
+ if opts.json:
1306
+ _emit_json(project_root, [], [], [], [], checks=checks, origin=origin)
1307
+ elif opts.check is None:
1308
+ print(f" 📍 project_root: {project_root} (origin: {origin})")
1309
+ if bridge_present:
1310
+ print(" ℹ️ global-only consumer: bridge marker present, no "
1311
+ "project lockfile (expected under ADR-020)")
1312
+ print(" project-manifest checks are skipped — they apply only "
1313
+ "to project-local distributed tools")
1314
+ else:
1315
+ print(" ⚠️ no project lockfile and no bridge marker at "
1316
+ f"{project_root}", file=sys.stderr)
1317
+ print(" run `agent-config init` (project install) or "
1318
+ "`agent-config refresh --project` (global-only consumer)",
1319
+ file=sys.stderr)
1320
+ _emit_checks_text(checks)
1321
+ else:
1322
+ _emit_checks_text(checks)
1323
+
1324
+ if opts.check is not None:
1325
+ if skipped_requested:
1326
+ return 2
1327
+ return 1 if fail_check else 0
1328
+ return 0 if bridge_present else 2
1329
+
1162
1330
 
1163
1331
  def _emit_json(
1164
1332
  project_root: Path,
@@ -1371,17 +1539,12 @@ def main(argv: list[str] | None = None) -> int:
1371
1539
  manifest_pth = installed_tools.manifest_path(project_root)
1372
1540
  manifest = installed_tools.read_manifest(manifest_pth)
1373
1541
  if manifest is None:
1374
- print(
1375
- f"❌ doctor: no project lockfile at {manifest_pth}",
1376
- file=sys.stderr,
1377
- )
1378
- print(
1379
- f" project_root: {project_root} (origin: {origin})",
1380
- file=sys.stderr,
1381
- )
1382
- print(" run `./agent-config init` to create one",
1383
- file=sys.stderr)
1384
- return 2
1542
+ # No project lockfile. Under ADR-020 global-only this is a
1543
+ # legitimate state, not an error — run the lockfile-independent
1544
+ # checks and report consumer state instead of a hard bail. The
1545
+ # per-scope split + exit-code contract live in _run_no_manifest.
1546
+ bridge_present = (project_root / BRIDGE_MARKER_RELATIVE).is_file()
1547
+ return _run_no_manifest(opts, project_root, origin, bridge_present)
1385
1548
 
1386
1549
  records, known = _collect_manifest_entries(project_root, manifest)
1387
1550
  missing, modified, tag_drift = _classify(records)
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Release-published drift gate.
4
+
5
+ Catches the "release merged to main but never tagged/published" failure
6
+ mode — where ``main``'s ``package.json`` claims a version that has no
7
+ matching git tag, and npm's ``latest`` therefore lags behind main. This
8
+ is the backstop that would have surfaced the 5.8.0-stuck-on-5.7.0 state
9
+ the moment it happened, instead of weeks later.
10
+
11
+ Two independent invariants:
12
+
13
+ 1. **Tag invariant** (always checkable, no network): the version in
14
+ ``package.json`` MUST have a matching git tag (local or remote).
15
+ 2. **npm invariant** (``--check-npm``, network): ``npm view <pkg>
16
+ dist-tags.latest`` MUST equal the ``package.json`` version.
17
+
18
+ Scope guard: this only makes sense on the release trunk. Off ``main``
19
+ (e.g. a feature branch, or a ``release/X.Y.Z`` branch mid-flight where
20
+ the bump legitimately precedes the tag) the gate **no-ops** unless
21
+ ``--strict`` is combined with an explicit on-main signal. The daily
22
+ scheduled workflow runs ``--strict --check-npm`` on ``main``; the local
23
+ ``task check-release-published`` runs the tag invariant only.
24
+
25
+ Exit codes: 0 = pass / no-op · 1 = drift detected · 3 = internal error.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import json
31
+ import os
32
+ import re
33
+ import subprocess
34
+ import sys
35
+ from pathlib import Path
36
+
37
+ SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$")
38
+ REPO_ROOT = Path(__file__).resolve().parent.parent
39
+ PACKAGE_JSON = REPO_ROOT / "package.json"
40
+ MAIN_BRANCH = "main"
41
+
42
+
43
+ def _git(*args: str) -> tuple[int, str]:
44
+ proc = subprocess.run(["git", *args], capture_output=True, text=True, check=False)
45
+ return proc.returncode, (proc.stdout or "").strip()
46
+
47
+
48
+ def _package_version() -> str:
49
+ data = json.loads(PACKAGE_JSON.read_text(encoding="utf-8"))
50
+ return str(data["version"])
51
+
52
+
53
+ def _package_name() -> str:
54
+ data = json.loads(PACKAGE_JSON.read_text(encoding="utf-8"))
55
+ return str(data["name"])
56
+
57
+
58
+ def _tag_exists(tag: str) -> bool:
59
+ rc, out = _git("tag", "-l", tag)
60
+ if rc == 0 and tag in out.splitlines():
61
+ return True
62
+ rc, _ = _git("ls-remote", "--exit-code", "--tags", "origin", tag)
63
+ return rc == 0
64
+
65
+
66
+ def _on_main() -> bool:
67
+ # Local checkout, CI push ref, or CI scheduled ref all map to main.
68
+ ref = os.environ.get("GITHUB_REF", "")
69
+ if ref in ("refs/heads/main", "refs/heads/master"):
70
+ return True
71
+ rc, head = _git("rev-parse", "--abbrev-ref", "HEAD")
72
+ return rc == 0 and head == MAIN_BRANCH
73
+
74
+
75
+ def _npm_latest(pkg: str) -> str | None:
76
+ proc = subprocess.run(
77
+ ["npm", "view", pkg, "dist-tags.latest"],
78
+ capture_output=True, text=True, check=False,
79
+ )
80
+ if proc.returncode != 0:
81
+ return None
82
+ return (proc.stdout or "").strip() or None
83
+
84
+
85
+ def main(argv: list[str] | None = None) -> int:
86
+ ap = argparse.ArgumentParser(description="Release-published drift gate.")
87
+ ap.add_argument("--strict", action="store_true",
88
+ help="Fail on drift (default: informational, exit 0).")
89
+ ap.add_argument("--check-npm", action="store_true",
90
+ help="Also assert npm dist-tags.latest == package.json version (network).")
91
+ ap.add_argument("--require-main", action="store_true",
92
+ help="Only enforce when on main; no-op elsewhere (default for scheduled).")
93
+ args = ap.parse_args(argv)
94
+
95
+ try:
96
+ version = _package_version()
97
+ except (OSError, KeyError, json.JSONDecodeError) as exc:
98
+ print(f"❌ cannot read package.json version: {exc}", file=sys.stderr)
99
+ return 3
100
+ if not SEMVER_RE.match(version):
101
+ print(f"❌ package.json version is not semver: {version!r}", file=sys.stderr)
102
+ return 3
103
+
104
+ if args.require_main and not _on_main():
105
+ print(f"ℹ️ not on {MAIN_BRANCH} — release-published gate skipped.")
106
+ return 0
107
+
108
+ problems: list[str] = []
109
+
110
+ if not _tag_exists(version):
111
+ problems.append(
112
+ f"package.json is {version} but no git tag {version} exists "
113
+ f"(local or origin) — the release was bumped/merged but never "
114
+ f"tagged. Complete it: tag the release-merge commit and push "
115
+ f"(triggers publish-npm.yml), e.g. `git tag {version} && git "
116
+ f"push origin {version}`."
117
+ )
118
+
119
+ if args.check_npm:
120
+ pkg = _package_name()
121
+ latest = _npm_latest(pkg)
122
+ if latest is None:
123
+ print(f"⚠️ could not read npm dist-tags.latest for {pkg} "
124
+ f"(network/registry) — npm invariant not checked.", file=sys.stderr)
125
+ elif latest != version:
126
+ problems.append(
127
+ f"npm {pkg}@latest is {latest} but package.json is {version} "
128
+ f"— the published release lags main. Check publish-npm.yml "
129
+ f"for tag {version}, or re-dispatch it."
130
+ )
131
+
132
+ if not problems:
133
+ suffix = " + npm" if args.check_npm else ""
134
+ print(f"✅ release-published: {version} is tagged{suffix} and in sync.")
135
+ return 0
136
+
137
+ header = "❌ Release-published drift:" if args.strict else "⚠️ Release-published drift (warn-only):"
138
+ print(header, file=sys.stderr)
139
+ for p in problems:
140
+ print(f" - {p}", file=sys.stderr)
141
+ return 1 if args.strict else 0
142
+
143
+
144
+ if __name__ == "__main__":
145
+ raise SystemExit(main())
@@ -665,7 +665,7 @@ def execute(
665
665
  resume: bool = False,
666
666
  ) -> None:
667
667
  branch = f"release/{plan.target}"
668
- total = 9
668
+ total = 10
669
669
 
670
670
  if dry_run:
671
671
  print("(dry-run) no git/gh mutations will be performed.")
@@ -842,6 +842,25 @@ def execute(
842
842
  "--notes", notes,
843
843
  )
844
844
 
845
+ # ─── 10. delete the merged release branch (local + remote) ───────────────
846
+ # Branch hygiene: a merged-but-undeleted release/X.Y.Z is what made
847
+ # `--resume` mis-detect an old version. Delete it now so it can never
848
+ # accumulate. Idempotent — skips whatever is already gone. Never touches
849
+ # `main` or any tag.
850
+ if dry_run:
851
+ _step(10, total, f"Would delete merged branch {branch} (local + remote)")
852
+ else:
853
+ deleted = []
854
+ if _branch_exists_local(branch) and \
855
+ git("rev-parse", "--abbrev-ref", "HEAD", capture=True) != branch:
856
+ run("git", "branch", "-D", branch, check=False)
857
+ deleted.append("local")
858
+ if _branch_exists_remote(branch):
859
+ run("git", "push", REMOTE, "--delete", branch, check=False)
860
+ deleted.append("remote")
861
+ where = " + ".join(deleted) if deleted else "already gone"
862
+ _step(10, total, f"Delete merged branch {branch} ({where})")
863
+
845
864
  print()
846
865
  print(f"✅ Released {plan.target}")
847
866
  print(f" https://github.com/{REPO_SLUG}/releases/tag/{plan.target}")
@@ -904,43 +923,47 @@ _RELEASE_BRANCH_RE = re.compile(r"^release/(\d+\.\d+\.\d+)$")
904
923
 
905
924
 
906
925
  def _detect_in_flight_target() -> str | None:
907
- """Find the in-flight release target from existing release branches.
908
-
909
- Resume mode needs to know which `release/X.Y.Z` is being recovered,
910
- not what the next bump would be. The release branch name is the
911
- canonical anchor: it was committed by step 1 of an earlier run and
912
- is the only state guaranteed to survive a partial pipeline.
913
-
914
- Local branches win over remote, current-branch wins over both — if
915
- you ran `git checkout release/1.15.0`, that's the target. Returns
916
- None if no release branch exists; caller falls back to the regular
917
- bump-inference path.
926
+ """Find the in-flight release target the SOURCE OF TRUTH is package.json.
927
+
928
+ An "in-flight" release is one whose version was already bumped into
929
+ ``main``'s ``package.json`` (and possibly merged) but whose tag has not
930
+ yet been pushed i.e. the publish step never completed. The canonical
931
+ anchor is therefore ``package.json`` version `V` **with no matching tag
932
+ `V`**, NOT the set of ``release/X.Y.Z`` branches.
933
+
934
+ Why not the branch set: merged release branches are frequently left
935
+ undeleted on the remote, so "highest existing release/* branch" can
936
+ resolve to an OLD, already-published version (e.g. picking 5.4.0 while
937
+ 5.8.0 is the real in-flight target) and tag a downgrade. The package.json
938
+ version cannot lie that way — it is the version main currently claims to
939
+ be, and an untagged claim is exactly an incomplete release.
940
+
941
+ Resolution order:
942
+ 1. If HEAD is on a ``release/X.Y.Z`` branch, that explicit checkout wins.
943
+ 2. Else: read ``package.json`` version `V`. If tag `V` does not exist
944
+ (local or remote), `V` is the in-flight target. If it is already
945
+ tagged, the release is complete → return None (regular bump path).
946
+
947
+ Stale ``release/*`` branches are never used for version detection.
918
948
  """
919
949
  head = git("rev-parse", "--abbrev-ref", "HEAD", capture=True)
920
950
  m = _RELEASE_BRANCH_RE.match(head)
921
951
  if m:
922
952
  return m.group(1)
923
953
 
924
- local_raw = git("for-each-ref", "--format=%(refname:short)", "refs/heads/release/", capture=True)
925
- candidates = [
926
- m.group(1)
927
- for line in local_raw.splitlines()
928
- if (m := _RELEASE_BRANCH_RE.match(line.strip()))
929
- ]
930
- remote_raw = git(
931
- "for-each-ref", "--format=%(refname:short)",
932
- f"refs/remotes/{REMOTE}/release/", capture=True,
933
- )
934
- for line in remote_raw.splitlines():
935
- bare = line.strip().removeprefix(f"{REMOTE}/")
936
- if (m := _RELEASE_BRANCH_RE.match(bare)):
937
- candidates.append(m.group(1))
954
+ try:
955
+ version = json.loads(PACKAGE_JSON.read_text(encoding="utf-8"))["version"]
956
+ except (OSError, KeyError, json.JSONDecodeError):
957
+ return None
958
+ try:
959
+ parse_version(version)
960
+ except Exception:
961
+ return None
938
962
 
939
- if not candidates:
963
+ # An already-tagged version is a completed release, not in-flight.
964
+ if _tag_exists_local(version) or _tag_exists_remote(version):
940
965
  return None
941
- # Sort semver-aware so 1.10.0 > 1.9.0 (lexicographic would lose).
942
- candidates.sort(key=parse_version)
943
- return candidates[-1]
966
+ return version
944
967
 
945
968
 
946
969
  def main(argv: list[str] | None = None) -> int:
@@ -961,7 +984,7 @@ def main(argv: list[str] | None = None) -> int:
961
984
  target = args.explicit
962
985
  elif in_flight:
963
986
  target = in_flight
964
- print(f"(resume) detected in-flight release branch release/{in_flight}")
987
+ print(f"(resume) in-flight target {in_flight} (package.json version with no tag yet)")
965
988
  else:
966
989
  target = bump_version(current, bump)
967
990
  parse_version(target)