@arbiterforge/ca-pi 0.6.3 → 0.10.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 (67) hide show
  1. package/README.md +41 -98
  2. package/package.json +1 -1
  3. package/plugins/ca-pi/CHANGELOG.md +145 -0
  4. package/plugins/ca-pi/COMMANDS.md +138 -68
  5. package/plugins/ca-pi/SKILLS.md +137 -30
  6. package/plugins/ca-pi/agents/INDEX.md +3 -2
  7. package/plugins/ca-pi/agents/checkpoint-aggregator.md +8 -7
  8. package/plugins/ca-pi/agents/design-quality-reviewer.md +1 -1
  9. package/plugins/ca-pi/agents/finding-triage.md +31 -14
  10. package/plugins/ca-pi/agents/verdict-aggregator.md +64 -0
  11. package/plugins/ca-pi/{ORCHESTRATOR.md → arbiter.md} +37 -36
  12. package/plugins/ca-pi/extensions/codearbiter.js +844 -19
  13. package/plugins/ca-pi/generated/command-catalog.json +386 -196
  14. package/plugins/ca-pi/generated/roles.json +9 -0
  15. package/plugins/ca-pi/hooks/_arbiterstatelib.py +59 -11
  16. package/plugins/ca-pi/hooks/_bashguardlib.py +30 -12
  17. package/plugins/ca-pi/hooks/_gitexec.py +23 -0
  18. package/plugins/ca-pi/hooks/_githooks.py +50 -23
  19. package/plugins/ca-pi/hooks/_hooklib.py +148 -20
  20. package/plugins/ca-pi/hooks/_host.py +9 -1
  21. package/plugins/ca-pi/hooks/_metricslib.py +20 -0
  22. package/plugins/ca-pi/hooks/_modelib.py +762 -0
  23. package/plugins/ca-pi/hooks/_protectedlib.py +13 -4
  24. package/plugins/ca-pi/hooks/_prunelib.py +51 -12
  25. package/plugins/ca-pi/hooks/_prunepolicy.py +33 -7
  26. package/plugins/ca-pi/hooks/_readinjectlib.py +10 -4
  27. package/plugins/ca-pi/hooks/_releaselib.py +278 -48
  28. package/plugins/ca-pi/hooks/_updatelib.py +230 -50
  29. package/plugins/ca-pi/hooks/doctor.py +58 -9
  30. package/plugins/ca-pi/hooks/git-enforce.py +10 -3
  31. package/plugins/ca-pi/hooks/hostapi.py +220 -22
  32. package/plugins/ca-pi/hooks/pi-bridge.py +10 -4
  33. package/plugins/ca-pi/hooks/prompt-submit.py +486 -0
  34. package/plugins/ca-pi/hooks/prune-transcript.py +23 -3
  35. package/plugins/ca-pi/hooks/session-start.py +529 -435
  36. package/plugins/ca-pi/hooks/statusline.py +28 -10
  37. package/plugins/ca-pi/hooks/wire-statusline.py +13 -8
  38. package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +1 -1
  39. package/plugins/ca-pi/includes/command-compatibility.md +16 -0
  40. package/plugins/ca-pi/includes/dangerous-mode.md +57 -0
  41. package/plugins/ca-pi/includes/ops-mode.md +96 -0
  42. package/plugins/ca-pi/includes/pi-host-notes.md +10 -1
  43. package/plugins/ca-pi/includes/redirect.md +12 -1
  44. package/plugins/ca-pi/includes/routing-table.md +14 -5
  45. package/plugins/ca-pi/includes/safety-core.md +86 -0
  46. package/plugins/ca-pi/includes/smarts/core.md +1 -1
  47. package/plugins/ca-pi/routines/INDEX.md +1 -1
  48. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +55 -3
  49. package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +9 -1
  50. package/plugins/ca-pi/routines/decompose/SKILL.md +1 -1
  51. package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +4 -4
  52. package/plugins/ca-pi/routines/release/SKILL.md +1 -1
  53. package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +5 -4
  54. package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +6 -0
  55. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +6 -0
  56. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +6 -0
  57. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +6 -0
  58. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +4 -0
  59. package/plugins/ca-pi/skills/ca-init/SKILL.md +18 -1
  60. package/plugins/ca-pi/skills/ca-pr/SKILL.md +17 -1
  61. package/plugins/ca-pi/skills/ca-review/SKILL.md +3 -4
  62. package/plugins/ca-pi/skills/ca-spike/SKILL.md +15 -8
  63. package/plugins/ca-pi/skills/ca-status/SKILL.md +13 -1
  64. package/plugins/ca-pi/skills/ca-watch/SKILL.md +6 -0
  65. package/plugins/ca-pi/includes/dev-mode.md +0 -30
  66. package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +0 -36
  67. package/plugins/ca-pi/skills/ca-dev/SKILL.md +0 -42
