@arbiterforge/ca-pi 0.10.12 → 0.14.6

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 (58) hide show
  1. package/README.md +6 -5
  2. package/package.json +2 -1
  3. package/plugins/ca-pi/CHANGELOG.md +165 -0
  4. package/plugins/ca-pi/SKILLS.md +1 -1
  5. package/plugins/ca-pi/SPRINT.md +40 -10
  6. package/plugins/ca-pi/agents/backend-author.md +1 -1
  7. package/plugins/ca-pi/agents/frontend-author.md +1 -1
  8. package/plugins/ca-pi/generated/command-catalog.json +1 -1
  9. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
  10. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
  11. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
  12. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
  13. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
  14. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
  15. package/plugins/ca-pi/helpers/artifacts/release.json +38 -0
  16. package/plugins/ca-pi/hooks/_approvallib.py +355 -0
  17. package/plugins/ca-pi/hooks/_artifactlib.py +855 -0
  18. package/plugins/ca-pi/hooks/_cleanuplib.py +1553 -0
  19. package/plugins/ca-pi/hooks/_hooklib.py +37 -13
  20. package/plugins/ca-pi/hooks/_host.py +1 -1
  21. package/plugins/ca-pi/hooks/_intentlib.py +31 -0
  22. package/plugins/ca-pi/hooks/_planfilelib.py +34 -1
  23. package/plugins/ca-pi/hooks/_protectedstatelib.py +11 -1
  24. package/plugins/ca-pi/hooks/_readinjectlib.py +120 -0
  25. package/plugins/ca-pi/hooks/_releaselib.py +933 -85
  26. package/plugins/ca-pi/hooks/_standuplib.py +38 -10
  27. package/plugins/ca-pi/hooks/doctor.py +103 -0
  28. package/plugins/ca-pi/hooks/hostapi.py +1 -1
  29. package/plugins/ca-pi/hooks/pi-bridge.py +5 -1
  30. package/plugins/ca-pi/hooks/prompt-submit.py +33 -10
  31. package/plugins/ca-pi/hooks/releasehash.py +46 -32
  32. package/plugins/ca-pi/hooks/session-start.py +5 -4
  33. package/plugins/ca-pi/includes/artifacts.md +166 -0
  34. package/plugins/ca-pi/includes/author-tdd-workflow.md +2 -1
  35. package/plugins/ca-pi/includes/farm.md +27 -4
  36. package/plugins/ca-pi/includes/routing-table.md +2 -2
  37. package/plugins/ca-pi/includes/verification-boundary.md +27 -0
  38. package/plugins/ca-pi/package.json +18 -0
  39. package/plugins/ca-pi/routines/INDEX.md +4 -4
  40. package/plugins/ca-pi/routines/brainstorming/SKILL.md +23 -4
  41. package/plugins/ca-pi/routines/commit-gate/SKILL.md +30 -5
  42. package/plugins/ca-pi/routines/executing-plans/SKILL.md +52 -7
  43. package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +25 -3
  44. package/plugins/ca-pi/routines/refactor/SKILL.md +3 -2
  45. package/plugins/ca-pi/routines/release/SKILL.md +150 -44
  46. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +53 -9
  47. package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +20 -3
  48. package/plugins/ca-pi/routines/tdd/SKILL.md +27 -5
  49. package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +11 -2
  50. package/plugins/ca-pi/routines/writing-plans/SKILL.md +39 -10
  51. package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +34 -13
  52. package/plugins/ca-pi/skills/ca-chore/SKILL.md +7 -3
  53. package/plugins/ca-pi/skills/ca-commit/SKILL.md +3 -2
  54. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +14 -0
  55. package/plugins/ca-pi/skills/ca-feature/SKILL.md +42 -8
  56. package/plugins/ca-pi/skills/ca-refactor/SKILL.md +4 -2
  57. package/plugins/ca-pi/skills/ca-release/SKILL.md +7 -6
  58. package/plugins/ca-pi/skills/ca-standup/SKILL.md +36 -8
@@ -37,9 +37,18 @@
37
37
  # semver_greater(current, base) -> bool
38
38
  # apply_bump(base, word) -> str | None
39
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
- # changelog_section(changelog_text, version) -> str | None
40
+ # numeric_sequence_key(value, initial_version) -> tuple | None
41
+ # version_greater(current, base, policy, initial_version) -> bool
42
+ # derive_version(base, word, policy, initial_version) -> str | None
43
+ # last_tag_select_for_policy(tags, prefix, policy, initial_version) -> str
44
+ # notes_heading_matches(notes_text, tag, policy, initial_version) -> bool
45
+ # release_dates_consistent(changelog_section, tag_message, policy,
46
+ # initial_version) -> bool
47
+ # changelog_section(changelog_text, version, policy, initial_version)
48
+ # -> str | None
49
+ # render_release_assets(templates, version, tag) -> list[str] | None
50
+ # verify_release_asset_inventory(directory, asset_names)
51
+ # -> list[str] | None
43
52
  # classify_publish_state(tag_exists, tag_sha, head_sha, tag_version,
44
53
  # manifest_version, release_is_nondraft) -> str
45
54
  # select_release_target_by_name(pairs, targets) -> str name-keyed
@@ -52,11 +61,13 @@
52
61
  # -> list[str] paths filtered to payload, minus
53
62
  # any payload-exclude entry (A-3.4)
54
63
  # provenance_trigger_paths(rows) -> list[str] every manifest/changelog/
55
- # generated_manifest/artifacts path a declared row
56
- # references, sorted and de-duplicated (A-5.6)
64
+ # generated_manifest/artifacts/reconciliation path a
65
+ # declared row references, sorted and de-duplicated
66
+ # (A-5.6)
57
67
  # _manifest_version(path) -> str | None
58
68
  # classify_commit(subject, body) -> dict
59
- # classify_window(commits) -> dict
69
+ # classify_window(commits, reconciliations=None, published_shas=None) -> dict
70
+ # parse_changelog_reconciliations(text, target) -> dict[str, str]
60
71
  # parse_window_log(text) -> list[dict]
61
72
  # first_release_baseline(adoption_log_text) -> str
62
73
  # peel_tag(ls_remote_text, tag) -> str
@@ -92,6 +103,8 @@
92
103
  # MultipleBlocksError — more than one delimiter block in the file
93
104
  # ValueTooLongError — a declared value exceeds VALUE_MAX_CHARS
94
105
  # (A-2.4); a sibling declared-file error, exits 4
106
+ # ChangelogReconciliationError — a declared reconciliation ledger is
107
+ # missing, unreadable, unsafe, or malformed
95
108
  # DelimiterInValueError — a value contains the literal closing delimiter,
96
109
  # which would otherwise truncate the block under a
97
110
  # naive non-greedy match
@@ -124,7 +137,9 @@
124
137
  from __future__ import annotations
125
138
 
126
139
  import hashlib
140
+ import json
127
141
  import os
142
+ import posixpath
128
143
  import re
129
144
  import shlex
130
145
  import shutil
@@ -251,6 +266,15 @@ class ValueTooLongError(ReleaseTargetsError):
251
266
  genuinely-absent state that triggers the Back-fill lane."""
252
267
 
253
268
 
269
+ class ChangelogReconciliationError(ReleaseTargetsError):
270
+ """A declared changelog-reconciliation ledger is malformed.
271
+
272
+ Reconciliations are release policy, not best-effort input. A malformed
273
+ record must therefore fail closed like the release-target declaration
274
+ that points at it.
275
+ """
276
+
277
+
254
278
  class DelimiterInValueError(ReleaseTargetsError):
255
279
  """A declared value contains the literal closing-delimiter text, which
256
280
  would otherwise silently truncate the block under a naive non-greedy
@@ -291,6 +315,92 @@ _PLAIN_SEMVER_CAPTURE_PATTERN = (
291
315
  r"(" + _SEMVER_NUMERIC_IDENTIFIER + r")\." +
292
316
  r"(" + _SEMVER_NUMERIC_IDENTIFIER + r")")
293
317
 
