@ccoalm/ccl-skills 0.10.0 → 0.12.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 (38) hide show
  1. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/SKILL.md +4 -3
  2. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/staged-review-contract.md +18 -0
  3. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/review_gate.py +178 -4
  4. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate.sh +127 -0
  5. package/dist/assets/marketplace/plugins/ccl-skills/skills/defect-diagnosis/SKILL.md +2 -1
  6. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/SKILL.md +1 -1
  7. package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/SKILL.md +1 -0
  8. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/SKILL.md +11 -1
  9. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/async-lifecycle-and-performance.md +16 -0
  10. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/source-map.md +1 -0
  11. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/SKILL.md +1 -1
  12. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/delivery-lifecycle.md +1 -1
  13. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/dispatch-owner-skills.md +9 -1
  14. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/problem-resolution-and-learning.md +2 -0
  15. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/SKILL.md +1 -1
  16. package/dist/assets/marketplace/plugins/ccl-skills/skills/release-coordination/references/tag-and-prod-pipeline-gate.md +9 -0
  17. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +9 -9
  18. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/dual-track-review-gate.md +7 -23
  19. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/external-practice-controls.md +16 -0
  20. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-quickstart.md +1 -1
  21. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/rule-consolidation.md +2 -0
  22. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +29 -0
  23. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-to-skill-extraction.md +2 -2
  24. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/validation-and-landing.md +2 -0
  25. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh +5 -0
  26. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/contract-anchors.tsv +1 -0
  27. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/impact-chain-gate.rb +35 -6
  28. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/review_ledger_binding.py +817 -0
  29. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_impact_chain_refscripts.sh +81 -1
  30. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +3 -0
  31. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_review_ledger_binding.sh +521 -0
  32. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_routing_pointer_integrity.sh +41 -1
  33. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_extraction_review_state.sh +116 -9
  34. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/validate_extraction_review_state.py +123 -32
  35. package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/SKILL.md +1 -1
  36. package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/SKILL.md +9 -8
  37. package/dist/assets/release.json +47 -37
  38. package/package.json +1 -1