@@ -39,6 +39,7 @@
39
39
  # last_tag_select(tags, prefix) -> str
40
40
  # notes_heading_matches(notes_text, tag) -> bool
41
41
  # release_dates_consistent(changelog_section, tag_message) -> bool
42
+ # changelog_section(changelog_text, version) -> str | None
42
43
  # classify_publish_state(tag_exists, tag_sha, head_sha, tag_version,
43
44
  # manifest_version, release_is_nondraft) -> str
44
45
  # select_release_target_by_name(pairs, targets) -> str name-keyed
@@ -280,27 +281,111 @@ class MissingRequiredKeyError(ReleaseTargetsError):
280
281
  # bug-fix cluster does not take on. If the anchor is ever relaxed, a second
281
282
  # check will need to be RE-ADDED deliberately, not un-deleted from history.
282
283
  _RELEASE_RE_CACHE = {}
284
+ _SEMVER_NUMERIC_IDENTIFIER = r"(?:0|[1-9][0-9]*)"
285
+ _PLAIN_SEMVER_PATTERN = (
286
+ _SEMVER_NUMERIC_IDENTIFIER + r"\." +
287
+ _SEMVER_NUMERIC_IDENTIFIER + r"\." +
288
+ _SEMVER_NUMERIC_IDENTIFIER)
289
+ _PLAIN_SEMVER_CAPTURE_PATTERN = (
290
+ r"(" + _SEMVER_NUMERIC_IDENTIFIER + r")\." +
291
+ r"(" + _SEMVER_NUMERIC_IDENTIFIER + r")\." +
292
+ r"(" + _SEMVER_NUMERIC_IDENTIFIER + r")")
283
293
 
284
294
 
285
295
  def _release_re(prefix):
286
296
  """The anchored `<prefix>MAJOR.MINOR.PATCH` matcher for one release series."""
287
297
  rx = _RELEASE_RE_CACHE.get(prefix)
288
298
  if rx is None:
289
- rx = re.compile(r"^" + re.escape(prefix) + r"(\d+)\.(\d+)\.(\d+)$")
299
+ rx = re.compile(
300
+ r"^" + re.escape(prefix) + _PLAIN_SEMVER_CAPTURE_PATTERN + r"$")
290
301
  _RELEASE_RE_CACHE[prefix] = rx
291
302
  return rx
292
303
 
293
304
 
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)
305
+ # H2 is the structural section boundary. A release heading is the narrower,
306
+ # exact grammar: either `## [X.Y.Z] ...`, `## vX.Y.Z ...`, or the one special
307
+ # `## [Unreleased]` marker. Tabs/spaces are admitted within ONE line; `\s` is
308
+ # deliberately absent because it also consumes newlines and used to turn
309
+ # `##\n[1.2.3]` into a release heading. The suffix, when present, must begin
310
+ # with horizontal whitespace, so `[1.2.3]garbage` cannot prefix-match.
311
+ _H2_RE = re.compile(r"^##(?:[ \t]+[^\r\n]*)?[ \t]*$", re.MULTILINE)
312
+ _HEADING_RE = re.compile(
313
+ r"^##[ \t]+(?:\[(Unreleased|" + _PLAIN_SEMVER_PATTERN + r")\]|"
314
+ r"v(" + _PLAIN_SEMVER_PATTERN + r"))(?:[ \t]+[^\r\n]*)?[ \t]*$",
315
+ re.MULTILINE)
316
+ _LEGACY_DATE_H2_RE = re.compile(
317
+ r"^##[ \t]+\[[0-9]{4}-[0-9]{2}-[0-9]{2}\]"
318
+ r"(?:[ \t]+[^\r\n]*)?[ \t]*$")
319
+ _CHANGELOG_DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})[ \t]*$")
303
320
  _RELEASED_AT_RE = re.compile(r"Released-at:\s*(\d{4}-\d{2}-\d{2})")