318
+ _RELEASE_ASSET_TEMPLATE_RE = re.compile(
319
+ r"^(?:[A-Za-z0-9._+-]|\{version\}|\{tag\})+$")
320
+ _RELEASE_ASSET_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$")
321
+ _WINDOWS_RESERVED_ASSET_BASENAMES = frozenset(
322
+ {"con", "prn", "aux", "nul"}
323
+ | {f"com{index}" for index in range(1, 10)}
324
+ | {f"lpt{index}" for index in range(1, 10)})
325
+
326
+
327
+ def _safe_release_asset_name(value):
328
+ """True only for one flat, shell-inert release filename."""
329
+ if (not isinstance(value, str)
330
+ or _RELEASE_ASSET_NAME_RE.fullmatch(value) is None
331
+ or value.endswith(".")):
332
+ return False
333
+ basename = value.split(".", 1)[0].casefold()
334
+ return basename not in _WINDOWS_RESERVED_ASSET_BASENAMES
335
+
336
+
337
+ def render_release_assets(templates, version, tag):
338
+ """Render a declared asset-template sequence, or refuse with ``None``.
339
+
340
+ The grammar is intentionally smaller than ``str.format``: only the two
341
+ exact placeholders are recognized, all literal characters are from the
342
+ flat filename allowlist, and the rendered names must remain unique.
343
+ """
344
+ if (not isinstance(templates, (list, tuple)) or not templates
345
+ or not isinstance(version, str) or not isinstance(tag, str)):
346
+ return None
347
+ rendered = []
348
+ seen = set()
349
+ for template in templates:
350
+ if (not isinstance(template, str)
351
+ or _RELEASE_ASSET_TEMPLATE_RE.fullmatch(template) is None):
352
+ return None
353
+ name = template.replace("{version}", version).replace("{tag}", tag)
354
+ identity = name.casefold()
355
+ if not _safe_release_asset_name(name) or identity in seen:
356
+ return None
357
+ seen.add(identity)
358
+ rendered.append(name)
359
+ return rendered
360
+
361
+
362
+ def verify_release_asset_inventory(directory, asset_names):
363
+ """Return declared-order absolute paths iff ``directory`` is exact.
364
+
365
+ The directory itself may not be a symlink. Every entry must be one of the
366
+ declared safe names and a non-symlink, non-empty regular file; missing or
367
+ additional entries fail closed with ``None``.
368
+ """
369
+ if (not isinstance(directory, (str, os.PathLike))
370
+ or not isinstance(asset_names, (list, tuple)) or not asset_names):
371
+ return None
372
+ names = list(asset_names)
373
+ identities = [name.casefold() for name in names
374
+ if isinstance(name, str)]
375
+ if (any(not _safe_release_asset_name(name) for name in names)
376
+ or len(identities) != len(names)
377
+ or len(set(identities)) != len(identities)):
378
+ return None
379
+ root = os.path.abspath(os.fspath(directory))
380
+ try:
381
+ if os.path.islink(root) or not os.path.isdir(root):
382
+ return None
383
+ entries = list(os.scandir(root))
384
+ except OSError:
385
+ return None
386
+ entry_names = [entry.name for entry in entries]
387
+ entry_identities = [name.casefold() for name in entry_names]
388
+ if (len(set(entry_identities)) != len(entry_identities)
389
+ or set(entry_identities) != set(identities)
390
+ or set(entry_names) != set(names)):
391
+ return None
392
+ by_name = {entry.name: entry for entry in entries}
393
+ try:
394
+ for name in names:
395
+ entry = by_name[name]
396
+ if (entry.is_symlink()
397
+ or not entry.is_file(follow_symlinks=False)
398
+ or entry.stat(follow_symlinks=False).st_size <= 0):
399
+ return None
400
+ except OSError:
401
+ return None
402
+ return [os.path.join(root, name) for name in names]
403
+
294
404
 
295
405
  def _release_re(prefix):
296
406
  """The anchored `<prefix>MAJOR.MINOR.PATCH` matcher for one release series."""
@@ -313,6 +423,10 @@ _HEADING_RE = re.compile(
313
423
  r"^##[ \t]+(?:\[(Unreleased|" + _PLAIN_SEMVER_PATTERN + r")\]|"
314
424
  r"v(" + _PLAIN_SEMVER_PATTERN + r"))(?:[ \t]+[^\r\n]*)?[ \t]*$",
315
425
  re.MULTILINE)
426
+ _DECLARED_HEADING_RE = re.compile(
427
+ r"^##[ \t]+(?:\[(Unreleased|([^\]\r\n]+))\]|v([^ \t\r\n]+))"
428
+ r"(?:[ \t]+[^\r\n]*)?[ \t]*$",
429
+ re.MULTILINE)
316
430
  _LEGACY_DATE_H2_RE = re.compile(
317
431
  r"^##[ \t]+\[[0-9]{4}-[0-9]{2}-[0-9]{2}\]"
318
432
  r"(?:[ \t]+[^\r\n]*)?[ \t]*$")
@@ -326,12 +440,41 @@ _SECTION_INVALID = "invalid"
326
440
  _SECTION_DUPLICATE = "duplicate"
327
441
 
328
442
 
329
- def _heading_version(line):
443
+ def _version_matches_policy(value, policy="semver", initial_version=None):
444
+ """True when `value` has the exact shape declared by `policy`."""
445
+ if policy == "semver" and initial_version is None:
446
+ return (isinstance(value, str)
447
+ and _BARE_RELEASE_VERSION_RE.fullmatch(value) is not None)
448
+ if policy == "numeric-sequence":
449
+ return numeric_sequence_key(value, initial_version) is not None
450
+ return False
451
+
452
+
453
+ def _policy_declaration_valid(policy="semver", initial_version=None):
454
+ """True for one complete, supported version-policy declaration."""
455
+ if policy == "semver":
456
+ return initial_version is None
457
+ return (policy == "numeric-sequence"
458
+ and numeric_sequence_key(
459
+ initial_version, initial_version) is not None)
460
+
461
+
462
+ def _heading_version(line, policy="semver", initial_version=None):
330
463
  """Bare version / `Unreleased` for one exact heading line, else None."""
331
- match = _HEADING_RE.fullmatch(line)
464
+ if policy == "semver" and initial_version is None:
465
+ match = _HEADING_RE.fullmatch(line)
466
+ if match is None:
467
+ return None
468
+ return match.group(1) or match.group(2)
469
+
470
+ match = _DECLARED_HEADING_RE.fullmatch(line)
332
471
  if match is None:
333
472
  return None
334
- return match.group(1) or match.group(2)
473
+ if match.group(1) == "Unreleased":
474
+ return "Unreleased"
475
+ version = match.group(2) or match.group(3)
476
+ return version if _version_matches_policy(
477
+ version, policy, initial_version) else None
335
478
 
336
479
 
337
480
  def _looks_like_changelog_heading(line):
@@ -345,11 +488,12 @@ def _looks_like_changelog_heading(line):
345
488
  return body.startswith("[") or body.startswith("v")
346
489
 
347
490
 
348
- def _changelog_section_result(changelog_text, version):
491
+ def _changelog_section_result(changelog_text, version, policy="semver",
492
+ initial_version=None):
349
493
  """Return `(section, status)` without raising on malformed changelogs."""
350
494
  if (not isinstance(changelog_text, str)
351
495
  or not isinstance(version, str)
352
- or _BARE_RELEASE_VERSION_RE.fullmatch(version) is None):
496
+ or not _version_matches_policy(version, policy, initial_version)):
353
497
  return None, _SECTION_INVALID
354
498
 
355
499
  boundaries = list(_H2_RE.finditer(changelog_text))
@@ -359,7 +503,7 @@ def _changelog_section_result(changelog_text, version):
359
503
 
360
504
  for index, boundary in enumerate(boundaries):
361
505
  line = boundary.group(0)
362
- heading_version = _heading_version(line)
506
+ heading_version = _heading_version(line, policy, initial_version)
363
507
  if heading_version is None:
364
508
  if _looks_like_changelog_heading(line):
365
509
  return None, _SECTION_INVALID
@@ -450,7 +594,7 @@ def semver_greater(current, base):
450
594
  return current_pre > base_pre
451
595
 
452
596
 
