@ccoalm/ccl-skills 0.12.0 → 0.14.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 (35) hide show
  1. package/dist/assets/marketplace/plugins/ccl-skills/skills/defect-diagnosis/SKILL.md +21 -12
  2. package/dist/assets/marketplace/plugins/ccl-skills/skills/defect-diagnosis/references/diagnosis-playbook.md +42 -1
  3. package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/SKILL.md +7 -4
  4. package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-context-freshness.md +4 -0
  5. package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-tool-dispatch.md +2 -0
  6. package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/inference-capacity-operations.md +20 -1
  7. package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/llm-client-gateway.md +11 -0
  8. package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/model-prompt-evaluation.md +3 -2
  9. package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/retrieval-agent-safety.md +5 -0
  10. package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-agent-delegation/SKILL.md +13 -9
  11. package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-agent-delegation/references/multi-agent-delegation-playbook.md +12 -1
  12. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +14 -41
  13. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/attention-budget-ratchet.md +11 -0
  14. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/correction-routing-map.md +22 -0
  15. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/coverage-exhaustion-traps.md +7 -0
  16. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/description-authoring.md +26 -0
  17. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/dual-track-review-gate.md +2 -2
  18. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/eval-routing.md +2 -0
  19. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/external-practice-controls.md +6 -0
  20. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-quickstart.md +4 -2
  21. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/harness-patterns-and-eval.md +22 -0
  22. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/incident-postmortem-extraction.md +8 -0
  23. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/rule-consolidation.md +1 -0
  24. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +70 -0
  25. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/uiux-judgment-extraction.md +11 -0
  26. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/validation-and-landing.md +11 -0
  27. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/entrypoint_form_census.py +169 -0
  28. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/reference-access-census.sh +157 -0
  29. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/review_ledger_binding.py +454 -103
  30. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +8 -0
  31. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_entrypoint_form_census.sh +174 -0
  32. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_reference_access_census.sh +209 -0
  33. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_review_ledger_binding.sh +331 -0
  34. package/dist/assets/release.json +56 -31
  35. package/package.json +1 -1
@@ -67,8 +67,37 @@ receipt predicate and committing it moves no partition; that is the load-bearing
67
67
  reason for the field, and the exclusion predicate itself is unchanged. What the
68
68
  manifest proves is the same narrow thing the single ledger proves, taken per
69
69
  part: every byte that lands is a byte some external round froze and inspected.
70
- Merge-queue aggregation of several pull requests into one HEAD is a different
71
- aggregate and remains unsolved here.
70
+
71
+ A third aggregate is an integration branch that accumulated several reviewed
72
+ rounds and is then promoted as one pull request. No single ledger binds that
73
+ candidate, and a path partition cannot either: two rounds that append to the
74
+ same file (a register, a changelog) leave that file's promotion diff equal to
75
+ the sum of both appends, which no round ever froze. What every such round DID
76
+ leave behind is a first-parent merge whose second parent is the reviewed head
77
+ and whose own pull request this gate already bound at the round's own base. So
78
+ the gate walks HEAD's first-parent chain down to the first commit already on
79
+ the target branch and classifies each step: a merge whose second parent is on
80
+ the target is a sync merge and owes no evidence; any other merge is a round,
81
+ rebound by running this same gate in a detached checkout of its second parent
82
+ against its first parent; a non-merge commit is refused, because nothing
83
+ reviewed binds what was pushed straight to the branch. Every step's tree must
84
+ equal `git merge-tree --write-tree` of its parents, so a merge commit that
85
+ carries a hand resolution or any other content beyond the automatic merge is
86
+ refused as unreviewed. A round is rebound with THIS checkout's controller and
87
+ validator, never the round's own: a round could carry a hollowed validator in
88
+ its own branch and a later round could restore the real one, so judging history
89
+ with history's tools would let that round's forged ledger stand forever. Using
90
+ the landing tree's tools means a controller or validator change between a round
91
+ and the promotion can stop an old round reproducing, and that reads as a refusal
92
+ rather than a pass. A round is never itself a chain, so the walk is one level
93
+ deep by construction. The detached checkout is released with `git worktree
94
+ remove` and its removal verified against the worktree list; a checkout that
95
+ cannot be released is an error, never a pass, and nothing prunes registrations
96
+ this run did not create. The chain is
97
+ consulted only for the default path set: a narrowed `--paths` has no round-level
98
+ ledger to bind. Merge-queue aggregation of several still-unmerged pull requests
99
+ into one HEAD is yet another aggregate and remains unsolved: those requests have
100
+ no first-parent merges of their own for a chain to bind.
72
101
 