321
+ _BARE_RELEASE_VERSION_RE = re.compile(r"^" + _PLAIN_SEMVER_PATTERN + r"$")
322
+
323
+ _SECTION_OK = "ok"
324
+ _SECTION_ABSENT = "absent"
325
+ _SECTION_INVALID = "invalid"
326
+ _SECTION_DUPLICATE = "duplicate"
327
+
328
+
329
+ def _heading_version(line):
330
+ """Bare version / `Unreleased` for one exact heading line, else None."""
331
+ match = _HEADING_RE.fullmatch(line)
332
+ if match is None:
333
+ return None
334
+ return match.group(1) or match.group(2)
335
+
336
+
337
+ def _looks_like_changelog_heading(line):
338
+ """True when an H2 claims changelog-heading syntax but is malformed."""
339
+ # This repository's pre-SemVer history used exact bracketed ISO dates,
340
+ # sometimes more than once for one day. They remain ordinary structural
341
+ # H2 boundaries, not release versions and not malformed SemVer claims.
342
+ if _LEGACY_DATE_H2_RE.fullmatch(line) is not None:
343
+ return False
344
+ body = line[2:].lstrip(" \t")
345
+ return body.startswith("[") or body.startswith("v")
346
+
347
+
348
+ def _changelog_section_result(changelog_text, version):
349
+ """Return `(section, status)` without raising on malformed changelogs."""
350
+ if (not isinstance(changelog_text, str)
351
+ or not isinstance(version, str)
352
+ or _BARE_RELEASE_VERSION_RE.fullmatch(version) is None):
353
+ return None, _SECTION_INVALID
354
+
355
+ boundaries = list(_H2_RE.finditer(changelog_text))
356
+ target_indexes = []
357
+ released_seen = False
358
+ unreleased_seen = False
359
+
360
+ for index, boundary in enumerate(boundaries):
361
+ line = boundary.group(0)
362
+ heading_version = _heading_version(line)
363
+ if heading_version is None:
364
+ if _looks_like_changelog_heading(line):
365
+ return None, _SECTION_INVALID
366
+ continue
367
+ if heading_version == "Unreleased":
368
+ # Repository-derived Keep-a-Changelog compatibility: exactly one
369
+ # Unreleased marker is valid only before every released section.
370
+ if unreleased_seen or released_seen:
371
+ return None, _SECTION_INVALID
372
+ unreleased_seen = True
373
+ continue
374
+ released_seen = True
375
+ if heading_version == version:
376
+ target_indexes.append(index)
377
+
378
+ if not target_indexes:
379
+ return None, _SECTION_ABSENT
380
+ if len(target_indexes) != 1:
381
+ return None, _SECTION_DUPLICATE
382
+
383
+ index = target_indexes[0]
384
+ start = boundaries[index].start()
385
+ end = (boundaries[index + 1].start()
386
+ if index + 1 < len(boundaries) else len(changelog_text))
387
+ section = changelog_text[start:end].rstrip("\r\n") + "\n"
388
+ return section, _SECTION_OK
304
389
 
305
390
  # Full SemVer, including the pre-release and build-metadata tails a release
306
391
  # tag never carries but a version MANIFEST can. The anchored `_release_re`
@@ -313,8 +398,8 @@ _RELEASED_AT_RE = re.compile(r"Released-at:\s*(\d{4}-\d{2}-\d{2})")
313
398
  VALUE_MAX_CHARS = 1024
314
399
 
315
400
  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-]*))*))?"
401
+ r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)"
402
+ r"(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?"
318
403
  r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
319
404
  )
320
405
 
@@ -380,7 +465,8 @@ def _bare_version(tag):
380
465
  if not isinstance(tag, str):
381
466
  return tag
382
467
  text = tag.strip().strip("[]")