453
- def _bare_version(tag):
597
+ def _bare_version(tag, policy="semver", initial_version=None):
454
598
  """`v2.6.0` / `[2.6.0]` / `2.6.0` / `myapp-v0.1.31` -> the bare SemVer.
455
599
 
456
600
  Lets the heading match compare a tag against a bracket-style changelog
@@ -465,6 +609,19 @@ def _bare_version(tag):
465
609
  if not isinstance(tag, str):
466
610
  return tag
467
611
  text = tag.strip().strip("[]")
612
+ if policy != "semver" or initial_version is not None:
613
+ initial = _numeric_sequence_components(initial_version)
614
+ if policy != "numeric-sequence" or initial is None:
615
+ return None
616
+ component = r"(?:0|[1-9][0-9]*)"
617
+ pattern = r"(?<![0-9.])(" + r"\.".join(
618
+ [component] * len(initial)) + r")$"
619
+ match = re.search(pattern, text)
620
+ if match is None:
621
+ return None
622
+ candidate = match.group(1)
623
+ return candidate if numeric_sequence_key(
624
+ candidate, initial_version) is not None else None
468
625
  match = re.search(
469
626
  r"(?<![0-9])(" + _PLAIN_SEMVER_PATTERN + r".*)$", text)
470
627
  return match.group(1) if match else text.lstrip("v")
@@ -512,36 +669,109 @@ def last_tag_select(tags, prefix):
512
669
  NONE_SENTINEL = "<none>"
513
670
 
514
671
 
515
- def notes_heading_matches(notes_text, tag):
516
- """True iff the FIRST changelog heading in `notes_text` (either `## vX.Y.Z`
517
- or the Keep-a-Changelog `## [X.Y.Z]` form) names the same version as
518
- `tag`. A stale notes-file (whose first section is an older version)
519
- returns False, so a release lane cannot publish the wrong changelog
520
- section under the right tag. Missing heading or non-string input ->
521
- False."""
672
+ _NUMERIC_SEQUENCE_COMPONENT_RE = re.compile(r"^(?:0|[1-9][0-9]*)$")
673
+
674
+
675
+ def _numeric_sequence_components(value):
676
+ """Canonical dotted numeric components, or None for malformed input."""
677
+ if not isinstance(value, str):
678
+ return None
679
+ parts = value.split(".")
680
+ if len(parts) < 2 or any(
681
+ _NUMERIC_SEQUENCE_COMPONENT_RE.fullmatch(part) is None
682
+ for part in parts):
683
+ return None
684
+ try:
685
+ return tuple(int(part) for part in parts)
686
+ except (ValueError, TypeError):
687
+ return None
688
+
689
+
690
+ def numeric_sequence_key(value, initial_version):
691
+ """Sortable fixed-shape numeric identity, or None when incompatible.
692
+
693
+ `initial_version` declares the component count for the series. Both the
694
+ initial identity and candidate use canonical decimal spelling: no signs,
695
+ empty components, leading zeroes, or non-ASCII digits.
696
+ """
697
+ initial = _numeric_sequence_components(initial_version)
698
+ candidate = _numeric_sequence_components(value)
699
+ if initial is None or candidate is None or len(candidate) != len(initial):
700
+ return None
701
+ return candidate
702
+
703
+
704
+ def version_greater(current, base, policy="semver", initial_version=None):
705
+ """True iff `current` strictly advances `base` under `policy`."""
706
+ if policy == "semver" and initial_version is None:
707
+ return semver_greater(current, base)
708
+ if policy != "numeric-sequence":
709
+ return False
710
+ current_key = numeric_sequence_key(current, initial_version)
711
+ base_key = numeric_sequence_key(base, initial_version)
712
+ initial_key = numeric_sequence_key(initial_version, initial_version)
713
+ if current_key is None or base_key is None or initial_key is None:
714
+ return False
715
+ return current_key >= initial_key and current_key > base_key
716
+
717
+
718
+ def last_tag_select_for_policy(tags, prefix, policy="semver", initial_version=None):
719
+ """Highest tag in one series under a declared version policy."""
720
+ if policy == "semver" and initial_version is None:
721
+ return last_tag_select(tags, prefix)
722
+ if (policy != "numeric-sequence" or not isinstance(tags, (list, tuple))
723
+ or not isinstance(prefix, str) or not prefix
724
+ or numeric_sequence_key(initial_version, initial_version) is None):
725
+ return NONE_SENTINEL
726
+
727
+ best = None
728
+ for tag in tags:
729
+ if not isinstance(tag, str) or not tag.startswith(prefix):
730
+ continue
731
+ key = numeric_sequence_key(tag[len(prefix):], initial_version)
732
+ if key is None:
733
+ continue
734
+ if best is None or key > best[0]:
735
+ best = (key, tag)
736
+ return best[1] if best else NONE_SENTINEL
737
+
738
+
739
+ def notes_heading_matches(notes_text, tag, policy="semver", initial_version=None):
740
+ """True iff the FIRST changelog heading names `tag` under `policy`.
741
+
742
+ Both `## vVERSION` and Keep-a-Changelog `## [VERSION]` spellings are
743
+ accepted. A stale notes-file returns False, so a release lane cannot
744
+ publish the wrong changelog section under the right tag. Missing heading,
745
+ malformed policy input, or non-string input -> False.
746
+ """
522
747
  if not isinstance(notes_text, str) or not isinstance(tag, str):
523
748
  return False
524
749
  headings = list(_H2_RE.finditer(notes_text))
525
750
  if not headings:
526
751
  return False
527
- version = _bare_version(tag)
528
- first_version = _heading_version(headings[0].group(0))
752
+ version = _bare_version(tag, policy, initial_version)
753
+ first_version = _heading_version(
754
+ headings[0].group(0), policy, initial_version)
529
755
  if first_version != version or first_version == "Unreleased":
530
756
  return False
531
- _section, status = _changelog_section_result(notes_text, version)
757
+ _section, status = _changelog_section_result(
758
+ notes_text, version, policy, initial_version)
532
759
  return status == _SECTION_OK
533
760
 
534
761
 
535
- def release_dates_consistent(changelog_section, tag_message):
536
- """True iff the date in `changelog_section`'s heading (`## vX.Y.Z - DATE`
537
- or `## [X.Y.Z] - DATE`) equals the `Released-at: DATE` date in
538
- `tag_message`. Guards against the date being hand-typed inconsistently
539
- across surfaces. Either date absent, or non-string input -> False."""
762
+ def release_dates_consistent(changelog_section, tag_message, policy="semver",
763
+ initial_version=None):
764
+ """True iff the changelog and `Released-at` dates agree under `policy`.
765
+
766
+ Guards against the date being hand-typed inconsistently across surfaces.
767
+ Either date absent, malformed policy input, or non-string input -> False.
768
+ """
540
769
  if not isinstance(changelog_section, str) or not isinstance(tag_message, str):
541
770
  return False
542
- heading = _HEADING_RE.match(changelog_section)
771
+ heading = _H2_RE.match(changelog_section)
543
772
  if (heading is None or heading.start() != 0
544
- or _heading_version(heading.group(0)) == "Unreleased"):
773
+ or _heading_version(
774
+ heading.group(0), policy, initial_version) in (None, "Unreleased")):
545
775
  return False
546
776
  cm = _CHANGELOG_DATE_RE.search(heading.group(0))
547
777
  tm = _RELEASED_AT_RE.search(tag_message)
@@ -550,12 +780,14 @@ def release_dates_consistent(changelog_section, tag_message):
550
780
  return cm.group(1) == tm.group(1)
551
781
 
552
782
 
553
- def changelog_section(changelog_text, version):
554
- """Extract the `## [VERSION] ...` (or `## vVERSION ...`) section from
555
- `changelog_text` VERBATIM -- from its own heading line up to (but not
556
- including) the next `##` heading, or end of text. Returns the section
557
- text with exactly one trailing newline, or `None` when no heading in
558
- `changelog_text` names `version` exactly. Non-string input -> `None`.
783
+ def changelog_section(changelog_text, version, policy="semver",
784
+ initial_version=None):
785
+ """Extract the declared-policy `VERSION` section verbatim.
786
+
787
+ The section starts at `## [VERSION] ...` (or `## vVERSION ...`) and ends
788
+ before the next `##` heading, or at end of text. Returns it with exactly
789
+ one trailing newline, or `None` when no valid heading names `version`
790
+ exactly. Non-string or malformed policy input -> `None`.
559
791
 
560
792
  This is the mechanical replacement for hand-copying a changelog section:
561
793
  Phase 3's `resume_publish` path needs the SAME text Phase 1 composed,
@@ -564,7 +796,8 @@ def changelog_section(changelog_text, version):
564
796
  COMMITTED `$CHANGELOG` -- which Phase 1 step 7 commits before any tag
565
797
  exists -- is reading the one permanent home of that text, not
566
798
  re-deriving or hand-writing new notes (blind exercise run 19, HIGH-2)."""
567
- section, status = _changelog_section_result(changelog_text, version)
799
+ section, status = _changelog_section_result(
800
+ changelog_text, version, policy, initial_version)
568
801
  return section if status == _SECTION_OK else None
569
802
 
570
803
 
@@ -646,20 +879,28 @@ def _committed_changelog_text(repo_root, revision, changelog_path):
646
879
  if os.path.normcase(supplied_root) != os.path.normcase(resolved_root):
647
880
  return None, "supplied path is not the repository root"
648
881
 
649
- tag_ref = f"refs/tags/{revision}"
650
- try:
651
- tag_probe = subprocess.run(
652
- [git, "check-ref-format", tag_ref],
653
- capture_output=True, timeout=30, env=git_environment)
654
- except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
655
- return None, f"cannot validate release tag: {exc}"
656
- if tag_probe.returncode != 0:
657
- return None, "release tag name is invalid"
882
+ # Hosted qualification runs before the tag exists and therefore binds the
883
+ # changelog blob by the exact candidate commit object. Recovery/read-back
884
+ # uses the immutable tag ref. Admit only those two explicit shapes: never
885
+ # let an arbitrary revision expression cross this trust boundary.
886
+ exact_object_id = re.fullmatch(r"[0-9a-fA-F]{40,64}", revision) is not None
887
+ if exact_object_id:
888
+ revision_ref = revision
889
+ else:
890
+ revision_ref = f"refs/tags/{revision}"
891
+ try:
892
+ tag_probe = subprocess.run(
893
+ [git, "check-ref-format", revision_ref],
894
+ capture_output=True, timeout=30, env=git_environment)
895
+ except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
896
+ return None, f"cannot validate release tag: {exc}"
897
+ if tag_probe.returncode != 0:
898
+ return None, "release tag name is invalid"
658
899
 
659
900
  try:
660
901
  commit_probe = subprocess.run(
661
902
  [git, "-C", actual_root, "rev-parse", "--verify",
662
- f"{tag_ref}^{{commit}}"],
903
+ f"{revision_ref}^{{commit}}"],
663
904
  capture_output=True, text=True, encoding="utf-8", errors="replace",
664
905
  timeout=30, env=git_environment)
665
906
  except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
@@ -667,6 +908,11 @@ def _committed_changelog_text(repo_root, revision, changelog_path):
667
908
  if commit_probe.returncode != 0:
668
909
  return None, "cannot resolve committed revision"
669
910
  commit = commit_probe.stdout.strip()
911
+ if exact_object_id and revision.lower() != commit.lower():
912
+ # Reject abbreviated SHA-256 object names and raw annotated-tag object
913
+ # IDs. Hosted qualification promises the exact commit object itself,
914
+ # not merely a spelling Git can peel or prefix-expand to that commit.
915
+ return None, "candidate object is not the exact full commit id"
670
916
 
671
917
  try:
672
918
  entry_probe = subprocess.run(
@@ -1041,7 +1287,8 @@ def provenance_trigger_paths(rows):
1041
1287
  for row in rows:
1042
1288
  if not isinstance(row, dict):
1043
1289
  continue
1044
- for key in ("manifest", "changelog", "artifacts", "generated_manifest"):
1290
+ for key in ("manifest", "changelog", "artifacts", "generated_manifest",
1291
+ "changelog_reconciliations"):
1045
1292
  value = row.get(key)
1046
1293
  for item in (value if isinstance(value, list) else [value]):
1047
1294
  if isinstance(item, str) and item.strip():
@@ -1072,9 +1319,12 @@ def _manifest_version(path):
1072
1319
  return None
1073
1320
  with open(path, "rb") as fh:
1074
1321
  data = tomllib.load(fh)
1075
- value = data.get("project", {}).get("version")
1076
- if value is None:
1077
- value = data.get("tool", {}).get("poetry", {}).get("version")
1322
+ if os.path.basename(lower) == "cargo.toml":
1323
+ value = data.get("package", {}).get("version")
1324
+ else:
1325
+ value = data.get("project", {}).get("version")
1326
+ if value is None:
1327
+ value = data.get("tool", {}).get("poetry", {}).get("version")
1078
1328
  else:
1079
1329
  return None
1080
1330
  except (OSError, ValueError, AttributeError, TypeError):
@@ -1123,7 +1373,7 @@ def classify_commit(subject, body=""):
1123
1373
  }
1124
1374
 
1125
1375
 
1126
- def classify_window(commits):
1376
+ def classify_window(commits, reconciliations=None, published_shas=None):
1127
1377
  """`[{sha, subject, body}, ...]` -> the whole window's verdict:
1128
1378
  `{bump, commits: [...], missing_footer: [...]}`.
1129
1379
 
@@ -1136,9 +1386,13 @@ def classify_window(commits):
1136
1386
  A breaking commit bumps major regardless of type, so a `chore!:` is
1137
1387
  reported as bumping and IS subject to the footer rule -- a hand-rolled
1138
1388
  type-list check misses that, because `chore` is not in the bumping
1139
- list.
1389
+ list. ``reconciliations`` may supply exact-SHA changelog text only for
1390
+ SHAs independently present in ``published_shas``. It never changes the
1391
+ commit classification or bump.
1140
1392
  """
1141
1393
  rows = []
1394
+ reconciliations = reconciliations if isinstance(reconciliations, dict) else {}
1395
+ published_shas = published_shas if isinstance(published_shas, (set, frozenset)) else set()
1142
1396
  if not isinstance(commits, (list, tuple)):
1143
1397
  commits = []
1144
1398
  for entry in commits:
@@ -1147,16 +1401,97 @@ def classify_window(commits):
1147
1401
  verdict = classify_commit(entry.get("subject", ""), entry.get("body", ""))
1148
1402
  verdict["sha"] = str(entry.get("sha", ""))
1149
1403
  verdict["subject"] = str(entry.get("subject", ""))
1404
+ sha = verdict["sha"]
1405
+ reconciled = (
1406
+ re.fullmatch(r"[0-9a-f]{40}", sha) is not None
1407
+ and not verdict["has_changelog_footer"]
1408
+ and sha in published_shas
1409
+ and isinstance(reconciliations.get(sha), str)
1410
+ and bool(reconciliations[sha].strip())
1411
+ and "\n" not in reconciliations[sha]
1412
+ and "\r" not in reconciliations[sha]
1413
+ )
1414
+ verdict["footer_reconciled"] = reconciled
1415
+ verdict["changelog"] = reconciliations[sha].strip() if reconciled else ""
1150
1416
  rows.append(verdict)
1151
1417
  bump = "none"
1152
1418
  for row in rows:
1153
1419
  if _BUMP_RANK[row["bump"]] > _BUMP_RANK[bump]:
1154
1420
  bump = row["bump"]
1155
1421
  missing = [r for r in rows
1156
- if r["bump"] != "none" and not r["has_changelog_footer"]]
1422
+ if (r["bump"] != "none"
1423
+ and not r["has_changelog_footer"]
1424
+ and not r["footer_reconciled"])]
1157
1425
  return {"bump": bump, "commits": rows, "missing_footer": missing}
1158
1426
 
1159
1427
 
1428
+ def parse_changelog_reconciliations(text, target):
1429
+ """Parse a strict SHA-bound changelog-reconciliation ledger.
1430
+
1431
+ Returns only the entries for ``target`` as ``{full_sha: changelog}``, but
1432
+ validates every entry first so an unrelated malformed row cannot be
1433
+ hidden by target filtering. The ledger is deliberately narrow: it can
1434
+ supply one single-line changelog entry for an exact published commit; it
1435
+ cannot change commit classification, version policy, or release scope.
1436
+ """
1437
+ def unique_object(pairs):
1438
+ result = {}
1439
+ for key, value in pairs:
1440
+ if key in result:
1441
+ raise ChangelogReconciliationError(
1442
+ f"duplicate JSON member {key!r}")
1443
+ result[key] = value
1444
+ return result
1445
+
1446
+ try:
1447
+ data = json.loads(text, object_pairs_hook=unique_object)
1448
+ except (TypeError, ValueError) as exc:
1449
+ raise ChangelogReconciliationError(
1450
+ f"invalid JSON: {exc}") from None
1451
+ if not isinstance(data, dict) or set(data) != {"schema_version", "entries"}:
1452
+ raise ChangelogReconciliationError(
1453
+ "top level must contain exactly schema_version and entries")
1454
+ if type(data["schema_version"]) is not int or data["schema_version"] != 1:
1455
+ raise ChangelogReconciliationError("schema_version must be integer 1")
1456
+ if not isinstance(data["entries"], list):
1457
+ raise ChangelogReconciliationError("entries must be a list")
1458
+ if len(data["entries"]) > 1024:
1459
+ raise ChangelogReconciliationError("entries exceeds the 1024-record limit")
1460
+
1461
+ required = {"target", "commit_sha", "changelog", "reason", "authorization"}
1462
+ seen = set()
1463
+ selected = {}
1464
+ for index, entry in enumerate(data["entries"]):
1465
+ if not isinstance(entry, dict) or set(entry) != required:
1466
+ raise ChangelogReconciliationError(
1467
+ f"entry {index} must contain exactly {', '.join(sorted(required))}")
1468
+ values = {}
1469
+ for key in required:
1470
+ value = entry[key]
1471
+ if (not isinstance(value, str) or value != value.strip()
1472
+ or not value or len(value) > 4096
1473
+ or "\r" in value or "\n" in value):
1474
+ raise ChangelogReconciliationError(
1475
+ f"entry {index} field {key!r} must be a non-empty "
1476
+ "single-line string of at most 4096 characters")
1477
+ values[key] = value
1478
+ if re.fullmatch(r"[A-Za-z0-9._-]+", values["target"]) is None:
1479
+ raise ChangelogReconciliationError(
1480
+ f"entry {index} target is not a valid release target name")
1481
+ if re.fullmatch(r"[0-9a-f]{40}", values["commit_sha"]) is None:
1482
+ raise ChangelogReconciliationError(
1483
+ f"entry {index} commit_sha must be a full lowercase Git SHA")
1484
+ identity = (values["target"], values["commit_sha"])
1485
+ if identity in seen:
1486
+ raise ChangelogReconciliationError(
1487
+ f"duplicate reconciliation for {values['target']} "
1488
+ f"{values['commit_sha']}")
1489
+ seen.add(identity)
1490
+ if values["target"] == target:
1491
+ selected[values["commit_sha"]] = values["changelog"]
1492
+ return selected
1493
+
1494
+
1160
1495
  def parse_window_log(text):