@@ -0,0 +1,817 @@
1
+ #!/usr/bin/env python3
2
+ """Require the landing candidate to be the candidate an external round reviewed.
3
+
4
+ The dual-track ledger is caller-built and caller-run. Nothing at merge time read
5
+ it, so which candidate the rounds actually inspected was unchecked: a chain could
6
+ close out on the pre-fix candidate while a different tree merged. This gate closes
7
+ that gap from the merge side.
8
+
9
+ The reviewed identity is the packet the controller froze, so this recomputes that
10
+ packet with the controller's own `freeze_packet` rather than a second
11
+ implementation of the same bytes -- two implementations of one hash drift, and the
12
+ drift would read as a forged ledger. The bound set is every tracked path, minus
13
+ exactly what this round adds under a round's evidence directory. It is a default
14
+ of everything rather than a whitelist because a whitelist binds only the paths
15
+ some round happened to review: a pull request could carry a ledger valid for its
16
+ skill changes while also landing a root build script, a release script, or any
17
+ other executable path, and because that content does not move the candidate the
18
+ existing ledger still passed and the unreviewed content merged. Binding
19
+ everything makes the default fail-closed -- a new top-level path is bound the day
20
+ it appears rather than the day somebody remembers to add it. The cost is stated
21
+ rather than hidden: a change confined to documentation now needs a ledger too,
22
+ which is the direction this repository has already chosen for shared gates, where
23
+ a false positive is cheaper than a false negative.
24
+
25
+ The exclusion is computed per run (`added_evidence_paths`) rather than written
26
+ down as a subtree, and the difference is load-bearing. Something must be outside
27
+ the candidate or no ledger could ever be committed: a receipt inside the bound set
28
+ would move the very hash it records. But excluding all of `specs/` would exclude
29
+ far more than that -- a pull request could delete or rewrite an earlier round's
30
+ plan and receipts, the committed review history itself, and none of it would reach
31
+ the candidate, so the gate would pass while that history was corrupted. Only the
32
+ paths this round ADDS under a round's own `<round>/evidence/` directory are excluded. Every
33
+ modification and deletion under `specs/`, and every added path outside an evidence
34
+ directory, is bound like any other file. What remains outside is narrow and worth
35
+ naming: a file added under an EARLIER round's evidence directory is excluded too,
36
+ because the rule is structural rather than round-aware.
37
+
38
+ The candidate must also be committed (`require_committed_tree`). The frozen packet
39
+ is built from the working tree and includes untracked files, so a scratch file or
40
+ an unstaged edit inside the bound paths would silently produce a hash no clean
41
+ checkout recomputes -- the author records it in the ledger, and the merge-side run
42
+ then reports that nothing binds the landing candidate. Refusing out loud costs a
43
+ commit; the alternative costs a review round nobody can reproduce. The workflow
44
+ directory stays bound, as it was before: with only `skills/` bound, deleting the CI
45
+ step that runs this gate would not move the candidate the evidence has to match.
46
+
47
+ The candidate's Git identity and the reviewer's input are two different sizes.
48
+ A landing candidate is base..HEAD and has no natural byte limit; a review packet
49
+ is what one reviewer can read whole, and the controller caps it at
50
+ `MAX_PACKET_BYTES`. Binding the landing candidate to ONE packet hash therefore
51
+ made the reviewer's ceiling the pull request's ceiling: a candidate larger than
52
+ one packet could not be frozen, no ledger could ever bind it, and authors split
53
+ the pull request instead of the review -- eight merges for one release. The
54
+ review side already allowed splitting a large candidate by path into partitions
55
+ (the `code-review` skill's packet rule); what was missing was the merge side
56
+ consuming them. A committed landing partition manifest closes that: it names
57
+ path partitions whose changed files together equal the candidate's changed files
58
+ exactly once, and each partition's `candidate_sha256`, which is what
59
+ `--print-candidate --paths <partition>` already answers. This gate recomputes
60
+ every partition with the same `freeze_packet`, requires a validator-accepted
61
+ ledger per partition, and refuses any manifest whose parts do not add up to the
62
+ whole: an uncovered file, an overlapping file, a partition that no longer
63
+ reproduces, a base other than the fork point, or an aggregate hash that does not
64
+ reproduce its own partitions. The manifest carries a top-level 64-hex
65
+ `candidate_sha256` -- the aggregate identity -- so it satisfies the existing
66
+ receipt predicate and committing it moves no partition; that is the load-bearing
67
+ reason for the field, and the exclusion predicate itself is unchanged. What the
68
+ manifest proves is the same narrow thing the single ledger proves, taken per
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.
72
+
73
+ Boundaries this gate does NOT close, stated because a gate that lives inside the
74
+ candidate cannot authenticate itself: it cannot prove the caller retained every
75
+ earlier chain; it runs the candidate's own validator, so a candidate that also
76
+ rewrites that validator is outside what any in-repo check can settle; and a pull
77
+ request may edit the workflow step that runs it. The fail-closed branch is pinned
78
+ as a contract anchor so hollowing it also edits a registry another required check
79
+ verifies; the workflow step itself is NOT pinned, because that registry addresses
80
+ skill files and a cross-tree row breaks the checker against its own fixtures. The terminal authority is the platform's
81
+ required-check configuration plus human review of changes to this gate itself,
82
+ both of which live outside the candidate. What this proves is narrow and worth
83
+ stating plainly: that what merges is the candidate an external round inspected --
84
+ not that the round was honest, and not that it found nothing. Any terminal state
85
+ the validator accepts satisfies this gate, including one that carries unresolved
86
+ findings forward for a human: this binds identity, and the verdict on the findings
87
+ stays with the human who merges.
88
+ """
89
+
90
+ from __future__ import annotations
91
+
92
+ import sys
93
+
94
+ # Importing the controller must not perturb the tree this gate hashes: a written
95
+ # __pycache__ lands as an untracked binary file inside the reviewed paths and the
96
+ # packet freeze then fails on it. Set before any import that can write bytecode.
97
+ sys.dont_write_bytecode = True
98
+
99
+ import argparse
100
+ import hashlib
101
+ import importlib.util
102
+ import json
103
+ import os
104
+ import re
105
+ import subprocess
106
+ import time
107
+ import types
108
+ from pathlib import Path
109
+
110
+ VALIDATOR = "validate_extraction_review_state.py"
111
+ CONTROLLER = Path("skills") / "code-review" / "scripts" / "review_gate.py"
112
+ # Every tracked path. See the module docstring: the inversion is what stops an
113
+ # unreviewed path from riding along on a valid ledger. The only exclusion is
114
+ # computed per run by `added_evidence_paths` -- the receipts this round adds --
115
+ # because a written-down subtree would also hide edits to committed history.
116
+ EVIDENCE_ROOT = "specs"
117
+ EVIDENCE_MEMBER = re.compile(r"^specs/[^/]+/evidence/")
118
+ # A receipt is small; anything larger is not one, and reading it is not free.
119
+ MAX_RECEIPT_BYTES = 4_000_000
120
+ DEFAULT_PATHS = (".",)
121
+ # A landing partition manifest splits one candidate by path into packets a
122
+ # reviewer can read whole. Its shape is closed: exactly these keys, this kind,
123
+ # and a bounded partition count, so a manifest cannot double as a ledger or
124
+ # smuggle fields the gate does not read.
125
+ MANIFEST_KIND = "landing_partition_manifest"
126
+ MANIFEST_SCHEMA_VERSION = 1
127
+ MANIFEST_KEYS = {"schema_version", "kind", "base", "partitions", "candidate_sha256"}
128
+ PARTITION_KEYS = {"paths", "candidate_sha256"}
129
+ MAX_PARTITIONS = 64
130
+ HEX40 = re.compile(r"^[0-9a-f]{40}$")
131
+ HEX64 = re.compile(r"^[0-9a-f]{64}$")
132
+
133
+
134
+ class ManifestError(Exception):
135
+ """A manifest that does not describe this candidate; the message is the reason."""
136
+
137
+
138
+ def emit(message: str) -> None:
139
+ print(message, file=sys.stderr)
140
+
141
+
142
+ def load_controller(repo_root: Path) -> types.ModuleType:
143
+ controller_path = repo_root / CONTROLLER
144
+ if not controller_path.is_file():
145
+ raise SystemExit(
146
+ f"review_ledger_binding_error: controller not found at {controller_path}"
147
+ )
148
+ spec = importlib.util.spec_from_file_location(
149
+ "ccl_review_gate_for_binding", controller_path
150
+ )
151
+ if spec is None or spec.loader is None:
152
+ raise SystemExit("review_ledger_binding_error: controller is not importable")
153
+ module = importlib.util.module_from_spec(spec)
154
+ spec.loader.exec_module(module)
155
+ return module
156
+
157
+
158
+ def resolve_base(repo_root: Path, base: str) -> str:
159
+ """Resolve the caller's base to a commit id before it reaches any git command.
160
+
161
+ An option-shaped base (``--quiet``) is read by git as an option rather than a
162
+ revision, and ``git diff`` with no revision compares the index to the working
163
+ tree: in a clean checkout that reports no paths at all, so the gate would pass
164
+ having compared nothing.
165
+ """
166
+ result = subprocess.run(
167
+ [
168
+ "git",
169
+ "-C",
170
+ str(repo_root),
171
+ "rev-parse",
172
+ "--verify",
173
+ "--quiet",
174
+ "--end-of-options",
175
+ f"{base}^{{commit}}",
176
+ ],
177
+ stdout=subprocess.PIPE,
178
+ stderr=subprocess.PIPE,
179
+ text=True,
180
+ check=False,
181
+ )
182
+ resolved = result.stdout.strip()
183
+ if result.returncode != 0 or len(resolved) != 40 or not all(
184
+ character in "0123456789abcdef" for character in resolved
185
+ ):
186
+ raise SystemExit(
187
+ f"review_ledger_binding_error: base does not resolve to a commit: {base}"
188
+ )
189
+ return resolved
190
+
191
+
192
+ def fork_point(repo_root: Path, base: str) -> str:
193
+ """Compare against where this branch left the base, not the base's tip.
194
+
195
+ A candidate measured against the tip absorbs every unrelated change the base
196
+ branch gained meanwhile, so an advance on the target branch silently restates
197
+ what this branch is and voids evidence that is still correct. The fork point
198
+ is what the branch actually adds, and it does not move when someone else
199
+ merges.
200
+ """
201
+ result = subprocess.run(
202
+ ["git", "-C", str(repo_root), "merge-base", base, "HEAD"],
203
+ stdout=subprocess.PIPE,
204
+ stderr=subprocess.PIPE,
205
+ text=True,
206
+ check=False,
207
+ )
208
+ resolved = result.stdout.strip()
209
+ if result.returncode != 0 or len(resolved) != 40:
210
+ raise SystemExit(
211
+ f"review_ledger_binding_error: no fork point between HEAD and {base}"
212
+ )
213
+ return resolved
214
+
215
+
216
+ def added_evidence_paths(repo_root: Path, base: str) -> list[str]:
217
+ """Paths this round ADDS under a round's evidence directory.
218
+
219
+ These are the only paths the candidate may exclude, and the predicate is what
220
+ the file IS, not where it sits. Two earlier shapes of this exclusion were
221
+ each broken by an adversarial round, and both failures were the same one: the
222
+ rule named a location and the location stood in for "this is a receipt".
223
+ Excluding all of `specs/` let a pull request delete or rewrite an earlier
224
+ round's plan and receipts -- the committed review history itself -- with no
225
+ evidence required. Narrowing that to added paths under an evidence directory
226
+ then let an arbitrary added file there, a script included, ride through
227
+ unreviewed for exactly the same reason.
228
+
229
+ So the third shape stops using the path as a proxy. A file is excluded only
230
+ when it is what the exclusion exists for: a committed JSON object carrying
231
+ the 64-hex `candidate_sha256` that makes it a receipt about some candidate.
232
+ Its directory still has to be a round's evidence directory, because that is
233
+ where receipts belong, but the directory alone no longer buys exclusion.
234
+ Anything else added there -- a script, a fixture, a data file, a JSON file
235
+ with no candidate binding -- is bound like any other path, as is every
236
+ modification and deletion under `specs/`.
237
+ """
238
+ result = subprocess.run(
239
+ [
240
+ "git", "-C", str(repo_root), "diff", "--name-only",
241
+ "--diff-filter=A", base, "HEAD", "--", EVIDENCE_ROOT,
242
+ ],
243
+ stdout=subprocess.PIPE,
244
+ stderr=subprocess.PIPE,
245
+ text=True,
246
+ check=False,
247
+ )
248
+ if result.returncode != 0:
249
+ raise SystemExit(
250
+ "review_ledger_binding_error: cannot enumerate added evidence: "
251
+ f"{result.stderr.strip()}"
252
+ )
253
+ excluded: list[str] = []
254
+ for line in result.stdout.splitlines():
255
+ if not EVIDENCE_MEMBER.match(line):
256
+ continue
257
+ if is_candidate_receipt(repo_root, line):
258
+ excluded.append(line)
259
+ return excluded
260
+
261
+
262
+ def is_candidate_receipt(repo_root: Path, path_value: str) -> bool:
263
+ """Whether the committed blob at this path is a receipt about a candidate.
264
+
265
+ Read from the object store rather than the working tree: the exclusion has to
266
+ describe what merges. A blob that is not JSON, is not an object, or carries no
267
+ 64-hex `candidate_sha256` is not a receipt, whatever it is named or wherever
268
+ it sits, and it stays inside the candidate.
269
+ """
270
+ result = subprocess.run(
271
+ ["git", "-C", str(repo_root), "cat-file", "blob", f"HEAD:{path_value}"],
272
+ stdout=subprocess.PIPE,
273
+ stderr=subprocess.PIPE,
274
+ check=False,
275
+ )
276
+ if result.returncode != 0 or len(result.stdout) > MAX_RECEIPT_BYTES:
277
+ return False
278
+ try:
279
+ payload = json.loads(result.stdout.decode("utf-8"))
280
+ except (UnicodeDecodeError, json.JSONDecodeError):
281
+ return False
282
+ if not isinstance(payload, dict):
283
+ return False
284
+ binding = payload.get("candidate_sha256")
285
+ return (
286
+ isinstance(binding, str)
287
+ and len(binding) == 64
288
+ and all(character in "0123456789abcdef" for character in binding)
289
+ )
290
+
291
+
292
+ def require_committed_tree(repo_root: Path, paths: tuple[str, ...]) -> None:
293
+ """Refuse a candidate the merge cannot reproduce.
294
+
295
+ The frozen packet is built from the working tree and includes untracked
296
+ files, so a scratch file or an unstaged edit inside the bound paths silently
297
+ produces a hash no clean checkout will ever recompute: the author records it
298
+ in the ledger and the merge-side run then reports that no evidence binds the
299
+ landing candidate. Refusing out loud costs a commit; the alternative costs a
300
+ review round nobody can reproduce. This is the same stance the evidence tree
301
+ already takes -- what merges is the committed tree.
302
+ """
303
+ result = subprocess.run(
304
+ ["git", "-C", str(repo_root), "status", "--porcelain", "--", *paths],
305
+ stdout=subprocess.PIPE,
306
+ stderr=subprocess.PIPE,
307
+ text=True,
308
+ check=False,
309
+ )
310
+ if result.returncode != 0:
311
+ raise SystemExit(
312
+ f"review_ledger_binding_error: cannot read tree state: {result.stderr.strip()}"
313
+ )
314
+ dirty = [line for line in result.stdout.splitlines() if line]
315
+ if dirty:
316
+ raise SystemExit(
317
+ "review_ledger_binding_error: the candidate tree carries uncommitted "
318
+ "changes, so its hash is not the one a clean checkout recomputes; "
319
+ "commit them first: " + ", ".join(entry[3:] for entry in dirty[:5])
320
+ )
321
+
322
+
323
+ def changed_skill_paths(repo_root: Path, base: str, paths: tuple[str, ...]) -> list[str]:
324
+ result = subprocess.run(
325
+ ["git", "-C", str(repo_root), "diff", "--name-only", base, "--", *paths],
326
+ stdout=subprocess.PIPE,
327
+ stderr=subprocess.PIPE,
328
+ text=True,
329
+ check=False,
330
+ )
331
+ if result.returncode != 0:
332
+ raise SystemExit(
333
+ f"review_ledger_binding_error: cannot diff against {base}: {result.stderr.strip()}"
334
+ )
335
+ return [line for line in result.stdout.splitlines() if line]
336
+
337
+
338
+ def candidate_hash(module: types.ModuleType, repo_root: Path, base: str, paths: tuple[str, ...]) -> str:
339
+ args = argparse.Namespace(
340
+ cwd=str(repo_root),
341
+ diff_file=None,
342
+ base=base,
343
+ paths=list(paths),
344
+ wording_only_proof_file=None,
345
+ )
346
+ packet_path, packet_sha256, _paths, _secrets = module.freeze_packet(
347
+ args, time.monotonic() + 120
348
+ )
349
+ try:
350
+ Path(packet_path).unlink(missing_ok=True)
351
+ except OSError:
352
+ pass
353
+ return packet_sha256
354
+
355
+
356
+ def canonical_digest(value: object) -> str:
357
+ canonical = json.dumps(
358
+ value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
359
+ ).encode("utf-8")
360
+ return hashlib.sha256(canonical).hexdigest()
361
+
362
+
363
+ def manifest_aggregate(base: str, partitions: list[dict]) -> str:
364
+ """The aggregate identity a manifest carries as its own `candidate_sha256`.
365
+
366
+ Computed here and nowhere else: the renderer writes it and the gate rechecks
367
+ it, so two implementations of one hash cannot drift into a forged manifest.
368
+ """
369
+ return canonical_digest(
370
+ {
371
+ "schema_version": MANIFEST_SCHEMA_VERSION,
372
+ "kind": MANIFEST_KIND,
373
+ "base": base,
374
+ "partitions": partitions,
375
+ }
376
+ )
377
+
378
+
379
+ def validate_partition_path(value: object) -> str:
380
+ """A partition path is a plain relative path, never a pathspec.
381
+
382
+ The gate appends its own exclusions; a manifest that could name `:(exclude)`
383
+ or a glob would choose what its partition does not cover, and a leading `-`
384
+ would reach git as an option. Reject the shape here rather than trust git to
385
+ interpret it the way the manifest author hoped.
386
+ """
387
+ if not isinstance(value, str) or not value:
388
+ raise ManifestError("partition path must be a non-empty string")
389
+ if value.startswith((":", "-", "/")) or any(ord(ch) < 32 for ch in value):
390
+ raise ManifestError(f"partition path is not a plain relative path: {value!r}")
391
+ # Git reads `*`, `?`, `[` and `\` inside a pathspec as glob/escape syntax even
392
+ # without magic; a wildcard partition would let the manifest choose its own
393
+ # coverage, so it is refused as a path rather than handed to git.
394
+ if any(ch in value for ch in "*?[]\\"):
395
+ raise ManifestError(f"partition path contains a pathspec wildcard: {value!r}")
396
+ if ".." in value.split("/"):
397
+ raise ManifestError(f"partition path escapes the repository: {value!r}")
398
+ return value
399
+
400
+
401
+ def parse_manifest(payload: dict, fork: str) -> tuple[list[list[str]], list[str]]:
402
+ """Return (partition path lists, partition hashes) or raise ManifestError.
403
+
404
+ Order of checks is cheapest first and each failure names one reason, so an
405
+ author reads which part failed to add up rather than a generic refusal.
406
+ """
407
+ if set(payload) != MANIFEST_KEYS:
408
+ raise ManifestError("manifest does not carry exactly the manifest keys")
409
+ if payload["schema_version"] != MANIFEST_SCHEMA_VERSION or type(payload["schema_version"]) is not int:
410
+ raise ManifestError(f"manifest schema_version must be {MANIFEST_SCHEMA_VERSION}")
411
+ base = payload["base"]
412
+ if not isinstance(base, str) or not HEX40.match(base):
413
+ raise ManifestError("manifest base must be a 40-hex commit id")
414
+ if base != fork:
415
+ raise ManifestError(
416
+ f"manifest base {base[:12]} is not this candidate's fork point {fork[:12]}"
417
+ )
418
+ partitions = payload["partitions"]
419
+ if not isinstance(partitions, list) or not 1 <= len(partitions) <= MAX_PARTITIONS:
420
+ raise ManifestError(f"manifest must list between 1 and {MAX_PARTITIONS} partitions")
421
+ path_lists: list[list[str]] = []
422
+ digests: list[str] = []
423
+ seen: set[str] = set()
424
+ for index, partition in enumerate(partitions, start=1):
425
+ if not isinstance(partition, dict) or set(partition) != PARTITION_KEYS:
426
+ raise ManifestError(f"partition {index} does not carry exactly paths and candidate_sha256")
427
+ paths = partition["paths"]
428
+ if not isinstance(paths, list) or not paths:
429
+ raise ManifestError(f"partition {index} names no paths")
430
+ validated = [validate_partition_path(value) for value in paths]
431
+ for value in validated:
432
+ if value in seen:
433
+ raise ManifestError(f"partition path is listed twice: {value}")
434
+ seen.add(value)
435
+ digest = partition["candidate_sha256"]
436
+ if not isinstance(digest, str) or not HEX64.match(digest):
437
+ raise ManifestError(f"partition {index} candidate_sha256 must be 64-hex")
438
+ path_lists.append(validated)
439
+ digests.append(digest)
440
+ aggregate = payload["candidate_sha256"]
441
+ if aggregate != manifest_aggregate(base, partitions):
442
+ raise ManifestError("manifest aggregate candidate_sha256 does not reproduce its partitions")
443
+ return path_lists, digests
444
+
445
+
446
+ def partition_coverage(
447
+ repo_root: Path,
448
+ base: str,
449
+ path_lists: list[list[str]],
450
+ excludes: tuple[str, ...],
451
+ changed_all: list[str],
452
+ ) -> list[list[str]]:
453
+ """Each partition's changed files; refuse unless they tile the candidate.
454
+
455
+ Name-only diffs are cheap, so every way the parts can fail to add up is found
456
+ before any packet is frozen: an empty partition, a changed file in no
457
+ partition, or a changed file in two. Overlap is refused rather than tolerated
458
+ because two verdicts over one file leave undefined which one covers it.
459
+ """
460
+ per_partition: list[list[str]] = []
461
+ owner: dict[str, int] = {}
462
+ overlaps: list[str] = []
463
+ for index, paths in enumerate(path_lists, start=1):
464
+ changed = changed_skill_paths(repo_root, base, tuple(paths) + excludes)
465
+ if not changed:
466
+ raise ManifestError(f"partition {index} ({' '.join(paths)}) covers no changed path")
467
+ for value in changed:
468
+ if value in owner:
469
+ overlaps.append(value)
470
+ owner[value] = index
471
+ per_partition.append(changed)
472
+ if overlaps:
473
+ raise ManifestError(
474
+ "partitions overlap on changed paths: " + ", ".join(sorted(set(overlaps))[:5])
475
+ )
476
+ uncovered = sorted(set(changed_all) - set(owner))
477
+ if uncovered:
478
+ raise ManifestError(
479
+ "changed paths uncovered by every partition: " + ", ".join(uncovered[:5])
480
+ )
481
+ # Equality, not containment: under a narrowed --paths scope a partition can
482
+ # reach changed files outside the reviewed set, and a union larger than the
483
+ # candidate is as wrong as one smaller than it.
484
+ outside = sorted(set(owner) - set(changed_all))
485
+ if outside:
486
+ raise ManifestError(
487
+ "partitions cover changed paths outside the reviewed scope: " + ", ".join(outside[:5])
488
+ )
489
+ return per_partition
490
+
491
+
492
+ def render_manifest(
493
+ module: types.ModuleType,
494
+ repo_root: Path,
495
+ base: str,
496
+ path_lists: list[list[str]],
497
+ excludes: tuple[str, ...],
498
+ changed_all: list[str],
499
+ ) -> dict:
500
+ """Build the manifest an author commits, with every hash computed by this gate."""
501
+ validated = [[validate_partition_path(value) for value in paths] for paths in path_lists]
502
+ seen: set[str] = set()
503
+ for paths in validated:
504
+ for value in paths:
505
+ if value in seen:
506
+ raise ManifestError(f"partition path is listed twice: {value}")
507
+ seen.add(value)
508
+ partition_coverage(repo_root, base, validated, excludes, changed_all)
509
+ partitions = [
510
+ {
511
+ "paths": paths,
512
+ "candidate_sha256": candidate_hash(module, repo_root, base, tuple(paths) + excludes),
513
+ }
514
+ for paths in validated
515
+ ]
516
+ return {
517
+ "schema_version": MANIFEST_SCHEMA_VERSION,
518
+ "kind": MANIFEST_KIND,
519
+ "base": base,
520
+ "partitions": partitions,
521
+ "candidate_sha256": manifest_aggregate(base, partitions),
522
+ }
523
+
524
+
525
+ def accepted_ledger_for(
526
+ evidence: list[tuple[Path, dict]],
527
+ repo_root: Path,
528
+ validator: Path,
529
+ digest: str,
530
+ rejected: list[str],
531
+ ) -> str | None:
532
+ """The first validator-accepted closeout ledger bound to `digest`, if any.
533
+
534
+ The same criterion the single-candidate path uses: a receipt-shaped file is
535
+ not evidence, only a ledger the validator accepts, because this gate cannot
536
+ authenticate that a controller minted what it reads.
537
+ """
538
+ for path, payload in evidence:
539
+ if payload.get("candidate_sha256") != digest:
540
+ continue
541
+ if "closeout_state" not in payload or "controller_receipts" not in payload:
542
+ continue
543
+ relative = str(path.relative_to(repo_root))
544
+ accepted, output = validator_accepts(validator, path)
545
+ if accepted:
546
+ return f"{relative} -- {output}"
547
+ rejected.append(f"{relative}: {output}")
548
+ return None
549
+
550
+
551
+ def bind_manifest(
552
+ module: types.ModuleType,
553
+ repo_root: Path,
554
+ base: str,
555
+ payload: dict,
556
+ excludes: tuple[str, ...],
557
+ changed_all: list[str],
558
+ evidence: list[tuple[Path, dict]],
559
+ validator: Path,
560
+ rejected_ledgers: list[str],
561
+ ) -> list[str]:
562
+ """Bind the candidate through one manifest; return per-partition proof lines.
563
+
564
+ Raises ManifestError naming the first part that does not add up.
565
+ """
566
+ path_lists, digests = parse_manifest(payload, base)
567
+ partition_coverage(repo_root, base, path_lists, excludes, changed_all)
568
+ proofs: list[str] = []
569
+ for index, (paths, recorded) in enumerate(zip(path_lists, digests), start=1):
570
+ label = f"partition {index} ({' '.join(paths)})"
571
+ actual = candidate_hash(module, repo_root, base, tuple(paths) + excludes)
572
+ if actual != recorded:
573
+ raise ManifestError(
574
+ f"{label} recorded {recorded[:12]}... but does not reproduce: the candidate now hashes to {actual[:12]}..."
575
+ )
576
+ proof = accepted_ledger_for(evidence, repo_root, validator, actual, rejected_ledgers)
577
+ if proof is None:
578
+ raise ManifestError(f"no accepted ledger binds {label} {actual}")
579
+ proofs.append(f" {label} {actual[:12]}... <- {proof}")
580
+ return proofs
581
+
582
+
583
+ def validator_accepts(validator: Path, ledger_path: Path) -> tuple[bool, str]:
584
+ result = subprocess.run(
585
+ [sys.executable, str(validator), str(ledger_path)],
586
+ stdout=subprocess.PIPE,
587
+ stderr=subprocess.STDOUT,
588
+ text=True,
589
+ check=False,
590
+ timeout=60,
591
+ )
592
+ return result.returncode == 0, result.stdout.strip()
593
+
594
+
595
+ def scan(repo_root: Path, evidence_root: str) -> list[tuple[Path, dict]]:
596
+ """Enumerate committed evidence only.
597
+
598
+ What merges is the committed tree, so evidence that is untracked or modified
599
+ in the working tree is not evidence about the landing candidate -- and a gate
600
+ that reads it would accept a ledger nobody can find after the merge. The
601
+ enumeration comes from HEAD, and a dirty evidence tree is refused outright
602
+ rather than silently read from disk.
603
+ """
604
+ listing = subprocess.run(
605
+ ["git", "-C", str(repo_root), "ls-tree", "-r", "--name-only", "HEAD", "--", evidence_root],
606
+ stdout=subprocess.PIPE,
607
+ stderr=subprocess.PIPE,
608
+ text=True,
609
+ check=False,
610
+ )
611
+ if listing.returncode != 0:
612
+ return []
613
+ dirty = subprocess.run(
614
+ ["git", "-C", str(repo_root), "status", "--porcelain", "--", evidence_root],
615
+ stdout=subprocess.PIPE,
616
+ stderr=subprocess.PIPE,
617
+ text=True,
618
+ check=False,
619
+ )
620
+ if dirty.returncode != 0 or dirty.stdout.strip():
621
+ raise SystemExit(
622
+ "review_ledger_binding_error: the evidence tree has uncommitted changes; "
623
+ "commit the ledger and its receipts before this gate can read them"
624
+ )
625
+ found: list[tuple[Path, dict]] = []
626
+ for relative in sorted(line for line in listing.stdout.splitlines() if line.endswith(".json")):
627
+ path = repo_root / relative
628
+ try:
629
+ payload = json.loads(path.read_text(encoding="utf-8"))
630
+ except (OSError, UnicodeError, json.JSONDecodeError):
631
+ continue
632
+ if isinstance(payload, dict):
633
+ found.append((path, payload))
634
+ return found
635
+
636
+
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",
645
+ )
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",
652
+ )
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
+ ),
660
+ )
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)",
668
+ )
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
+
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"
686
+ )
687
+ return 0 if args.allow_unevaluated else 2
688
+
689
+ base = fork_point(repo_root, resolve_base(repo_root, base))
690
+ # The exclusion is derived from this round's own diff, not written down as a
691
+ # subtree, so edits to committed history stay inside the candidate.
692
+ # These names come from the candidate's own tree and are handed back to git as
693
+ # pathspecs, so `literal` stops git reading a filename as a pattern. A review
694
+ # round called this a total bypass -- a receipt named `*` excluding everything
695
+ # -- and that did not reproduce: the exclusion carries the full path, so a glob
696
+ # in the filename expands only within that one evidence directory, whose other
697
+ # members are receipts anyway. The claim is recorded as narrowed rather than
698
+ # confirmed, and no test asserts a bypass this gate does not have. `literal`
699
+ # stays because interpreting these names as patterns is a capability the gate
700
+ # 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
705
+ require_committed_tree(repo_root, paths)
706
+ changed = changed_skill_paths(repo_root, base, paths)
707
+ if not changed:
708
+ # No reviewed path moved, so there is no candidate to freeze and nothing to
709
+ # bind. Say which it is rather than letting an empty packet surface as a
710
+ # 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
730
+
731
+ # The whole candidate may be larger than one packet. That is no longer a
732
+ # terminal error: record why the single freeze failed and let a committed
733
+ # partition manifest bind the candidate part by part.
734
+ expected: str | None = None
735
+ whole_error: str | None = None
736
+ try:
737
+ expected = candidate_hash(module, repo_root, base, paths)
738
+ except Exception as exc: # noqa: BLE001 - surface the controller's own message
739
+ whole_error = str(exc)
740
+
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)
750
+ ledgers: list[str] = []
751
+ if expected is not None:
752
+ # Only a validator-accepted ledger counts. A receipt-shaped file proves
753
+ # nothing on its own: this gate cannot authenticate that a controller
754
+ # minted it, so any branch keyed on a self-declared field is a bypass a
755
+ # contributor can hand-write.
756
+ proof = accepted_ledger_for(evidence, repo_root, validator, expected, ledgers)
757
+ 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]}"
761
+ )
762
+ return 0
763
+
764
+ manifests: list[str] = []
765
+ for path, payload in evidence:
766
+ if payload.get("kind") != MANIFEST_KIND:
767
+ continue
768
+ relative = str(path.relative_to(repo_root))
769
+ try:
770
+ proofs = bind_manifest(
771
+ module, repo_root, base, payload, excludes, changed, evidence, validator, ledgers
772
+ )
773
+ except ManifestError as exc:
774
+ manifests.append(f"{relative}: {exc}")
775
+ continue
776
+ except Exception as exc: # noqa: BLE001 - surface the controller's own message
777
+ manifests.append(f"{relative}: cannot freeze a partition packet: {exc}")
778
+ 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]}...)"
782
+ )
783
+ for line in proofs:
784
+ print(line)
785
+ return 0
786
+
787
+ if expected is None:
788
+ emit(
789
+ "review_ledger_binding_failed: the whole candidate cannot be frozen as one "
790
+ f"packet ({whole_error}) and no committed landing partition manifest binds it"
791
+ )
792
+ emit(
793
+ " split the candidate by path: --print-manifest --partition <paths> "
794
+ "[--partition <paths> ...] renders the manifest; commit it with one "
795
+ "validated ledger per partition"
796
+ )
797
+ else:
798
+ emit(
799
+ "review_ledger_binding_failed: no accepted review evidence binds the landing "
800
+ f"candidate {expected}"
801
+ )
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}")
808
+ if not ledgers and not manifests:
809
+ emit(
810
+ " no committed ledger records this candidate; run the extraction review "
811
+ "lane against the final, committed tree"
812
+ )
813
+ return 1
814
+
815
+
816
+ if __name__ == "__main__":
817
+ raise SystemExit(main())