@arbiterforge/ca-pi 0.6.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 (206) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +558 -0
  3. package/package.json +35 -0
  4. package/plugins/ca-pi/CHANGELOG.md +1030 -0
  5. package/plugins/ca-pi/COMMANDS.md +90 -0
  6. package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
  7. package/plugins/ca-pi/SKILLS.md +47 -0
  8. package/plugins/ca-pi/SPRINT.md +142 -0
  9. package/plugins/ca-pi/agents/INDEX.md +31 -0
  10. package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
  11. package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
  12. package/plugins/ca-pi/agents/backend-author.md +60 -0
  13. package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
  14. package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
  15. package/plugins/ca-pi/agents/decision-challenger.md +116 -0
  16. package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
  17. package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
  18. package/plugins/ca-pi/agents/finding-triage.md +86 -0
  19. package/plugins/ca-pi/agents/frontend-author.md +64 -0
  20. package/plugins/ca-pi/agents/grader.md +173 -0
  21. package/plugins/ca-pi/agents/infra-author.md +64 -0
  22. package/plugins/ca-pi/agents/map-deps.md +35 -0
  23. package/plugins/ca-pi/agents/map-structure.md +37 -0
  24. package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
  25. package/plugins/ca-pi/agents/scout.md +127 -0
  26. package/plugins/ca-pi/agents/security-reviewer.md +72 -0
  27. package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
  28. package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
  29. package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
  30. package/plugins/ca-pi/generated/command-catalog.json +197 -0
  31. package/plugins/ca-pi/generated/roles.json +213 -0
  32. package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
  33. package/plugins/ca-pi/hooks/_activationlib.py +196 -0
  34. package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
  35. package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
  36. package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
  37. package/plugins/ca-pi/hooks/_boxlib.py +131 -0
  38. package/plugins/ca-pi/hooks/_colorlib.py +304 -0
  39. package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
  40. package/plugins/ca-pi/hooks/_entrylib.py +41 -0
  41. package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
  42. package/plugins/ca-pi/hooks/_gitexec.py +45 -0
  43. package/plugins/ca-pi/hooks/_githooks.py +920 -0
  44. package/plugins/ca-pi/hooks/_gitlib.py +110 -0
  45. package/plugins/ca-pi/hooks/_hooklib.py +595 -0
  46. package/plugins/ca-pi/hooks/_host.py +115 -0
  47. package/plugins/ca-pi/hooks/_intentlib.py +242 -0
  48. package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
  49. package/plugins/ca-pi/hooks/_metricslib.py +709 -0
  50. package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
  51. package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
  52. package/plugins/ca-pi/hooks/_previewlib.py +193 -0
  53. package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
  54. package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
  55. package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
  56. package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
  57. package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
  58. package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
  59. package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
  60. package/plugins/ca-pi/hooks/_scopelib.py +262 -0
  61. package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
  62. package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
  63. package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
  64. package/plugins/ca-pi/hooks/_sloplib.py +244 -0
  65. package/plugins/ca-pi/hooks/_standuplib.py +214 -0
  66. package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
  67. package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
  68. package/plugins/ca-pi/hooks/_updatelib.py +278 -0
  69. package/plugins/ca-pi/hooks/babysit.py +47 -0
  70. package/plugins/ca-pi/hooks/boardsync.py +129 -0
  71. package/plugins/ca-pi/hooks/doctor.py +420 -0
  72. package/plugins/ca-pi/hooks/git-enforce.py +325 -0
  73. package/plugins/ca-pi/hooks/hostapi.py +460 -0
  74. package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
  75. package/plugins/ca-pi/hooks/metrics.py +62 -0
  76. package/plugins/ca-pi/hooks/migration-pass.py +129 -0
  77. package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
  78. package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
  79. package/plugins/ca-pi/hooks/pre-bash.py +90 -0
  80. package/plugins/ca-pi/hooks/pre-edit.py +284 -0
  81. package/plugins/ca-pi/hooks/pre-read.py +81 -0
  82. package/plugins/ca-pi/hooks/pre-write.py +217 -0
  83. package/plugins/ca-pi/hooks/preview.py +69 -0
  84. package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
  85. package/plugins/ca-pi/hooks/releasehash.py +216 -0
  86. package/plugins/ca-pi/hooks/security-pass.py +139 -0
  87. package/plugins/ca-pi/hooks/session-start.py +1218 -0
  88. package/plugins/ca-pi/hooks/statusline.py +736 -0
  89. package/plugins/ca-pi/hooks/taskwrite.py +351 -0
  90. package/plugins/ca-pi/hooks/update-refresh.py +51 -0
  91. package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
  92. package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
  93. package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
  94. package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
  95. package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
  96. package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
  97. package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
  98. package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
  99. package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
  100. package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
  101. package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
  102. package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
  103. package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
  104. package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
  105. package/plugins/ca-pi/includes/compaction-charter.md +16 -0
  106. package/plugins/ca-pi/includes/cut-docs.md +16 -0
  107. package/plugins/ca-pi/includes/dev-mode.md +30 -0
  108. package/plugins/ca-pi/includes/farm.md +237 -0
  109. package/plugins/ca-pi/includes/fresh-verification.md +14 -0
  110. package/plugins/ca-pi/includes/harvest.md +69 -0
  111. package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
  112. package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
  113. package/plugins/ca-pi/includes/redirect.md +69 -0
  114. package/plugins/ca-pi/includes/reference-map.md +22 -0
  115. package/plugins/ca-pi/includes/review-matrix.md +14 -0
  116. package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
  117. package/plugins/ca-pi/includes/routing-table.md +47 -0
  118. package/plugins/ca-pi/includes/security-gate-record.md +22 -0
  119. package/plugins/ca-pi/includes/smarts/core.md +90 -0
  120. package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
  121. package/plugins/ca-pi/routines/INDEX.md +32 -0
  122. package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
  123. package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
  124. package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
  125. package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
  126. package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
  127. package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
  128. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
  129. package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
  130. package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
  131. package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
  132. package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
  133. package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
  134. package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
  135. package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
  136. package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
  137. package/plugins/ca-pi/routines/release/SKILL.md +315 -0
  138. package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
  139. package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
  140. package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
  141. package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
  142. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
  143. package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
  144. package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
  145. package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
  146. package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
  147. package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
  148. package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
  149. package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
  150. package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
  151. package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
  152. package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
  153. package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
  154. package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
  155. package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
  156. package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
  157. package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
  158. package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
  159. package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
  160. package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
  161. package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
  162. package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
  163. package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
  164. package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
  165. package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
  166. package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
  167. package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
  168. package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
  169. package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
  170. package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
  171. package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
  172. package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
  173. package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
  174. package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
  175. package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
  176. package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
  177. package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
  178. package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
  179. package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
  180. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
  181. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
  182. package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
  183. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
  184. package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
  185. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
  186. package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
  187. package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
  188. package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
  189. package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
  190. package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
  191. package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
  192. package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
  193. package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
  194. package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
  195. package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
  196. package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
  197. package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
  198. package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
  199. package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
  200. package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
  201. package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
  202. package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
  203. package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
  204. package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
  205. package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
  206. package/plugins/ca-pi/skills/ca-watch/SKILL.md +78 -0