383
- match = re.search(r"(\d+\.\d+\.\d+.*)$", text)
468
+ match = re.search(
469
+ r"(?<![0-9])(" + _PLAIN_SEMVER_PATTERN + r".*)$", text)
384
470
  return match.group(1) if match else text.lstrip("v")
385
471
 
386
472
 
@@ -435,10 +521,15 @@ def notes_heading_matches(notes_text, tag):
435
521
  False."""
436
522
  if not isinstance(notes_text, str) or not isinstance(tag, str):
437
523
  return False
438
- m = _HEADING_RE.search(notes_text)
439
- if not m:
524
+ headings = list(_H2_RE.finditer(notes_text))
525
+ if not headings:
526
+ return False
527
+ version = _bare_version(tag)
528
+ first_version = _heading_version(headings[0].group(0))
529
+ if first_version != version or first_version == "Unreleased":
440
530
  return False
441
- return m.group(1) == _bare_version(tag)
531
+ _section, status = _changelog_section_result(notes_text, version)
532
+ return status == _SECTION_OK
442
533
 
443
534
 
444
535
  def release_dates_consistent(changelog_section, tag_message):
@@ -448,7 +539,11 @@ def release_dates_consistent(changelog_section, tag_message):
448
539
  across surfaces. Either date absent, or non-string input -> False."""
449
540
  if not isinstance(changelog_section, str) or not isinstance(tag_message, str):
450
541
  return False
451
- cm = _CHANGELOG_DATE_RE.search(changelog_section)
542
+ heading = _HEADING_RE.match(changelog_section)
543
+ if (heading is None or heading.start() != 0
544
+ or _heading_version(heading.group(0)) == "Unreleased"):
545
+ return False
546
+ cm = _CHANGELOG_DATE_RE.search(heading.group(0))
452
547
  tm = _RELEASED_AT_RE.search(tag_message)
453
548
  if not cm or not tm:
454
549
  return False
@@ -469,16 +564,142 @@ def changelog_section(changelog_text, version):
469
564
  COMMITTED `$CHANGELOG` -- which Phase 1 step 7 commits before any tag
470
565
  exists -- is reading the one permanent home of that text, not
471
566
  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):
567
+ section, status = _changelog_section_result(changelog_text, version)
568
+ return section if status == _SECTION_OK else None
569
+
570
+
571
+ def _normalize_git_tree_path(path):
572
+ """Normalize portable separators; reject paths outside a Git tree."""
573
+ if not isinstance(path, str) or not path or "\0" in path:
473
574
  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