1161
1496
  """`git log --pretty=format:%H%n%s%n%b%n----` output -> the
1162
1497
  `[{sha, subject, body}]` shape `classify_window` consumes.
@@ -1287,6 +1622,30 @@ def apply_bump(base, word):
1287
1622
  return f"{major}.{minor}.{patch + 1}"
1288
1623
 
1289
1624
 
1625
+ def derive_version(base, word, policy="semver", initial_version=None):
1626
+ """Derive one release identity under `policy`, or None on invalid input.
1627
+
1628
+ Existing SemVer arithmetic delegates unchanged to `apply_bump`. A numeric
1629
+ sequence emits its declared initial identity for an empty series and then
1630
+ increments only the final fixed-shape component for every bumping
1631
+ Conventional Commit classification. `none` is refused for both policies.
1632
+ """
1633
+ if policy == "semver" and initial_version is None:
1634
+ return apply_bump(base, word)
1635
+ if policy != "numeric-sequence" or word not in _BUMP_WORDS:
1636
+ return None
1637
+ initial_key = numeric_sequence_key(initial_version, initial_version)
1638
+ if initial_key is None:
1639
+ return None
1640
+ if base == NONE_SENTINEL:
1641
+ return initial_version
1642
+ base_key = numeric_sequence_key(base, initial_version)
1643
+ if base_key is None or base_key < initial_key:
1644
+ return None
1645
+ next_key = base_key[:-1] + (base_key[-1] + 1,)
1646
+ return ".".join(str(part) for part in next_key)
1647
+
1648
+
1290
1649
  # Exit codes a POSIX shell uses to report that a command's INTERPRETER OR
1291
1650
  # PROGRAM ITSELF could not be located or executed, as distinct from the
1292
1651
  # program running and reporting a failure: 127 is POSIX "command not
@@ -1399,6 +1758,7 @@ _HEADER_RE = re.compile(r"^\[([A-Za-z0-9._-]+)\]$")
1399
1758
  # List keys repeat by design and preserve declaration order.
1400
1759
  _LIST_KEYS = frozenset({
1401
1760
  "manifest", "artifacts", "pre-tag", "payload-exclude", "generated-manifest",
1761
+ "release-asset",
1402
1762
  })
1403
1763
  _BOOLEAN_KEYS = frozenset({"latest-eligible"})
1404
1764
  _REQUIRED_KEYS = ("prefix", "changelog", "payload")
@@ -1460,6 +1820,11 @@ _KEY_FIELD = {
1460
1820
  # (`codeArbiter`). Optional; the skill falls back to `$TARGET` itself
1461
1821
  # when a row declares none.
1462
1822
  "display-name": "display_name",
1823
+ "version-policy": "version_policy",
1824
+ "initial-version": "initial_version",
1825
+ "release-build": "release_build",
1826
+ "release-asset": "release_assets",
1827
+ "changelog-reconciliations": "changelog_reconciliations",
1463
1828
  }
1464
1829
 
1465
1830
 
@@ -1479,6 +1844,11 @@ def _new_row(name):
1479
1844
  "generated_manifest": [],
1480
1845
  "generate": None,
1481
1846
  "display_name": None,
1847
+ "version_policy": "semver",
1848
+ "initial_version": None,
1849
+ "release_build": None,
1850
+ "release_assets": [],
1851
+ "changelog_reconciliations": None,
1482
1852
  }
1483
1853
 
1484
1854
 
@@ -1496,6 +1866,154 @@ def _finish_row(row):
1496
1866
  f"target {row['target']!r} is missing required key(s): "
1497
1867
  + ", ".join(missing)
1498
1868
  )
1869
+ policy = row["version_policy"]
1870
+ initial_version = row["initial_version"]
1871
+ if policy not in ("semver", "numeric-sequence"):
1872
+ raise MalformedBlockError(
1873
+ f"target {row['target']!r} declares unknown version-policy "
1874
+ f"{policy!r}")
1875
+ if policy == "semver" and initial_version is not None:
1876
+ raise MalformedBlockError(
1877
+ f"target {row['target']!r} declares initial-version for the "
1878
+ "semver policy")
1879
+ if (policy == "numeric-sequence"
1880
+ and numeric_sequence_key(initial_version, initial_version) is None):
1881
+ raise MalformedBlockError(
1882
+ f"target {row['target']!r} numeric-sequence policy requires an "
1883
+ "initial-version of two or more canonical dotted numeric components")
1884
+ build_declared = row["release_build"] is not None
1885
+ assets_declared = bool(row["release_assets"])
1886
+ if build_declared != assets_declared:
1887
+ raise MalformedBlockError(
1888
+ f"target {row['target']!r} must declare release-build and one "
1889
+ "or more release-asset values together")
1890
+ if build_declared:
1891
+ if not row["release_build"].strip():
1892
+ raise MalformedBlockError(
1893
+ f"target {row['target']!r} declares a blank release-build")
1894
+ representative_version = (
1895
+ initial_version if policy == "numeric-sequence" else "1.2.3")
1896
+ representative_tag = row["prefix"] + representative_version
1897
+ if render_release_assets(
1898
+ row["release_assets"], representative_version,
1899
+ representative_tag) is None:
1900
+ raise MalformedBlockError(
1901
+ f"target {row['target']!r} declares an unsafe, malformed, "
1902
+ "or duplicate release-asset template")
1903
+ reconciliations = row["changelog_reconciliations"]
1904
+ if (reconciliations is not None
1905
+ and _normalised_release_path(reconciliations) in (None, ".")):
1906
+ raise MalformedBlockError(
1907
+ f"target {row['target']!r} changelog-reconciliations must name "
1908
+ "one repository-relative file")
1909
+
1910
+
1911
+ _GOVERNANCE_SCRATCH_PATHS = (
1912
+ ".codearbiter/gate-events.log",
1913
+ ".codearbiter/.markers",
1914
+ )
1915
+
1916
+
1917
+ def _normalised_release_path(value):
1918
+ """Return a conservative repository-relative path, or None on ambiguity.
1919
+
1920
+ A path that cannot be compared safely must prevent a scratch exclusion;
1921
+ uncertainty may make a clean-tree gate noisier, never blind it.
1922
+ """
1923
+ if not isinstance(value, str):
1924
+ return None
1925
+ if value != value.strip() or any(char.isspace() for char in value):
1926
+ return None
1927
+ raw = value.replace("\\", "/")
1928
+ if (not raw or raw.startswith(('/', ':'))
1929
+ or any(char in raw for char in "*?[]")
1930
+ or re.match(r"^[A-Za-z]:", raw)):
1931
+ return None
1932
+ components = raw.split("/")
1933
+ if (raw != "."
1934
+ and any(component.endswith((".", " "))
1935
+ or re.search(r"~\d", component)
1936
+ for component in components)):
1937
+ return None
1938
+ normalised = posixpath.normpath(raw)
1939
+ if normalised == ".." or normalised.startswith("../"):
1940
+ return None
1941
+ while normalised.startswith("./"):
1942
+ normalised = normalised[2:]
1943
+ return (normalised or ".").casefold()
1944
+
1945
+
1946
+ def _release_paths_overlap(left, right):
1947
+ if left is None or right is None:
1948
+ return True
1949
+ if left == "." or right == ".":
1950
+ return True
1951
+ return (left == right
1952
+ or left.startswith(right.rstrip("/") + "/")
1953
+ or right.startswith(left.rstrip("/") + "/"))
1954
+
1955
+
1956
+ def _release_path_contains(container, path):
1957
+ if container is None or path is None:
1958
+ return False
1959
+ return container == "." or path == container or path.startswith(
1960
+ container.rstrip("/") + "/")
1961
+
1962
+
1963
+ def governance_scratch_exclusions(row):
1964
+ """Return scratch paths safe to exclude for this declared target.
1965
+
1966
+ Governance-owned scratch is normally outside a target, but an operator
1967
+ may deliberately declare it (or the whole repository) as a payload,
1968
+ manifest, changelog, generated manifest, provenance manifest, artifact,
1969
+ or release asset. Such a declaration wins: the path remains visible to
1970
+ clean-tree and pre-tag mutation gates.
1971
+ """
1972
+ surfaces = [row.get("changelog"), row.get("provenance_manifest"),
1973
+ row.get("changelog_reconciliations")]
1974
+ for field in ("manifest", "generated_manifest", "artifacts",
1975
+ "release_assets"):
1976
+ surfaces.extend(row.get(field) or [])
1977
+
1978
+ normalised_surfaces = [
1979
+ _normalised_release_path(value)
1980
+ for value in surfaces
1981
+ if value is not None and (not isinstance(value, str) or value.strip())
1982
+ ]
1983
+
1984
+ payload = _normalised_release_path(row.get("payload"))
1985
+ payload_excludes = [
1986
+ _normalised_release_path(value)
1987
+ for value in (row.get("payload_exclude") or [])
1988
+ ]
1989
+
1990
+ def overlaps(scratch):
1991
+ scratch_path = _normalised_release_path(scratch)
1992
+ for surface in normalised_surfaces:
1993
+ if _release_paths_overlap(surface, scratch_path):
1994
+ return True
1995
+ if payload is None:
1996
+ return True
1997
+ if _release_paths_overlap(payload, scratch_path):
1998
+ if not any(_release_path_contains(exclusion, scratch_path)
1999
+ for exclusion in payload_excludes):
2000
+ return True
2001
+ return False
2002
+
2003
+ return [path for path in _GOVERNANCE_SCRATCH_PATHS if not overlaps(path)]
2004
+
2005
+
2006
+ def release_tree_status(row, project_root):
2007
+ """Run the target-aware repository-wide clean-tree probe."""
2008
+ scratch_pathspecs = [
2009
+ f":(exclude,top){path}"
2010
+ for path in governance_scratch_exclusions(row)
2011
+ ]
2012
+ return subprocess.run(
2013
+ [git_executable(), "status", "--porcelain", "--", ":/",
2014
+ *scratch_pathspecs],
2015
+ capture_output=True, text=True, cwd=project_root,
2016
+ env=_sanitized_git_environment())
1499
2017
 
1500
2018
 
1501
2019
  def parse_release_targets(text):
@@ -1504,7 +2022,8 @@ def parse_release_targets(text):
1504
2022
  carrying: target, prefix, manifest (list), changelog, payload,
1505
2023
  payload_exclude (list), rebuild, artifacts (list), provenance_manifest,
1506
2024
  pre_tag (list), latest_eligible (bool), generated_manifest (list),
1507
- generate, display_name.
2025
+ generate, display_name, version_policy, initial_version, release_build,
2026
+ release_assets.
1508
2027
 
1509
2028
  Pure — no file I/O — so it is testable with synthetic input; `load_targets`
1510
2029
  is the one function that touches the filesystem.
@@ -1786,7 +2305,7 @@ def detect_candidate_target(manifest_candidates, changelog_candidates,
1786
2305
  (`scan_backfill_candidates` is the one filesystem reader, kept separate
1787
2306
  per this module's read-isolation convention). Returns a row dict shaped
1788
2307
  like one `load_targets` entry (`target`, `prefix`, `manifest`,
1789
- `changelog`, `payload`, `latest_eligible`) ONLY when exactly one manifest
2308
+ `changelog`, `payload`, `payload_exclude`, `latest_eligible`) ONLY when exactly one manifest
1790
2309
  candidate and exactly one changelog candidate were found. Raises
1791
2310
  `BackfillAmbiguousError` for every other case — zero or multiple of
1792
2311
  either — naming which side was ambiguous and what was found, so a caller
@@ -1838,6 +2357,10 @@ def detect_candidate_target(manifest_candidates, changelog_candidates,
1838
2357
  "manifest": [manifest_candidates[0]],
1839
2358
  "changelog": changelog_candidates[0],
1840
2359
  "payload": ".",
2360
+ # Back-fill is the release-only adoption lane. Its own hooks create
2361
+ # governance scratch under .codearbiter/, so a root payload without
2362
+ # this exclusion can never become clean after ordinary hook use.
2363
+ "payload_exclude": [".codearbiter/"],
1841
2364
  "latest_eligible": True,
1842
2365
  }
1843
2366
 
@@ -1863,6 +2386,8 @@ def format_release_targets_block(row):
1863
2386
  lines.append(f"manifest: {manifest}")
1864
2387
  lines.append(f"changelog: {row['changelog']}")
1865
2388
  lines.append(f"payload: {row['payload']}")
2389
+ for excluded in row.get("payload_exclude", []):
2390
+ lines.append(f"payload-exclude: {excluded}")
1866
2391
  if "latest_eligible" in row:
1867
2392
  lines.append(f"latest-eligible: {'true' if row['latest_eligible'] else 'false'}")
1868
2393
  lines.append("<!-- /release-targets -->")
@@ -1920,6 +2445,87 @@ def default_backfill_root():
1920
2445
  return os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
1921
2446
 
1922
2447
 
2448
+ def _load_declared_changelog_reconciliations(
2449
+ row, target, project_root, published_commit):
2450
+ """Load one target's ledger from the fetched default-branch commit."""
2451
+ declared = row.get("changelog_reconciliations")
2452
+ if declared is None:
2453
+ return {}
2454
+ tree_path = _normalize_git_tree_path(declared)
2455
+ if tree_path is None or _normalised_release_path(declared) == ".":
2456
+ raise ChangelogReconciliationError(
2457
+ "changelog-reconciliations must name one repository-relative file")
2458
+ root = os.path.realpath(os.path.abspath(project_root))
2459
+ try:
2460
+ git = git_executable()
2461
+ environment = _sanitized_git_environment()
2462
+ root_probe = subprocess.run(
2463
+ [git, "rev-parse", "--show-toplevel"], cwd=root,
2464
+ capture_output=True, text=True, timeout=30, env=environment)
2465
+ except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
2466
+ raise ChangelogReconciliationError(
2467
+ f"could not verify repository root: {exc}") from None
2468
+ if (root_probe.returncode != 0
2469
+ or os.path.normcase(os.path.realpath(os.path.abspath(
2470
+ root_probe.stdout.strip()))) != os.path.normcase(root)):
2471
+ raise ChangelogReconciliationError(
2472
+ "could not bind changelog-reconciliations to the repository root")
2473
+
2474
+ try:
2475
+ entry_probe = subprocess.run(
2476
+ [git, "ls-tree", "-z", published_commit, "--", tree_path], cwd=root,
2477
+ capture_output=True, timeout=30, env=environment)
2478
+ except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
2479
+ raise ChangelogReconciliationError(
2480
+ f"could not inspect committed ledger: {exc}") from None
2481
+ entries = [entry for entry in entry_probe.stdout.split(b"\0") if entry]
2482
+ if entry_probe.returncode != 0 or len(entries) != 1:
2483
+ raise ChangelogReconciliationError(
2484
+ "changelog-reconciliations must be one regular file committed on "
2485
+ "the fetched default branch")
2486
+ match = re.fullmatch(
2487
+ rb"(100644|100755) blob ([0-9a-f]{40})\t(.+)", entries[0])
2488
+ if match is None:
2489
+ raise ChangelogReconciliationError(
2490
+ "changelog-reconciliations must be a committed regular blob")
2491
+ try:
2492
+ found_path = match.group(3).decode("utf-8")
2493
+ except UnicodeError as exc:
2494
+ raise ChangelogReconciliationError(
2495
+ f"committed ledger path is not UTF-8: {exc}") from None
2496
+ if found_path != tree_path:
2497
+ raise ChangelogReconciliationError(
2498
+ "committed ledger path does not match the declaration exactly")
2499
+ object_sha = match.group(2).decode("ascii")
2500
+ try:
2501
+ size_probe = subprocess.run(
2502
+ [git, "cat-file", "-s", object_sha], cwd=root,
2503
+ capture_output=True, text=True, timeout=30, env=environment)
2504
+ except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
2505
+ raise ChangelogReconciliationError(
2506
+ f"could not inspect committed ledger size: {exc}") from None
2507
+ if size_probe.returncode != 0 or not size_probe.stdout.strip().isdigit():
2508
+ raise ChangelogReconciliationError(
2509
+ "could not verify committed ledger size")
2510
+ if int(size_probe.stdout.strip()) > 1024 * 1024:
2511
+ raise ChangelogReconciliationError("ledger exceeds the 1 MiB limit")
2512
+ try:
2513
+ blob_probe = subprocess.run(
2514
+ [git, "cat-file", "blob", object_sha], cwd=root,
2515
+ capture_output=True, timeout=30, env=environment)
2516
+ except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
2517
+ raise ChangelogReconciliationError(
2518
+ f"could not read committed ledger: {exc}") from None
2519
+ if blob_probe.returncode != 0:
2520
+ raise ChangelogReconciliationError("could not read committed ledger")
2521
+ try:
2522
+ text = blob_probe.stdout.decode("utf-8")
2523
+ except UnicodeError as exc:
2524
+ raise ChangelogReconciliationError(
2525
+ f"committed ledger is not UTF-8: {exc}") from None
2526
+ return parse_changelog_reconciliations(text, target)
2527
+
2528
+
1923
2529
  def _resolve_target_row(target, targets_file):