73
102
  Boundaries this gate does NOT close, stated because a gate that lives inside the
74
103
  candidate cannot authenticate itself: it cannot prove the caller retained every
@@ -97,12 +126,15 @@ import sys
97
126
  sys.dont_write_bytecode = True
98
127
 
99
128
  import argparse
129
+ import contextlib
100
130
  import hashlib
101
131
  import importlib.util
102
132
  import json
103
133
  import os
104
134
  import re
135
+ import shutil
105
136
  import subprocess
137
+ import tempfile
106
138
  import time
107
139
  import types
108
140
  from pathlib import Path
@@ -129,12 +161,37 @@ PARTITION_KEYS = {"paths", "candidate_sha256"}
129
161
  MAX_PARTITIONS = 64
130
162
  HEX40 = re.compile(r"^[0-9a-f]{40}$")
131
163
  HEX64 = re.compile(r"^[0-9a-f]{64}$")
164
+ # A first-parent chain longer than this is not an integration branch's round
165
+ # history; refuse rather than walk an unbounded history.
166
+ MAX_CHAIN_STEPS = 64
132
167
 
133
168
 
134
169
  class ManifestError(Exception):
135
170
  """A manifest that does not describe this candidate; the message is the reason."""
136
171
 
137
172
 
173
+ class ChainError(Exception):
174
+ """A first-parent step that is not a bound round; the message names the step."""
175
+
176
+
177
+ class Binding:
178
+ """What one evaluation of a checkout against a base concluded.
179
+
180
+ `ok` with `summary` is the accept line (without its token prefix) plus any
181
+ per-part proof lines; otherwise `failure` carries the diagnostics in the
182
+ order they are printed. `changed` is the candidate's changed-file set, which
183
+ the chain path uses to check that rounds cover the promotion.
184
+ """
185
+
186
+ def __init__(self) -> None:
187
+ self.ok = False
188
+ self.summary = ""
189
+ self.proofs: list[str] = []
190
+ self.failure: list[str] = []
191
+ self.changed: list[str] = []
192
+ self.fork = ""
193
+
194
+
138
195
  def emit(message: str) -> None:
139
196
  print(message, file=sys.stderr)
140
197
 
@@ -634,59 +691,245 @@ def scan(repo_root: Path, evidence_root: str) -> list[tuple[Path, dict]]:
634
691
  return found
635
692
 
636
693
 