575
+ normalized = path.replace("\\", "/")
576
+ if (normalized.startswith("/")
577
+ or re.match(r"^[A-Za-z]:", normalized)
578
+ or ":" in normalized):
579
+ return None
580
+ parts = normalized.split("/")
581
+ if any(part in ("", "..") for part in parts):
582
+ return None
583
+ parts = [part for part in parts if part != "."]
584
+ return "/".join(parts) if parts else None
585
+
586
+
587
+ _GIT_REPOSITORY_ENV = {
588
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
589
+ "GIT_CEILING_DIRECTORIES",
590
+ "GIT_COMMON_DIR",
591
+ "GIT_DIR",
592
+ "GIT_DISCOVERY_ACROSS_FILESYSTEM",
593
+ "GIT_GRAFT_FILE",
594
+ "GIT_INDEX_FILE",
595
+ "GIT_NAMESPACE",
596
+ "GIT_OBJECT_DIRECTORY",
597
+ "GIT_PREFIX",
598
+ "GIT_QUARANTINE_PATH",
599
+ "GIT_REPLACE_REF_BASE",
600
+ "GIT_SHALLOW_FILE",
601
+ "GIT_WORK_TREE",
602
+ }
603
+
604
+
605
+ def _sanitized_git_environment():
606
+ """Return a Git environment that cannot redirect repository/object reads."""
607
+ environment = os.environ.copy()
608
+ for name in _GIT_REPOSITORY_ENV:
609
+ environment.pop(name, None)
610
+ # Command-scoped config injected through GIT_CONFIG_COUNT can redefine
611
+ # repository paths without using the better-known GIT_DIR variables.
612
+ environment.pop("GIT_CONFIG_COUNT", None)
613
+ for name in tuple(environment):
614
+ if re.fullmatch(r"GIT_CONFIG_(?:KEY|VALUE)_\d+", name):
615
+ environment.pop(name, None)
616
+ # Replacement refs rewrite the commit/tree/blob Git presents for a known
617
+ # object id. Release notes must reflect the tag's stored objects exactly.
618
+ environment["GIT_NO_REPLACE_OBJECTS"] = "1"
619
+ return environment
620
+
621
+
622
+ def _committed_changelog_text(repo_root, revision, changelog_path):
623
+ """Return `(text, error)` from one exact regular-file blob at revision."""
624
+ if (not isinstance(repo_root, str) or not repo_root
625
+ or not isinstance(revision, str) or not revision
626
+ or revision.startswith("-") or "\0" in revision):
627
+ return None, "invalid repository root or revision"
628
+ tree_path = _normalize_git_tree_path(changelog_path)
629
+ if tree_path is None:
630
+ return None, "changelog path is not a repository-relative Git path"
631
+
632
+ try:
633
+ git = git_executable()
634
+ git_environment = _sanitized_git_environment()
635
+ root_probe = subprocess.run(
636
+ [git, "-C", repo_root, "rev-parse", "--show-toplevel"],
637
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
638
+ timeout=30, env=git_environment)
639
+ except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
640
+ return None, f"cannot resolve repository root: {exc}"
641
+ if root_probe.returncode != 0:
642
+ return None, "cannot resolve repository root"
643
+ actual_root = root_probe.stdout.strip()
644
+ supplied_root = os.path.realpath(os.path.abspath(repo_root))
645
+ resolved_root = os.path.realpath(os.path.abspath(actual_root))
646
+ if os.path.normcase(supplied_root) != os.path.normcase(resolved_root):
647
+ return None, "supplied path is not the repository root"
648
+
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"
658
+
659
+ try:
660
+ commit_probe = subprocess.run(
661
+ [git, "-C", actual_root, "rev-parse", "--verify",
662
+ f"{tag_ref}^{{commit}}"],
663
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
664
+ timeout=30, env=git_environment)
665
+ except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
666
+ return None, f"cannot resolve committed revision: {exc}"
667
+ if commit_probe.returncode != 0:
668
+ return None, "cannot resolve committed revision"
669
+ commit = commit_probe.stdout.strip()
670
+
671
+ try:
672
+ entry_probe = subprocess.run(
673
+ [git, "-C", actual_root, "ls-tree", "-z", commit, "--", tree_path],
674
+ capture_output=True, timeout=30, env=git_environment)
675
+ except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
676
+ return None, f"cannot resolve committed changelog: {exc}"
677
+ records = [record for record in entry_probe.stdout.split(b"\0") if record]
678
+ if entry_probe.returncode != 0 or len(records) != 1:
679
+ return None, "committed changelog path is absent or ambiguous"
680
+ try:
681
+ metadata, encoded_path = records[0].split(b"\t", 1)
682
+ mode, object_type, oid = metadata.split(b" ", 2)
683
+ found_path = encoded_path.decode("utf-8")
684
+ except (ValueError, UnicodeDecodeError):
685
+ return None, "committed changelog tree entry is malformed"
686
+ if (found_path != tree_path or object_type != b"blob"
687
+ or mode not in (b"100644", b"100755")):
688
+ return None, "committed changelog is not an exact regular-file path"
689
+
690
+ try:
691
+ blob = subprocess.run(
692
+ [git, "-C", actual_root, "cat-file", "blob", oid.decode("ascii")],
693
+ capture_output=True, timeout=30, env=git_environment)
694
+ except (OSError, subprocess.TimeoutExpired, ValueError, UnicodeDecodeError) as exc:
695
+ return None, f"cannot read committed changelog: {exc}"
696
+ if blob.returncode != 0:
697
+ return None, "cannot read committed changelog"
698
+ try:
699
+ text = blob.stdout.decode("utf-8")
700
+ except UnicodeDecodeError:
701
+ return None, "committed changelog is not UTF-8"
702
+ return text.replace("\r\n", "\n").replace("\r", "\n"), None
482
703
 