1924
2530
  """The declared row named `target` in `targets_file`, or `None` if no
1925
2531
  row of that name is declared. Raises `ReleaseTargetsError` (any
@@ -1999,13 +2605,19 @@ def main(argv):
1999
2605
  equality guard: `classify` short-circuits on
2000
2606
  a fresh publish and never reaches its own
2001
2607
  version comparison (run 12).
2002
- classify-window stdin = `git log $WINDOW --pretty=format:
2608
+ classify-window [<target> <default-branch>]
2609
+ stdin = `git log $WINDOW --pretty=format:
2003
2610
  %H%n%s%n%b%n---- -- $PAYLOAD` -> prints the
2004
- derived bump, then one
2611
+ derived bump, then accepted `[RECONCILED]`
2612
+ rows and one
2005
2613
  `[NEEDS-TRIAGE] <sha> <subject>` line per
2006
2614
  BUMPING commit missing a CHANGELOG: footer.
2007
2615
  exit 0 clean - 1 a footer is missing (the
2008
2616
  step-3 BLOCK) - 2 the window is non-bumping
2617
+ - 4 a declared reconciliation ledger or
2618
+ exact default-branch proof failed closed.
2619
+ The no-argument form remains the strict
2620
+ legacy classifier and accepts no ledger.
2009
2621
  (the step-2 STOP). Classifies and reports;
2010
2622
  the skill keeps the decision.
2011
2623
  adoption-commit stdin = `git log --diff-filter=A --format=%H
@@ -2114,14 +2726,24 @@ def main(argv):
2114
2726
  window subtraction plain `git log --
2115
2727
  <path>` cannot express. Same declared-file
2116
2728
  resolution as `show-row`.
2729
+ clean-tree-status <target> runs repository-wide `git status
2730
+ --porcelain` while excluding governance
2731
+ scratch only when it does not overlap any
2732
+ surface declared by the target. Prints the
2733
+ porcelain result; exit 0 on a successful
2734
+ probe even when dirty, 2 for bad invocation
2735
+ or unknown target, 3/4 for declared-file
2736
+ states, and git's non-zero status on probe
2737
+ failure.
2117
2738
 
2118
2739
  `--targets-file PATH` overrides `default_targets_path()` for `tag-prefix`
2119
- and `list-targets` only; `show-row` and `payload-pathspec` always resolve
2120
- the declared file through `default_targets_path()` with no override, and
2121
- every remaining subcommand needs no declared file at all. `tag-prefix`,
2122
- `list-targets`, `show-row`, and `payload-pathspec` all exit 3 when the
2123
- declared file is genuinely absent and 4 for every other declared-file
2124
- error (HIGH-1, `_targets_error_exit_code`); every other subcommand
2740
+ and `list-targets` only; `show-row`, `payload-pathspec`, and
2741
+ `clean-tree-status` always resolve the declared file through
2742
+ `default_targets_path()` with no override, and every remaining subcommand
2743
+ needs no declared file at all. `tag-prefix`, `list-targets`, `show-row`,
2744
+ `payload-pathspec`, and `clean-tree-status` all exit 3 when the declared
2745
+ file is genuinely absent and 4 for every other declared-file error
2746
+ (HIGH-1, `_targets_error_exit_code`); every other subcommand
2125
2747
  prints a value/label and exits 0, or writes a short cause to stderr and
2126
2748
  exits non-zero — never a bare traceback, so a caller shelling out to
2127
2749
  this file gets a diagnosable failure either way. Returns a process exit
@@ -2129,8 +2751,10 @@ def main(argv):
2129
2751
  if not argv:
2130
2752
  sys.stderr.write(
2131
2753
  "usage: _releaselib.py {tag-prefix|list-targets|show-row|"
2132
- "payload-pathspec|last-tag|notes-match|changelog-section|"
2133
- "dates-match|semver-greater|apply-bump|classify|peel-tag|"
2754
+ "payload-pathspec|clean-tree-status|last-tag|last-tag-for-policy|notes-match|"
2755
+ "changelog-section|dates-match|semver-greater|version-greater|"
2756
+ "apply-bump|derive-version|render-release-assets|"
2757
+ "verify-release-assets|classify|peel-tag|"
2134
2758
  "run-pre-tag|adoption-commit|classify-window|check-manifests|"
2135
2759
  "backfill-detect} ...\n")
2136
2760
  return 2
@@ -2213,7 +2837,10 @@ def main(argv):
2213
2837
  payload = row.get("payload") or "."
2214
2838
  parts = [payload] + [f":(exclude){p}"
2215
2839
  for p in (row.get("payload_exclude") or [])]
2216
- print(" ".join(parts))
2840
+ # One record per line preserves a pathspec containing spaces.
2841
+ # The release skill loads these records into quoted positional
2842
+ # parameters; shell words cannot represent this boundary safely.
2843
+ print("\n".join(parts))
2217
2844
  return 0
2218
2845
 
2219
2846
  # Emitted as SHELL-QUOTED `NAME='value'` pairs, and named for the
@@ -2250,7 +2877,14 @@ def main(argv):
2250
2877
  ("PRE_TAG", "pre_tag"),
2251
2878
  ("PROVENANCE_MANIFEST", "provenance_manifest"),
2252
2879
  ("LATEST_ELIGIBLE", "latest_eligible"),
2880
+ ("CHANGELOG_RECONCILIATIONS", "changelog_reconciliations"),
2253
2881
  ("DISPLAY_NAME", "display_name")]
2882
+ query_fields = fields + [
2883
+ ("VERSION_POLICY", "version_policy"),
2884
+ ("INITIAL_VERSION", "initial_version"),
2885
+ ("RELEASE_BUILD", "release_build"),
2886
+ ("RELEASE_ASSETS", "release_assets"),
2887
+ ]
2254
2888
 
2255
2889
  def _flatten(value):
2256
2890
  if isinstance(value, (list, tuple)):
@@ -2261,11 +2895,11 @@ def main(argv):
2261
2895
 
2262
2896
  if field is not None:
2263
2897
  wanted = field.strip().lower().replace("-", "_")
2264
- by_key = {key: name for name, key in fields}
2898
+ by_key = {key: name for name, key in query_fields}
2265
2899
  if wanted not in by_key:
2266
2900
  sys.stderr.write(
2267
2901
  f"unknown field {field!r}; declared fields are: "
2268
- + ", ".join(key for _n, key in fields) + "\n")
2902
+ + ", ".join(key for _n, key in query_fields) + "\n")
2269
2903
  return 2
2270
2904
  print(_flatten(row.get(wanted)))
2271
2905
  return 0
@@ -2307,15 +2941,35 @@ def main(argv):
2307
2941
  print(last_tag_select(sys.stdin.read().split(), rest[0]))
2308
2942
  return 0
2309
2943
 
2310
- if cmd == "notes-match" and len(rest) == 2:
2944
+ if cmd == "last-tag-for-policy" and len(rest) == 3:
2945
+ prefix, policy, initial_version = rest
2946
+ initial = initial_version or None
2947
+ if (policy not in ("semver", "numeric-sequence")
2948
+ or (policy == "semver" and initial is not None)
2949
+ or (policy == "numeric-sequence"
2950
+ and numeric_sequence_key(initial, initial) is None)):
2951
+ sys.stderr.write("last-tag-for-policy: invalid policy declaration\n")
2952
+ return 2
2953
+ print(last_tag_select_for_policy(
2954
+ sys.stdin.read().split(), prefix, policy, initial))
2955
+ return 0
2956
+
2957
+ if cmd == "notes-match" and len(rest) in (2, 4):
2958
+ tag, notes_path = rest[:2]
2959
+ policy, initial = (rest[2], rest[3] or None) if len(rest) == 4 else (
2960
+ "semver", None)
2961
+ if not _policy_declaration_valid(policy, initial):
2962
+ sys.stderr.write("notes-match: invalid policy declaration\n")
2963
+ return 2
2311
2964
  try:
2312
- with open(rest[1], encoding="utf-8") as fh:
2965
+ with open(notes_path, encoding="utf-8") as fh:
2313
2966
  notes_text = fh.read()
2314
2967
  except OSError:
2315
2968
  notes_text = ""
2316
- return 0 if notes_heading_matches(notes_text, rest[0]) else 1
2969
+ return 0 if notes_heading_matches(
2970
+ notes_text, tag, policy, initial) else 1
2317
2971
 
2318
- if cmd == "changelog-section" and len(rest) == 4:
2972
+ if cmd == "changelog-section" and len(rest) in (4, 6):
2319
2973
  # Mechanical reconstruction of Phase 1's composed section, for
2320
2974
  # `resume_publish` -- see `changelog_section`'s docstring. Exit 0
2321
2975
  # with the section on stdout - 1 the changelog has no heading for
@@ -2325,7 +2979,13 @@ def main(argv):
2325
2979
  # `revision` is resolved to a commit hash before the blob read, so a
2326
2980
  # current HEAD move or dirty/deleted working file cannot change the
2327
2981
  # notes selected for an already-composed tag.
2328
- repo_root, revision, changelog_path, version = rest
2982
+ repo_root, revision, changelog_path, version = rest[:4]
2983
+ policy, initial = (rest[4], rest[5] or None) if len(rest) == 6 else (
2984
+ "semver", None)
2985
+ if not _policy_declaration_valid(policy, initial):
2986
+ sys.stderr.write(
2987
+ "changelog-section: invalid policy declaration\n")
2988
+ return 2
2329
2989
  changelog_text, source_error = _committed_changelog_text(
2330
2990
  repo_root, revision, changelog_path)
2331
2991
  if source_error is not None:
@@ -2333,7 +2993,8 @@ def main(argv):
2333
2993
  f"changelog-section: cannot read committed "
2334
2994
  f"{changelog_path!r}: {source_error}\n")
2335
2995
  return 3
2336
- section, status = _changelog_section_result(changelog_text, version)
2996
+ section, status = _changelog_section_result(
2997
+ changelog_text, version, policy, initial)
2337
2998
  if status == _SECTION_ABSENT:
2338
2999
  sys.stderr.write(
2339
3000
  f"changelog-section: no '## [{version}]' heading in "
@@ -2416,7 +3077,7 @@ def main(argv):
2416
3077
  return 2
2417
3078
  return 1 if mismatched else 0
2418
3079
 
2419
- if cmd == "classify-window" and not rest:
3080
+ if cmd == "classify-window" and len(rest) in (0, 2):
2420
3081
  # stdin = `git log $WINDOW --pretty=format:%H%n%s%n%b%n---- --
2421
3082
  # $PAYLOAD`. Prints the derived bump on the first line, then one
2422
3083
  # `[NEEDS-TRIAGE] <short-sha> <subject>` line per BUMPING commit
@@ -2431,8 +3092,105 @@ def main(argv):
2431
3092
  # skill keeps the BLOCK. A helper returning proceed/stop would put
2432
3093
  # a governance decision inside a library, which is the wrong side
2433
3094
  # of ADR-0010's cooperative-agent line.
2434
- window = classify_window(parse_window_log(sys.stdin.read()))
3095
+ commits = parse_window_log(sys.stdin.read())
3096
+ reconciliations = {}
3097
+ published_shas = set()
3098
+ if rest:
3099
+ target, default_branch = rest
3100
+ targets_file = default_targets_path()
3101
+ project_root = os.path.dirname(os.path.dirname(targets_file))
3102
+ try:
3103
+ row = _resolve_target_row(target, targets_file)
3104
+ if row is None:
3105
+ sys.stderr.write(f"unknown release target: {target}\n")
3106
+ return 2
3107
+ except ReleaseTargetsError as exc:
3108
+ sys.stderr.write(
3109
+ f"{type(exc).__name__}: could not load changelog "
3110
+ f"reconciliations: {exc}\n")
3111
+ return _targets_error_exit_code(exc)
3112
+
3113
+ if row.get("changelog_reconciliations") is not None:
3114
+ try:
3115
+ branch_probe = subprocess.run(
3116
+ [git_executable(), "check-ref-format", "--branch",
3117
+ default_branch], cwd=project_root, capture_output=True,
3118
+ text=True, timeout=30,
3119
+ env=_sanitized_git_environment())
3120
+ except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
3121
+ sys.stderr.write(
3122
+ f"classify-window: could not validate default branch: "
3123
+ f"{exc}\n")
3124
+ return 4
3125
+ if branch_probe.returncode != 0:
3126
+ sys.stderr.write(
3127
+ f"classify-window: invalid default branch "
3128
+ f"{default_branch!r}\n")
3129
+ return 4
3130
+ published_ref = f"refs/remotes/origin/{default_branch}"
3131
+ try:
3132
+ published_probe = subprocess.run(
3133
+ [git_executable(), "rev-parse", "--verify",
3134
+ f"{published_ref}^{{commit}}"], cwd=project_root,
3135
+ capture_output=True, text=True, timeout=30,
3136
+ env=_sanitized_git_environment())
3137
+ except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
3138
+ sys.stderr.write(
3139
+ f"classify-window: could not resolve published default "
3140
+ f"branch: {exc}\n")
3141
+ return 4
3142
+ published_commit = published_probe.stdout.strip()
3143
+ if (published_probe.returncode != 0
3144
+ or re.fullmatch(
3145
+ r"[0-9a-f]{40}", published_commit) is None):
3146
+ sys.stderr.write(
3147
+ f"classify-window: could not resolve exact published "
3148
+ f"ref {published_ref!r}\n")
3149
+ return 4
3150
+ try:
3151
+ reconciliations = _load_declared_changelog_reconciliations(
3152
+ row, target, project_root, published_commit)
3153
+ except ReleaseTargetsError as exc:
3154
+ sys.stderr.write(
3155
+ f"{type(exc).__name__}: could not load changelog "
3156
+ f"reconciliations: {exc}\n")
3157
+ return _targets_error_exit_code(exc)
3158
+
3159
+ window_shas = {
3160
+ entry.get("sha") for entry in commits
3161
+ if isinstance(entry, dict)
3162
+ }
3163
+ for sha in sorted(set(reconciliations).intersection(window_shas)):
3164
+ try:
3165
+ ancestor = subprocess.run(
3166
+ [git_executable(), "merge-base", "--is-ancestor",
3167
+ sha, published_commit],
3168
+ cwd=project_root, capture_output=True, text=True,
3169
+ timeout=30, env=_sanitized_git_environment())
3170
+ except (OSError, subprocess.TimeoutExpired) as exc:
3171
+ sys.stderr.write(
3172
+ f"classify-window: could not verify published "
3173
+ f"commit {sha}: {exc}\n")
3174
+ return 4
3175
+ if ancestor.returncode == 0:
3176
+ published_shas.add(sha)
3177
+ elif ancestor.returncode != 1:
3178
+ sys.stderr.write(
3179
+ f"classify-window: could not verify {sha} against "
3180
+ f"published ref {published_ref!r}: "
3181
+ f"{ancestor.stderr.strip()}\n")
3182
+ return 4
3183
+
3184
+ window = classify_window(
3185
+ commits,
3186
+ reconciliations=reconciliations,
3187
+ published_shas=published_shas)
2435
3188
  print(window["bump"])
3189
+ for row in window["commits"]:
3190
+ if row["footer_reconciled"]:
3191
+ print(
3192
+ f"[RECONCILED] {row['sha'][:7]} {row['subject']} :: "
3193
+ f"{row['changelog']}")
2436
3194
  for row in window["missing_footer"]:
2437
3195
  print(f"[NEEDS-TRIAGE] {row['sha'][:7]} {row['subject']}")
2438
3196
  if window["missing_footer"]:
@@ -2454,6 +3212,24 @@ def main(argv):
2454
3212
  print(baseline)
2455
3213
  return 0
2456
3214
 
3215
+ if cmd == "clean-tree-status" and len(rest) == 1:
3216
+ target = rest[0]
3217
+ try:
3218
+ rows = load_targets(default_targets_path())
3219
+ except ReleaseTargetsError as exc:
3220
+ sys.stderr.write(f"{type(exc).__name__}: {exc}\n")
3221
+ return _targets_error_exit_code(exc)
3222
+ row = next((item for item in rows if item["target"] == target), None)
3223
+ if row is None:
3224
+ sys.stderr.write(f"unknown release target: {target}\n")
3225
+ return 2
3226
+ project_root = os.path.dirname(
3227
+ os.path.dirname(default_targets_path())) or "."
3228
+ probe = release_tree_status(row, project_root)
3229
+ sys.stdout.write(probe.stdout)
3230
+ sys.stderr.write(probe.stderr)
3231
+ return probe.returncode
3232
+
2457
3233
  if cmd == "run-pre-tag" and len(rest) == 1:
2458
3234
  # A-2.1/2.2/2.3 (DECISION-0034). Runs the row's declared `pre-tag`
2459
3235
  # commands IN DECLARED ORDER, stops at the first non-zero exit, and
@@ -2587,11 +3363,7 @@ def main(argv):
2587
3363
  # delete a release gate for it. `.markers/` is exempted for the
2588
3364
  # same reason the skill exempts it: a per-machine confirmation
2589
3365
  # marker minted by this same run is not a release surface.
2590
- probe = subprocess.run(
2591
- [git_executable(), "status", "--porcelain", "--", ":/",
2592
- ":(exclude,top).codearbiter/gate-events.log",
2593
- ":(exclude,top).codearbiter/.markers/"],
2594
- capture_output=True, text=True, cwd=project_root)
3366
+ probe = release_tree_status(row, project_root)
2595
3367
  if probe.returncode != 0:
2596
3368
  return None, (probe.stderr.strip() or "git status failed")
2597
3369
  state = {}
@@ -2799,6 +3571,27 @@ def main(argv):
2799
3571
  return 2
2800
3572
  return 0 if semver_greater(rest[0], rest[1]) else 1
2801
3573
 
3574
+ if cmd == "version-greater" and len(rest) == 4:
3575
+ candidate, floor, policy, initial_version = rest
3576
+ initial = initial_version or None
3577
+ if policy == "semver" and initial is None:
3578
+ if semver_key(candidate) is None or semver_key(floor) is None:
3579
+ sys.stderr.write("version-greater: invalid semver comparison\n")
3580
+ return 2
3581
+ elif policy == "numeric-sequence":
3582
+ candidate_key = numeric_sequence_key(candidate, initial)
3583
+ floor_key = numeric_sequence_key(floor, initial)
3584
+ initial_key = numeric_sequence_key(initial, initial)
3585
+ if (candidate_key is None or floor_key is None
3586
+ or initial_key is None or candidate_key < initial_key):
3587
+ sys.stderr.write(
3588
+ "version-greater: invalid numeric-sequence comparison\n")
3589
+ return 2
3590
+ else:
3591
+ sys.stderr.write("version-greater: invalid policy declaration\n")
3592
+ return 2
3593
+ return 0 if version_greater(candidate, floor, policy, initial) else 1
3594
+
2802
3595
  if cmd == "apply-bump" and len(rest) == 2:
2803
3596
  # #585 MEDIUM-3: the one number that mattered was the only step
2804
3597
  # left to the eye. Step 4 had the operator "apply the step-2 bump
@@ -2826,7 +3619,46 @@ def main(argv):
2826
3619
  print(result)
2827
3620
  return 0
2828
3621
 
2829
- if cmd == "dates-match" and len(rest) == 2:
3622
+ if cmd == "derive-version" and len(rest) == 4:
3623
+ base, word, policy, initial_version = rest
3624
+ result = derive_version(base, word, policy, initial_version or None)
3625
+ if result is None:
3626
+ sys.stderr.write(
3627
+ f"derive-version: cannot derive from {base!r} under policy "
3628
+ f"{policy!r} with initial-version {initial_version!r}\n")
3629
+ return 2
3630
+ print(result)
3631
+ return 0
3632
+
3633
+ if cmd == "render-release-assets" and len(rest) >= 3:
3634
+ version, tag = rest[:2]
3635
+ rendered = render_release_assets(rest[2:], version, tag)
3636
+ if rendered is None:
3637
+ sys.stderr.write(
3638
+ "render-release-assets: invalid or duplicate asset template\n")
3639
+ return 2
3640
+ for name in rendered:
3641
+ print(name)
3642
+ return 0
3643
+
3644
+ if cmd == "verify-release-assets" and len(rest) >= 2:
3645
+ directory, names = rest[0], rest[1:]
3646
+ if (any(not _safe_release_asset_name(name) for name in names)
3647
+ or len(set(names)) != len(names)):
3648
+ sys.stderr.write(
3649
+ "verify-release-assets: invalid or duplicate asset name\n")
3650
+ return 2
3651
+ verified = verify_release_asset_inventory(directory, names)
3652
+ if verified is None:
3653
+ sys.stderr.write(
3654
+ "verify-release-assets: local inventory does not exactly "
3655
+ "match the declared non-empty regular files\n")
3656
+ return 1
3657
+ for path in verified:
3658
+ print(path)
3659
+ return 0
3660
+
3661
+ if cmd == "dates-match" and len(rest) in (2, 4):
2830
3662
  # MEDIUM (adversarial review 2026-07-31, run 4): Phase 1 step 5 and
2831
3663
  # Phase 2 step 1 both name `release_dates_consistent`, and Phase 2
2832
3664
  # says it "must pass" -- but this CLI exposed no way to run it, so
@@ -2837,14 +3669,21 @@ def main(argv):
2837
3669
  # unreadable-file-is-empty-text behaviour (an unreadable file has
2838
3670
  # no date, so the comparison is False, so the exit code is 1 --
2839
3671
  # never a traceback).
3672
+ paths = rest[:2]
3673
+ policy, initial = (rest[2], rest[3] or None) if len(rest) == 4 else (
3674
+ "semver", None)
3675
+ if not _policy_declaration_valid(policy, initial):
3676
+ sys.stderr.write("dates-match: invalid policy declaration\n")
3677
+ return 2
2840
3678
  texts = []
2841
- for path in rest:
3679
+ for path in paths:
2842
3680
  try:
2843
3681
  with open(path, encoding="utf-8") as fh:
2844
3682
  texts.append(fh.read())
2845
3683
  except OSError:
2846
3684
  texts.append("")
2847
- return 0 if release_dates_consistent(texts[0], texts[1]) else 1
3685
+ return 0 if release_dates_consistent(
3686
+ texts[0], texts[1], policy, initial) else 1
2848
3687
 
2849
3688
  if cmd == "classify" and len(rest) == 6:
2850
3689
  as_bool = lambda s: str(s).lower() == "true"
@@ -2884,4 +3723,13 @@ def main(argv):
2884
3723
 
2885
3724
 
2886
3725
  if __name__ == "__main__":
3726
+ # This CLI is consumed as a line-record protocol by POSIX shells on every
3727
+ # supported host. Native Windows text stdout otherwise translates each
3728
+ # ``\n`` to ``\r\n``; Git Bash's ``read -r`` removes the newline but keeps
3729
+ # the carriage return, corrupting pathspecs, target names, and asset paths.
3730
+ # Force the wire format to LF once at the executable boundary. Imported
3731
+ # library calls retain their caller-owned stream configuration.
3732
+ reconfigure = getattr(sys.stdout, "reconfigure", None)
3733
+ if callable(reconfigure):
3734
+ reconfigure(newline="\n")
2887
3735
  sys.exit(main(sys.argv[1:]))