637
- def main() -> int:
638
- parser = argparse.ArgumentParser(description=__doc__)
639
- parser.add_argument("--repo-root", default=".")
640
- parser.add_argument("--base", default=None)
641
- parser.add_argument(
642
- "--allow-unevaluated",
643
- action="store_true",
644
- help="permit a run with no resolvable base to exit 0, for events that have none",
694
+ def git_read(repo_root: Path, arguments: list[str], failure: str) -> str:
695
+ """Run a read-only git query whose failure is an environment error, not a verdict."""
696
+ result = subprocess.run(
697
+ ["git", "-C", str(repo_root), *arguments],
698
+ stdout=subprocess.PIPE,
699
+ stderr=subprocess.PIPE,
700
+ text=True,
701
+ check=False,
645
702
  )
646
- parser.add_argument("--evidence-root", default="specs")
647
- parser.add_argument("--paths", nargs="*", default=list(DEFAULT_PATHS))
648
- parser.add_argument(
649
- "--print-candidate",
650
- action="store_true",
651
- help="print the candidate hash the evidence must bind, then exit",
703
+ if result.returncode != 0:
704
+ raise SystemExit(f"review_ledger_binding_error: {failure}: {result.stderr.strip()}")
705
+ return result.stdout.strip()
706
+
707
+
708
+ def is_ancestor(repo_root: Path, commit: str, tip: str) -> bool:
709
+ result = subprocess.run(
710
+ ["git", "-C", str(repo_root), "merge-base", "--is-ancestor", commit, tip],
711
+ stdout=subprocess.PIPE,
712
+ stderr=subprocess.PIPE,
713
+ text=True,
714
+ check=False,
652
715
  )
653
- parser.add_argument(
654
- "--print-manifest",
655
- action="store_true",
656
- help=(
657
- "render a landing partition manifest for the --partition groups given, "
658
- "with every hash computed by this gate, then exit"
659
- ),
716
+ if result.returncode == 0:
717
+ return True
718
+ if result.returncode == 1:
719
+ return False
720
+ raise SystemExit(
721
+ f"review_ledger_binding_error: cannot test ancestry of {commit[:12]}: {result.stderr.strip()}"
660
722
  )
661
- parser.add_argument(
662
- "--partition",
663
- action="append",
664
- nargs="+",
665
- metavar="PATH",
666
- default=[],
667
- help="one partition's paths; repeat per partition (only with --print-manifest)",
723
+
724
+
725
+ def automatic_merge_tree(repo_root: Path, first: str, second: str) -> str | None:
726
+ """The tree git itself produces merging `second` into `first`, or None on conflict.
727
+
728
+ A merge commit whose tree is anything else carries content beyond its two
729
+ parents -- a hand resolution, an extra file, a post-merge edit -- and that
730
+ content was reviewed by nobody. Conflicts are refused for the same reason:
731
+ whatever resolved them is unreviewed. An old git that lacks `--write-tree`
732
+ is an environment error rather than a verdict either way.
733
+ """
734
+ result = subprocess.run(
735
+ ["git", "-C", str(repo_root), "merge-tree", "--write-tree", first, second],
736
+ stdout=subprocess.PIPE,
737
+ stderr=subprocess.PIPE,
738
+ text=True,
739
+ check=False,
740
+ )
741
+ if result.returncode == 0:
742
+ tree = result.stdout.splitlines()[0].strip() if result.stdout else ""
743
+ if not HEX40.match(tree):
744
+ raise SystemExit("review_ledger_binding_error: git merge-tree --write-tree printed no tree")
745
+ return tree
746
+ if result.returncode == 1:
747
+ return None
748
+ raise SystemExit(
749
+ "review_ledger_binding_error: git merge-tree --write-tree is unavailable "
750
+ f"(git 2.38 or newer is required): {result.stderr.strip()}"
668
751
  )
669
- args = parser.parse_args()
670
- if args.print_manifest and not args.partition:
671
- parser.error("--print-manifest needs at least one --partition")
672
- if args.partition and not args.print_manifest:
673
- parser.error("--partition is only meaningful with --print-manifest")
674
752
 
675
- repo_root = Path(args.repo_root).resolve()
676
- base = args.base or os.environ.get("CCL_SKILL_BASE_REF", "")
677
- if not base:
678
- # The gate is base-relative by construction, so a run with no base has
679
- # checked nothing. Exiting 0 there is how a base-relative gate becomes
680
- # decorative: a base-wiring mistake would read as a passing required
681
- # check. Fail closed; a caller whose event genuinely has no base must
682
- # say so out loud with --allow-unevaluated.
683
- emit(
684
- "review_ledger_binding_unevaluated: no base ref supplied "
685
- "(pass --base or set CCL_SKILL_BASE_REF); nothing was checked"
753
+
754
+ def walk_first_parent_chain(repo_root: Path, base_tip: str) -> list[tuple[str, str, str, str]]:
755
+ """Classify each first-parent step from HEAD down to the target branch.
756
+
757
+ Returns (kind, merge, first_parent, second_parent) per step, newest first,
758
+ where kind is `sync` (second parent already on the target) or `round`.
759
+ Raises ChainError at the first step that is not a merge, is not the
760
+ automatic merge of its parents, or when the walk does not reach the target.
761
+ """
762
+ steps: list[tuple[str, str, str, str]] = []
763
+ commit = git_read(repo_root, ["rev-parse", "--verify", "HEAD^{commit}"], "cannot resolve HEAD")
764
+ while True:
765
+ if is_ancestor(repo_root, commit, base_tip):
766
+ return steps
767
+ if len(steps) >= MAX_CHAIN_STEPS:
768
+ break
769
+ parents = git_read(
770
+ repo_root, ["rev-list", "--parents", "-n", "1", commit], f"cannot read parents of {commit[:12]}"
771
+ ).split()[1:]
772
+ if len(parents) != 2:
773
+ shape = "an octopus merge" if len(parents) > 2 else "not a merge commit"
774
+ raise ChainError(
775
+ f"{commit[:12]} is {shape}; nothing reviewed binds its content"
776
+ if len(parents) < 2
777
+ else f"{commit[:12]} is {shape}; only two-parent merges are bound"
778
+ )
779
+ first, second = parents
780
+ own_tree = git_read(repo_root, ["rev-parse", f"{commit}^{{tree}}"], f"cannot read tree of {commit[:12]}")
781
+ if automatic_merge_tree(repo_root, first, second) != own_tree:
782
+ raise ChainError(
783
+ f"{commit[:12]} tree differs from the automatic merge of its parents "
784
+ f"({first[:12]} + {second[:12]}); whatever else it carries was reviewed by nobody"
785
+ )
786
+ kind = "sync" if is_ancestor(repo_root, second, base_tip) else "round"
787
+ steps.append((kind, commit, first, second))
788
+ commit = first
789
+ raise ChainError(
790
+ f"the first-parent chain runs more than {MAX_CHAIN_STEPS} steps without reaching the base"
791
+ )
792
+
793
+
794
+ @contextlib.contextmanager
795
+ def detached_checkout(repo_root: Path, commit: str):
796
+ """A throwaway worktree at `commit`, outside the repository, removed on exit.
797
+
798
+ The controller freezes the working tree, so a round can only be rebound from
799
+ a checkout of its head. The checkout lives outside the repository root so it
800
+ is never an untracked path inside the bound set, and it is removed whether
801
+ the round binds or not.
802
+ """
803
+ path = Path(tempfile.mkdtemp(prefix="review-ledger-binding-round-"))
804
+ added = subprocess.run(
805
+ ["git", "-C", str(repo_root), "worktree", "add", "--detach", "--quiet", str(path), commit],
806
+ stdout=subprocess.PIPE,
807
+ stderr=subprocess.PIPE,
808
+ text=True,
809
+ check=False,
810
+ )
811
+ resolved = path.resolve()
812
+ if added.returncode != 0:
813
+ # A failed add can still have registered the checkout or populated the
814
+ # directory; release it through the same verified path as a success, so
815
+ # a failed run leaves no repository worktree state behind either.
816
+ problem = release_checkout(repo_root, path, resolved)
817
+ if problem:
818
+ problem = f" (and the partial checkout could not be released: {problem})"
819
+ raise SystemExit(
820
+ f"review_ledger_binding_error: cannot check out round head {commit[:12]}: "
821
+ f"{added.stderr.strip()}{problem or ''}"
686
822
  )
687
- return 0 if args.allow_unevaluated else 2
823
+ try:
824
+ yield resolved
825
+ finally:
826
+ problem = release_checkout(repo_root, path, resolved)
827
+ if problem:
828
+ emit(f"review_ledger_binding_error: cannot release the detached checkout {path}: {problem}")
829
+ # Reached only when the body did not raise: a verdict that would have been
830
+ # `ok` must not stand on a checkout this run failed to release. When the body
831
+ # raised, the outcome is already a refusal or an error and the problem was
832
+ # emitted above.
833
+ if problem:
834
+ raise SystemExit(
835
+ f"review_ledger_binding_error: cannot release the detached checkout {path}: {problem}"
836
+ )
837
+
838
+
839
+ def release_checkout(repo_root: Path, path: Path, resolved: Path) -> str | None:
840
+ """Remove the detached checkout and verify git no longer registers it.
841
+
842
+ The removal's result is checked, not discarded, and the registry is read
843
+ back: a stale registration left behind would make the next run's worktree
844
+ state a lie. No `worktree prune` runs here, because prune is repository-wide
845
+ and would also drop registrations this run did not create.
846
+ """
847
+ removed = subprocess.run(
848
+ ["git", "-C", str(repo_root), "worktree", "remove", "--force", str(path)],
849
+ stdout=subprocess.PIPE,
850
+ stderr=subprocess.PIPE,
851
+ text=True,
852
+ check=False,
853
+ )
854
+ # NUL-terminated output: the line-oriented porcelain quotes a path that
855
+ # carries a tab, newline, or other unusual byte, and a quoted line would
856
+ # match neither spelling below, reading a surviving registration as gone.
857
+ listing = subprocess.run(
858
+ ["git", "-C", str(repo_root), "worktree", "list", "--porcelain", "-z"],
859
+ stdout=subprocess.PIPE,
860
+ stderr=subprocess.PIPE,
861
+ check=False,
862
+ )
863
+ if listing.returncode != 0:
864
+ return f"cannot read the worktree list: {listing.stderr.decode('utf-8', 'replace').strip()}"
865
+ registered = {
866
+ field.decode("utf-8", "surrogateescape")
867
+ for field in listing.stdout.split(b"\0")
868
+ if field.startswith(b"worktree ")
869
+ }
870
+ if f"worktree {path}" in registered or f"worktree {resolved}" in registered:
871
+ detail = f": {removed.stderr.strip()}" if removed.returncode != 0 else ""
872
+ return f"the checkout is still registered after removal{detail}"
873
+ if path.exists():
874
+ # Unregistered but present: the directory this run created before git
875
+ # registered anything, or files git left without a registration. It is
876
+ # this run's own temporary directory, so deleting it touches no
877
+ # repository state; a directory that survives that is reported.
878
+ shutil.rmtree(path, ignore_errors=True)
879
+ if path.exists():
880
+ return "the checkout directory still exists after removal"
881
+ return None
882
+
883
+
884
+ def bind_chain(
885
+ repo_root: Path, base_tip: str, changed_all: list[str], evidence_root: str
886
+ ) -> tuple[int, list[str]]:
887
+ """Bind the candidate round by round along HEAD's first-parent chain.
888
+
889
+ Returns (round count, proof lines) or raises ChainError naming the first
890
+ step that does not add up. Each round is rebound by this same gate in a
891
+ detached checkout of its head against its first parent, with THIS tree's
892
+ controller and validator (never the round's own), and never as a chain of
893
+ its own.
894
+ """
895
+ steps = walk_first_parent_chain(repo_root, base_tip)
896
+ proofs: list[str] = []
897
+ covered: set[str] = set()
898
+ rounds = 0
899
+ for kind, merge, first, second in steps:
900
+ if kind == "sync":
901
+ proofs.append(f" sync {merge[:12]} (already on the base)")
902
+ continue
903
+ with detached_checkout(repo_root, second) as round_root:
904
+ binding = bind_candidate(
905
+ round_root, first, DEFAULT_PATHS, evidence_root, allow_chain=False, tools_root=repo_root
906
+ )
907
+ subject = git_read(repo_root, ["log", "-1", "--format=%s", merge], f"cannot read {merge[:12]}")
908
+ if not binding.ok:
909
+ reason = binding.failure[0] if binding.failure else "no accepted review evidence"
910
+ raise ChainError(
911
+ f"round {merge[:12]} ({subject}) does not bind at its own base {first[:12]}: {reason}"
912
+ )
913
+ rounds += 1
914
+ covered.update(binding.changed)
915
+ proofs.append(f" round {merge[:12]} <- {binding.summary}")
916
+ proofs.extend(" " + line for line in binding.proofs)
917
+ if rounds == 0:
918
+ raise ChainError("no round merge on the chain binds the changed paths")
919
+ # Under the automatic-merge invariant every promoted change reached HEAD
920
+ # through some round; this name-level check is the second net under that
921
+ # invariant, not the invariant itself.
922
+ outside = sorted(set(changed_all) - covered)
923
+ if outside:
924
+ raise ChainError("changed paths outside every bound round: " + ", ".join(outside[:5]))
925
+ return rounds, proofs
926
+
688
927
 
689
- base = fork_point(repo_root, resolve_base(repo_root, base))
928
+ def candidate_scope(
929
+ repo_root: Path, base_tip: str, user_paths: tuple[str, ...]
930
+ ) -> tuple[str, tuple[str, ...], tuple[str, ...], list[str]]:
931
+ """Resolve (fork point, exclusions, bound paths, changed files) for one checkout."""
932
+ fork = fork_point(repo_root, base_tip)
690
933
  # The exclusion is derived from this round's own diff, not written down as a
691
934
  # subtree, so edits to committed history stay inside the candidate.
692
935
  # These names come from the candidate's own tree and are handed back to git as
@@ -698,55 +941,53 @@ def main() -> int:
698
941
  # confirmed, and no test asserts a bypass this gate does not have. `literal`
699
942
  # stays because interpreting these names as patterns is a capability the gate
700
943
  # never needed, and removing it costs nothing.
701
- excludes = tuple(
702
- f":(exclude,literal){path}" for path in added_evidence_paths(repo_root, base)
703
- )
704
- paths = tuple(args.paths) + excludes
944
+ excludes = tuple(f":(exclude,literal){path}" for path in added_evidence_paths(repo_root, fork))
945
+ paths = tuple(user_paths) + excludes
705
946
  require_committed_tree(repo_root, paths)
706
- changed = changed_skill_paths(repo_root, base, paths)
947
+ changed = changed_skill_paths(repo_root, fork, paths)
948
+ return fork, excludes, paths, changed
949
+
950
+
951
+ def bind_candidate(
952
+ repo_root: Path,
953
+ base_tip: str,
954
+ user_paths: tuple[str, ...],
955
+ evidence_root: str,
956
+ allow_chain: bool,
957
+ tools_root: Path | None = None,
958
+ ) -> Binding:
959
+ """Evaluate one checkout against one base: single ledger, then manifest, then chain.
960
+
961
+ `tools_root` names the tree whose controller and validator judge the
962
+ candidate; it defaults to the checkout itself and is the landing tree when a
963
+ historical round is rebound, so a round never judges itself with its own tools.
964
+ """
965
+ binding = Binding()
966
+ tools = tools_root if tools_root is not None else repo_root
967
+ fork, excludes, paths, changed = candidate_scope(repo_root, base_tip, user_paths)
968
+ binding.fork = fork
969
+ binding.changed = changed
707
970
  if not changed:
708
971
  # No reviewed path moved, so there is no candidate to freeze and nothing to
709
972
  # bind. Say which it is rather than letting an empty packet surface as a
710
973
  # freeze error, which reads like a broken gate.
711
- if args.print_candidate or args.print_manifest:
712
- emit(f"review_ledger_binding_no_change: no reviewed-path change against {base}")
713
- else:
714
- print(f"review_ledger_binding_ok: no reviewed-path change against {base}")
715
- return 0
716
-
717
- module = load_controller(repo_root)
718
-
719
- if args.print_manifest:
720
- try:
721
- manifest = render_manifest(module, repo_root, base, args.partition, excludes, changed)
722
- except ManifestError as exc:
723
- emit(f"review_ledger_binding_error: cannot render a landing partition manifest: {exc}")
724
- return 1
725
- except Exception as exc: # noqa: BLE001 - surface the controller's own message
726
- emit(f"review_ledger_binding_error: cannot freeze a partition packet: {exc}")
727
- return 1
728
- print(json.dumps(manifest, indent=2, ensure_ascii=False))
729
- return 0
974
+ binding.ok = True
975
+ binding.summary = f"no reviewed-path change against {fork}"
976
+ return binding
730
977
 
978
+ module = load_controller(tools)
731
979
  # The whole candidate may be larger than one packet. That is no longer a
732
980
  # terminal error: record why the single freeze failed and let a committed
733
981
  # partition manifest bind the candidate part by part.
734
982
  expected: str | None = None
735
983
  whole_error: str | None = None
736
984
  try:
737
- expected = candidate_hash(module, repo_root, base, paths)
985
+ expected = candidate_hash(module, repo_root, fork, paths)
738
986
  except Exception as exc: # noqa: BLE001 - surface the controller's own message
739
987
  whole_error = str(exc)
740
988
 
741
- if args.print_candidate:
742
- if expected is None:
743
- emit(f"review_ledger_binding_error: cannot freeze the candidate packet: {whole_error}")
744
- return 1
745
- print(expected)
746
- return 0
747
-
748
- validator = repo_root / "skills" / "skill-extraction-workflow" / "scripts" / VALIDATOR
749
- evidence = scan(repo_root, args.evidence_root)
989
+ validator = tools / "skills" / "skill-extraction-workflow" / "scripts" / VALIDATOR
990
+ evidence = scan(repo_root, evidence_root)
750
991
  ledgers: list[str] = []
751
992
  if expected is not None:
752
993
  # Only a validator-accepted ledger counts. A receipt-shaped file proves
@@ -755,11 +996,12 @@ def main() -> int:
755
996
  # contributor can hand-write.
756
997
  proof = accepted_ledger_for(evidence, repo_root, validator, expected, ledgers)
757
998
  if proof is not None:
758
- print(
759
- f"review_ledger_binding_ok: {proof.split(' -- ', 1)[0]} binds the landing "
760
- f"candidate ({expected[:12]}...) -- {proof.split(' -- ', 1)[1]}"
999
+ binding.ok = True
1000
+ binding.summary = (
1001
+ f"{proof.split(' -- ', 1)[0]} binds the landing candidate "
1002
+ f"({expected[:12]}...) -- {proof.split(' -- ', 1)[1]}"
761
1003
  )
762
- return 0
1004
+ return binding
763
1005
 
764
1006
  manifests: list[str] = []
765
1007
  for path, payload in evidence:
@@ -768,7 +1010,7 @@ def main() -> int:
768
1010
  relative = str(path.relative_to(repo_root))
769
1011
  try:
770
1012
  proofs = bind_manifest(
771
- module, repo_root, base, payload, excludes, changed, evidence, validator, ledgers
1013
+ module, repo_root, fork, payload, excludes, changed, evidence, validator, ledgers
772
1014
  )
773
1015
  except ManifestError as exc:
774
1016
  manifests.append(f"{relative}: {exc}")
@@ -776,40 +1018,149 @@ def main() -> int:
776
1018
  except Exception as exc: # noqa: BLE001 - surface the controller's own message
777
1019
  manifests.append(f"{relative}: cannot freeze a partition packet: {exc}")
778
1020
  continue
779
- print(
780
- f"review_ledger_binding_ok: {relative} binds the landing candidate as "
781
- f"{len(proofs)} partitions (aggregate {payload['candidate_sha256'][:12]}...)"
1021
+ binding.ok = True
1022
+ binding.summary = (
1023
+ f"{relative} binds the landing candidate as {len(proofs)} partitions "
1024
+ f"(aggregate {payload['candidate_sha256'][:12]}...)"
1025
+ )
1026
+ binding.proofs = proofs
1027
+ return binding
1028
+
1029
+ chain_rows: list[str] = []
1030
+ if allow_chain and tuple(user_paths) == DEFAULT_PATHS:
1031
+ try:
1032
+ rounds, proofs = bind_chain(repo_root, base_tip, changed, evidence_root)
1033
+ except ChainError as exc:
1034
+ chain_rows.append(str(exc))
1035
+ else:
1036
+ binding.ok = True
1037
+ binding.summary = (
1038
+ f"the first-parent chain binds the landing candidate as {rounds} rounds "
1039
+ f"({fork[:12]}..HEAD)"
1040
+ )
1041
+ binding.proofs = proofs
1042
+ return binding
1043
+ elif allow_chain:
1044
+ chain_rows.append(
1045
+ "first-parent chain binding is evaluated only over the default path set "
1046
+ "(--paths .); a narrowed scope has no round-level ledger to bind"
782
1047
  )
783
- for line in proofs:
784
- print(line)
785
- return 0
786
1048
 
787
1049
  if expected is None:
788
- emit(
1050
+ binding.failure.append(
789
1051
  "review_ledger_binding_failed: the whole candidate cannot be frozen as one "
790
1052
  f"packet ({whole_error}) and no committed landing partition manifest binds it"
791
1053
  )
792
- emit(
1054
+ binding.failure.append(
793
1055
  " split the candidate by path: --print-manifest --partition <paths> "
794
1056
  "[--partition <paths> ...] renders the manifest; commit it with one "
795
1057
  "validated ledger per partition"
796
1058
  )
797
1059
  else:
798
- emit(
1060
+ binding.failure.append(
799
1061
  "review_ledger_binding_failed: no accepted review evidence binds the landing "
800
1062
  f"candidate {expected}"
801
1063
  )
802
- emit(f" reviewed paths: {' '.join(paths)} against {base}")
803
- emit(f" changed files: {len(changed)}")
804
- for row in ledgers:
805
- emit(f" rejected ledger -> {row}")
806
- for row in manifests:
807
- emit(f" rejected manifest -> {row}")
1064
+ binding.failure.append(f" reviewed paths: {' '.join(paths)} against {fork}")
1065
+ binding.failure.append(f" changed files: {len(changed)}")
1066
+ binding.failure.extend(f" rejected ledger -> {row}" for row in ledgers)
1067
+ binding.failure.extend(f" rejected manifest -> {row}" for row in manifests)
1068
+ binding.failure.extend(f" rejected chain -> {row}" for row in chain_rows)
808
1069
  if not ledgers and not manifests:
809
- emit(
1070
+ binding.failure.append(
810
1071
  " no committed ledger records this candidate; run the extraction review "
811
1072
  "lane against the final, committed tree"
812
1073
  )
1074
+ return binding
1075
+
1076
+
1077
+ def main() -> int:
1078
+ parser = argparse.ArgumentParser(description=__doc__)
1079
+ parser.add_argument("--repo-root", default=".")
1080
+ parser.add_argument("--base", default=None)
1081
+ parser.add_argument(
1082
+ "--allow-unevaluated",
1083
+ action="store_true",
1084
+ help="permit a run with no resolvable base to exit 0, for events that have none",
1085
+ )
1086
+ parser.add_argument("--evidence-root", default="specs")
1087
+ parser.add_argument("--paths", nargs="*", default=list(DEFAULT_PATHS))
1088
+ parser.add_argument(
1089
+ "--print-candidate",
1090
+ action="store_true",
1091
+ help="print the candidate hash the evidence must bind, then exit",
1092
+ )
1093
+ parser.add_argument(
1094
+ "--print-manifest",
1095
+ action="store_true",
1096
+ help=(
1097
+ "render a landing partition manifest for the --partition groups given, "
1098
+ "with every hash computed by this gate, then exit"
1099
+ ),
1100
+ )
1101
+ parser.add_argument(
1102
+ "--partition",
1103
+ action="append",
1104
+ nargs="+",
1105
+ metavar="PATH",
1106
+ default=[],
1107
+ help="one partition's paths; repeat per partition (only with --print-manifest)",
1108
+ )
1109
+ args = parser.parse_args()
1110
+ if args.print_manifest and not args.partition:
1111
+ parser.error("--print-manifest needs at least one --partition")
1112
+ if args.partition and not args.print_manifest:
1113
+ parser.error("--partition is only meaningful with --print-manifest")
1114
+
1115
+ repo_root = Path(args.repo_root).resolve()
1116
+ base = args.base or os.environ.get("CCL_SKILL_BASE_REF", "")
1117
+ if not base:
1118
+ # The gate is base-relative by construction, so a run with no base has
1119
+ # checked nothing. Exiting 0 there is how a base-relative gate becomes
1120
+ # decorative: a base-wiring mistake would read as a passing required
1121
+ # check. Fail closed; a caller whose event genuinely has no base must
1122
+ # say so out loud with --allow-unevaluated.
1123
+ emit(
1124
+ "review_ledger_binding_unevaluated: no base ref supplied "
1125
+ "(pass --base or set CCL_SKILL_BASE_REF); nothing was checked"
1126
+ )
1127
+ return 0 if args.allow_unevaluated else 2
1128
+
1129
+ base_tip = resolve_base(repo_root, base)
1130
+ user_paths = tuple(args.paths)
1131
+
1132
+ if args.print_candidate or args.print_manifest:
1133
+ fork, excludes, paths, changed = candidate_scope(repo_root, base_tip, user_paths)
1134
+ if not changed:
1135
+ emit(f"review_ledger_binding_no_change: no reviewed-path change against {fork}")
1136
+ return 0
1137
+ module = load_controller(repo_root)
1138
+ if args.print_manifest:
1139
+ try:
1140
+ manifest = render_manifest(module, repo_root, fork, args.partition, excludes, changed)
1141
+ except ManifestError as exc:
1142
+ emit(f"review_ledger_binding_error: cannot render a landing partition manifest: {exc}")
1143
+ return 1
1144
+ except Exception as exc: # noqa: BLE001 - surface the controller's own message
1145
+ emit(f"review_ledger_binding_error: cannot freeze a partition packet: {exc}")
1146
+ return 1
1147
+ print(json.dumps(manifest, indent=2, ensure_ascii=False))
1148
+ return 0
1149
+ try:
1150
+ print(candidate_hash(module, repo_root, fork, paths))
1151
+ except Exception as exc: # noqa: BLE001 - surface the controller's own message
1152
+ emit(f"review_ledger_binding_error: cannot freeze the candidate packet: {exc}")
1153
+ return 1
1154
+ return 0
1155
+
1156
+ binding = bind_candidate(repo_root, base_tip, user_paths, args.evidence_root, allow_chain=True)
1157
+ if binding.ok:
1158
+ print(f"review_ledger_binding_ok: {binding.summary}")
1159
+ for line in binding.proofs:
1160
+ print(line)
1161
+ return 0
1162
+ for line in binding.failure:
1163
+ emit(line)
813
1164
  return 1
814
1165
 
815
1166