@@ -0,0 +1,2657 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — portable release-lane MECHANISM (anchored per-series tag
3
+ # selection, semver comparison, publish-state classification, notes-heading
4
+ # matching, date consistency) plus the declared-target-file parser.
5
+ #
6
+ # This module is the PORTABLE half of the release helper split (issue #563).
7
+ # It ships from core/pysrc/ into every governance plugin's hooks/ directory
8
+ # (tools/sync-core.py, CI-enforced byte-identity) and therefore MUST carry no
9
+ # fact about this repository or its CI vocabulary — no plugin name, no path
10
+ # under this repository, no check-run name, no tag-namespace mapping. Every
11
+ # such fact is DATA, supplied by the caller (a required parameter) or read
12
+ # from an operator-declared file via load_targets(). A consuming repository
13
+ # supplies its own facts; this module supplies only the mechanism.
14
+ #
15
+ # Design invariants (mirror the other _*lib helpers):
16
+ # - Stdlib only; zero side effects at import (no git, no file I/O, no
17
+ # argument parsing at import time).
18
+ # - Every mechanism function (semver_key, semver_greater, last_tag_select,
19
+ # notes_heading_matches, release_dates_consistent, classify_publish_state,
20
+ # select_release_target, classify_merge_readiness, peel_tag) is pure over
21
+ # synthetic input and NEVER raises on malformed input — it degrades to the
22
+ # safe/refusing answer, per this codebase's "never raise on malformed user
23
+ # input" rule for hook-adjacent helpers.
24
+ # - The declared-target-file parser (parse_release_targets / load_targets)
25
+ # is the deliberate, documented exception to that rule: its input is not
26
+ # arbitrary user/session data but an OPERATOR-AUTHORED configuration file
27
+ # that a `contents: write` release lane later executes. A malformed
28
+ # declaration is a configuration error that MUST surface loudly to the
29
+ # operator rather than silently defaulting or partially parsing — so every
30
+ # parser-contract violation raises its own distinguishable
31
+ # ReleaseTargetsError subclass instead of returning a degraded value or
32
+ # letting a bare exception escape from deep inside the parser.
33
+ #
34
+ # Public API:
35
+ # git_executable() -> str git resolved through the trusted-path seam
36
+ # semver_key(value) -> tuple | None
37
+ # semver_greater(current, base) -> bool
38
+ # apply_bump(base, word) -> str | None
39
+ # last_tag_select(tags, prefix) -> str
40
+ # notes_heading_matches(notes_text, tag) -> bool
41
+ # release_dates_consistent(changelog_section, tag_message) -> bool
42
+ # classify_publish_state(tag_exists, tag_sha, head_sha, tag_version,
43
+ # manifest_version, release_is_nondraft) -> str
44
+ # select_release_target_by_name(pairs, targets) -> str name-keyed
45
+ # resolver (A-4.2); pairs are `name=value` strings
46
+ # select_release_target(*confirmations, targets) -> str
47
+ # classify_merge_readiness(check_runs, head_sha, check_name) -> str
48
+ # row_assertions(row) -> dict which lane steps a row's declared fields
49
+ # turn on (A-3.1..3.5)
50
+ # window_excludes_payload_paths(paths, payload, payload_exclude)
51
+ # -> list[str] paths filtered to payload, minus
52
+ # any payload-exclude entry (A-3.4)
53
+ # provenance_trigger_paths(rows) -> list[str] every manifest/changelog/
54
+ # generated_manifest/artifacts path a declared row
55
+ # references, sorted and de-duplicated (A-5.6)
56
+ # _manifest_version(path) -> str | None
57
+ # classify_commit(subject, body) -> dict
58
+ # classify_window(commits) -> dict
59
+ # parse_window_log(text) -> list[dict]
60
+ # first_release_baseline(adoption_log_text) -> str
61
+ # peel_tag(ls_remote_text, tag) -> str
62
+ # parse_release_targets(text) -> list[dict]
63
+ # load_targets(path) -> list[dict]
64
+ # default_backfill_root() -> str
65
+ # scan_backfill_candidates(root) -> (list[str], list[str])
66
+ # detect_candidate_target(manifest_candidates, changelog_candidates,
67
+ # target=..., prefix=...) -> dict
68
+ # format_release_targets_block(row) -> str
69
+ # default_targets_path() -> str
70
+ # _targets_error_exit_code(exc) -> int
71
+ # main(argv) -> int
72
+ #
73
+ # CLI (T-41f): every consuming host vendors this file byte-identically into
74
+ # its own `hooks/` directory (`tools/sync-core.py`), and that vendored copy —
75
+ # `${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py` — is what the release skill
76
+ # actually shells out to post-portability (issue #563). Before this, only the
77
+ # permanent `.github/scripts/_releaselib.py` shim (this repo's OWN CI entry
78
+ # point, never replaced by this one) carried a `__main__`; repointing the
79
+ # skill's invocations without this CLI would aim prose at a file nothing
80
+ # could run. See "CLI entry point" below the parser for the subcommands.
81
+ #
82
+ # Declared exceptions (all subclass ReleaseTargetsError):
83
+ # AbsentBlockError — no delimiter block present at all
84
+ # EmptyBlockError — the delimiter block is present but blank
85
+ # MalformedBlockError — bad `[target]` header grammar, a key before the
86
+ # first header, or an unparsable line
87
+ # UnknownKeyError — a key outside the declared grammar
88
+ # DuplicateKeyError — a scalar key repeated within one target block
89
+ # DuplicateTargetError — the same `[target]` header declared twice
90
+ # InvalidBooleanError — a boolean value that is not exactly true/false
91
+ # MultipleBlocksError — more than one delimiter block in the file
92
+ # ValueTooLongError — a declared value exceeds VALUE_MAX_CHARS
93
+ # (A-2.4); a sibling declared-file error, exits 4
94
+ # DelimiterInValueError — a value contains the literal closing delimiter,
95
+ # which would otherwise truncate the block under a
96
+ # naive non-greedy match
97
+ # MissingRequiredKeyError — a target block is missing prefix/changelog/payload
98
+ # BackfillAmbiguousError — the back-fill scan found zero, or more than one,
99
+ # candidate manifest or changelog file (T-49/T-50)
100
+ # FileExistsNoBlockError — the declared-target file EXISTS on disk but
101
+ # carries no delimiter block at all (HIGH-1,
102
+ # adversarial review 2026-07-31). Deliberately a
103
+ # SIBLING of AbsentBlockError, not a subclass of
104
+ # it: raised only by load_targets() (which knows
105
+ # the file was opened successfully), never by
106
+ # parse_release_targets() on synthetic text,
107
+ # and never caught by an `except
108
+ # AbsentBlockError:` clause written for the
109
+ # genuinely-missing-file case — see "Back-fill
110
+ # detection" below for why that distinction is
111
+ # load-bearing.
112
+ # UnreadableTargetsFileError — the declared path EXISTS but `open()` still
113
+ # failed for a reason other than "not found"
114
+ # (permission denied, a directory, ...). A
115
+ # SIBLING of AbsentBlockError, not a subclass,
116
+ # for the same reason FileExistsNoBlockError is:
117
+ # "unreadable" is not "absent"
118
+ # ([[never-fold-unreadable-into-absent]]) and
119
+ # must not silently trigger the Back-fill lane's
120
+ # `except AbsentBlockError:` on a file that is
121
+ # actually there but denied to this process.
122
+
123
+ from __future__ import annotations
124
+
125
+ import hashlib
126
+ import os
127
+ import re
128
+ import shlex
129
+ import shutil
130
+ import subprocess
131
+ import sys
132
+
133
+
134
+ def git_executable():
135
+ """Git resolved through the trusted-path seam, never a bare `git`.
136
+
137
+ Resolved LAZILY and imported inside the call rather than at module
138
+ scope, because this module is loaded two different ways and only one of
139
+ them has `_gitexec` importable by name: as an ordinary sibling module
140
+ in a host's hook directory (a plain import works), and by explicit file
141
+ path, where nothing put this directory on `sys.path`. A module-level
142
+ import would break the second, and would also violate the documented
143
+ invariant that importing this mechanism has no side effects beyond the
144
+ load itself.
145
+
146
+ `_gitexec.py` sits beside this file wherever this file lives, so the
147
+ fallback adds only this module's own directory.
148
+ """
149
+ try:
150
+ from _gitexec import git_executable as resolve
151
+ except ImportError: # loaded by path
152
+ here = os.path.dirname(os.path.abspath(__file__))
153
+ if here not in sys.path:
154
+ sys.path.insert(0, here)
155
+ from _gitexec import git_executable as resolve
156
+ return resolve()
157
+
158
+
159
+ class ReleaseTargetsError(RuntimeError):
160
+ """Base for every declared release-targets-file parse error. Callers that
161
+ only care that the declaration was bad, not which rule it broke, can catch
162
+ this one type; callers that need to react differently per violation catch
163
+ the specific subclass."""
164
+
165
+
166
+ class AbsentBlockError(ReleaseTargetsError):
167
+ """No delimiter block is present in the file at all."""
168
+
169
+
170
+ class EmptyBlockError(ReleaseTargetsError):
171
+ """The delimiter block is present but contains no declaration content."""
172
+
173
+
174
+ class FileExistsNoBlockError(ReleaseTargetsError):
175
+ """The declared-target file EXISTS on disk (it opened and read
176
+ successfully) but contains no `<!-- release-targets -->` delimiter block
177
+ at all.
178
+
179
+ HIGH-1 (adversarial review 2026-07-31): before this class existed,
180
+ `load_targets` raised the SAME `AbsentBlockError` for this case as it did
181
+ for a genuinely missing file, because it delegated straight to
182
+ `parse_release_targets`, which cannot tell "no text at all" from "text
183
+ with no block in it" apart from "no file" -- it only ever sees text. An
184
+ agent implementing the release skill's Back-fill lane literally as
185
+ written -- catch `AbsentBlockError`, enter back-fill, "write the
186
+ confirmed block verbatim" -- would silently overwrite an operator's
187
+ EXISTING file that merely lacks the block, discarding whatever they
188
+ actually put there. That is exactly the outcome the skill's own prose
189
+ says this lane must never cause.
190
+
191
+ Deliberately a SIBLING of `AbsentBlockError` under `ReleaseTargetsError`,
192
+ not a subclass of it: an `except AbsentBlockError:` clause written for
193
+ "the file is genuinely absent" (the Back-fill lane's ONE sanctioned
194
+ trigger) continues to see only that case, unchanged, and does not
195
+ accidentally widen to catch this one. A broad `except
196
+ ReleaseTargetsError:` clause still catches both, as it always did. Only
197
+ `load_targets` ever raises this -- `parse_release_targets` is pure over
198
+ text and has no way to know whether a file existed, so its own
199
+ AbsentBlockError-on-no-block behavior against synthetic text is
200
+ unchanged."""
201
+
202
+
203
+ class UnreadableTargetsFileError(ReleaseTargetsError):
204
+ """`open()` on the declared path failed for a reason OTHER than "not
205
+ found" -- a permissions error, the path naming a directory, or any other
206
+ `OSError` whose `errno` is not `ENOENT`. The path exists (in the
207
+ filesystem sense) but this process could not read it.
208
+
209
+ Deliberately a SIBLING of `AbsentBlockError`, not a subclass of it, for
210
+ the identical reason `FileExistsNoBlockError` is one:
211
+ [[never-fold-unreadable-into-absent]] -- "could not read" is a different
212
+ fact than "is not there", and folding the two together means a
213
+ permissions error on an EXISTING declared-targets file would silently
214
+ satisfy the Back-fill lane's `except AbsentBlockError:` trigger, driving
215
+ it to write a fresh file over one that was never actually missing, only
216
+ denied. Only `load_targets` ever raises this -- `parse_release_targets`
217
+ is pure over text and never touches the filesystem."""
218
+
219
+
220
+ class MalformedBlockError(ReleaseTargetsError):
221
+ """A `[target]` header is malformed (empty, or carries a character outside
222
+ `[A-Za-z0-9._-]`), a key line appears before the first header, or a line is
223
+ neither a header nor a `key: value` pair."""
224
+
225
+
226
+ class UnknownKeyError(ReleaseTargetsError):
227
+ """A key outside the declared grammar (e.g. a typo) was used."""
228
+
229
+
230
+ class DuplicateKeyError(ReleaseTargetsError):
231
+ """A scalar (non-repeating) key was declared twice within one target block."""
232
+
233
+
234
+ class DuplicateTargetError(ReleaseTargetsError):
235
+ """The same `[target]` header was declared more than once."""
236
+
237
+
238
+ class InvalidBooleanError(ReleaseTargetsError):
239
+ """A boolean-typed value was neither exactly `true` nor exactly `false`."""
240
+
241
+
242
+ class MultipleBlocksError(ReleaseTargetsError):
243
+ """More than one delimiter block was found in the file."""
244
+
245
+
246
+ class ValueTooLongError(ReleaseTargetsError):
247
+ """A declared value exceeds `VALUE_MAX_CHARS` (A-2.4, ADR-0002's
248
+ precedent). A sibling of every other declared-file error, so it exits 4
249
+ and never 3 -- an over-long value is a malformed declaration, never the
250
+ genuinely-absent state that triggers the Back-fill lane."""
251
+
252
+
253
+ class DelimiterInValueError(ReleaseTargetsError):
254
+ """A declared value contains the literal closing-delimiter text, which
255
+ would otherwise silently truncate the block under a naive non-greedy
256
+ match rather than being treated as part of the value."""
257
+
258
+
259
+ class MissingRequiredKeyError(ReleaseTargetsError):
260
+ """A target block is missing one of the required keys (prefix, changelog,
261
+ payload)."""
262
+
263
+
264
+ # A `2.9.1`-style series tag is exactly `<prefix>MAJOR.MINOR.PATCH` — no
265
+ # suffix. The anchored form excludes pre-releases (`2.6.0-beta.1`) outright:
266
+ # the trailing `$` rejects any tag carrying a suffix past MAJOR.MINOR.PATCH,
267
+ # so a pre-release tag never matches this regex at all.
268
+ #
269
+ # Issue #568: this module used to also carry `_PRERELEASE_MARKERS = ("-beta",
270
+ # "-rc", "-alpha")` and a second check in `last_tag_select` re-testing the
271
+ # prefix-stripped version against it, documented as a "second line of
272
+ # defense". That check was proven UNREACHABLE through the public API as
273
+ # shipped — the anchor above already rejects every tag the marker check
274
+ # could have caught, so no tag can both match the regex and carry a marker,
275
+ # and mutating the tuple to `()` changed nothing observable. Deleted rather
276
+ # than kept as unreachable dead code or made load-bearing by relaxing the
277
+ # anchor: relaxing the anchor to admit a suffix would also newly admit
278
+ # tags like `v1.0.0+build.5` and `v1.0.0.1` that this anchor currently
279
+ # rejects wholesale, a behavior change with its own blast radius this
280
+ # bug-fix cluster does not take on. If the anchor is ever relaxed, a second
281
+ # check will need to be RE-ADDED deliberately, not un-deleted from history.
282
+ _RELEASE_RE_CACHE = {}
283
+
284
+
285
+ def _release_re(prefix):
286
+ """The anchored `<prefix>MAJOR.MINOR.PATCH` matcher for one release series."""
287
+ rx = _RELEASE_RE_CACHE.get(prefix)
288
+ if rx is None:
289
+ rx = re.compile(r"^" + re.escape(prefix) + r"(\d+)\.(\d+)\.(\d+)$")
290
+ _RELEASE_RE_CACHE[prefix] = rx
291
+ return rx
292
+
293
+
294
+ # A changelog section heading, in either the `## vX.Y.Z - DATE` form or the
295
+ # Keep-a-Changelog `## [X.Y.Z] - DATE` bracket form. The capture is the bare
296
+ # `X.Y.Z`; the optional leading `v` and the surrounding brackets sit OUTSIDE
297
+ # the group, so heading comparison is style-agnostic. Any separator is
298
+ # allowed between version and date. Plus the annotated-tag `Released-at:`
299
+ # footer.
300
+ _HEADING_RE = re.compile(r"^##\s+\[?v?(\d+\.\d+\.\d+)\]?", re.MULTILINE)
301
+ _CHANGELOG_DATE_RE = re.compile(
302
+ r"^##\s+\[?v?\d+\.\d+\.\d+\]?\D+(\d{4}-\d{2}-\d{2})", re.MULTILINE)
303
+ _RELEASED_AT_RE = re.compile(r"Released-at:\s*(\d{4}-\d{2}-\d{2})")
304
+
305
+ # Full SemVer, including the pre-release and build-metadata tails a release
306
+ # tag never carries but a version MANIFEST can. The anchored `_release_re`
307
+ # above deliberately rejects those, because it selects a published release
308
+ # series; this one parses a version for ORDERING, which is a different
309
+ # question and needs the tail.
310
+ # A-2.4 / ADR-0002 precedent. Named rather than inlined so the parser, the
311
+ # error message, and the tests all read the same number, and so raising it
312
+ # is one edit rather than three.
313
+ VALUE_MAX_CHARS = 1024
314
+
315
+ SEMVER = re.compile(
316
+ r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
317
+ r"(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?"
318
+ r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
319
+ )
320
+
321
+
322
+ def semver_key(value):
323
+ """`"2.9.1"` -> a sortable key; `None` when `value` is not valid SemVer.
324
+
325
+ Non-raising per this module's mechanism-function invariant. Build
326
+ metadata is parsed and discarded: SemVer §10 says it is not part of
327
+ precedence, so `1.0.0+a` and `1.0.0+b` compare equal.
328
+ """
329
+ if not isinstance(value, str):
330
+ return None
331
+ match = SEMVER.fullmatch(value)
332
+ if match is None:
333
+ return None
334
+ prerelease = match.group(4)
335
+ if prerelease is None:
336
+ pre_key = None
337
+ else:
338
+ pre_key = tuple(
339
+ (0, int(part)) if part.isdigit() else (1, part)
340
+ for part in prerelease.split(".")
341
+ )
342
+ return int(match.group(1)), int(match.group(2)), int(match.group(3)), pre_key
343
+
344
+
345
+ def semver_greater(current, base):
346
+ """True iff `current` is a STRICT SemVer advance over `base`.
347
+
348
+ The single definition of "advance" every payload-version gate shares.
349
+ Degrades to False when either side is unparseable, which refuses the
350
+ gate rather than passing it. Pre-release ordering follows SemVer §11: a
351
+ pre-release is LOWER than its release (`1.0.0-beta` < `1.0.0`), numeric
352
+ identifiers compare numerically and rank below alphanumeric ones.
353
+ """
354
+ current_key = semver_key(current)
355
+ base_key = semver_key(base)
356
+ if current_key is None or base_key is None:
357
+ return False
358
+ if current_key[:3] != base_key[:3]:
359
+ return current_key[:3] > base_key[:3]
360
+ current_pre, base_pre = current_key[3], base_key[3]
361
+ if current_pre is None:
362
+ return base_pre is not None
363
+ if base_pre is None:
364
+ return False
365
+ return current_pre > base_pre
366
+
367
+
368
+ def _bare_version(tag):
369
+ """`v2.6.0` / `[2.6.0]` / `2.6.0` / `myapp-v0.1.31` -> the bare SemVer.
370
+
371
+ Lets the heading match compare a tag against a bracket-style changelog
372
+ heading without caring about either spelling.
373
+
374
+ Anchored on the SemVer at the END rather than by stripping a known
375
+ prefix, so a namespaced series' tag (`<prefix>vMAJOR.MINOR.PATCH`) works
376
+ without the prefix being known here — stripping only a LEADING "v" is
377
+ right for a bare `v2.9.1` and wrong for any namespaced series, since
378
+ `"myapp-v0.1.31".lstrip("v")` is unchanged and never equals the `0.1.31`
379
+ parsed out of the heading."""
380
+ if not isinstance(tag, str):
381
+ return tag
382
+ text = tag.strip().strip("[]")
383
+ match = re.search(r"(\d+\.\d+\.\d+.*)$", text)
384
+ return match.group(1) if match else text.lstrip("v")
385
+
386
+
387
+ def last_tag_select(tags, prefix):
388
+ """Return the highest SemVer tag in `tags` for ONE release series,
389
+ excluding pre-releases (`-beta`/`-rc`/`-alpha`) via the anchored
390
+ `_release_re` match alone (issue #568) — no separate marker check.
391
+ Returns NONE_SENTINEL when the series has no release tag yet.
392
+
393
+ `prefix` selects the series and is REQUIRED — this repository's default
394
+ was a repo-specific fact (which series is "the" release) and could not
395
+ survive as a module default without smuggling that fact back in. The
396
+ caller supplies the prefix for the series it means, typically a value
397
+ loaded from a declared row (see `load_targets`).
398
+
399
+ This is the single source of `LAST_TAG`, replacing an inline grep
400
+ one-liner: bare `git describe --tags` returns the nearest tag by commit-
401
+ graph ANCESTRY, which in a multi-series repo is routinely another
402
+ series' tag, and silently bases an entire release on the wrong baseline.
403
+
404
+ Series isolation is a property of the ANCHORED match rather than a list
405
+ of exclusions to maintain: `^v` cannot match `myapp-v0.1.30`, and
406
+ `^myapp-v` cannot match `v2.9.1`. A new series therefore cannot leak into
407
+ an existing one by being forgotten in an exclusion list."""
408
+ best = None # ((major, minor, patch), original_tag)
409
+ if not isinstance(tags, (list, tuple)):
410
+ return NONE_SENTINEL
411
+ if not isinstance(prefix, str) or not prefix:
412
+ return NONE_SENTINEL
413
+ matcher = _release_re(prefix)
414
+ for t in tags:
415
+ if not isinstance(t, str):
416
+ continue
417
+ m = matcher.match(t)
418
+ if not m:
419
+ continue
420
+ ver = tuple(int(g) for g in m.groups())
421
+ if best is None or ver > best[0]:
422
+ best = (ver, t)
423
+ return best[1] if best else NONE_SENTINEL
424
+
425
+
426
+ NONE_SENTINEL = "<none>"
427
+
428
+
429
+ def notes_heading_matches(notes_text, tag):
430
+ """True iff the FIRST changelog heading in `notes_text` (either `## vX.Y.Z`
431
+ or the Keep-a-Changelog `## [X.Y.Z]` form) names the same version as
432
+ `tag`. A stale notes-file (whose first section is an older version)
433
+ returns False, so a release lane cannot publish the wrong changelog
434
+ section under the right tag. Missing heading or non-string input ->
435
+ False."""
436
+ if not isinstance(notes_text, str) or not isinstance(tag, str):
437
+ return False
438
+ m = _HEADING_RE.search(notes_text)
439
+ if not m:
440
+ return False
441
+ return m.group(1) == _bare_version(tag)
442
+
443
+
444
+ def release_dates_consistent(changelog_section, tag_message):
445
+ """True iff the date in `changelog_section`'s heading (`## vX.Y.Z - DATE`
446
+ or `## [X.Y.Z] - DATE`) equals the `Released-at: DATE` date in
447
+ `tag_message`. Guards against the date being hand-typed inconsistently
448
+ across surfaces. Either date absent, or non-string input -> False."""
449
+ if not isinstance(changelog_section, str) or not isinstance(tag_message, str):
450
+ return False
451
+ cm = _CHANGELOG_DATE_RE.search(changelog_section)
452
+ tm = _RELEASED_AT_RE.search(tag_message)
453
+ if not cm or not tm:
454
+ return False
455
+ return cm.group(1) == tm.group(1)
456
+
457
+
458
+ def changelog_section(changelog_text, version):
459
+ """Extract the `## [VERSION] ...` (or `## vVERSION ...`) section from
460
+ `changelog_text` VERBATIM -- from its own heading line up to (but not
461
+ including) the next `##` heading, or end of text. Returns the section
462
+ text with exactly one trailing newline, or `None` when no heading in
463
+ `changelog_text` names `version` exactly. Non-string input -> `None`.
464
+
465
+ This is the mechanical replacement for hand-copying a changelog section:
466
+ Phase 3's `resume_publish` path needs the SAME text Phase 1 composed,
467
+ but Phase 1's own scratch copy is explicitly discardable and routinely
468
+ gone by the time a resumed publish runs. Reading it back out of the
469
+ COMMITTED `$CHANGELOG` -- which Phase 1 step 7 commits before any tag
470
+ exists -- is reading the one permanent home of that text, not
471
+ re-deriving or hand-writing new notes (blind exercise run 19, HIGH-2)."""
472
+ if not isinstance(changelog_text, str) or not isinstance(version, str):
473
+ return None
474
+ matches = list(_HEADING_RE.finditer(changelog_text))
475
+ for i, m in enumerate(matches):
476
+ if m.group(1) != version:
477
+ continue
478
+ start = m.start()
479
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(changelog_text)
480
+ return changelog_text[start:end].rstrip("\n") + "\n"
481
+ return None
482
+
483
+
484
+ def classify_publish_state(tag_exists, tag_sha, head_sha, tag_version,
485
+ manifest_version, release_is_nondraft):
486
+ """Classify a (re)publish attempt so a release lane can resume a
487
+ half-finished publish instead of dead-ending on 'tag exists -> STOP'.
488
+ Returns one of:
489
+
490
+ publish_fresh - no tag yet; the normal path.
491
+ already_published - the tag is at HEAD and a non-draft release exists.
492
+ resume_publish - tag is at HEAD and its version matches the
493
+ manifest, but no non-draft release exists (tag
494
+ pushed, release never created) -> finish publish.
495
+ abort_mismatch - tag points at a non-HEAD commit, or its version
496
+ disagrees with the manifest -> STOP, never overwrite.
497
+
498
+ Mismatch OUTRANKS publication state. An existing release used to
499
+ short-circuit to `already_published` before the tag was compared to
500
+ HEAD, so a resumed publish could silently accept a release whose tag
501
+ installs a different snapshot. The tag is what consumers actually fetch;
502
+ if it does not name this commit, nothing about the release makes the
503
+ state safe.
504
+ """
505
+ if not tag_exists:
506
+ return "publish_fresh"
507
+ if tag_sha != head_sha or tag_version != manifest_version:
508
+ return "abort_mismatch"
509
+ if release_is_nondraft:
510
+ return "already_published"
511
+ return "resume_publish"
512
+
513
+
514
+ def select_release_target_by_name(pairs, targets):
515
+ """Resolve a release dispatch from NAME-KEYED inputs (A-4.2).
516
+
517
+ `pairs` are `name=value` strings — one per confirmation input, each
518
+ carrying the target it belongs to. `targets` is the declared register.
519
+ Returns the same label vocabulary as the positional resolver:
520
+
521
+ <target> exactly one non-blank value; that target
522
+ none nothing supplied; nothing to publish
523
+ multiple more than one; ambiguous, MUST be refused
524
+ unknown a pair names a target the declared file does not contain
525
+
526
+ WHY NAME-KEYED. The positional form aligns confirmations to `targets`
527
+ by INDEX, so it is correct only while the workflow's input order and
528
+ the declared file's row order agree. Nothing enforced that. Insert a
529
+ row in the middle of the declared file, or reorder the workflow's
530
+ inputs, and every confirmation shifts by one — a dispatch meaning to
531
+ publish the second declared target publishes the first instead, with a
532
+ `contents: write` token, and every downstream check passes because the
533
+ wrong release is internally consistent. Order was load-bearing and
534
+ invisible.
535
+
536
+ `unknown` is deliberately a LABEL, not an exception, matching this
537
+ module's "prints a label and never raises" contract for the release
538
+ lane. It is also deliberately not `none`: a caller's fail-closed
539
+ default arm refuses both, but only one of them means "somebody named a
540
+ target that does not exist", which is a declaration/workflow
541
+ disagreement worth reporting rather than a quiet no-op.
542
+
543
+ Blank-ish values count as not-selected, exactly as in the positional
544
+ form, so a stray space cannot read as a second target. A pair with no
545
+ `=` at all, or whose NAME is blank (e.g. `"=1.2.3"`), is ignored rather
546
+ than fatal — an empty workflow input can arrive as a bare name or a
547
+ stray leading `=` — but a pair whose NAME is non-blank and unknown is
548
+ reported, because that is a real mismatch rather than an empty slot.
549
+
550
+ Pure and non-raising over synthetic input.
551
+ """
552
+ known = [t for t in targets if isinstance(t, str)] if isinstance(
553
+ targets, (list, tuple)) else []
554
+ selected = []
555
+ for pair in (pairs if isinstance(pairs, (list, tuple)) else []):
556
+ if not isinstance(pair, str) or "=" not in pair:
557
+ continue
558
+ name, _, value = pair.partition("=")
559
+ name = name.strip()
560
+ if not name:
561
+ # A blank name (e.g. "=1.2.3") identifies no target -- ignore
562
+ # it exactly like a pair with no "=" at all, rather than
563
+ # falling through and selecting "" as if it were a target.
564
+ continue
565
+ if name not in known:
566
+ return "unknown"
567
+ if value.strip():
568
+ selected.append(name)
569
+ if not selected:
570
+ return "none"
571
+ if len(set(selected)) > 1 or len(selected) > 1:
572
+ return "multiple"
573
+ return selected[0]
574
+
575
+
576
+ def select_release_target(*confirmations, targets):
577
+ """Resolve which single target a release dispatch selected.
578
+ `confirmations` are the per-target version inputs, positionally aligned
579
+ with `targets`. `targets` is REQUIRED — the register of releasable names
580
+ is a repo-specific fact and cannot survive as a module default. Returns
581
+ one of:
582
+
583
+ <target> - exactly one input was supplied; the matching name from
584
+ `targets`.
585
+ none - no input was supplied; there is nothing to publish.
586
+ multiple - more than one; the dispatch is ambiguous and MUST be
587
+ refused.
588
+ arity - `confirmations` and `targets` are not the same length.
589
+
590
+ Selection is one decision, made once, by a caller that holds no write
591
+ token of its own, so a dispatch that supplies more than one confirmation
592
+ can never start two `contents: write` publishers. Blank-ish input
593
+ (whitespace, non-string) counts as "not selected" so a stray space can
594
+ never read as a second target.
595
+
596
+ The count is checked against `targets` rather than zipped-to-shortest on
597
+ purpose: a caller wired for fewer targets than were actually supplied
598
+ would otherwise silently resolve the wrong one. `arity` is not a target
599
+ and is meant to match no dispatch case, so a caller's fail-closed default
600
+ arm refuses it - and, like every other return here, it is a LABEL rather
601
+ than an exception, so a caller's contract of "prints a label and never
602
+ raises" holds."""
603
+ def _selected(value):
604
+ return isinstance(value, str) and value.strip() != ""
605
+
606
+ if not isinstance(targets, (list, tuple)):
607
+ return "arity"
608
+ if len(confirmations) != len(targets):
609
+ return "arity"
610
+ selected = [target for target, value in zip(targets, confirmations)
611
+ if _selected(value)]
612
+ if len(selected) > 1:
613
+ return "multiple"
614
+ if selected:
615
+ return selected[0]
616
+ return "none"
617
+
618
+
619
+ def classify_merge_readiness(check_runs, head_sha, check_name):
620
+ """Classify the merge-readiness evidence for ONE exact commit. `check_runs`
621
+ is the `check_runs` array from a commit's check-runs API response.
622
+ `check_name` — the single aggregate check that means "every required job
623
+ for this commit concluded green" — is REQUIRED: its exact text is a
624
+ repo-specific fact (this codebase's own CI vocabulary) and cannot survive
625
+ as a module default. Returns one of:
626
+
627
+ green - the gate ran for this commit, completed, and succeeded.
628
+ missing - no check run by that name is present at all.
629
+ pending - present but not `completed` (queued / in_progress / ...).
630
+ sha_mismatch - a matching run reports a different `head_sha`.
631
+ not_successful - completed with any conclusion other than `success`
632
+ (failure, cancelled, skipped, timed_out, neutral, ...).
633
+
634
+ A hosted release workflow that only proves it was dispatched from a
635
+ protected branch shows how a commit ENTERED that branch, not that
636
+ post-merge evidence exists for the exact commit about to be tagged.
637
+
638
+ Fail-closed throughout: unparseable input is `missing`, and several runs
639
+ share one name only when a re-run is in flight - we cannot tell which
640
+ verdict is authoritative, so EVERY matching run must be green."""
641
+ if not isinstance(check_runs, list):
642
+ return "missing"
643
+ matching = [run for run in check_runs
644
+ if isinstance(run, dict) and run.get("name") == check_name]
645
+ if not matching:
646
+ return "missing"
647
+ if any(run.get("head_sha") != head_sha for run in matching):
648
+ return "sha_mismatch"
649
+ if any(run.get("status") != "completed" for run in matching):
650
+ return "pending"
651
+ if any(run.get("conclusion") != "success" for run in matching):
652
+ return "not_successful"
653
+ return "green"
654
+
655
+
656
+ # Conventional-Commits subject grammar: `type(optional-scope)!: subject`.
657
+ # The `!` sits AFTER the closing paren when a scope is present and directly
658
+ # after the type when it is not -- both spellings mark a breaking change,
659
+ # and a hand-rolled `split(':')[0]` that strips `!` before checking for it
660
+ # loses the marker entirely.
661
+ _CC_SUBJECT_RE = re.compile(r"^(?P<type>[a-zA-Z]+)(?:\((?P<scope>[^)]*)\))?(?P<bang>!)?:\s")
662
+
663
+ # `BREAKING CHANGE:` (and the hyphenated spelling the spec also permits) as
664
+ # a FOOTER -- at the start of its own line, never mid-sentence, so prose
665
+ # that merely discusses a breaking change does not silently bump a major.
666
+ _BREAKING_FOOTER_RE = re.compile(r"^BREAKING[ -]CHANGE:", re.MULTILINE)
667
+ _CHANGELOG_FOOTER_RE = re.compile(r"^CHANGELOG:", re.MULTILINE)
668
+
669
+ # Which types bump, and to what. `refactor` bumps patch and IS harvested
670
+ # (see the changelog-grouping rule); `docs`/`chore`/`test`/`ci` bump
671
+ # nothing but may still carry a harvested footer.
672
+ _BUMPING_TYPES = {"feat": "minor", "fix": "patch", "perf": "patch",
673
+ "refactor": "patch"}
674
+ _BUMP_RANK = {"none": 0, "patch": 1, "minor": 2, "major": 3}
675
+
676
+
677
+ def row_assertions(row):
678
+ """Which release-lane steps a row's DECLARED FIELDS turn on (A-3.1..3.5).
679
+
680
+ Returns a dict:
681
+
682
+ version_source "manifest" | "tag"
683
+ assert_manifest_equal bool -- A-3.1/3.2
684
+ manifests list -- every declared manifest path
685
+ rebuild str|None
686
+ artifacts list -- asserted clean after `rebuild` (A-3.3)
687
+ payload_exclude list -- removed from the window (A-3.4)
688
+ record_provenance bool -- A-3.5
689
+ skipped list -- steps that do NOT apply, each with a
690
+ reason, so the report can say a step
691
+ was SKIPPED rather than leaving the
692
+ reader to infer it from silence
693
+
694
+ The five criteria are not independent: "declares no manifest" IS "the
695
+ tag is the version source". Deriving them together makes the two
696
+ answers structurally incapable of disagreeing, which two separate
697
+ checks could not promise.
698
+
699
+ `skipped` exists because the skill's own rule is that a skipped step
700
+ and a forgotten step must never look alike. An optional field's absence
701
+ is a decision the operator made; reporting it explicitly is what makes
702
+ the difference visible in the release report.
703
+
704
+ This REPORTS what applies; it performs none of it. `check-manifests`
705
+ does the equality comparison and `run-pre-tag` runs the commands.
706
+ Folding execution in here would make one function both planner and
707
+ actor, and this lane's history is that the planner/actor seam is
708
+ exactly where its defects have lived.
709
+
710
+ Pure and non-raising: a non-dict yields the all-absent shape.
711
+ """
712
+ if not isinstance(row, dict):
713
+ row = {}
714
+
715
+ def _list(key):
716
+ value = row.get(key)
717
+ if isinstance(value, list):
718
+ return [v for v in value if isinstance(v, str) and v.strip()]
719
+ return [value] if isinstance(value, str) and value.strip() else []
720
+
721
+ manifests = _list("manifest")
722
+ artifacts = _list("artifacts")
723
+ excludes = _list("payload_exclude")
724
+ rebuild = row.get("rebuild")
725
+ rebuild = rebuild if isinstance(rebuild, str) and rebuild.strip() else None
726
+ provenance = row.get("provenance_manifest")
727
+ provenance = provenance if isinstance(provenance, str) and provenance.strip() else None
728
+
729
+ skipped = []
730
+ if not manifests:
731
+ skipped.append(("manifest-equality",
732
+ "the row declares no manifest, so the derived tag is "
733
+ "the version source and there is nothing to compare"))
734
+ if rebuild is None:
735
+ skipped.append(("rebuild",
736
+ "the row declares no rebuild command"))
737
+ if not artifacts:
738
+ skipped.append(("artifacts-clean",
739
+ "the row declares no artifacts to assert clean"))
740
+ if not excludes:
741
+ skipped.append(("payload-exclude",
742
+ "the row excludes nothing from its payload"))
743
+ if provenance is None:
744
+ skipped.append(("provenance-recording",
745
+ "the row declares no provenance-manifest"))
746
+
747
+ return {
748
+ "version_source": "manifest" if manifests else "tag",
749
+ "assert_manifest_equal": bool(manifests),
750
+ "manifests": manifests,
751
+ "rebuild": rebuild,
752
+ "artifacts": artifacts,
753
+ "payload_exclude": excludes,
754
+ "record_provenance": provenance is not None,
755
+ "provenance_manifest": provenance,
756
+ "skipped": skipped,
757
+ }
758
+
759
+
760
+ def window_excludes_payload_paths(paths, payload, payload_exclude):
761
+ """`paths` filtered to `payload`, minus anything under an exclude (A-3.4).
762
+
763
+ Directory-prefix semantics on normalized separators, so `tools` never
764
+ matches `toolsmith/` — a substring test would silently drop a sibling
765
+ directory whose name merely starts the same way.
766
+
767
+ Pure over synthetic input; non-raising.
768
+ """
769
+ def _norm(value):
770
+ return str(value).replace("\\", "/").strip().strip("/") if value else ""
771
+
772
+ scope = _norm(payload)
773
+ excludes = [_norm(e) for e in (payload_exclude or []) if _norm(e)]
774
+ kept = []
775
+ for path in (paths or []):
776
+ rel = _norm(path)
777
+ if not rel:
778
+ continue
779
+ if scope and scope != "." and not (rel == scope or rel.startswith(scope + "/")):
780
+ continue
781
+ if any(rel == ex or rel.startswith(ex + "/") for ex in excludes):
782
+ continue
783
+ kept.append(path)
784
+ return kept
785
+
786
+
787
+ def provenance_trigger_paths(rows):
788
+ """Every path a declared row REFERENCES, sorted and de-duplicated:
789
+ each `manifest`, each `changelog`, each `artifacts`, and each
790
+ `generated_manifest` entry (A-5.6).
791
+
792
+ These are the drift triggers for `.codearbiter/.provenance/release-
793
+ targets.json`. The point is that the declaration and the files it names
794
+ move together: if a manifest path is renamed and the row is not
795
+ updated, the row now points at nothing, and the release lane resolves
796
+ it to a missing file at the worst possible moment.
797
+
798
+ Deliberately NOT a CONTEXT.md-Scope trigger. `compute_drift` compares
799
+ whole-file git oids with no section-level machinery, so a Scope trigger
800
+ would fire on an unrelated `stage:` flip AND stay silent on the thing
801
+ that matters -- a manifest path moving. Wrong in both directions.
802
+
803
+ `payload` and `payload-exclude` are excluded on purpose: they are
804
+ directory scopes, not files, and hashing a directory path is not
805
+ something `batch_hash` can do. `pre-tag`/`rebuild`/`generate` are
806
+ excluded too -- they are commands, not paths, and a command string is
807
+ not a file whose oid can drift.
808
+
809
+ Routine per-release version bumps WILL trip these triggers, by design:
810
+ a release edits its manifest, which is the point of watching it.
811
+ `heal_worklist` re-baselines them in the same release commit. That is
812
+ recorded here so a later maintainer reads it as intended behaviour
813
+ rather than deleting the triggers to quiet the noise.
814
+
815
+ Pure and non-raising over synthetic input.
816
+ """
817
+ paths = set()
818
+ if not isinstance(rows, (list, tuple)):
819
+ return []
820
+ for row in rows:
821
+ if not isinstance(row, dict):
822
+ continue
823
+ for key in ("manifest", "changelog", "artifacts", "generated_manifest"):
824
+ value = row.get(key)
825
+ for item in (value if isinstance(value, list) else [value]):
826
+ if isinstance(item, str) and item.strip():
827
+ paths.add(item.strip())
828
+ return sorted(paths)
829
+
830
+
831
+ def _manifest_version(path):
832
+ """The `version` a manifest declares, or `None` when it cannot be read
833
+ or parsed. Dispatches on EXTENSION, because the declared-file grammar
834
+ permits any format and one reader cannot serve them all -- applying a
835
+ JSON parser to a `pyproject.toml` raises rather than answering.
836
+
837
+ `None` means "no comparison happened", which callers MUST keep
838
+ distinct from "the versions differ". Non-raising, per this module's
839
+ mechanism invariant.
840
+ """
841
+ lower = str(path).lower()
842
+ try:
843
+ if lower.endswith(".json"):
844
+ import json
845
+ with open(path, encoding="utf-8") as fh:
846
+ value = json.load(fh).get("version")
847
+ elif lower.endswith(".toml"):
848
+ try:
849
+ import tomllib
850
+ except ImportError: # pragma: no cover - Python < 3.11
851
+ return None
852
+ with open(path, "rb") as fh:
853
+ data = tomllib.load(fh)
854
+ value = data.get("project", {}).get("version")
855
+ if value is None:
856
+ value = data.get("tool", {}).get("poetry", {}).get("version")
857
+ else:
858
+ return None
859
+ except (OSError, ValueError, AttributeError, TypeError):
860
+ return None
861
+ return value if isinstance(value, str) else None
862
+
863
+
864
+ def classify_commit(subject, body=""):
865
+ """One commit -> `{type, scope, breaking, bump, has_changelog_footer}`.
866
+
867
+ Pure and non-raising over synthetic input, per this module's mechanism
868
+ invariant: a subject that is not Conventional-Commits at all yields
869
+ `type=""`, `bump="none"` -- an unparseable subject cannot bump, which
870
+ is the safe direction.
871
+
872
+ Exists because this was the last mechanical step in the release lane
873
+ with no helper behind it, on the check the hard rules mark MUST-level
874
+ (adversarial review run 11). An exercising agent wrote
875
+ `subject.split('(')[0].split(':')[0].rstrip('!')` as its own reading;
876
+ that strips the `!` BEFORE anything checks for it, so `feat!:` and
877
+ `feat(api)!:` both classify as an ordinary `feat` and a major release
878
+ silently becomes a minor one. Two operators writing two parses produce
879
+ two different gates on the check that decides whether a release may
880
+ proceed.
881
+ """
882
+ if not isinstance(subject, str):
883
+ subject = ""
884
+ if not isinstance(body, str):
885
+ body = ""
886
+ match = _CC_SUBJECT_RE.match(subject)
887
+ if match is None:
888
+ return {"type": "", "scope": "", "breaking": False, "bump": "none",
889
+ "has_changelog_footer": bool(_CHANGELOG_FOOTER_RE.search(body))}
890
+ ctype = match.group("type").lower()
891
+ breaking = bool(match.group("bang")) or bool(_BREAKING_FOOTER_RE.search(body))
892
+ if breaking:
893
+ bump = "major"
894
+ else:
895
+ bump = _BUMPING_TYPES.get(ctype, "none")
896
+ return {
897
+ "type": ctype,
898
+ "scope": match.group("scope") or "",
899
+ "breaking": breaking,
900
+ "bump": bump,
901
+ "has_changelog_footer": bool(_CHANGELOG_FOOTER_RE.search(body)),
902
+ }
903
+
904
+
905
+ def classify_window(commits):
906
+ """`[{sha, subject, body}, ...]` -> the whole window's verdict:
907
+ `{bump, commits: [...], missing_footer: [...]}`.
908
+
909
+ `bump` is the highest precedence across the window (`major` > `minor`
910
+ > `patch` > `none`). `missing_footer` lists every BUMPING commit with
911
+ no `CHANGELOG:` footer -- the exact set Phase 1 step 3 turns into
912
+ `[NEEDS-TRIAGE]` lines, in window order, so the report's shape is not
913
+ re-derived per release either.
914
+
915
+ A breaking commit bumps major regardless of type, so a `chore!:` is
916
+ reported as bumping and IS subject to the footer rule -- a hand-rolled
917
+ type-list check misses that, because `chore` is not in the bumping
918
+ list.
919
+ """
920
+ rows = []
921
+ if not isinstance(commits, (list, tuple)):
922
+ commits = []
923
+ for entry in commits:
924
+ if not isinstance(entry, dict):
925
+ continue
926
+ verdict = classify_commit(entry.get("subject", ""), entry.get("body", ""))
927
+ verdict["sha"] = str(entry.get("sha", ""))
928
+ verdict["subject"] = str(entry.get("subject", ""))
929
+ rows.append(verdict)
930
+ bump = "none"
931
+ for row in rows:
932
+ if _BUMP_RANK[row["bump"]] > _BUMP_RANK[bump]:
933
+ bump = row["bump"]
934
+ missing = [r for r in rows
935
+ if r["bump"] != "none" and not r["has_changelog_footer"]]
936
+ return {"bump": bump, "commits": rows, "missing_footer": missing}
937
+
938
+
939
+ def parse_window_log(text):
940
+ """`git log --pretty=format:%H%n%s%n%b%n----` output -> the
941
+ `[{sha, subject, body}]` shape `classify_window` consumes.
942
+
943
+ The separator is the one the release skill already prescribes, so the
944
+ prose and this parser cannot drift apart into two different readings
945
+ of the same command's output.
946
+ """
947
+ if not isinstance(text, str):
948
+ return []
949
+ entries = []
950
+ for chunk in text.split("\n----"):
951
+ lines = [ln for ln in chunk.split("\n")]
952
+ while lines and not lines[0].strip():
953
+ lines.pop(0)
954
+ if not lines or not lines[0].strip():
955
+ continue
956
+ sha = lines[0].strip()
957
+ subject = lines[1] if len(lines) > 1 else ""
958
+ body = "\n".join(lines[2:])
959
+ entries.append({"sha": sha, "subject": subject, "body": body})
960
+ return entries
961
+
962
+
963
+ def first_release_baseline(adoption_log_text):
964
+ """The commit sha that ADOPTED codeArbiter -- the one that added
965
+ `.codearbiter/CONTEXT.md` -- from `git log --diff-filter=A
966
+ --format=%H -- .codearbiter/CONTEXT.md` output. `""` when the file was
967
+ never added (no adoption commit, or a repo that never onboarded).
968
+
969
+ A-5.5. On a project's FIRST release the tag series is empty, so
970
+ `LAST_TAG` is `<none>` and the window is the entire history. Every
971
+ pre-adoption `feat`/`fix`/`perf`/`refactor` commit therefore enters
972
+ the footer-completeness check -- and none of them carries a
973
+ `CHANGELOG:` footer, because they predate the convention entirely. The
974
+ lane would emit one `[NEEDS-TRIAGE]` line per such commit and STOP: a
975
+ repository adopting at its 500th commit gets a 500-line block on a
976
+ release where nothing is actually wrong. That is a hard block on a
977
+ legitimate release, and it lands on precisely the population the
978
+ Back-fill lane exists to serve.
979
+
980
+ The adoption commit is the honest boundary: commits before it were
981
+ authored under no changelog convention and cannot retroactively
982
+ acquire footers, while commits after it were authored under one and
983
+ SHOULD be held to it.
984
+
985
+ Non-raising and pure over text, per this module's mechanism-function
986
+ invariant. Takes the LAST line when several are present: `git log`
987
+ prints newest-first, so the last line is the EARLIEST addition, which
988
+ is the real adoption. (A file added, deleted, and re-added produces
989
+ two entries -- taking the newest would silently treat a re-adoption as
990
+ the boundary and drop every commit between the two, which is the same
991
+ class of quiet history loss this function exists to prevent.)
992
+ """
993
+ if not isinstance(adoption_log_text, str):
994
+ return ""
995
+ shas = [line.strip().split()[0] for line in adoption_log_text.splitlines()
996
+ if line.strip()]
997
+ if not shas:
998
+ return ""
999
+ candidate = shas[-1]
1000
+ return candidate if re.fullmatch(r"[0-9a-fA-F]{7,64}", candidate) else ""
1001
+
1002
+
1003
+ def peel_tag(ls_remote_text, tag):
1004
+ """Resolve the COMMIT a remote tag names, from `git ls-remote --tags`
1005
+ output. Returns "" when the tag is absent.
1006
+
1007
+ An annotated tag's own object id is not the commit it points at; the
1008
+ peeled `refs/tags/<tag>^{}` line is. A workflow that treats any remote
1009
+ hit as a resumable publish without comparing the tag to the current
1010
+ commit can accept a stale tag as a successful rerun and publish for the
1011
+ wrong commit. Matching is exact on the ref name, so `v2.6.0` is never
1012
+ resolved from `v2.6.0-beta.1`."""
1013
+ if not isinstance(ls_remote_text, str) or not isinstance(tag, str):
1014
+ return ""
1015
+ direct = peeled = ""
1016
+ ref = f"refs/tags/{tag}"
1017
+ for line in ls_remote_text.splitlines():
1018
+ parts = line.split()
1019
+ if len(parts) != 2:
1020
+ continue
1021
+ sha, name = parts
1022
+ if name == ref + "^{}":
1023
+ peeled = sha
1024
+ elif name == ref:
1025
+ direct = sha
1026
+ return peeled or direct
1027
+
1028
+
1029
+ _PLAIN_SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
1030
+ _BUMP_WORDS = ("major", "minor", "patch")
1031
+
1032
+
1033
+ def apply_bump(base, word):
1034
+ """Apply a classify-window bump `word` to `base`, a PLAIN SemVer string
1035
+ (`MAJOR.MINOR.PATCH`, no pre-release or build metadata), and return the
1036
+ bumped SemVer string. `major`: `X+1.0.0`. `minor`: `X.Y+1.0`. `patch`:
1037
+ `X.Y.Z+1`.
1038
+
1039
+ Returns `None` -- never raises, per this module's mechanism-function
1040
+ invariant -- when `base` is not plain SemVer (reusing the same
1041
+ no-leading-zero grammar `SEMVER` anchors its own major/minor/patch
1042
+ groups with) or `word` is not exactly one of the three bumping words.
1043
+ This deliberately EXCLUDES `word == "none"`: a caller must never apply a
1044
+ non-bump, and passing `none` here is refused rather than echoing `base`
1045
+ back unchanged, which would silently look like a successful (if inert)
1046
+ bump.
1047
+
1048
+ #585 MEDIUM-3: the bump arithmetic was the single unmechanized judgment
1049
+ left in the release lane -- step 4 had the operator "apply the step-2
1050
+ bump to `$BASE_VERSION`" by hand, and nothing downstream re-derived it,
1051
+ so the hard rule "a `feat` in the window cannot ship as a `patch`" had
1052
+ no enforcement anywhere. `apply-bump` (the CLI subcommand below) is the
1053
+ sanctioned way to do this arithmetic instead of by eye.
1054
+ """
1055
+ if not isinstance(base, str) or not isinstance(word, str):
1056
+ return None
1057
+ match = _PLAIN_SEMVER_RE.fullmatch(base.strip())
1058
+ if match is None or word not in _BUMP_WORDS:
1059
+ return None
1060
+ major, minor, patch = (int(g) for g in match.groups())
1061
+ if word == "major":
1062
+ return f"{major + 1}.0.0"
1063
+ if word == "minor":
1064
+ return f"{major}.{minor + 1}.0"
1065
+ return f"{major}.{minor}.{patch + 1}"
1066
+
1067
+
1068
+ # Exit codes a POSIX shell uses to report that a command's INTERPRETER OR
1069
+ # PROGRAM ITSELF could not be located or executed, as distinct from the
1070
+ # program running and reporting a failure: 127 is POSIX "command not
1071
+ # found", 126 is POSIX "found but not executable" (e.g. missing execute
1072
+ # bit, or a script with a shebang naming an interpreter that itself is
1073
+ # missing), and 9009 is the code Windows' `cmd.exe` is documented to report
1074
+ # for an unresolvable command in several invocation shapes (batch-file/npm
1075
+ # wrapper contexts). #585 MEDIUM-2 / #584 MEDIUM-1: this is a BEST-EFFORT,
1076
+ # POSIX-reliable signal -- a bare `subprocess.run(cmd, shell=True)` "is not
1077
+ # recognized" error under a raw `cmd.exe /c` was measured on a Windows 11
1078
+ # host to return 1, indistinguishable there from an ordinary command
1079
+ # failure, so this set does not catch every Windows "not found" shape. It
1080
+ # reliably catches the POSIX shape this campaign's issues were filed
1081
+ # against (a row hardcoding `python3` on a host that has only `python`),
1082
+ # and any Windows invocation that does surface 9009.
1083
+ _COULD_NOT_RUN_CODES = frozenset({126, 127, 9009})
1084
+
1085
+
1086
+ def _could_not_run(returncode):
1087
+ """True iff `returncode` is one of the exit codes above that means the
1088
+ declared command's interpreter or program itself was never located --
1089
+ "could not run", never "ran and disagreed" (house rule: the two must
1090
+ never be folded together)."""
1091
+ return returncode in _COULD_NOT_RUN_CODES
1092
+
1093
+
1094
+ # #602: `run-pre-tag` dispatches every declared `pre-tag` row with
1095
+ # `subprocess.run(command, shell=True, …)`. On POSIX that already runs
1096
+ # through a POSIX shell (`/bin/sh`), so a row spelled `"$PY"` (the #601
1097
+ # convention) already expands there. On WINDOWS, `shell=True` unconditionally
1098
+ # dispatches through `cmd.exe` -- a CPython `subprocess` behavior, not host
1099
+ # configuration -- and `cmd.exe` performs no `$VAR` expansion, so `"$PY"`
1100
+ # passes through as the literal, unrecognized token and fails with an exit
1101
+ # code outside the could-not-run set, misdiagnosed as drift (exit 5). This
1102
+ # was MEASURED, not hypothesized (issue #602).
1103
+ #
1104
+ # `_resolve_posix_shell()` finds a POSIX-compatible shell (Git for Windows'
1105
+ # own `bash.exe`, an MSYS2 build that runs Windows-native binaries directly
1106
+ # -- NOT WSL's `bash.exe`, which runs inside a separate Linux filesystem and
1107
+ # cannot see a Windows path like `cwd=project_root` or a Windows `PY`
1108
+ # interpreter path the same way) so the caller can dispatch a declared row
1109
+ # through it instead of `cmd.exe`.
1110
+ def _resolve_posix_shell():
1111
+ """Resolve an absolute path to a POSIX-compatible shell for dispatching
1112
+ declared `pre-tag` rows, or None if none could be found.
1113
+
1114
+ Returns None unconditionally on a non-Windows host: `shell=True` already
1115
+ dispatches through a POSIX shell there (`/bin/sh`), so no resolution is
1116
+ needed and the caller keeps its existing `shell=True` dispatch unchanged.
1117
+
1118
+ On Windows, resolution order:
1119
+ 1. Relative to `git --exec-path` (via the trusted `git_executable()`
1120
+ seam). Git for Windows ships its own `bash.exe` at a fixed position
1121
+ relative to the install root -- `<root>/bin/bash.exe` -- and
1122
+ `git --exec-path` reports a path under
1123
+ `<root>/mingw64/libexec/git-core`, so `../../../bin/bash.exe`
1124
+ relative to that finds it deterministically, tied to the SAME git
1125
+ install this process already trusts, even when bash was never
1126
+ added to PATH (Git for Windows' installer does not add it by
1127
+ default). This is the PRIMARY strategy because it cannot resolve
1128
+ to a different program entirely the way a PATH search can.
1129
+ 2. `shutil.which("bash")`, as a fallback for a non-standard Git for
1130
+ Windows layout -- but a hit under `system32` or `WindowsApps` is
1131
+ rejected: both are where Windows' own WSL launcher stub installs a
1132
+ same-named `bash.exe` that runs inside a separate Linux
1133
+ filesystem, not a POSIX-on-Windows shell, and accepting it would
1134
+ swap one silent misdispatch for another.
1135
+
1136
+ Never raises: any resolution failure (git not found, `--exec-path`
1137
+ failing, a filesystem error) is treated as "not found", matching every
1138
+ other mechanism function in this module's "never raise on malformed
1139
+ input" convention -- the CALLER decides what a None means (here: report
1140
+ a distinct could-not-run exit rather than silently falling back to
1141
+ `cmd.exe`, the exact misdiagnosis this fix closes).
1142
+ """
1143
+ if os.name != "nt":
1144
+ return None
1145
+ try:
1146
+ exec_path = subprocess.run(
1147
+ [git_executable(), "--exec-path"],
1148
+ capture_output=True, text=True).stdout.strip()
1149
+ except OSError:
1150
+ exec_path = ""
1151
+ if exec_path:
1152
+ candidate = os.path.normpath(
1153
+ os.path.join(exec_path, "..", "..", "..", "bin", "bash.exe"))
1154
+ if os.path.isfile(candidate):
1155
+ return candidate
1156
+ found = shutil.which("bash")
1157
+ if found:
1158
+ normalized = os.path.normpath(found).lower()
1159
+ if "\\system32\\" not in normalized and "\\windowsapps\\" not in normalized:
1160
+ return found
1161
+ return None
1162
+
1163
+
1164
+ # --------------------------------------------------------------------------- #
1165
+ # Declared-target-file parser. Grammar: per-target `[name]` sub-blocks of
1166
+ # `key: value` lines inside the HTML-comment delimiter convention this
1167
+ # codebase's path-scope reader (`_scopelib.py`) already uses — reused here
1168
+ # rather than inventing a second delimiter syntax.
1169
+ # --------------------------------------------------------------------------- #
1170
+
1171
+ _OPEN_RE = re.compile(r"<!--\s*release-targets\s*-->")
1172
+ _CLOSE_RE = re.compile(r"<!--\s*/release-targets\s*-->")
1173
+ _HEADER_RE = re.compile(r"^\[([A-Za-z0-9._-]+)\]$")
1174
+
1175
+ # A key not in _LIST_KEYS is scalar: exactly one value per target block, a
1176
+ # second occurrence of the same key within one block is a DuplicateKeyError.
1177
+ # List keys repeat by design and preserve declaration order.
1178
+ _LIST_KEYS = frozenset({
1179
+ "manifest", "artifacts", "pre-tag", "payload-exclude", "generated-manifest",
1180
+ })
1181
+ _BOOLEAN_KEYS = frozenset({"latest-eligible"})
1182
+ _REQUIRED_KEYS = ("prefix", "changelog", "payload")
1183
+
1184
+ # Grammar key -> row field name (rows use `_` throughout, the grammar uses
1185
+ # `-`, matching this codebase's `key: value` / `snake_case` convention split).
1186
+ _KEY_FIELD = {
1187
+ "prefix": "prefix",
1188
+ "changelog": "changelog",
1189
+ "payload": "payload",
1190
+ "rebuild": "rebuild",
1191
+ "provenance-manifest": "provenance_manifest",
1192
+ "latest-eligible": "latest_eligible",
1193
+ "manifest": "manifest",
1194
+ "artifacts": "artifacts",
1195
+ "pre-tag": "pre_tag",
1196
+ "payload-exclude": "payload_exclude",
1197
+ # HIGH-3 (adversarial review 2026-07-31): a `manifest` path that is
1198
+ # GENERATED output (regenerated by a build/packaging step, never
1199
+ # hand-written) is otherwise indistinguishable from an ordinary
1200
+ # hand-edited manifest, so nothing stops the generic "update every
1201
+ # manifest path to the derived version" instruction from hand-editing
1202
+ # generated output. `generated-manifest` names the subset of `manifest`
1203
+ # entries that are generated.
1204
+ #
1205
+ # This is TWO keys, not the one-key "marker on the manifest entry" the
1206
+ # finding also offered as an option. `rebuild` already exists as an
1207
+ # operator-authored, mutating command this lane runs, so pairing
1208
+ # `generated-manifest` with its own `generate` command (mirroring the
1209
+ # existing `rebuild`/`artifacts` shape) creates no NEW trust class on
1210
+ # this file — it reuses one already present. `rebuild` itself cannot be
1211
+ # reused for this: its contract is "run it, then `git diff --quiet` the
1212
+ # result" (nothing should have changed beyond what was already
1213
+ # committed), whereas regenerating a version manifest legitimately
1214
+ # CHANGES the file on every release. Folding a version-bump
1215
+ # regeneration into `rebuild`'s clean-tree contract would make every
1216
+ # release's own `generate` step look like build drift. A single-key
1217
+ # marker form (e.g. a `[generated]` suffix on the `manifest:` value
1218
+ # itself) would need its own value-shape parsing distinct from every
1219
+ # other list key's plain-path values for no offsetting benefit, since a
1220
+ # command is exactly what a caller needs to run anyway.
1221
+ #
1222
+ # `generate` widens this file's own executable-input surface by one key
1223
+ # while the H-22 protected-state enrolment of this file (T-33) and its
1224
+ # security-controls.md boundary entry (T-32) are both still PENDING on
1225
+ # this repo's own plan — declared here, not smuggled: see
1226
+ # `.codearbiter/release-targets.md`'s own header note.
1227
+ #
1228
+ # Declarative only — this module does not cross-validate that a
1229
+ # `generated-manifest` entry also appears in `manifest`, the same way it
1230
+ # does not validate `payload-exclude` against `payload`; the release
1231
+ # skill is what acts on the relationship.
1232
+ "generated-manifest": "generated_manifest",
1233
+ "generate": "generate",
1234
+ # M-1 (adversarial review 2026-07-31): the Phase-3 Release title names a
1235
+ # "display name" no grammar key ever supplied, so a consumer had no way
1236
+ # to declare one and this repo's own next release would silently title
1237
+ # itself from `$TARGET` (`ca`) rather than its established display name
1238
+ # (`codeArbiter`). Optional; the skill falls back to `$TARGET` itself
1239
+ # when a row declares none.
1240
+ "display-name": "display_name",
1241
+ }
1242
+
1243
+
1244
+ def _new_row(name):
1245
+ return {
1246
+ "target": name,
1247
+ "prefix": None,
1248
+ "manifest": [],
1249
+ "changelog": None,
1250
+ "payload": None,
1251
+ "payload_exclude": [],
1252
+ "rebuild": None,
1253
+ "artifacts": [],
1254
+ "provenance_manifest": None,
1255
+ "pre_tag": [],
1256
+ "latest_eligible": False,
1257
+ "generated_manifest": [],
1258
+ "generate": None,
1259
+ "display_name": None,
1260
+ }
1261
+
1262
+
1263
+ def _finish_row(row):
1264
+ # A parsed key line always assigns a string (`.strip()`-ed at read time),
1265
+ # so `prefix:` with no value yields `''`, never `None` — an `is None`
1266
+ # check alone lets that empty declaration pass as "present". Treat a
1267
+ # blank or whitespace-only value as missing too, so a typo'd required key
1268
+ # cannot silently become a first-release baseline downstream (`''` fed to
1269
+ # `last_tag_select` resolves the NONE_SENTINEL).
1270
+ missing = [key for key in _REQUIRED_KEYS
1271
+ if (row[_KEY_FIELD[key]] or "").strip() == ""]
1272
+ if missing:
1273
+ raise MissingRequiredKeyError(
1274
+ f"target {row['target']!r} is missing required key(s): "
1275
+ + ", ".join(missing)
1276
+ )
1277
+
1278
+
1279
+ def parse_release_targets(text):
1280
+ """Parse the declared-target-file GRAMMAR from `text` (already-read file
1281
+ content) into a list of row dicts, one per `[target]` block, each
1282
+ carrying: target, prefix, manifest (list), changelog, payload,
1283
+ payload_exclude (list), rebuild, artifacts (list), provenance_manifest,
1284
+ pre_tag (list), latest_eligible (bool), generated_manifest (list),
1285
+ generate, display_name.
1286
+
1287
+ Pure — no file I/O — so it is testable with synthetic input; `load_targets`
1288
+ is the one function that touches the filesystem.
1289
+
1290
+ Every parser-contract violation raises its own ReleaseTargetsError
1291
+ subclass; never a silent default, never a partial parse. See the module
1292
+ docstring for the full list of declared exceptions.
1293
+
1294
+ Cross-platform LF/CRLF editing drift means a value like
1295
+ `latest-eligible: true\\r` must parse as the boolean `true`, not as an
1296
+ unrecognised value that would otherwise silently drop a feature — the
1297
+ exact silent-default failure this module's loud-failure contract
1298
+ forbids. There is no dedicated CRLF-stripping pass: every line is
1299
+ `.strip()`-ed on extraction from the block (`raw_line.strip()` below)
1300
+ and every key/value pair is independently `.strip()`-ed again off the
1301
+ split — Python's `str.strip()` with no argument removes `\\r` along with
1302
+ every other whitespace character, so a trailing `\\r` never survives to
1303
+ a comparison regardless of which layer runs first."""
1304
+ if not isinstance(text, str):
1305
+ # Every declared parser-contract violation raises a ReleaseTargetsError
1306
+ # subclass so a caller can catch one type (see module docstring); a
1307
+ # non-string input must not be the one escape hatch that raises a bare
1308
+ # TypeError instead. There is no block to find in non-text input, so
1309
+ # this is the same declared answer as an absent block.
1310
+ raise AbsentBlockError(
1311
+ "no <!-- release-targets --> block found (input is not text)")
1312
+ normalized = text
1313
+
1314
+ opens = list(_OPEN_RE.finditer(normalized))
1315
+ if not opens:
1316
+ raise AbsentBlockError("no <!-- release-targets --> block found")
1317
+ if len(opens) > 1:
1318
+ raise MultipleBlocksError(
1319
+ f"found {len(opens)} <!-- release-targets --> opening delimiters; "
1320
+ "exactly one is allowed")
1321
+
1322
+ after_open = normalized[opens[0].end():]
1323
+ closes = list(_CLOSE_RE.finditer(after_open))
1324
+ if not closes:
1325
+ raise MalformedBlockError(
1326
+ "<!-- release-targets --> block is never closed")
1327
+
1328
+ # The GENUINE closing delimiter is the first match that sits ALONE on its
1329
+ # line (only whitespace precedes it since the last newline). A match that
1330
+ # is preceded by other content on the same line is embedded inside a
1331
+ # declared value (e.g. `rebuild: echo <!-- /release-targets -->`) and
1332
+ # must error rather than silently become the block boundary — otherwise a
1333
+ # value's embedded delimiter truncates the block and, for a REQUIRED key,
1334
+ # can silently empty it (`payload: <!-- /release-targets -->` would parse
1335
+ # with `payload == ''`). A close match that occurs entirely AFTER the
1336
+ # genuine terminator — a legitimate stray mention of the delimiter text in
1337
+ # prose following the block — is not inspected at all, so it can never be
1338
+ # misdiagnosed as a value violation.
1339
+ genuine = None
1340
+ for m in closes:
1341
+ line_start = after_open.rfind("\n", 0, m.start()) + 1
1342
+ prefix = after_open[line_start:m.start()]
1343
+ if prefix.strip() == "":
1344
+ genuine = m
1345
+ break
1346
+ raise DelimiterInValueError(
1347
+ "a declared value contains the literal closing delimiter "
1348
+ "'<!-- /release-targets -->', which would truncate the block "
1349
+ "under a naive parse instead of being treated as part of the value")
1350
+
1351
+ block = after_open[:genuine.start()]
1352
+ if not block.strip():
1353
+ raise EmptyBlockError("<!-- release-targets --> block is empty")
1354
+
1355
+ rows = []
1356
+ row = None
1357
+ seen_keys = None
1358
+ seen_names = set()
1359
+
1360
+ for raw_line in block.split("\n"):
1361
+ line = raw_line.strip()
1362
+ if not line:
1363
+ continue
1364
+
1365
+ if line.startswith("["):
1366
+ m = _HEADER_RE.match(line)
1367
+ if not m:
1368
+ raise MalformedBlockError(
1369
+ f"malformed target header: {raw_line!r}")
1370
+ name = m.group(1)
1371
+ if name in seen_names:
1372
+ raise DuplicateTargetError(f"duplicate target block: {name!r}")
1373
+ seen_names.add(name)
1374
+ if row is not None:
1375
+ _finish_row(row)
1376
+ rows.append(row)
1377
+ row = _new_row(name)
1378
+ seen_keys = set()
1379
+ continue
1380
+
1381
+ if row is None:
1382
+ raise MalformedBlockError(
1383
+ f"key line before the first [target] header: {raw_line!r}")
1384
+
1385
+ idx = line.find(":")
1386
+ if idx == -1:
1387
+ raise MalformedBlockError(
1388
+ f"malformed line (expected 'key: value'): {raw_line!r}")
1389
+ key = line[:idx].strip()
1390
+ value = line[idx + 1:].strip()
1391
+
1392
+ if key not in _KEY_FIELD:
1393
+ raise UnknownKeyError(
1394
+ f"unknown key {key!r} in target {row['target']!r}")
1395
+ field = _KEY_FIELD[key]
1396
+
1397
+ # A-2.4: a declared value longer than VALUE_MAX_CHARS is rejected,
1398
+ # on ADR-0002's precedent. Checked for EVERY key, not only
1399
+ # `pre-tag`: the cap exists because these values are operator-
1400
+ # authored input a `contents: write` lane later executes or
1401
+ # interpolates, and `rebuild`/`generate` are executed exactly like
1402
+ # `pre-tag` is. Capping only the key that motivated the rule would
1403
+ # leave the same exposure one field over.
1404
+ if len(value) > VALUE_MAX_CHARS:
1405
+ raise ValueTooLongError(
1406
+ f"key {key!r} in target {row['target']!r} declares a value "
1407
+ f"of {len(value)} characters, over the {VALUE_MAX_CHARS}-"
1408
+ "character limit. A declared value this long is far more "
1409
+ "likely to be a smuggled command line than a path or a "
1410
+ "build invocation")
1411
+
1412
+ if key in _LIST_KEYS:
1413
+ row[field].append(value)
1414
+ continue
1415
+
1416
+ if key in seen_keys:
1417
+ raise DuplicateKeyError(
1418
+ f"duplicate key {key!r} in target {row['target']!r}")
1419
+ seen_keys.add(key)
1420
+
1421
+ if key in _BOOLEAN_KEYS:
1422
+ if value == "true":
1423
+ row[field] = True
1424
+ elif value == "false":
1425
+ row[field] = False
1426
+ else:
1427
+ raise InvalidBooleanError(
1428
+ f"key {key!r} in target {row['target']!r} must be "
1429
+ f"exactly 'true' or 'false', got {value!r}")
1430
+ else:
1431
+ row[field] = value
1432
+
1433
+ if row is not None:
1434
+ _finish_row(row)
1435
+ rows.append(row)
1436
+
1437
+ return rows
1438
+
1439
+
1440
+ def load_targets(path):
1441
+ """Read `path` and parse it via `parse_release_targets`. The one function
1442
+ in this module that touches the filesystem — opened with `newline=""` so
1443
+ a `\\r\\n` line ending survives into the parser exactly as it is on disk,
1444
+ rather than being silently normalised away by Python's own text-mode
1445
+ universal-newline translation before this module's own CRLF handling
1446
+ ever runs.
1447
+
1448
+ A genuinely MISSING path (`FileNotFoundError`, i.e. `errno == ENOENT`)
1449
+ raises `AbsentBlockError` rather than a bare `OSError` — the same
1450
+ declared-error contract `parse_release_targets` gives every other
1451
+ violation, so a `contents: write` caller can catch one exception type
1452
+ instead of one type for content problems and another for I/O ones. There
1453
+ is, in the end, no block to find at a path with nothing on it.
1454
+
1455
+ Any OTHER `OSError` (permission denied, the path naming a directory, a
1456
+ transient I/O failure, ...) means the path exists in some form but this
1457
+ process could not read it, and raises `UnreadableTargetsFileError`
1458
+ instead — NOT `AbsentBlockError` — because "unreadable" and "absent" are
1459
+ different facts about the project ([[never-fold-unreadable-into-absent]]):
1460
+ the Back-fill lane's sanctioned trigger is `except AbsentBlockError:`,
1461
+ and a permissions error on an existing declared file must STOP there,
1462
+ never be folded into "nothing here, safe to write a fresh one."
1463
+
1464
+ An EXISTING, readable file that simply carries no delimiter block is a
1465
+ THIRD, different failure (HIGH-1, adversarial review 2026-07-31) and
1466
+ raises `FileExistsNoBlockError` instead — see that class's docstring.
1467
+ This function is the only place any of these three distinctions can be
1468
+ made, since it is the only one that knows whether `open()` actually
1469
+ succeeded and, if not, why."""
1470
+ try:
1471
+ with open(path, encoding="utf-8", newline="") as fh:
1472
+ text = fh.read()
1473
+ except FileNotFoundError as exc:
1474
+ raise AbsentBlockError(
1475
+ f"could not read release-targets file {path!r}: {exc}") from exc
1476
+ except OSError as exc:
1477
+ raise UnreadableTargetsFileError(
1478
+ f"could not read release-targets file {path!r}: {exc}") from exc
1479
+ try:
1480
+ return parse_release_targets(text)
1481
+ except AbsentBlockError as exc:
1482
+ # The open() above already succeeded, so this is NOT "no file" -- it
1483
+ # is "a file that exists and contains no block". Re-raised under the
1484
+ # sibling class so a caller's `except AbsentBlockError:` (the
1485
+ # Back-fill lane's trigger) never mistakes the two states for one
1486
+ # another.
1487
+ raise FileExistsNoBlockError(
1488
+ f"{path!r} exists but contains no <!-- release-targets --> "
1489
+ f"block: {exc}") from exc
1490
+
1491
+
1492
+ # --------------------------------------------------------------------------- #
1493
+ # Back-fill detection (T-49/T-50, issue #563, spec AC-5.3/5.4). Fires only
1494
+ # when a caller has already observed `load_targets` raise `AbsentBlockError`
1495
+ # — a genuinely MISSING declared file. `load_targets` itself is unchanged and
1496
+ # keeps raising on absence; nothing here is a silent default inside the
1497
+ # parser. An unparseable EXISTING file (any other ReleaseTargetsError
1498
+ # subclass) is a different failure and is never routed through this — the
1499
+ # release skill's own "Targets" prose still STOPs outright on that case.
1500
+ #
1501
+ # Detection is honest about ambiguity by construction: it never returns a
1502
+ # single guess unless the scan found EXACTLY one candidate manifest and
1503
+ # EXACTLY one candidate changelog. Zero of either (nothing plausible) or more
1504
+ # than one of either (several plausible candidates, no signal for which one)
1505
+ # both raise `BackfillAmbiguousError` — the caller (the release skill's
1506
+ # back-fill lane) surfaces that as "cannot propose a row, route to full
1507
+ # elicitation instead" rather than inventing a target from a guess.
1508
+ # --------------------------------------------------------------------------- #
1509
+
1510
+ # Generic, ecosystem-level manifest/changelog filenames — not a fact about
1511
+ # any one consuming repository, so these stay clear of the module denylist
1512
+ # (A-1.2) the same way the shared grammar keys already do.
1513
+ BACKFILL_MANIFEST_CANDIDATES = (
1514
+ "package.json", "pyproject.toml", "Cargo.toml", "composer.json",
1515
+ )
1516
+ BACKFILL_CHANGELOG_CANDIDATES = (
1517
+ "CHANGELOG.md", "CHANGES.md", "HISTORY.md",
1518
+ )
1519
+
1520
+ # The generic single-target example name/prefix this module's own docstring
1521
+ # and the release-portable-fixture spec's grammar section both use for a
1522
+ # one-target consumer (`[app]` / `prefix: v`) — reused here as the back-fill
1523
+ # lane's default rather than restated as a second, drifting copy.
1524
+ _BACKFILL_DEFAULT_TARGET = "app"
1525
+ _BACKFILL_DEFAULT_PREFIX = "v"
1526
+
1527
+
1528
+ class BackfillAmbiguousError(ReleaseTargetsError):
1529
+ """Raised by `detect_candidate_target` when the scan found zero, or more
1530
+ than one, candidate manifest or changelog file. A repo with several
1531
+ plausible manifests (or none) must not receive a confidently-wrong
1532
+ proposal — the never-guess posture this whole module's parser already
1533
+ applies to a malformed declaration applies here too, to an AMBIGUOUS
1534
+ absence rather than a malformed one."""
1535
+
1536
+
1537
+ def scan_backfill_candidates(root):
1538
+ """The one filesystem reader for back-fill detection: lists `root`'s
1539
+ top-level entries and returns `(manifest_candidates, changelog_candidates)`
1540
+ — the repo-relative names present from `BACKFILL_MANIFEST_CANDIDATES` and
1541
+ `BACKFILL_CHANGELOG_CANDIDATES`, each sorted for determinism. Deliberately
1542
+ a TOP-LEVEL-ONLY scan: this is a first-pass detection that gets PRESENTED
1543
+ to the user for explicit confirmation, never a silent multi-directory
1544
+ guess. An unreadable `root` degrades to "nothing found" (both lists
1545
+ empty) rather than raising — the caller's own ambiguity handling already
1546
+ treats "zero candidates" as a case to surface, so a missing/unreadable
1547
+ root reaches the same honest "cannot propose one" outcome instead of a
1548
+ bare `OSError` escaping a detection helper."""
1549
+ try:
1550
+ entries = set(os.listdir(root))
1551
+ except OSError:
1552
+ entries = set()
1553
+ manifests = sorted(name for name in BACKFILL_MANIFEST_CANDIDATES
1554
+ if name in entries)
1555
+ changelogs = sorted(name for name in BACKFILL_CHANGELOG_CANDIDATES
1556
+ if name in entries)
1557
+ return manifests, changelogs
1558
+
1559
+
1560
+ def detect_candidate_target(manifest_candidates, changelog_candidates,
1561
+ target=_BACKFILL_DEFAULT_TARGET,
1562
+ prefix=_BACKFILL_DEFAULT_PREFIX):
1563
+ """Pure detection logic over an ALREADY-SCANNED set of candidate names
1564
+ (`scan_backfill_candidates` is the one filesystem reader, kept separate
1565
+ per this module's read-isolation convention). Returns a row dict shaped
1566
+ like one `load_targets` entry (`target`, `prefix`, `manifest`,
1567
+ `changelog`, `payload`, `latest_eligible`) ONLY when exactly one manifest
1568
+ candidate and exactly one changelog candidate were found. Raises
1569
+ `BackfillAmbiguousError` for every other case — zero or multiple of
1570
+ either — naming which side was ambiguous and what was found, so a caller
1571
+ surfacing the error has something concrete to show the user.
1572
+
1573
+ HIGH-2 (adversarial review 2026-07-31): the returned row declares
1574
+ `latest_eligible: True`. This detector can only ever propose ONE row —
1575
+ it fires on a SINGLE candidate manifest and a SINGLE candidate
1576
+ changelog, which is what "back-fill a consumer with no declared file
1577
+ yet" means by construction — so the project it is proposing a row for
1578
+ is, at the moment of detection, a single-target project. The release
1579
+ skill's hard rule ("at most one declared target may set
1580
+ `latest-eligible: true`, and every other target's Phase-3 publish MUST
1581
+ pass `--latest=false` EXPLICITLY") was written to stop one of several
1582
+ SIBLING series stealing the "Latest" badge from another in a
1583
+ multi-target repository; applied unconditionally to a single-target
1584
+ project's own first-ever release, the same rule demoted the one release
1585
+ that exists out of the position every visitor sees, with nothing in the
1586
+ lane prompting the operator to notice or correct it. Declaring the key
1587
+ explicitly here — rather than leaving the rule to somehow infer
1588
+ "solo project" from a file that names only one target — is also the
1589
+ more honest choice for a project that later adds a SECOND target: the
1590
+ Back-fill lane's own "Present, and require explicit confirmation"
1591
+ step already shows this exact printed block to the operator verbatim
1592
+ before anything is written, so `latest-eligible: true` is a line they
1593
+ read and can strike, not a behavior that silently changes the day a
1594
+ second row is declared by hand."""
1595
+ # Plain ASCII throughout this message, deliberately: unlike every
1596
+ # DOCSTRING/comment in this module, this text is actually written to a
1597
+ # CLI's stdout/stderr and captured by a real subprocess call. A child
1598
+ # Python process with no PYTHONIOENCODING/PYTHONUTF8 set encodes its
1599
+ # stdout/stderr using the ambient console codepage on Windows (not
1600
+ # UTF-8), so a non-ASCII character here (an em-dash raised this exact
1601
+ # failure, verified) can produce bytes a UTF-8-decoding parent
1602
+ # (`subprocess.run(..., encoding="utf-8")`) cannot decode at all.
1603
+ if len(manifest_candidates) != 1:
1604
+ raise BackfillAmbiguousError(
1605
+ f"found {len(manifest_candidates)} candidate manifest file(s) "
1606
+ f"({', '.join(manifest_candidates) or 'none'}) - cannot propose "
1607
+ "a single release-targets.md row without asking which one")
1608
+ if len(changelog_candidates) != 1:
1609
+ raise BackfillAmbiguousError(
1610
+ f"found {len(changelog_candidates)} candidate changelog file(s) "
1611
+ f"({', '.join(changelog_candidates) or 'none'}) - cannot propose "
1612
+ "a single release-targets.md row without asking which one")
1613
+ return {
1614
+ "target": target,
1615
+ "prefix": prefix,
1616
+ "manifest": [manifest_candidates[0]],
1617
+ "changelog": changelog_candidates[0],
1618
+ "payload": ".",
1619
+ "latest_eligible": True,
1620
+ }
1621
+
1622
+
1623
+ def format_release_targets_block(row):
1624
+ """Render one `load_targets`-loadable file body from a row dict shaped
1625
+ like `detect_candidate_target`'s return value (or any dict carrying at
1626
+ least `target`, `prefix`, `changelog`, `payload`, and a `manifest` list).
1627
+ Round-trips through `parse_release_targets` — this is the one function
1628
+ that turns a detected/confirmed candidate into the exact text the
1629
+ back-fill lane persists, so a caller never hand-assembles the delimiter
1630
+ grammar itself.
1631
+
1632
+ Emits a `latest-eligible` line when `row` declares one (HIGH-2,
1633
+ adversarial review 2026-07-31) — `detect_candidate_target` always does,
1634
+ since it can only ever propose a single-target row — rendered as the
1635
+ grammar's own `true`/`false` literal, never a bare Python truthiness
1636
+ string, so the round-trip through `parse_release_targets` parses it back
1637
+ as the same boolean rather than an `InvalidBooleanError`."""
1638
+ lines = ["<!-- release-targets -->", f"[{row['target']}]",
1639
+ f"prefix: {row['prefix']}"]
1640
+ for manifest in row.get("manifest", []):
1641
+ lines.append(f"manifest: {manifest}")
1642
+ lines.append(f"changelog: {row['changelog']}")
1643
+ lines.append(f"payload: {row['payload']}")
1644
+ if "latest_eligible" in row:
1645
+ lines.append(f"latest-eligible: {'true' if row['latest_eligible'] else 'false'}")
1646
+ lines.append("<!-- /release-targets -->")
1647
+ return "\n".join(lines) + "\n"
1648
+
1649
+
1650
+ # --------------------------------------------------------------------------- #
1651
+ # CLI entry point (T-41f, issue #563). Gated behind `if __name__ ==
1652
+ # "__main__":` at the bottom of this file, so nothing here touches the
1653
+ # zero-side-effects-at-import invariant every other section of this module
1654
+ # states — importing this module never parses argv, resolves a path, or
1655
+ # reads a file; only running it as a script does.
1656
+ #
1657
+ # This is the CLI the release skill's helper invocations resolve against
1658
+ # once repointed under `${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py` (T-41b).
1659
+ # It is intentionally NOT the same surface as `.github/scripts/_releaselib.py`
1660
+ # (this repo's permanent CI shim, which additionally carries this repo's own
1661
+ # data constants and CI-only subcommands like `select-target` and
1662
+ # `merge-readiness` — those stay CI-internal and are not part of a portable
1663
+ # skill's vocabulary). This CLI supports only the subcommands a release
1664
+ # skill actually needs to shell out to: resolving a target's declared
1665
+ # prefix, selecting the last tag in a series, checking a notes file's
1666
+ # heading, classifying a (re)publish attempt, and peeling a tag to the
1667
+ # commit it names (`peel-tag`, HIGH-1, adversarial review 2026-07-31 — added
1668
+ # here because the CI shim already had it and a consumer had no equivalent).
1669
+ # --------------------------------------------------------------------------- #
1670
+
1671
+
1672
+ def default_targets_path():
1673
+ """The declared-target file's default location: `.codearbiter/release-
1674
+ targets.md` under the project root. `CLAUDE_PROJECT_DIR` is read first —
1675
+ the same env-first signal every hook in this codebase trusts as the
1676
+ harness's own authoritative project-root pointer (a subprocess is not
1677
+ guaranteed to start with the project directory as its cwd) — falling
1678
+ back to the process's current working directory when the variable is
1679
+ unset (a bare script invocation outside a governed session, e.g. this
1680
+ module's own tests). Deliberately reimplemented here rather than
1681
+ importing `_activationlib.project_root`: this file must stay loadable
1682
+ standalone via `importlib.util.spec_from_file_location` with no sibling
1683
+ module on `sys.path` (the CI shim, and every test that loads this module
1684
+ under a private name, does exactly that), so it cannot depend on another
1685
+ `core/pysrc/` file being importable by plain `import` at CLI time."""
1686
+ root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
1687
+ return os.path.join(root, ".codearbiter", "release-targets.md")
1688
+
1689
+
1690
+ def default_backfill_root():
1691
+ """The back-fill scan's default root, mirroring `default_targets_path`'s
1692
+ own env-first precedence exactly: `CLAUDE_PROJECT_DIR` when set, else the
1693
+ process's current working directory. Without this, `backfill-detect`
1694
+ invoked with no positional root (the shape the release skill's own
1695
+ prose uses) would scan whatever directory the CALLER happens to be
1696
+ running in rather than the project root — the same T-41f defect class
1697
+ this module's CLI already guards against for `tag-prefix`."""
1698
+ return os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
1699
+
1700
+
1701
+ def _resolve_target_row(target, targets_file):
1702
+ """The declared row named `target` in `targets_file`, or `None` if no
1703
+ row of that name is declared. Raises `ReleaseTargetsError` (any
1704
+ subclass) exactly as `load_targets` does when the file itself is
1705
+ absent, empty, or malformed — a caller distinguishes "no such target"
1706
+ from "the declaration is broken" by catching the exception type."""
1707
+ rows = load_targets(targets_file)
1708
+ for row in rows:
1709
+ if row["target"] == target:
1710
+ return row
1711
+ return None
1712
+
1713
+
1714
+ def _targets_error_exit_code(exc):
1715
+ """The CLI's exit-code discriminator for any `ReleaseTargetsError`
1716
+ surfaced from `load_targets` (HIGH-1, adversarial review 2026-07-31):
1717
+
1718
+ 3 - a genuinely ABSENT declared file (`AbsentBlockError`, and nothing
1719
+ else) -- nothing on disk at all. This is the release skill's
1720
+ Back-fill lane's ONE sanctioned trigger.
1721
+ 4 - every other case: an EXISTING file that has no block
1722
+ (`FileExistsNoBlockError`), one this process could not read for
1723
+ any other reason such as a permissions error
1724
+ (`UnreadableTargetsFileError`), or is empty, malformed, or
1725
+ otherwise unparseable. All of these STOP outright per the
1726
+ skill's "Targets" section and must never be mistaken for
1727
+ "safe to back-fill".
1728
+
1729
+ Exit code 2 is deliberately NOT reused for either state here: it already
1730
+ means "bad CLI invocation" or "unrecognised target name" elsewhere in
1731
+ this dispatcher, neither of which is a statement about the declared
1732
+ file's own state -- collapsing them together is exactly how the
1733
+ original defect made "absent" and "exists but broken" indistinguishable
1734
+ from the CLI."""
1735
+ return 3 if isinstance(exc, AbsentBlockError) else 4
1736
+
1737
+
1738
+ def main(argv):
1739
+ """CLI dispatch. Subcommands:
1740
+
1741
+ tag-prefix <target> [--targets-file PATH]
1742
+ prints the target's declared `prefix`.
1743
+ list-targets [--targets-file PATH]
1744
+ prints every declared target's name, one
1745
+ per line, in declaration order -- the
1746
+ sanctioned way to enumerate targets without
1747
+ hand-parsing the file (never guaranteed to
1748
+ be a single name; a multi-target file names
1749
+ more than one).
1750
+ last-tag <prefix> stdin = tags (whitespace/newline separated)
1751
+ -> prints the selected tag or <none>.
1752
+ notes-match <tag> <notes_file>
1753
+ exit 0 iff the notes file's first heading
1754
+ names the same version as `tag`.
1755
+ changelog-section <changelog_file> <version>
1756
+ prints the `## [<version>] ...` section of
1757
+ `<changelog_file>` verbatim, from its
1758
+ heading up to the next `##` heading or EOF.
1759
+ exit 0 with the section on stdout - 1 no
1760
+ heading names `<version>` - 3 the file
1761
+ could not be read (distinct from 1: "could
1762
+ not compare" is never "compared and
1763
+ disagreed"). The sanctioned way for
1764
+ Phase 3's `resume_publish` path to
1765
+ reconstruct release notes when Phase 1's
1766
+ own scratch file did not survive to a
1767
+ later session (blind exercise run 19,
1768
+ HIGH-2).
1769
+ check-manifests <target> <version>
1770
+ asserts EVERY declared `manifest` in the row
1771
+ equals <version>. exit 0 all match - 1 at
1772
+ least one disagrees (each named) - 2 bad
1773
+ invocation or a manifest that cannot be
1774
+ parsed. The lane's only runnable all-paths
1775
+ equality guard: `classify` short-circuits on
1776
+ a fresh publish and never reaches its own
1777
+ version comparison (run 12).
1778
+ classify-window stdin = `git log $WINDOW --pretty=format:
1779
+ %H%n%s%n%b%n---- -- $PAYLOAD` -> prints the
1780
+ derived bump, then one
1781
+ `[NEEDS-TRIAGE] <sha> <subject>` line per
1782
+ BUMPING commit missing a CHANGELOG: footer.
1783
+ exit 0 clean - 1 a footer is missing (the
1784
+ step-3 BLOCK) - 2 the window is non-bumping
1785
+ (the step-2 STOP). Classifies and reports;
1786
+ the skill keeps the decision.
1787
+ adoption-commit stdin = `git log --diff-filter=A --format=%H
1788
+ -- .codearbiter/CONTEXT.md` -> prints the
1789
+ commit that ADOPTED codeArbiter, or nothing
1790
+ when there is none. The honest window floor
1791
+ for a project's FIRST release: without it,
1792
+ every pre-adoption commit enters the
1793
+ footer check and none can pass, blocking a
1794
+ legitimate release once per commit (A-5.5).
1795
+ run-pre-tag <target> runs the row's declared `pre-tag` commands in
1796
+ DECLARED ORDER, stopping at the first
1797
+ non-zero exit, and asserts a clean tree after
1798
+ each (DECISION-0034: check-only, never a
1799
+ fixer). Each declared command's environment
1800
+ carries `PY=<this process's own
1801
+ interpreter>`, so a row may portably spell
1802
+ `"$PY"` instead of a hardcoded interpreter
1803
+ on every platform (#583 MEDIUM-2 / #584
1804
+ MEDIUM-3): on Windows this dispatch resolves
1805
+ a POSIX-compatible shell (Git for Windows'
1806
+ own `bash.exe`) rather than falling through
1807
+ to `shell=True`'s default `cmd.exe`, which
1808
+ cannot expand `$VAR` (#602). exit 0 all
1809
+ passed - 5 a command RAN and reported drift
1810
+ - 6 a command exited 0 but MUTATED the tree
1811
+ (or the tree was already dirty on a probe
1812
+ failure path that predates this fix) - 7 a
1813
+ command's interpreter or program itself
1814
+ could not be located/executed at all --
1815
+ NOT drift, no release-edit discard needed
1816
+ (#585 MEDIUM-2 / #584 MEDIUM-1) - 8 the
1817
+ tree-state PROBE itself failed, so no
1818
+ verdict about the declared commands exists
1819
+ (distinct from 6, which names a command as
1820
+ the fault) - 9 no POSIX-compatible shell
1821
+ could be resolved to dispatch a declared
1822
+ row on Windows, so NO command ran at all --
1823
+ NOT drift, distinct from 7 (#602) - 2 bad
1824
+ invocation / unknown target - 3/4
1825
+ declared-file states.
1826
+ semver-greater <candidate> <floor>
1827
+ exit 0 iff `candidate` is STRICTLY greater
1828
+ than `floor`; 1 when equal or lesser; 2 when
1829
+ either is unparseable. The sanctioned way to
1830
+ run the lane's strictly-greater assertions,
1831
+ including the manifest FLOOR check -- both
1832
+ were hand-done against a hard rule saying
1833
+ the version MUST NOT be guessed.
1834
+ apply-bump <base> <word> prints the SemVer `base` bumped by `word`
1835
+ (`major`/`minor`/`patch`; `none` and any
1836
+ other value are refused). exit 0 with the
1837
+ bumped version on stdout - 2 when `base` is
1838
+ not plain SemVer or `word` is not one of
1839
+ the three bumping words. The sanctioned way
1840
+ to apply the bump `classify-window`
1841
+ derived, instead of by eye (#585 MEDIUM-3).
1842
+ dates-match <changelog_section_file> <tag_message_file>
1843
+ exit 0 iff the changelog section's heading
1844
+ date equals the `Released-at:` date in the
1845
+ tag message. The prose has always REQUIRED
1846
+ this check; until run 4 it had no CLI
1847
+ entry point, so no operator following the
1848
+ skill could actually run it.
1849
+ classify <tag_exists> <tag_sha> <head_sha> <tag_version>
1850
+ <manifest_version> <release_nondraft>
1851
+ prints the publish-state label. Bools are
1852
+ the bare literals `true`/`false` -- NOT
1853
+ `gh`'s JSON. Note `release_nondraft` is
1854
+ the NEGATION of `gh`'s `isDraft`.
1855
+ peel-tag <tag> stdin = `<sha> <ref>` lines in either
1856
+ `git ls-remote --tags` or `git show-ref
1857
+ --tags -d` format -> prints the COMMIT
1858
+ `tag` names (peeled through its `^{}`
1859
+ line when annotated), or "" when `tag`
1860
+ is absent from stdin (HIGH-1, adversarial
1861
+ review 2026-07-31). This is the one
1862
+ sanctioned way to produce `<tag_sha>` for
1863
+ `classify` below: a bare `git rev-parse
1864
+ <tag>` returns an ANNOTATED tag's own
1865
+ object id, not the commit it points at,
1866
+ which would feed `classify` a value that
1867
+ can never equal `<head_sha>` and
1868
+ misclassify a healthy tag as
1869
+ `abort_mismatch`.
1870
+ backfill-detect [root] scans `root` (default: `default_backfill_
1871
+ root()`, i.e. `CLAUDE_PROJECT_DIR` then
1872
+ cwd) for exactly one candidate manifest and
1873
+ one candidate changelog (T-49/T-50); on a
1874
+ single unambiguous candidate of each,
1875
+ prints the exact `release-targets.md`
1876
+ block text and exits 0; on zero or multiple
1877
+ of either, writes the ambiguity to stderr
1878
+ and exits 1 — it never prints a guess.
1879
+ show-row <target> [--field NAME]
1880
+ prints every field the row declares, one
1881
+ `name: value` line each (multi-valued
1882
+ fields comma-separated), or just NAME's raw
1883
+ value with `--field`. Reads via
1884
+ `default_targets_path()` only, no
1885
+ `--targets-file` override.
1886
+ payload-pathspec <target> prints the row's `payload`, minus its
1887
+ `payload-exclude` entries, as verbatim git
1888
+ pathspec arguments (`:(exclude)` forms
1889
+ included) — the sanctioned way to spell a
1890
+ window subtraction plain `git log --
1891
+ <path>` cannot express. Same declared-file
1892
+ resolution as `show-row`.
1893
+
1894
+ `--targets-file PATH` overrides `default_targets_path()` for `tag-prefix`
1895
+ and `list-targets` only; `show-row` and `payload-pathspec` always resolve
1896
+ the declared file through `default_targets_path()` with no override, and
1897
+ every remaining subcommand needs no declared file at all. `tag-prefix`,
1898
+ `list-targets`, `show-row`, and `payload-pathspec` all exit 3 when the
1899
+ declared file is genuinely absent and 4 for every other declared-file
1900
+ error (HIGH-1, `_targets_error_exit_code`); every other subcommand
1901
+ prints a value/label and exits 0, or writes a short cause to stderr and
1902
+ exits non-zero — never a bare traceback, so a caller shelling out to
1903
+ this file gets a diagnosable failure either way. Returns a process exit
1904
+ code."""
1905
+ if not argv:
1906
+ sys.stderr.write(
1907
+ "usage: _releaselib.py {tag-prefix|list-targets|show-row|"
1908
+ "payload-pathspec|last-tag|notes-match|changelog-section|"
1909
+ "dates-match|semver-greater|apply-bump|classify|peel-tag|"
1910
+ "run-pre-tag|adoption-commit|classify-window|check-manifests|"
1911
+ "backfill-detect} ...\n")
1912
+ return 2
1913
+
1914
+ cmd, rest = argv[0], list(argv[1:])
1915
+
1916
+ if cmd == "tag-prefix":
1917
+ # `--targets-file` is stripped HERE, inside the one subcommand that
1918
+ # reads it, rather than unconditionally over every subcommand's
1919
+ # `rest` — `classify`'s six positional arguments are caller-supplied
1920
+ # data (a sha, a version string, a bool) and must never have a
1921
+ # literal substring lexically special-cased out from under them.
1922
+ targets_file = default_targets_path()
1923
+ if "--targets-file" in rest:
1924
+ idx = rest.index("--targets-file")
1925
+ if idx + 1 >= len(rest):
1926
+ sys.stderr.write("--targets-file requires a value\n")
1927
+ return 2
1928
+ targets_file = rest[idx + 1]
1929
+ rest = rest[:idx] + rest[idx + 2:]
1930
+ if len(rest) != 1:
1931
+ sys.stderr.write(f"_releaselib.py: bad invocation: {' '.join(argv)}\n")
1932
+ return 2
1933
+ target = rest[0]
1934
+ try:
1935
+ row = _resolve_target_row(target, targets_file)
1936
+ except ReleaseTargetsError as exc:
1937
+ sys.stderr.write(
1938
+ f"{type(exc).__name__}: could not read declared release "
1939
+ f"targets from {targets_file!r}: {exc}\n")
1940
+ return _targets_error_exit_code(exc)
1941
+ if row is None:
1942
+ sys.stderr.write(f"unknown release target: {target}\n")
1943
+ return 2
1944
+ print(row["prefix"])
1945
+ return 0
1946
+
1947
+ if cmd in ("show-row", "payload-pathspec"):
1948
+ # Blind-exercise HIGH (run 14). The lane's own rule is that the
1949
+ # declared file must be read "through the same tested grammar", not
1950
+ # "by-eye scan of the delimiter block" -- but only `prefix` and the
1951
+ # target names had readers. Nine fields (`manifest`,
1952
+ # `generated-manifest`, `generate`, `changelog`, `payload`,
1953
+ # `payload-exclude`, `artifacts`, `pre-tag`, `provenance-manifest`,
1954
+ # `latest-eligible`) had none, so following the lane REQUIRED doing
1955
+ # the thing it forbids. An exercising agent read all nine by eye and
1956
+ # said so.
1957
+ #
1958
+ # `payload-pathspec` exists separately because `$PAYLOAD` is
1959
+ # documented as "payload, minus payload-exclude" and plain
1960
+ # `git log -- <path>` cannot express subtraction. The exclusion was
1961
+ # therefore unspellable from the prose, and silently absent from any
1962
+ # window for a row that declares one. This prints the pathspec
1963
+ # arguments to pass verbatim, `:(exclude)` forms included.
1964
+ field = None
1965
+ if "--field" in rest:
1966
+ idx = rest.index("--field")
1967
+ if idx + 1 >= len(rest):
1968
+ sys.stderr.write("--field requires a value\n")
1969
+ return 2
1970
+ field = rest[idx + 1]
1971
+ rest = rest[:idx] + rest[idx + 2:]
1972
+ if len(rest) != 1:
1973
+ sys.stderr.write(f"_releaselib.py: bad invocation: {' '.join(argv)}\n")
1974
+ return 2
1975
+ target = rest[0]
1976
+ targets_file = default_targets_path()
1977
+ try:
1978
+ row = _resolve_target_row(target, targets_file)
1979
+ except ReleaseTargetsError as exc:
1980
+ sys.stderr.write(
1981
+ f"{type(exc).__name__}: could not read declared release "
1982
+ f"targets from {targets_file!r}: {exc}\n")
1983
+ return _targets_error_exit_code(exc)
1984
+ if row is None:
1985
+ sys.stderr.write(f"unknown release target: {target}\n")
1986
+ return 2
1987
+
1988
+ if cmd == "payload-pathspec":
1989
+ payload = row.get("payload") or "."
1990
+ parts = [payload] + [f":(exclude){p}"
1991
+ for p in (row.get("payload_exclude") or [])]
1992
+ print(" ".join(parts))
1993
+ return 0
1994
+
1995
+ # Emitted as SHELL-QUOTED `NAME='value'` pairs, and named for the
1996
+ # variables the release skill actually spells (`TAG_PREFIX`, not
1997
+ # `PREFIX`). Both halves are load-bearing, and blind exercise run 15
1998
+ # found the cost of getting either wrong:
1999
+ #
2000
+ # * UNQUOTED output made the documented `eval "$(… show-row …)"`
2001
+ # EXECUTE declared field values. `rebuild: cd x && npm run build`
2002
+ # parsed as the assignment `REBUILD=cd` followed by the command
2003
+ # `x`, with `&& npm run build` next in line -- it was one
2004
+ # successful exit away from running a build nobody asked for, and
2005
+ # `eval` still reported 0 because plain assignments followed. A
2006
+ # declared file's values are operator-authored shell that this
2007
+ # lane executes only AFTER `releasehash` confirms a human read
2008
+ # them; executing a fragment of that at row-read time runs it
2009
+ # BEFORE the gate that exists for it. `shlex.quote` closes it.
2010
+ # * MISNAMED keys silently left `TAG_PREFIX`, `REBUILD` and
2011
+ # `PRE_TAG` unset after the eval -- the three the lane leans on
2012
+ # hardest -- so the mandated reader delivered 10 of 13 fields and
2013
+ # the operator had to read the rest by eye, which is the exact
2014
+ # thing this subcommand was added to prevent.
2015
+ #
2016
+ # `--field NAME` prints ONE raw value with no quoting and no `NAME=`,
2017
+ # for `X=$(… --field payload)` command substitution. That form needs
2018
+ # no `eval` at all and is what the skill now uses.
2019
+ fields = [("TARGET", "target"), ("TAG_PREFIX", "prefix"),
2020
+ ("MANIFEST", "manifest"),
2021
+ ("GENERATED_MANIFEST", "generated_manifest"),
2022
+ ("GENERATE", "generate"), ("CHANGELOG", "changelog"),
2023
+ ("PAYLOAD", "payload"),
2024
+ ("PAYLOAD_EXCLUDE", "payload_exclude"),
2025
+ ("ARTIFACTS", "artifacts"), ("REBUILD", "rebuild"),
2026
+ ("PRE_TAG", "pre_tag"),
2027
+ ("PROVENANCE_MANIFEST", "provenance_manifest"),
2028
+ ("LATEST_ELIGIBLE", "latest_eligible"),
2029
+ ("DISPLAY_NAME", "display_name")]
2030
+
2031
+ def _flatten(value):
2032
+ if isinstance(value, (list, tuple)):
2033
+ return ",".join(str(v) for v in value)
2034
+ if isinstance(value, bool):
2035
+ return "true" if value else "false"
2036
+ return "" if value is None else str(value)
2037
+
2038
+ if field is not None:
2039
+ wanted = field.strip().lower().replace("-", "_")
2040
+ by_key = {key: name for name, key in fields}
2041
+ if wanted not in by_key:
2042
+ sys.stderr.write(
2043
+ f"unknown field {field!r}; declared fields are: "
2044
+ + ", ".join(key for _n, key in fields) + "\n")
2045
+ return 2
2046
+ print(_flatten(row.get(wanted)))
2047
+ return 0
2048
+
2049
+ for name, key in fields:
2050
+ print(f"{name}={shlex.quote(_flatten(row.get(key)))}")
2051
+ return 0
2052
+
2053
+ if cmd == "list-targets":
2054
+ # MEDIUM (adversarial review 2026-07-31): the single-target rule
2055
+ # requires knowing a target's name, but `tag-prefix` takes the name
2056
+ # as INPUT and, before this subcommand existed, nothing enumerated
2057
+ # the declared names -- an agent had no sanctioned way to answer
2058
+ # "what targets exist?" except hand-parsing the file, exactly the
2059
+ # grammar this module exists to be the one tested parser for.
2060
+ targets_file = default_targets_path()
2061
+ if "--targets-file" in rest:
2062
+ idx = rest.index("--targets-file")
2063
+ if idx + 1 >= len(rest):
2064
+ sys.stderr.write("--targets-file requires a value\n")
2065
+ return 2
2066
+ targets_file = rest[idx + 1]
2067
+ rest = rest[:idx] + rest[idx + 2:]
2068
+ if rest:
2069
+ sys.stderr.write(f"_releaselib.py: bad invocation: {' '.join(argv)}\n")
2070
+ return 2
2071
+ try:
2072
+ rows = load_targets(targets_file)
2073
+ except ReleaseTargetsError as exc:
2074
+ sys.stderr.write(
2075
+ f"{type(exc).__name__}: could not read declared release "
2076
+ f"targets from {targets_file!r}: {exc}\n")
2077
+ return _targets_error_exit_code(exc)
2078
+ for row in rows:
2079
+ print(row["target"])
2080
+ return 0
2081
+
2082
+ if cmd == "last-tag" and len(rest) == 1:
2083
+ print(last_tag_select(sys.stdin.read().split(), rest[0]))
2084
+ return 0
2085
+
2086
+ if cmd == "notes-match" and len(rest) == 2:
2087
+ try:
2088
+ with open(rest[1], encoding="utf-8") as fh:
2089
+ notes_text = fh.read()
2090
+ except OSError:
2091
+ notes_text = ""
2092
+ return 0 if notes_heading_matches(notes_text, rest[0]) else 1
2093
+
2094
+ if cmd == "changelog-section" and len(rest) == 2:
2095
+ # Mechanical reconstruction of Phase 1's composed section, for
2096
+ # `resume_publish` -- see `changelog_section`'s docstring. Exit 0
2097
+ # with the section on stdout - 1 the changelog has no heading for
2098
+ # `<version>` (drift between $CHANGELOG and the tag, not a
2099
+ # bad-invocation) - 3 the changelog file could not be read. 3, not
2100
+ # 2, so "unreadable" and "bad invocation" stay distinguishable
2101
+ # (this module's own [never-fold-unreadable-into-absent] rule) --
2102
+ # 2 is reserved for bad invocation across this whole CLI.
2103
+ changelog_path, version = rest
2104
+ try:
2105
+ with open(changelog_path, encoding="utf-8") as fh:
2106
+ changelog_text = fh.read()
2107
+ except OSError as exc:
2108
+ sys.stderr.write(
2109
+ f"changelog-section: cannot read {changelog_path!r}: {exc}\n")
2110
+ return 3
2111
+ section = changelog_section(changelog_text, version)
2112
+ if section is None:
2113
+ sys.stderr.write(
2114
+ f"changelog-section: no '## [{version}]' heading in "
2115
+ f"{changelog_path!r}\n")
2116
+ return 1
2117
+ # `sys.stdout.write` is text-mode: on Windows with no
2118
+ # PYTHONIOENCODING/PYTHONUTF8 set it encodes using the ambient
2119
+ # console codepage (cp1252, not UTF-8) AND translates `\n` to
2120
+ # `\r\n`. A changelog section legitimately contains an em-dash in
2121
+ # every heading (`## [X.Y.Z] — DATE`), so unlike backfill-detect's
2122
+ # deliberately-ASCII-only block (see its own comment on this exact
2123
+ # failure), this output cannot dodge the problem by staying ASCII.
2124
+ # Writing UTF-8 bytes straight to the binary buffer bypasses both
2125
+ # the codepage transcoding and the newline translation. `.buffer`
2126
+ # is absent on an `io.StringIO` (what a direct, in-process
2127
+ # `main(argv)` call under test redirects to) -- that caller already
2128
+ # gets the exact string back with no encoding step in between, so
2129
+ # falling back to plain `.write()` there is not a weaker code path,
2130
+ # it is the correct one for an object that was never bytes.
2131
+ out_buffer = getattr(sys.stdout, "buffer", None)
2132
+ if out_buffer is not None:
2133
+ out_buffer.write(section.encode("utf-8"))
2134
+ else:
2135
+ sys.stdout.write(section)
2136
+ return 0
2137
+
2138
+ if cmd == "check-manifests" and len(rest) == 2:
2139
+ # HIGH (adversarial review 2026-07-31, run 12). A row MAY declare
2140
+ # several manifests, and Phase 1 must bump every one to the derived
2141
+ # version -- but nothing mechanical asserted it. The skill claimed
2142
+ # `classify` would catch a partial bump; it does not on the path
2143
+ # that matters. `classify_publish_state` short-circuits on
2144
+ # `if not tag_exists: return "publish_fresh"` BEFORE comparing
2145
+ # versions, so the catch fires only when a tag already exists (the
2146
+ # resume path). On a FRESH publish -- every ordinary release, and
2147
+ # every first release -- a lagging secondary manifest sails
2148
+ # through, and the Traps section's own named consequence lands: a
2149
+ # tag that installs a version string the tag does not name.
2150
+ #
2151
+ # Exit 0 every declared manifest equals <version> - 1 at least one
2152
+ # disagrees (each named) - 2 bad invocation, unknown target, or a
2153
+ # manifest that cannot be read or parsed. Unparseable is NEVER
2154
+ # folded into "disagrees": one is "I compared and they differ", the
2155
+ # other is "I could not compare", and this lane has already had to
2156
+ # separate those twice.
2157
+ target, expected = rest
2158
+ try:
2159
+ rows = load_targets(default_targets_path())
2160
+ except ReleaseTargetsError as exc:
2161
+ sys.stderr.write(f"{type(exc).__name__}: {exc}\n")
2162
+ return _targets_error_exit_code(exc)
2163
+ row = next((r for r in rows if r["target"] == target), None)
2164
+ if row is None:
2165
+ sys.stderr.write(f"unknown release target: {target}\n")
2166
+ return 2
2167
+ root = os.path.dirname(os.path.dirname(default_targets_path())) or "."
2168
+ mismatched, unreadable = [], []
2169
+ for rel in (row.get("manifest") or []):
2170
+ path = os.path.join(root, *rel.split("/"))
2171
+ found = _manifest_version(path)
2172
+ if found is None:
2173
+ unreadable.append(rel)
2174
+ elif found != expected:
2175
+ mismatched.append((rel, found))
2176
+ for rel in unreadable:
2177
+ sys.stderr.write(
2178
+ f"check-manifests: cannot read a version from {rel!r} -- this "
2179
+ "is NOT the same answer as 'disagrees' (exit 1); no "
2180
+ "comparison happened\n")
2181
+ for rel, found in mismatched:
2182
+ sys.stderr.write(
2183
+ f"check-manifests: {rel} declares {found!r}, expected "
2184
+ f"{expected!r}\n")
2185
+ if unreadable:
2186
+ return 2
2187
+ return 1 if mismatched else 0
2188
+
2189
+ if cmd == "classify-window" and not rest:
2190
+ # stdin = `git log $WINDOW --pretty=format:%H%n%s%n%b%n---- --
2191
+ # $PAYLOAD`. Prints the derived bump on the first line, then one
2192
+ # `[NEEDS-TRIAGE] <short-sha> <subject>` line per BUMPING commit
2193
+ # with no CHANGELOG: footer -- the exact report shape Phase 1
2194
+ # step 3 specifies, so it is not re-derived per release.
2195
+ #
2196
+ # Exit 0 = classified, no missing footers. Exit 1 = at least one
2197
+ # bumping commit lacks a footer (the step-3 BLOCK). Exit 2 = the
2198
+ # window is non-bumping, which is the step-2 STOP.
2199
+ #
2200
+ # It CLASSIFIES and REPORTS; it does not decide the release. The
2201
+ # skill keeps the BLOCK. A helper returning proceed/stop would put
2202
+ # a governance decision inside a library, which is the wrong side
2203
+ # of ADR-0010's cooperative-agent line.
2204
+ window = classify_window(parse_window_log(sys.stdin.read()))
2205
+ print(window["bump"])
2206
+ for row in window["missing_footer"]:
2207
+ print(f"[NEEDS-TRIAGE] {row['sha'][:7]} {row['subject']}")
2208
+ if window["missing_footer"]:
2209
+ return 1
2210
+ return 2 if window["bump"] == "none" else 0
2211
+
2212
+ if cmd == "adoption-commit" and not rest:
2213
+ # A-5.5. stdin = `git log --diff-filter=A --format=%H --
2214
+ # .codearbiter/CONTEXT.md`. Prints the adoption commit, or nothing
2215
+ # at all when the project has no adoption commit.
2216
+ #
2217
+ # Exit 0 either way, deliberately: "this project has no adoption
2218
+ # commit" is a normal answer for a repo that never onboarded, not
2219
+ # an error, and the caller distinguishes the two by empty output
2220
+ # exactly as it already does for `peel-tag`. A non-zero exit here
2221
+ # would break a `set -e` lane on the ordinary path.
2222
+ baseline = first_release_baseline(sys.stdin.read())
2223
+ if baseline:
2224
+ print(baseline)
2225
+ return 0
2226
+
2227
+ if cmd == "run-pre-tag" and len(rest) == 1:
2228
+ # A-2.1/2.2/2.3 (DECISION-0034). Runs the row's declared `pre-tag`
2229
+ # commands IN DECLARED ORDER, stops at the first non-zero exit, and
2230
+ # asserts a clean tree after each one.
2231
+ #
2232
+ # This is a subcommand rather than four prose rules because
2233
+ # operator-declared shell commands are exactly where an
2234
+ # agent-followed procedure is least trustworthy: the clean-tree
2235
+ # assertion is what surfaces a rogue command's writes before
2236
+ # tagging, and an assertion an agent has to remember is one it can
2237
+ # skip. Logged as a SMARTS decision in .codearbiter/sprint-log.md
2238
+ # (2026-07-31), Scalable weighted heavily per the standing steer.
2239
+ #
2240
+ # The clean-tree check runs BEFORE any `rebuild` (2.3): this
2241
+ # subcommand never invokes rebuild at all, so a rebuild's
2242
+ # legitimate bundle rewrite can never be attributed to a pre-tag
2243
+ # command. Ordering the lane correctly is the caller's job; making
2244
+ # it impossible to conflate the two is this command's.
2245
+ #
2246
+ # Exit codes: 0 all passed - 5 a command RAN and reported drift
2247
+ # (non-zero, not one of the could-not-run codes below) - 6 a
2248
+ # command exited 0 but MUTATED the tree - 7 a command's interpreter
2249
+ # or program itself could not be located/executed at all (#585
2250
+ # MEDIUM-2 / #584 MEDIUM-1: "could not run" is never "ran and
2251
+ # disagreed") - 8 the tree-state PROBE itself failed, so no verdict
2252
+ # about the declared commands exists at all (distinct from 6, which
2253
+ # means a command mutated the tree -- a probe failure means this
2254
+ # subcommand never got far enough to know) - 9 no POSIX-compatible
2255
+ # shell could be resolved to dispatch a declared row on Windows, so
2256
+ # NO declared command ran at all (#602: distinct from 7, which names
2257
+ # a specific command's own interpreter as unresolvable -- 9 means
2258
+ # the DISPATCH MECHANISM itself is unavailable, before any command
2259
+ # is even attempted) - 2 bad invocation or unknown target - 3/4 the
2260
+ # declared-file states, unchanged.
2261
+ #
2262
+ # PY env-var contract: every declared command below runs with
2263
+ # `PY` set in its environment to THIS process's own interpreter
2264
+ # (`sys.executable`), so a row may portably spell `"$PY"` instead
2265
+ # of a hardcoded `python3`/`python` (#583 MEDIUM-2 / #584 MEDIUM-3).
2266
+ # This convention now holds on Windows too (#602): the dispatch
2267
+ # below resolves a POSIX-compatible shell there instead of falling
2268
+ # through to `subprocess.run(shell=True)`'s default `cmd.exe`.
2269
+ try:
2270
+ rows = load_targets(default_targets_path())
2271
+ except ReleaseTargetsError as exc:
2272
+ sys.stderr.write(f"{type(exc).__name__}: {exc}\n")
2273
+ return _targets_error_exit_code(exc)
2274
+ row = next((r for r in rows if r["target"] == rest[0]), None)
2275
+ if row is None:
2276
+ sys.stderr.write(f"unknown release target: {rest[0]}\n")
2277
+ return 2
2278
+
2279
+ # Run the declared commands in the PROJECT root, not whatever cwd
2280
+ # this process inherited (MEDIUM, run 9). The declaration is
2281
+ # resolved from CLAUDE_PROJECT_DIR while the commands and the tree
2282
+ # probe used to run wherever the caller happened to be -- so a
2283
+ # declared check could pass having inspected a DIFFERENT
2284
+ # repository's files, and the clean-tree probe could report an
2285
+ # unrelated repo's dirt. Both directions were demonstrated.
2286
+ project_root = os.path.dirname(os.path.dirname(default_targets_path())) or "."
2287
+
2288
+ # #602: resolve the POSIX-shell dispatch ONCE, before any declared
2289
+ # command runs (and before the tree-state baseline below, which a
2290
+ # row with no `pre-tag` commands at all does not even need this
2291
+ # resolution to reach). `_resolve_posix_shell()` itself returns None
2292
+ # unconditionally on a non-Windows host, so `posix_shell` stays None
2293
+ # and the dispatch below is byte-identical to the pre-#602 behavior
2294
+ # there. A row declaring no `pre-tag` commands never needs a shell
2295
+ # at all, so resolution -- and its failure mode -- is skipped
2296
+ # entirely rather than blocking a target that never runs anything.
2297
+ pre_tag_commands = row.get("pre_tag") or []
2298
+ posix_shell = (
2299
+ _resolve_posix_shell() if (os.name == "nt" and pre_tag_commands) else None)
2300
+ if os.name == "nt" and pre_tag_commands and posix_shell is None:
2301
+ # Exit 9, never 7 (which names a specific command's own
2302
+ # interpreter as unresolvable): this is the DISPATCH MECHANISM
2303
+ # itself being unavailable, before any declared command was even
2304
+ # attempted -- "could not run" one level up, the same way exit 8
2305
+ # is "the probe could not run" one level up from exit 6.
2306
+ sys.stderr.write(
2307
+ "run-pre-tag: COULD NOT RUN -- no POSIX-compatible shell "
2308
+ "could be resolved on this Windows host.\n"
2309
+ " This is NOT drift. No declared command has run, so "
2310
+ "nothing was checked -- do not reconcile it as a check "
2311
+ "failure.\n"
2312
+ " `subprocess.run(shell=True)` dispatches via `cmd.exe` on "
2313
+ "Windows, which cannot expand a row's `\"$PY\"` / `$VAR` "
2314
+ "syntax (#602), so this subcommand requires a POSIX shell "
2315
+ "to dispatch declared rows portably.\n"
2316
+ " Remedy: install Git for Windows "
2317
+ "(https://git-scm.com/download/win), which ships "
2318
+ "`bash.exe`, or put an existing Git-for-Windows `bash.exe` "
2319
+ "on PATH. Then re-run. No release-edit discard is needed: "
2320
+ "nothing was checked, so there is nothing to undo.\n")
2321
+ return 9
2322
+
2323
+ def _tree_state():
2324
+ """(paths -> content digest, failure). Content, not just the
2325
+ porcelain LINE (HIGH, adversarial review run 10).
2326
+
2327
+ A porcelain-line set alone cannot see a command that mutates a
2328
+ file which was ALREADY modified: the line is byte-identical
2329
+ (` M CHANGELOG.md` before and after), so the change falls out
2330
+ of the set difference and the command exits 0. That blind spot
2331
+ covered exactly `$CHANGELOG` and `$MANIFEST` -- the two files
2332
+ Phase 1 touches immediately before this step, and the two an
2333
+ injected line would actually damage, since both ship: one into
2334
+ the tag message and the Release notes, the other as the
2335
+ version the tag claims. Demonstrated with a changed sha256 and
2336
+ an `INJECTED` line surviving to exit 0.
2337
+
2338
+ Digesting every path git reports as changed closes it. Paths
2339
+ git lists but that do not exist (a deletion, or a rename's old
2340
+ side) get a sentinel rather than being skipped, so a delete is
2341
+ a state change like any other.
2342
+ """
2343
+ # `git_executable()`, not a bare "git": Pi-reachable modules
2344
+ # must resolve git through the trusted-path seam, because a
2345
+ # host that has no `git` on PATH (or a PATH an attacker can
2346
+ # prepend to) would otherwise silently run the wrong binary or
2347
+ # none at all. Enforced by test_pi_package's
2348
+ # `test_shared_python_contains_no_direct_bare_git_subprocess`.
2349
+ # `--` plus the same `:/` + `,top`-exclusion pathspec the
2350
+ # release skill's own Pre-flight and step-7 clean-tree checks
2351
+ # are required to spell (#584 MEDIUM-1): the hooks append to
2352
+ # `gate-events.log` on essentially every command, INCLUDING the
2353
+ # commands this probe's own caller (`run-pre-tag`) runs, so a
2354
+ # mid-window append between the baseline snapshot and a
2355
+ # post-command probe put a blameless audit log in the changed
2356
+ # set -- and exit 6's remedy tells the operator to permanently
2357
+ # delete a release gate for it. `.markers/` is exempted for the
2358
+ # same reason the skill exempts it: a per-machine confirmation
2359
+ # marker minted by this same run is not a release surface.
2360
+ probe = subprocess.run(
2361
+ [git_executable(), "status", "--porcelain", "--", ":/",
2362
+ ":(exclude,top).codearbiter/gate-events.log",
2363
+ ":(exclude,top).codearbiter/.markers/"],
2364
+ capture_output=True, text=True, cwd=project_root)
2365
+ if probe.returncode != 0:
2366
+ return None, (probe.stderr.strip() or "git status failed")
2367
+ state = {}
2368
+ for line in probe.stdout.splitlines():
2369
+ # Porcelain v1: XY then a space then the path. A rename
2370
+ # carries `old -> new`; take the destination, which is the
2371
+ # path that exists on disk.
2372
+ rel = line[3:].strip().strip('"')
2373
+ if " -> " in rel:
2374
+ rel = rel.split(" -> ", 1)[1].strip().strip('"')
2375
+ if not rel:
2376
+ continue
2377
+ absolute = os.path.join(project_root, *rel.split("/"))
2378
+ try:
2379
+ with open(absolute, "rb") as fh:
2380
+ digest = hashlib.sha256(fh.read()).hexdigest()
2381
+ except OSError:
2382
+ digest = "<absent-or-unreadable>"
2383
+ state[rel] = digest
2384
+ return state, None
2385
+
2386
+ # The assertion is "this command changed NOTHING NEW", not "the
2387
+ # tree is pristine" (HIGH, run 9). The first form is what this
2388
+ # command is for; the second form BLOCKS EVERY RELEASE, because
2389
+ # Phase 1 rolls the changelog and bumps the manifest BEFORE this
2390
+ # step runs -- and it must, since a badge or catalog check compares
2391
+ # a surface against the NEW version and would pass vacuously
2392
+ # against the old one. Requiring a pristine tree here made the lane
2393
+ # exit 6 even for a row declaring no commands at all.
2394
+ #
2395
+ # Snapshotting instead keeps the property that matters: any path a
2396
+ # declared command touches appears as a NEW entry and is reported,
2397
+ # while the operator's own in-flight release edits are carried
2398
+ # through untouched.
2399
+ baseline, failure = _tree_state()
2400
+ if failure is not None:
2401
+ # Exit 8, never 6 (#584 residual / house rule: "could not run"
2402
+ # is never folded into "ran and disagreed", and the same holds
2403
+ # one level up for a PROBE that could not run at all). Exit 6
2404
+ # is a specific, actionable diagnosis -- "a command mutated the
2405
+ # tree" -- and this is not that: the probe failed before any
2406
+ # declared command even ran, so there is no verdict about the
2407
+ # commands to report at all, and exit 6's "fix the declaration"
2408
+ # remedy would misdirect an operator at the wrong problem.
2409
+ sys.stderr.write(
2410
+ f"run-pre-tag: the tree-state PROBE itself failed: {failure}\n"
2411
+ " This is not a verdict about any declared command -- no "
2412
+ "command has run yet, so nothing has been checked or "
2413
+ "mutated. Investigate why `git status` failed in this tree "
2414
+ "(not a git repository, no readable .git, etc.) before "
2415
+ "re-running.\n")
2416
+ return 8
2417
+
2418
+ for command in pre_tag_commands:
2419
+ # flush=True: the subprocess writes to the same fds directly and
2420
+ # is not buffered, so without this the label lands AFTER the
2421
+ # output it labels and the log misattributes which command
2422
+ # produced what -- actively misleading in the one report an
2423
+ # operator reads to decide whether a release is safe.
2424
+ print(f"pre-tag: {command}", flush=True)
2425
+ # `PY` exported to the child's environment (#583 MEDIUM-2 / #584
2426
+ # MEDIUM-3): the interpreter-resolution convention the release
2427
+ # skill establishes for its OWN invocations stopped at the
2428
+ # skill's own commands -- a declared row is operator shell this
2429
+ # lane EXECUTES exactly like any other step, so a row hardcoding
2430
+ # `python3` fails on exactly the host the convention exists for.
2431
+ # `sys.executable` is THIS process's own resolved interpreter,
2432
+ # so a row may portably spell `"$PY"` instead.
2433
+ if posix_shell is not None:
2434
+ # #602: dispatch via the resolved POSIX shell's OWN `-c`
2435
+ # argv form (`shell=False`), never
2436
+ # `shell=True, executable=posix_shell`. CPython's Windows
2437
+ # `shell=True` path unconditionally builds
2438
+ # `<executable> /c "<command>"` -- cmd.exe-flag syntax --
2439
+ # regardless of what `executable` names, so passing
2440
+ # `executable=<bash.exe>` there would invoke
2441
+ # `bash.exe /c "command"`; `/c` is not a bash option (bash
2442
+ # options start with `-`), so bash would try to run a
2443
+ # nonexistent file literally named `/c` and fail with exit
2444
+ # 127 -- misdiagnosing EVERY row as could-not-run regardless
2445
+ # of whether the row itself is valid. Building the argv
2446
+ # ourselves is the only correct way to dispatch a
2447
+ # POSIX-syntax command string through a specific non-default
2448
+ # shell on Windows.
2449
+ proc = subprocess.run(
2450
+ [posix_shell, "-c", command], shell=False, cwd=project_root,
2451
+ env={**os.environ, "PY": sys.executable})
2452
+ else:
2453
+ proc = subprocess.run(
2454
+ command, shell=True, cwd=project_root,
2455
+ env={**os.environ, "PY": sys.executable})
2456
+ if _could_not_run(proc.returncode):
2457
+ # Exit 7, never 5 (#585 MEDIUM-2 / #584 MEDIUM-1): a command
2458
+ # whose interpreter or program itself could not be located
2459
+ # was never actually RUN, so nothing was checked and there
2460
+ # is no drift to reconcile -- the exit-5 remedy below is the
2461
+ # wrong diagnosis for this case and its "discard this run's
2462
+ # uncommitted release edits" step is unnecessary busywork,
2463
+ # since nothing the row asserts was ever evaluated.
2464
+ sys.stderr.write(
2465
+ f"run-pre-tag: COULD NOT RUN -- {command!r} exited "
2466
+ f"{proc.returncode} (interpreter or command not "
2467
+ "found).\n"
2468
+ " This is NOT drift. The command's interpreter or "
2469
+ "program itself could not be located, so it never ran "
2470
+ "and nothing was checked -- do not reconcile it as a "
2471
+ "check failure.\n"
2472
+ " Remedy: fix the interpreter this row names for THIS "
2473
+ "host (a common cause is a row hardcoding a specific "
2474
+ "interpreter, e.g. `python3`, on a host that has only "
2475
+ "`python`) -- `\"$PY\"` (#601/#602) is the portable "
2476
+ "spelling on every platform this dispatch supports. "
2477
+ "Then re-run. No release-edit discard is needed: "
2478
+ "nothing was checked, so there is nothing to undo.\n")
2479
+ return 7
2480
+ if proc.returncode != 0:
2481
+ sys.stderr.write(
2482
+ f"run-pre-tag: BLOCK -- {command!r} exited "
2483
+ f"{proc.returncode}.\n"
2484
+ " A pre-tag command is a check, never a fixer "
2485
+ "(DECISION-0034), so reconcile the drift it reports.\n"
2486
+ " Then DISCARD this run's uncommitted release edits "
2487
+ "(the manifest bump and the composed changelog section) "
2488
+ "before starting over: leaving the bump in place makes "
2489
+ "it the NEXT run's version floor, so the restart derives "
2490
+ "a HIGHER version and strands the section this run "
2491
+ "already wrote for a version that was never tagged "
2492
+ "(HIGH, adversarial review run 9). Commit the "
2493
+ "reconciliation alone, then re-run the release from "
2494
+ "Pre-flight.\n")
2495
+ return 5
2496
+ current, failure = _tree_state()
2497
+ if failure is not None:
2498
+ sys.stderr.write(
2499
+ f"run-pre-tag: the tree-state PROBE itself failed after "
2500
+ f"{command!r} ran: {failure}\n"
2501
+ " This is not a verdict about the command that just "
2502
+ "ran -- the probe that would confirm or refute a "
2503
+ "mutation could not complete, so no verdict about it "
2504
+ "exists.\n")
2505
+ return 8
2506
+ # The UNION of both key sets, not `current` alone. A path git
2507
+ # reported as changed at baseline and no longer reports has been
2508
+ # REVERTED by the command -- which is a mutation of the tree in
2509
+ # exactly the sense this gate exists to catch, and the most
2510
+ # dangerous one: a pre-tag command that quietly undoes the
2511
+ # lane's own manifest bump or changelog section leaves a release
2512
+ # that tags a version the payload never claims. Walking
2513
+ # `current.items()` could not see it, because a reverted path
2514
+ # simply stops appearing.
2515
+ changed = sorted(
2516
+ rel for rel in set(baseline) | set(current)
2517
+ if baseline.get(rel) != current.get(rel))
2518
+ if changed:
2519
+ sys.stderr.write(
2520
+ f"run-pre-tag: BLOCK -- {command!r} exited 0 but MUTATED "
2521
+ "the tree. Declared pre-tag commands are check-only "
2522
+ "(DECISION-0034); reconciliation is a separate action "
2523
+ "the operator commits before releasing.\n"
2524
+ " Changed by this command (added, edited, or reverted):"
2525
+ "\n "
2526
+ + "\n ".join(changed) + "\n"
2527
+ " FIX THE DECLARATION -- do not simply re-run. This "
2528
+ "command mutates the tree every time it is invoked, so "
2529
+ "reverting and re-running cannot converge; make it "
2530
+ "check-only (assert and exit non-zero on drift) or "
2531
+ "remove the row entry. Only then restart the release "
2532
+ "from Pre-flight.\n"
2533
+ " This run's edits can be discarded wholesale, because "
2534
+ "nothing has been committed or tagged yet: `git "
2535
+ "checkout -- <tracked paths>` for files that existed "
2536
+ "before, and `rm` for any the lane CREATED (a first "
2537
+ "release composes $CHANGELOG from nothing, and `git "
2538
+ "checkout --` errors on a path git has never seen).\n")
2539
+ return 6
2540
+ return 0
2541
+
2542
+ if cmd == "semver-greater" and len(rest) == 2:
2543
+ # MEDIUM (adversarial review 2026-07-31, run 6): the hard rules say
2544
+ # "MUST NOT guess the version", and every other mechanical step in
2545
+ # the lane got a tested helper -- but the bump arithmetic and the
2546
+ # strictly-greater assertion were both hand-done, because
2547
+ # `semver_greater` was public API reachable only by import. This
2548
+ # is also the mechanism the manifest-floor check needs (HIGH, run
2549
+ # 6: a never-tagged project whose manifest already reads 1.4.2 was
2550
+ # released as 0.1.0, walking its own version backward, because the
2551
+ # only greater-than check compared against a `<none>` sentinel).
2552
+ # Exit 0 iff `candidate` is STRICTLY greater than `floor`; 1
2553
+ # otherwise -- including equal, which is the case that matters.
2554
+ # Parseability is checked SEPARATELY and reported as exit 2, never
2555
+ # folded into the exit-1 "not greater" answer. `semver_greater` is
2556
+ # non-raising by this module's mechanism-function invariant, so an
2557
+ # unparseable version returns False -- which is fail-CLOSED for the
2558
+ # floor check (a garbage version cannot clear the floor) but is
2559
+ # indistinguishable from a genuine "not greater". Conflating "I
2560
+ # compared them and the answer is no" with "I could not compare
2561
+ # them" is the exact defect class this lane's exit-3-vs-4 work
2562
+ # already fixed once; it is not reintroduced here.
2563
+ for value in rest:
2564
+ if semver_key(value) is None:
2565
+ sys.stderr.write(
2566
+ f"semver-greater: not valid SemVer: {value!r} -- this is "
2567
+ "NOT the same answer as 'not greater' (exit 1); the "
2568
+ "comparison did not happen\n")
2569
+ return 2
2570
+ return 0 if semver_greater(rest[0], rest[1]) else 1
2571
+
2572
+ if cmd == "apply-bump" and len(rest) == 2:
2573
+ # #585 MEDIUM-3: the one number that mattered was the only step
2574
+ # left to the eye. Step 4 had the operator "apply the step-2 bump
2575
+ # to `$BASE_VERSION`" by hand -- none of the other fifteen
2576
+ # subcommands does this arithmetic, and nothing downstream
2577
+ # re-derives it (a minor window mis-applied as a patch still passes
2578
+ # `semver-greater` and `check-manifests`, because both compare
2579
+ # against the same wrong value this step just wrote). This is the
2580
+ # sanctioned way to do it instead: exit 0 with the bumped version
2581
+ # on stdout, or exit 2 with nothing to stdout when `base` is not
2582
+ # plain SemVer or `word` is not exactly one of `major`/`minor`/
2583
+ # `patch` -- `none` included, deliberately: a caller must never
2584
+ # apply a non-bump, and this refuses rather than echoing `base`
2585
+ # back unchanged.
2586
+ base, word = rest
2587
+ result = apply_bump(base, word)
2588
+ if result is None:
2589
+ sys.stderr.write(
2590
+ f"apply-bump: cannot apply bump {word!r} to base {base!r}. "
2591
+ "`base` must be plain MAJOR.MINOR.PATCH SemVer (no "
2592
+ "pre-release or build metadata) and `word` must be exactly "
2593
+ "'major', 'minor', or 'patch' -- 'none' is deliberately "
2594
+ "refused here, since a caller must never apply a non-bump.\n")
2595
+ return 2
2596
+ print(result)
2597
+ return 0
2598
+
2599
+ if cmd == "dates-match" and len(rest) == 2:
2600
+ # MEDIUM (adversarial review 2026-07-31, run 4): Phase 1 step 5 and
2601
+ # Phase 2 step 1 both name `release_dates_consistent`, and Phase 2
2602
+ # says it "must pass" -- but this CLI exposed no way to run it, so
2603
+ # an operator following the prose could not perform a check the
2604
+ # prose demanded. The exercising agent could only reach it by
2605
+ # importing the module, which the skill never tells anyone to do.
2606
+ # Same read-and-compare shape as `notes-match` above, including its
2607
+ # unreadable-file-is-empty-text behaviour (an unreadable file has
2608
+ # no date, so the comparison is False, so the exit code is 1 --
2609
+ # never a traceback).
2610
+ texts = []
2611
+ for path in rest:
2612
+ try:
2613
+ with open(path, encoding="utf-8") as fh:
2614
+ texts.append(fh.read())
2615
+ except OSError:
2616
+ texts.append("")
2617
+ return 0 if release_dates_consistent(texts[0], texts[1]) else 1
2618
+
2619
+ if cmd == "classify" and len(rest) == 6:
2620
+ as_bool = lambda s: str(s).lower() == "true"
2621
+ print(classify_publish_state(
2622
+ tag_exists=as_bool(rest[0]), tag_sha=rest[1], head_sha=rest[2],
2623
+ tag_version=rest[3], manifest_version=rest[4],
2624
+ release_is_nondraft=as_bool(rest[5])))
2625
+ return 0
2626
+
2627
+ if cmd == "peel-tag" and len(rest) == 1:
2628
+ # HIGH-1 (adversarial review 2026-07-31): this subcommand did not
2629
+ # exist in this module's own CLI before this fix, even though
2630
+ # `peel_tag` was already public API and `.github/scripts/
2631
+ # _releaselib.py` (this repo's OWN, non-portable CI shim) already
2632
+ # exposed it. A consumer shelling out to the VENDORED copy of THIS
2633
+ # file had no sanctioned way to peel an annotated tag to its commit
2634
+ # at all, which left `git rev-parse <tag>` as the only thing a
2635
+ # reader would reach for -- exactly the value that misclassifies a
2636
+ # healthy tag (see the docstring above and the release skill's
2637
+ # Phase 2 step 1).
2638
+ print(peel_tag(sys.stdin.read(), rest[0]))
2639
+ return 0
2640
+
2641
+ if cmd == "backfill-detect" and len(rest) <= 1:
2642
+ root = rest[0] if rest else default_backfill_root()
2643
+ manifests, changelogs = scan_backfill_candidates(root)
2644
+ try:
2645
+ row = detect_candidate_target(manifests, changelogs)
2646
+ except BackfillAmbiguousError as exc:
2647
+ sys.stderr.write(f"{exc}\n")
2648
+ return 1
2649
+ sys.stdout.write(format_release_targets_block(row))
2650
+ return 0
2651
+
2652
+ sys.stderr.write(f"_releaselib.py: bad invocation: {' '.join(argv)}\n")
2653
+ return 2
2654
+
2655
+
2656
+ if __name__ == "__main__":
2657
+ sys.exit(main(sys.argv[1:]))