483
704
 
484
705
  def classify_publish_state(tag_exists, tag_sha, head_sha, tag_version,
@@ -1026,7 +1247,8 @@ def peel_tag(ls_remote_text, tag):
1026
1247
  return peeled or direct
1027
1248
 
1028
1249
 
1029
- _PLAIN_SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
1250
+ _PLAIN_SEMVER_RE = re.compile(
1251
+ r"^" + _PLAIN_SEMVER_CAPTURE_PATTERN + r"$")
1030
1252
  _BUMP_WORDS = ("major", "minor", "patch")
1031
1253
 
1032
1254
 
@@ -1752,15 +1974,17 @@ def main(argv):
1752
1974
  notes-match <tag> <notes_file>
1753
1975
  exit 0 iff the notes file's first heading
1754
1976
  names the same version as `tag`.
1755
- changelog-section <changelog_file> <version>
1977
+ changelog-section <repo_root> <revision> <changelog_path> <version>
1756
1978
  prints the `## [<version>] ...` section of
1757
- `<changelog_file>` verbatim, from its
1758
- heading up to the next `##` heading or EOF.
1979
+ the exact regular-file blob at
1980
+ `<revision>:<changelog_path>`, bound to the
1981
+ supplied repository root, from its heading
1982
+ up to the next H2 heading or EOF.
1759
1983
  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
1984
+ heading names `<version>` - 3 source/root/
1985
+ path/revision cannot be read safely - 4 the
1986
+ changelog is malformed or ambiguous. The
1987
+ sanctioned way for
1764
1988
  Phase 3's `resume_publish` path to
1765
1989
  reconstruct release notes when Phase 1's
1766
1990
  own scratch file did not survive to a
@@ -2091,29 +2315,35 @@ def main(argv):
2091
2315
  notes_text = ""
2092
2316
  return 0 if notes_heading_matches(notes_text, rest[0]) else 1
2093
2317
 
2094
- if cmd == "changelog-section" and len(rest) == 2:
2318
+ if cmd == "changelog-section" and len(rest) == 4:
2095
2319
  # Mechanical reconstruction of Phase 1's composed section, for
2096
2320
  # `resume_publish` -- see `changelog_section`'s docstring. Exit 0
2097
2321
  # with the section on stdout - 1 the changelog has no heading for
2098
2322
  # `<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:
2323
+ # bad-invocation) - 3 the repository/revision/path/blob cannot be
2324
+ # read safely - 4 a malformed/duplicate/invalidly-ordered changelog.
2325
+ # `revision` is resolved to a commit hash before the blob read, so a
2326
+ # current HEAD move or dirty/deleted working file cannot change the
2327
+ # notes selected for an already-composed tag.
2328
+ repo_root, revision, changelog_path, version = rest
2329
+ changelog_text, source_error = _committed_changelog_text(
2330
+ repo_root, revision, changelog_path)
2331
+ if source_error is not None:
2108
2332
  sys.stderr.write(
2109
- f"changelog-section: cannot read {changelog_path!r}: {exc}\n")
2333
+ f"changelog-section: cannot read committed "
2334
+ f"{changelog_path!r}: {source_error}\n")
2110
2335
  return 3
2111
- section = changelog_section(changelog_text, version)
2112
- if section is None:
2336
+ section, status = _changelog_section_result(changelog_text, version)
2337
+ if status == _SECTION_ABSENT:
2113
2338
  sys.stderr.write(
2114
2339
  f"changelog-section: no '## [{version}]' heading in "
2115
- f"{changelog_path!r}\n")
2340
+ f"committed {changelog_path!r}\n")
2116
2341
  return 1
2342
+ if status != _SECTION_OK:
2343
+ sys.stderr.write(
2344
+ f"changelog-section: committed {changelog_path!r} is "
2345
+ f"{status}; refusing ambiguous release notes\n")
2346
+ return 4
2117
2347
  # `sys.stdout.write` is text-mode: on Windows with no
2118
2348
  # PYTHONIOENCODING/PYTHONUTF8 set it encodes using the ambient
2119
2349
  # console codepage (cp1252, not UTF-8) AND translates `\n` to