@ccoalm/ccl-skills 0.11.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.
@@ -524,7 +524,7 @@ A **scope-cut / out-of-phase** finding (the scope-direction signal in `SKILL.md`
524
524
 
525
525
  The initial independent review plus Agent-initiated challenges share one **Agent-autonomous external-review budget of at most five rounds**. The initial review consumes round 1, so `challenge_budget` is `0..4`. Candidate edits, commits, rebases, amended plans, renamed slices, or a fresh controller invocation do not create more Agent authority. A stateless local controller cannot prove omitted history against a caller that controls its files, so the consuming workflow must preserve the complete review ledger and treat an Agent-created reset as a contract violation.
526
526
 
527
- Five is the generic `code-review` transport ceiling, not this extraction lane's spend. Non-wording Agent-autonomous extraction calls go through `scripts/extraction_review_gate.sh`, which fixes `challenge_budget=1` per chain: one review plus one challenge. **The lane spans at most two chains and three rounds; the third exists only because a fix batch moved the candidate.** Holding fixes keeps the challenge on the frozen round-1 candidate, so the batch that lands is unreviewed until a succeeding chain challenges it — and a fix touching a selected owner's `SKILL.md` or `references/**.md` moves that owner digest and ends the first chain anyway. The trigger is the candidate, never a disposition label the author writes: **landing hash equal to the challenged hash owes nothing; different owes one succession challenge bound to what lands.** There the autonomous lane ends. An authenticated human may request later review, but that is separately attributed human-requested evidence outside this chain/budget, never an additional Agent round. Unused generic capacity never authorizes automatic continuation. The closeout validator rejects referenced receipts whose recorded budget is not the wrapper-fixed value, rejects any post-chain round that is not a succession, and checks budget and ordering consistency within the caller-supplied set. `scripts/review_ledger_binding.py` is its merge-side half: it recomputes the candidate with the controller's own packet freeze and refuses a landing whose evidence binds a different one. Evidence lives outside the reviewed paths, so committing the ledger cannot move the hash it records. It cannot authenticate that the wrapper produced those receipts or that the caller retained every earlier chain or receipt. The wrapper does not mint or persist `review_chain_id` or `autonomous_review_index`: the caller still supplies both, and could start a fresh-looking chain after the final round. The validator detects bad order inside the referenced set but cannot detect a prior chain the caller omitted, so complete caller-owned ledger retention—and treating an Agent reset as a contract violation—remains part of the boundary rather than a property the local scripts prove.
527
+ Five is the generic `code-review` transport ceiling, not this extraction lane's spend. Non-wording Agent-autonomous extraction calls go through `scripts/extraction_review_gate.sh`, which fixes `challenge_budget=1` per chain: one review plus one challenge. **The lane spans at most two chains and three rounds; the third exists only because a fix batch moved the candidate.** Holding fixes keeps the challenge on the frozen round-1 candidate, so the batch that lands is unreviewed until a succeeding chain challenges it — and a fix touching a selected owner's `SKILL.md` or `references/**.md` moves that owner digest and ends the first chain anyway. The trigger is the candidate, never a disposition label the author writes: **landing hash equal to the challenged hash owes nothing; different owes one succession challenge bound to what lands.** There the autonomous lane ends. An authenticated human may request later review, but that is separately attributed human-requested evidence outside this chain/budget, never an additional Agent round. Unused generic capacity never authorizes automatic continuation. The closeout validator rejects referenced receipts whose recorded budget is not the wrapper-fixed value, rejects any post-chain round that is not a succession, and checks budget and ordering consistency within the caller-supplied set. `scripts/review_ledger_binding.py` is its merge-side half: it recomputes the candidate with the controller's own packet freeze and refuses a landing whose evidence binds a different one. Evidence lives outside the reviewed paths, so committing the ledger cannot move the hash it records. A candidate larger than one packet is not split as a pull request but as a review: `--print-manifest --partition <paths> [--partition <paths> ...]` renders a landing partition manifest whose path partitions cover every changed file exactly once, each partition hashing to what `--print-candidate --paths <partition>` answers; commit the manifest with one validated closeout ledger per partition, and the gate recomputes every partition and refuses a manifest whose parts do not add up to the whole (an uncovered or overlapping file, a partition that no longer reproduces, a base other than the fork point, or an aggregate hash that does not reproduce its partitions). It cannot authenticate that the wrapper produced those receipts or that the caller retained every earlier chain or receipt. The wrapper does not mint or persist `review_chain_id` or `autonomous_review_index`: the caller still supplies both, and could start a fresh-looking chain after the final round. The validator detects bad order inside the referenced set but cannot detect a prior chain the caller omitted, so complete caller-owned ledger retention—and treating an Agent reset as a contract violation—remains part of the boundary rather than a property the local scripts prove.
528
528
 
529
529
  **Self-hosted chains break on every fix; the budget is summed across chains, never per chain.** In a skill repository the candidate edits its own owner package by construction, so the chain's stable bindings make the dead-end the norm, not an edge case: the selected-owner digest hashes each owner package's current working tree and owners derive from the candidate's own paths, so a fix that touches any selected-owner tree ends the tracked chain (`review_chain_invalid`) — in an extraction round that is nearly every fix, while a fix confined to files outside every selected owner drifts only the candidate hash and continues in-chain — and a plan edit that changes the normalized review scope (intent, acceptance, stage/depth, risk tags, budget) ends it as `review_scope_changed` — a self-review- or evidence-only plan refresh keeps the scope digest and the chain (binding mechanics are owned by the staged review contract in `code-review`). A chain restarted at index 1 after such a break spends the SAME Agent-autonomous budget. Treating each restarted chain as a procedurally required fresh review loop is the observed way the budget hollows out: two consecutive extraction rounds ran 20+ reviewer rounds and then 12 restarted chains — 21 reviewer invocations to land a three-line diff — each restart looking locally mandatory. When a round returns findings, walk this enumeration before any further external call:
530
530
 
@@ -119,7 +119,7 @@ For maintainers running a fresh codebase / Figma / doc extraction. Read this fir
119
119
  - File: `~/.<host>/skills/.extraction-work/<project>-completion.md`
120
120
  - Final state: which batches done, which deferred, which sources unavailable.
121
121
  - Lessons: what surprised; what would change in next extraction; what to add to skill-extraction-workflow.
122
- - For every non-wording review chain, build the receipt-bound closeout ledger and run `scripts/validate_extraction_review_state.py <closeout.json>` before reporting a terminal state. Ask `scripts/review_ledger_binding.py --print-candidate` what the landing candidate hashes to first: if the held fix batch moved it, the ledger owes the succession challenge bound to that hash, and the same script is the merge-side gate that refuses a landing whose evidence binds a different candidate. A clean Round 2 challenge plus its exact-candidate completion receipt may validate as `ready_for_human_decision`; Round 2 findings at the exhausted budget validate as `continuation_authorization_required`; a second ordered base drift validates as `baseline_race`. Unknown, stale, omitted, or invalid evidence remains `interim`. The strict wording-only single-review path records its independent review row and does not fabricate a multi-round ledger — but note the cost the merge-side gate imposes on it: that gate accepts only a validator-checked ledger, because it cannot authenticate a hand-writable receipt, so a wording-only change that touches the bound paths still owes the two-round chain before it can land.
122
+ - For every non-wording review chain, build the receipt-bound closeout ledger and run `scripts/validate_extraction_review_state.py <closeout.json>` before reporting a terminal state. Ask `scripts/review_ledger_binding.py --print-candidate` what the landing candidate hashes to first: if the held fix batch moved it, the ledger owes the succession challenge bound to that hash, and the same script is the merge-side gate that refuses a landing whose evidence binds a different candidate. When the whole candidate exceeds one packet, split the review rather than the pull request: `--print-manifest --partition <paths> ...` renders a landing partition manifest, and one validated ledger per partition plus the committed manifest is what the gate binds. A clean Round 2 challenge plus its exact-candidate completion receipt may validate as `ready_for_human_decision`; Round 2 findings at the exhausted budget validate as `continuation_authorization_required`; a second ordered base drift validates as `baseline_race`. Unknown, stale, omitted, or invalid evidence remains `interim`. The strict wording-only single-review path records its independent review row and does not fabricate a multi-round ledger — but note the cost the merge-side gate imposes on it: that gate accepts only a validator-checked ledger, because it cannot authenticate a hand-writable receipt, so a wording-only change that touches the bound paths still owes the two-round chain before it can land.
123
123
 
124
124
  ### 5. Provenance migration
125
125
 
@@ -520,3 +520,5 @@ Round 073-receipt-bundling rows (new table so the entry renders as a table row a
520
520
  | A probe that cannot tell "this checkout cannot be measured" from "the thing being measured is broken" is deleted, not patched again: the repository checker runs against synthetic fixtures inside other suites, and a smoke that reds there fails suites that have nothing to do with it | `skill-extraction-workflow` | result-class: failure; behavioral-evidence: RED-baseline; observed-failure: yes; firing-path: command:skills/skill-extraction-workflow/scripts/check-ccl-skills.sh | `updated` | Owner key `skill-extraction-workflow/SKILL.md` (entrypoint unchanged; lands in scripts/check-ccl-skills.sh). Supersedes by pointer the row above that added this smoke. Observed failure, three times in one round and each time in a suite that does not own the gate: the catalog suite, then route-drift and sync-pointers together, then the source-register lifecycle suite — every one of them runs the checker against a synthetic repository where the smoke legitimately cannot operate, and the third failure additionally exposed that its capture was not `set -e` safe, so the checker died silently mid-run before printing the verdicts those suites read. Two patches had already narrowed the predicate (skip without a controller, skip without a parent commit) and a third would have narrowed it again, which is the signal this repository already records: when the same class recurs, question whether the capability should exist. It should not. The gate's own suite owns the real-checkout path with a case that runs it against the checkout it ships in, and the CI step is the enforcement point — verified green on this candidate's own pull request. What is lost is stated rather than glossed: nothing else runs the gate during `make test-repo-gates`, so a break in it surfaces at the CI step rather than locally. RED-baseline (applied): the lifecycle suite reds against the smoke-bearing checker and is green after its removal, with the gate's own eighteen-case suite unchanged in both runs. |
521
521
  | The merge gate binds every tracked path minus exactly what this round ADDS under a round's evidence directory, and refuses a candidate tree that is not committed: a whitelist binds only the paths some round happened to review, so unreviewed executable content rode along on a valid ledger; a written-down `specs/` exclusion would additionally hide edits to the committed review history itself; and a packet frozen from a dirty working tree produces a hash no clean checkout recomputes | `skill-extraction-workflow` | result-class: failure; behavioral-evidence: RED-baseline; observed-failure: yes; firing-path: command:skills/skill-extraction-workflow/scripts/test_review_ledger_binding.sh | `updated` | Owner key `skill-extraction-workflow/SKILL.md` (entrypoint unchanged; lands in scripts/review_ledger_binding.py, its suite, and .github/workflows/ci.yml). Observed failure: the succession round of the previous round's own lane raised the whitelist half and it was deferred with its reason -- every fix moves the candidate and voids the ledger, and that lane's budget was spent -- so it was recorded for the next round that touches this gate. This round's own review and challenge then each found that the first inversion traded one hole for another, and both land here rather than as further deferrals. The review found that the frozen packet includes untracked files, so a scratch file inside the widened set produces a candidate hash only that working copy can reproduce: the author records it in the ledger and the merge-side run then reports that nothing binds the landing candidate, refusing valid work. The challenge found that excluding all of `specs/` excludes the committed review history, so a pull request could delete or rewrite an earlier round's plan and receipts with no evidence required. The exclusion is therefore computed from the round's own diff rather than written down: only added paths under a round's evidence directory stay outside, because a receipt inside the bound set would move the hash it records, while every modification and deletion under `specs/` is bound like any other file. The succession round then broke that shape too -- an arbitrary added file under an evidence directory, a script included, was excluded for the same reason -- which is the third occurrence of one class: the rule kept naming a LOCATION and letting the location stand in for `this is a receipt`. Rather than narrow the path a fourth time, the predicate moved to an invariant this gate owns: a path is excluded only when its committed blob parses as a JSON object carrying a 64-hex `candidate_sha256`, so a script, a fixture, or an unbound JSON file committed there is bound like anything else. Backward compatibility was measured, not assumed: recomputed at the previous round's own fork point, the old and new path sets produce the identical candidate hash its committed ledger records. Supersedes by pointer the merge-queue half of the row above, which recorded that the CI step now also runs for `merge_group`: the workflow's `on:` never subscribed to that event, so in a merge-queue run the workflow would not start at all and the condition read as coverage while providing none. Restoring the trigger was rejected rather than done, because a merge_group HEAD combines several queued pull requests while each committed ledger binds one individual candidate, so no ledger binds the aggregate and every otherwise-valid queued request would be refused -- the trigger would make the sentence true and the system worse. The unreachable branch is removed and the real coverage boundary is stated where the step lives. RED-baseline (applied, differential, two mutants each attributed to its own cases and nothing else): restoring the whole-subtree exclusion reds exactly the three committed-history cases -- rewriting an earlier plan, rewriting an earlier receipt, deleting an earlier receipt; removing the committed-tree refusal reds exactly the three dirty-tree cases; degenerating the receipt predicate to always-true reds exactly the three smuggled-file cases; three unbound executable paths (a root Makefile, a README, a release script) each red against the original whitelist and are refused after; the added-evidence case stays green throughout, which is what proves the self-reference exclusion survived. Control is 34 passing with no case disturbed. A fourth round then broke the content predicate too -- a JSON file carrying any 64-hex `candidate_sha256` is accepted as a receipt -- and that one is NOT fixed, deliberately. Four shapes of this exclusion have now been broken in four rounds, and every one of them was a proxy for `this is a controller-generated receipt` over a file the candidate itself supplies, which is the already-recorded boundary that a gate living inside the candidate cannot authenticate what it reads. This repository's own standard is that a class recurring across rounds is a question about the design rather than a fifth patch, so the residual is recorded for a person: accept it, or replace the exclusion mechanism outright -- binding the tree as of the commit before the evidence lands would need no exclusion predicate at all. What did close is real: history can no longer be rewritten unnoticed, and a script or binary can no longer ride in under an evidence directory. |
522
522
  | A succession may not carry the chain id of the chain it succeeds, and the controller refuses it at mint rather than leaving the refusal to the closeout validator: the validator only sees a lane it reads whole, while the controller mints one receipt at a time, so a caller that never closes a ledger never reaches that check | `code-review` | result-class: failure; behavioral-evidence: RED-baseline; observed-failure: yes; firing-path: command:skills/code-review/scripts/test_review_gate.sh | `updated` | Owner key `code-review/SKILL.md` (entrypoint unchanged this round; lands in scripts/review_gate.py and its suite). Observed failure: the previous round recorded this as a non-blocking deferral with its reason -- fixing it would have moved the controller digest and forfeited that round's ability to close its own ledger with the succession round it introduced. The severity recorded then is the one that holds now, and it is narrower than it first reads: this is not an open bypass, because `validate_extraction_review_state.py` already refuses a succession whose chain id equals the wrapper chain's. What lands is the same refusal at the point the receipt is made, which is the only place it applies to a controller run that never reaches a closeout. The equality direction is not inferred: the existing validator refusal uses the same predicate and the same words, so the intended semantics is that the two ids must differ. RED-baseline (applied, differential): a succession minted with its predecessor's own chain id reds against the pre-fix controller and is refused with its own diagnostic after, with the suite moving from 261 to 262 passing and no pre-existing case disturbed. |
523
+ | The merge gate binds a landing candidate larger than one review packet through a committed landing partition manifest: path partitions whose changed files together equal the candidate's exactly once, each recomputed with the controller's own freeze and bound by its own validated ledger, so the reviewer's byte ceiling is no longer the pull request's ceiling | `skill-extraction-workflow` | result-class: failure; behavioral-evidence: RED-baseline; observed-failure: yes; firing-path: command:skills/skill-extraction-workflow/scripts/test_review_ledger_binding.sh | `updated` | Owner key `skill-extraction-workflow/SKILL.md` (entrypoint unchanged; lands in scripts/review_ledger_binding.py, its suite, references/dual-track-review-gate.md, references/extraction-quickstart.md, and a .github/workflows/ci.yml comment). Observed failure: the gate defined the landing candidate as one packet hash, and the controller caps a packet at what one reviewer can read whole, so a candidate larger than that could not be frozen and no ledger could ever bind it -- a release whose whole diff was three times the ceiling had to land as eight separate merges, each splitting the pull request where the review side already permitted splitting the packet. The two identities are different sizes: base..HEAD has no natural byte limit, a reviewer's input does. A manifest committed under a round's evidence directory names the partitions and, for each, the hash that `--print-candidate --paths` already answers; the gate refuses any manifest whose parts do not add up to the whole -- a changed file in no partition, a changed file in two, a partition whose recorded hash no longer reproduces, a base other than the fork point, an aggregate hash that does not reproduce its partitions, or a partition path shaped like a pathspec. The manifest carries a top-level 64-hex `candidate_sha256` (the aggregate identity) so it satisfies the existing receipt predicate and committing it moves no partition; the exclusion predicate is unchanged and the accepted caller-controlled-evidence residual is not widened. `--print-manifest --partition ...` renders the manifest with every hash computed by the gate, so the canonical form lives in one place. Merge-queue aggregation of several pull requests into one HEAD is a different aggregate and stays unsolved, as the workflow comment now states. RED-baseline (applied, differential): the partition cases red 18 against the pre-fix gate with the existing 35 undisturbed; five in-place mutants -- coverage equality, disjointness, aggregate recomputation, base equality, partition-hash recomputation -- each red exactly their own cases (2, 2, 1, 1, 1) and nothing else, restored and verified after each. The candidate that triggered the round, measured at 623,458 bytes, renders as six freezable partitions. |
524
+ | The partition manifest refuses a wildcard partition path and requires the partition union to EQUAL the reviewed changed set, not merely contain it: git reads `*`, `?`, `[` and `\\` as glob syntax even in a non-magic pathspec, so a wildcard partition chooses its own coverage, and under a narrowed `--paths` scope a partition can reach changed files outside the reviewed set with no uncovered file and no overlap to refuse | `skill-extraction-workflow` | result-class: failure; behavioral-evidence: RED-baseline; observed-failure: yes; firing-path: command:skills/skill-extraction-workflow/scripts/test_review_ledger_binding.sh | `updated` | Owner key `skill-extraction-workflow/SKILL.md` (entrypoint unchanged; lands in scripts/review_ledger_binding.py and its suite). Observed failure: the round's own dual-track lane found both -- the independent review reported that `validate_partition_path` rejected only leading pathspec magic while `lane-*` passed through to git as a glob, and that `partition_coverage` checked only `changed_all - owner`, so with `--paths skills/a` a partition naming `.` covered changed files outside the scope and passed; the adversarial challenge independently hit the wildcard class on the same frozen candidate. Both are the same shape: the manifest was allowed to influence what git enumerated on its behalf. Fix: refuse the metacharacters as a path (never handed to git), and refuse a union larger than the reviewed set with the surplus named. Held until the challenge ran, then applied as one batch that moved the candidate, so the lane owes and runs one succession challenge bound to what lands. RED-baseline (applied, differential): the four wildcard shapes plus the rendering case red against the pre-fix gate and are refused after; the out-of-scope case reds against the pre-fix gate with a freeze error on the oversized `.` partition and is refused before freezing after; disabling the wildcard check reds exactly the five wildcard cases and disabling the equality check reds exactly the out-of-scope case, suite otherwise at 60 passing. |
@@ -44,6 +44,32 @@ commit; the alternative costs a review round nobody can reproduce. The workflow
44
44
  directory stays bound, as it was before: with only `skills/` bound, deleting the CI
45
45
  step that runs this gate would not move the candidate the evidence has to match.
46
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
+
47
73
  Boundaries this gate does NOT close, stated because a gate that lives inside the
48
74
  candidate cannot authenticate itself: it cannot prove the caller retained every
49
75
  earlier chain; it runs the candidate's own validator, so a candidate that also
@@ -92,6 +118,21 @@ EVIDENCE_MEMBER = re.compile(r"^specs/[^/]+/evidence/")
92
118
  # A receipt is small; anything larger is not one, and reading it is not free.
93
119
  MAX_RECEIPT_BYTES = 4_000_000
94
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."""
95
136
 
96
137
 
97
138
  def emit(message: str) -> None:
@@ -312,6 +353,233 @@ def candidate_hash(module: types.ModuleType, repo_root: Path, base: str, paths:
312
353
  return packet_sha256
313
354
 
314
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
+
315
583
  def validator_accepts(validator: Path, ledger_path: Path) -> tuple[bool, str]:
316
584
  result = subprocess.run(
317
585
  [sys.executable, str(validator), str(ledger_path)],
@@ -382,7 +650,27 @@ def main() -> int:
382
650
  action="store_true",
383
651
  help="print the candidate hash the evidence must bind, then exit",
384
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
+ )
385
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")
386
674
 
387
675
  repo_root = Path(args.repo_root).resolve()
388
676
  base = args.base or os.environ.get("CCL_SKILL_BASE_REF", "")
@@ -410,61 +698,114 @@ def main() -> int:
410
698
  # confirmed, and no test asserts a bypass this gate does not have. `literal`
411
699
  # stays because interpreting these names as patterns is a capability the gate
412
700
  # never needed, and removing it costs nothing.
413
- paths = tuple(args.paths) + tuple(
701
+ excludes = tuple(
414
702
  f":(exclude,literal){path}" for path in added_evidence_paths(repo_root, base)
415
703
  )
704
+ paths = tuple(args.paths) + excludes
416
705
  require_committed_tree(repo_root, paths)
417
706
  changed = changed_skill_paths(repo_root, base, paths)
418
707
  if not changed:
419
708
  # No reviewed path moved, so there is no candidate to freeze and nothing to
420
709
  # bind. Say which it is rather than letting an empty packet surface as a
421
710
  # freeze error, which reads like a broken gate.
422
- if args.print_candidate:
711
+ if args.print_candidate or args.print_manifest:
423
712
  emit(f"review_ledger_binding_no_change: no reviewed-path change against {base}")
424
713
  else:
425
714
  print(f"review_ledger_binding_ok: no reviewed-path change against {base}")
426
715
  return 0
427
716
 
428
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
429
736
  try:
430
737
  expected = candidate_hash(module, repo_root, base, paths)
431
738
  except Exception as exc: # noqa: BLE001 - surface the controller's own message
432
- emit(f"review_ledger_binding_error: cannot freeze the candidate packet: {exc}")
433
- return 1
739
+ whole_error = str(exc)
434
740
 
435
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
436
745
  print(expected)
437
746
  return 0
438
747
 
439
748
  validator = repo_root / "skills" / "skill-extraction-workflow" / "scripts" / VALIDATOR
749
+ evidence = scan(repo_root, args.evidence_root)
440
750
  ledgers: list[str] = []
441
- for path, payload in scan(repo_root, args.evidence_root):
442
- if payload.get("candidate_sha256") != expected:
443
- continue
444
- relative = str(path.relative_to(repo_root))
751
+ if expected is not None:
445
752
  # Only a validator-accepted ledger counts. A receipt-shaped file proves
446
753
  # nothing on its own: this gate cannot authenticate that a controller
447
754
  # minted it, so any branch keyed on a self-declared field is a bypass a
448
755
  # contributor can hand-write.
449
- if "closeout_state" in payload and "controller_receipts" in payload:
450
- accepted, output = validator_accepts(validator, path)
451
- if accepted:
452
- print(
453
- f"review_ledger_binding_ok: {relative} binds the landing candidate "
454
- f"({expected[:12]}...) -- {output}"
455
- )
456
- return 0
457
- ledgers.append(f"{relative}: {output}")
458
-
459
- emit(
460
- "review_ledger_binding_failed: no accepted review evidence binds the landing "
461
- f"candidate {expected}"
462
- )
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
+ )
463
802
  emit(f" reviewed paths: {' '.join(paths)} against {base}")
464
803
  emit(f" changed files: {len(changed)}")
465
804
  for row in ledgers:
466
805
  emit(f" rejected ledger -> {row}")
467
- if not ledgers:
806
+ for row in manifests:
807
+ emit(f" rejected manifest -> {row}")
808
+ if not ledgers and not manifests:
468
809
  emit(
469
810
  " no committed ledger records this candidate; run the extraction review "
470
811
  "lane against the final, committed tree"
@@ -307,6 +307,191 @@ out="$(run_scope --base "$SCOPE_BASE" --print-candidate)"; rc=$?
307
307
  check "the same clean checkout still reaches its normal verdict" \
308
308
  '[ "$rc" = 0 ] && case "$out" in *"no reviewed-path change"*) true;; *) false;; esac'
309
309
 
310
+ # Partition: the candidate's Git identity is base..HEAD, but the packet a reviewer
311
+ # can read is capped at the controller's byte ceiling. A landing candidate larger
312
+ # than one packet used to be unlandable as one pull request -- the whole-candidate
313
+ # freeze failed and no ledger could ever bind it -- so authors split the pull
314
+ # request instead of the review. A committed landing partition manifest names
315
+ # path partitions that together cover every changed file exactly once, each bound
316
+ # by its own validated ledger; the gate recomputes every partition and refuses any
317
+ # manifest whose parts do not add up to the whole.
318
+ PART="$WORK/partition-repo"
319
+ mkdir -p "$PART/skills/code-review/scripts" "$PART/skills/skill-extraction-workflow/scripts" "$PART/specs/round/evidence" "$PART/lane-a" "$PART/lane-b"
320
+ cp "$CONTROLLER" "$PART/skills/code-review/scripts/review_gate.py"
321
+ cat >"$PART/skills/skill-extraction-workflow/scripts/validate_extraction_review_state.py" <<'PY'
322
+ import sys
323
+ print("extraction_review_state_ok: stub accepted")
324
+ sys.exit(0)
325
+ PY
326
+ git -C "$PART" init -q .
327
+ git -C "$PART" config user.email t@example.invalid
328
+ git -C "$PART" config user.name tester
329
+ printf 'baseline\n' >"$PART/skills/skill-extraction-workflow/SKILL.md"
330
+ printf 'baseline\n' >"$PART/lane-a/big.txt"
331
+ printf 'baseline\n' >"$PART/lane-b/big.txt"
332
+ git -C "$PART" add -A && git -C "$PART" commit -qm baseline
333
+ PART_BASE="$(git -C "$PART" rev-parse HEAD)"
334
+
335
+ run_part() { python3 "$GATE" --repo-root "$PART" "$@" 2>&1; }
336
+
337
+ # The manifest oracle mirrors the documented canonical form (sorted keys, compact
338
+ # separators, UTF-8) independently of the gate, so a drift in either side reds.
339
+ cat >"$WORK/write-manifest.py" <<'PY'
340
+ import hashlib, json, sys
341
+ out, base, aggregate = sys.argv[1], sys.argv[2], sys.argv[3]
342
+ partitions = []
343
+ for spec in sys.argv[4:]:
344
+ paths, digest = spec.rsplit("=", 1)
345
+ partitions.append({"paths": paths.split(), "candidate_sha256": digest})
346
+ body = {"schema_version": 1, "kind": "landing_partition_manifest", "base": base, "partitions": partitions}
347
+ if aggregate == "AUTO":
348
+ canonical = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
349
+ aggregate = hashlib.sha256(canonical).hexdigest()
350
+ body["candidate_sha256"] = aggregate
351
+ with open(out, "w", encoding="utf-8") as handle:
352
+ json.dump(body, handle, indent=2)
353
+ handle.write("\n")
354
+ PY
355
+ write_closeout() { python3 - "$1" "$2" <<'PY'
356
+ import json, sys
357
+ from pathlib import Path
358
+ Path(sys.argv[1]).write_text(json.dumps({"schema_version": 3, "closeout_state": "ready_for_human_decision", "controller_receipts": [], "candidate_sha256": sys.argv[2]}))
359
+ PY
360
+ }
361
+
362
+ # Two lanes each change ~130 KB: either fits one packet, the pair does not.
363
+ python3 - "$PART" <<'PY'
364
+ import sys
365
+ from pathlib import Path
366
+ root = Path(sys.argv[1])
367
+ for lane in ("lane-a", "lane-b"):
368
+ lines = [f"{lane} line {index:06d} " + "x" * 40 for index in range(2200)]
369
+ (root / lane / "big.txt").write_text("\n".join(lines) + "\n")
370
+ PY
371
+ git -C "$PART" add -A && git -C "$PART" commit -qm "oversized landing"
372
+
373
+ out="$(run_part --base "$PART_BASE")"; rc=$?
374
+ check "a candidate too large for one packet is refused with the partition recipe, not a bare freeze error" \
375
+ '[ "$rc" = 1 ] && case "$out" in *"exceeds 200000 bytes"*"landing partition manifest"*) true;; *) false;; esac'
376
+
377
+ MANIFEST_JSON="$(run_part --base "$PART_BASE" --print-manifest --partition lane-a --partition lane-b)"; rc=$?
378
+ check "the gate renders a manifest for a partition that covers the whole candidate" \
379
+ '[ "$rc" = 0 ] && python3 -c "import json,sys; m=json.loads(sys.argv[1]); assert m[\"kind\"]==\"landing_partition_manifest\" and len(m[\"partitions\"])==2 and m[\"base\"]==sys.argv[2]" "$MANIFEST_JSON" "$PART_BASE"'
380
+
381
+ out="$(run_part --base "$PART_BASE" --print-manifest --partition lane-a)"; rc=$?
382
+ check "rendering refuses a partition that leaves a changed path uncovered" \
383
+ '[ "$rc" != 0 ] && case "$out" in *"uncovered"*"lane-b/big.txt"*) true;; *) false;; esac'
384
+
385
+ out="$(run_part --base "$PART_BASE" --print-manifest --partition lane-a --partition lane-a/big.txt lane-b)"; rc=$?
386
+ check "rendering refuses partitions that overlap on a changed path" \
387
+ '[ "$rc" != 0 ] && case "$out" in *"overlap"*"lane-a/big.txt"*) true;; *) false;; esac'
388
+
389
+ out="$(run_part --base "$PART_BASE" --print-manifest --partition lane-a --partition lane-a lane-b)"; rc=$?
390
+ check "rendering refuses the same path listed in two partitions before touching git" \
391
+ '[ "$rc" != 0 ] && case "$out" in *"listed twice"*"lane-a"*) true;; *) false;; esac'
392
+
393
+ HASH_A="$(run_part --base "$PART_BASE" --print-candidate --paths lane-a)"
394
+ HASH_B="$(run_part --base "$PART_BASE" --print-candidate --paths lane-b)"
395
+ check "a partition's hash is exactly what --print-candidate --paths already answers" \
396
+ '[ ${#HASH_A} = 64 ] && python3 -c "import json,sys; m=json.loads(sys.argv[1]); assert [p[\"candidate_sha256\"] for p in m[\"partitions\"]]==[sys.argv[2], sys.argv[3]]" "$MANIFEST_JSON" "$HASH_A" "$HASH_B"'
397
+
398
+ printf '%s\n' "$MANIFEST_JSON" >"$PART/specs/round/evidence/landing-partitions.json"
399
+ git -C "$PART" add -A && git -C "$PART" commit -qm "partition manifest"
400
+ out="$(run_part --base "$PART_BASE")"; rc=$?
401
+ check "a manifest whose partitions carry no validated ledger is refused, naming the partition" \
402
+ '[ "$rc" = 1 ] && case "$out" in *"no accepted ledger"*"lane-a"*) true;; *) false;; esac'
403
+ HASH_A_AFTER="$(run_part --base "$PART_BASE" --print-candidate --paths lane-a)"
404
+ check "committing the manifest does not move the partition it describes" \
405
+ '[ "$HASH_A_AFTER" = "$HASH_A" ]'
406
+
407
+ write_closeout "$PART/specs/round/evidence/closeout-a.json" "$HASH_A"
408
+ write_closeout "$PART/specs/round/evidence/closeout-b.json" "$HASH_B"
409
+ git -C "$PART" add -A && git -C "$PART" commit -qm "partition ledgers"
410
+ out="$(run_part --base "$PART_BASE")"; rc=$?
411
+ check "a complete manifest with a validated ledger per partition binds a candidate no single packet could" \
412
+ '[ "$rc" = 0 ] && case "$out" in *"binds the landing candidate"*"2 partitions"*) true;; *) false;; esac'
413
+ PART_GOOD="$(git -C "$PART" rev-parse HEAD)"
414
+
415
+ # Every way the parts can fail to add up to the whole is refused for its own
416
+ # reason. Each probe branches from the passing state so the cases stay independent.
417
+ MANIFEST="$PART/specs/round/evidence/landing-partitions.json"
418
+ probe_part() {
419
+ git -C "$PART" checkout -q -B "probe" "$PART_GOOD"
420
+ }
421
+
422
+ probe_part
423
+ python3 "$WORK/write-manifest.py" "$MANIFEST" "$PART_BASE" AUTO "lane-a=$HASH_A"
424
+ git -C "$PART" add -A && git -C "$PART" commit -qm "manifest drops a lane"
425
+ out="$(run_part --base "$PART_BASE")"; rc=$?
426
+ check "a manifest that leaves a changed path in no partition is refused" \
427
+ '[ "$rc" = 1 ] && case "$out" in *"uncovered"*"lane-b/big.txt"*) true;; *) false;; esac'
428
+
429
+ probe_part
430
+ python3 "$WORK/write-manifest.py" "$MANIFEST" "$PART_BASE" AUTO "lane-a=$HASH_A" "lane-a/big.txt lane-b=$HASH_B"
431
+ git -C "$PART" add -A && git -C "$PART" commit -qm "manifest overlaps"
432
+ out="$(run_part --base "$PART_BASE")"; rc=$?
433
+ check "a manifest whose partitions overlap is refused before any packet is frozen" \
434
+ '[ "$rc" = 1 ] && case "$out" in *"overlap"*"lane-a/big.txt"*) true;; *) false;; esac'
435
+
436
+ probe_part
437
+ printf 'moved after the manifest was written\n' >>"$PART/lane-a/big.txt"
438
+ git -C "$PART" add -A && git -C "$PART" commit -qm "candidate moves after manifest"
439
+ out="$(run_part --base "$PART_BASE")"; rc=$?
440
+ check "a partition whose recorded hash no longer reproduces is refused" \
441
+ '[ "$rc" = 1 ] && case "$out" in *"lane-a"*"does not reproduce"*) true;; *) false;; esac'
442
+
443
+ probe_part
444
+ python3 "$WORK/write-manifest.py" "$MANIFEST" "0000000000000000000000000000000000000000" AUTO "lane-a=$HASH_A" "lane-b=$HASH_B"
445
+ git -C "$PART" add -A && git -C "$PART" commit -qm "manifest names another base"
446
+ out="$(run_part --base "$PART_BASE")"; rc=$?
447
+ check "a manifest written against another base is refused" \
448
+ '[ "$rc" = 1 ] && case "$out" in *"base"*"fork point"*) true;; *) false;; esac'
449
+
450
+ probe_part
451
+ python3 "$WORK/write-manifest.py" "$MANIFEST" "$PART_BASE" "$(printf 'f%.0s' $(seq 64))" "lane-a=$HASH_A" "lane-b=$HASH_B"
452
+ git -C "$PART" add -A && git -C "$PART" commit -qm "manifest aggregate forged"
453
+ out="$(run_part --base "$PART_BASE")"; rc=$?
454
+ check "a manifest whose aggregate hash does not reproduce its partitions is refused" \
455
+ '[ "$rc" = 1 ] && case "$out" in *"aggregate"*) true;; *) false;; esac'
456
+
457
+ for bad_path in ":(exclude)lane-b" "-lane-b" "../lane-b" "/lane-b" "lane-*" "lane-?" "lane-[ab]" "lane-\\b"; do
458
+ probe_part
459
+ python3 "$WORK/write-manifest.py" "$MANIFEST" "$PART_BASE" AUTO "lane-a=$HASH_A" "$bad_path=$HASH_B"
460
+ git -C "$PART" add -A && git -C "$PART" commit -qm "manifest smuggles a pathspec"
461
+ out="$(run_part --base "$PART_BASE")"; rc=$?
462
+ check "a manifest partition path shaped like $bad_path is refused as a path, not handed to git" \
463
+ '[ "$rc" = 1 ] && case "$out" in *"partition path"*) true;; *) false;; esac'
464
+ done
465
+
466
+ out="$(run_part --base "$PART_BASE" --print-manifest --partition 'lane-*')"; rc=$?
467
+ check "rendering refuses a wildcard partition instead of letting git expand it" \
468
+ '[ "$rc" != 0 ] && case "$out" in *"partition path"*"wildcard"*) true;; *) false;; esac'
469
+
470
+ # Under a narrowed --paths scope the partition union must equal the reviewed
471
+ # changed set, not merely contain it: a partition naming `.` reaches lane-b even
472
+ # though only lane-a is under review, and that surplus is refused before any
473
+ # packet is frozen. The ledgers are removed so the single-ledger path cannot
474
+ # satisfy the narrowed scope first.
475
+ probe_part
476
+ git -C "$PART" rm -q "$PART/specs/round/evidence/closeout-a.json" "$PART/specs/round/evidence/closeout-b.json"
477
+ python3 "$WORK/write-manifest.py" "$MANIFEST" "$PART_BASE" AUTO ".=$(printf '0%.0s' $(seq 64))"
478
+ git -C "$PART" add -A && git -C "$PART" commit -qm "manifest reaches outside the reviewed scope"
479
+ out="$(run_part --base "$PART_BASE" --paths lane-a)"; rc=$?
480
+ check "a partition reaching changed paths outside a narrowed --paths scope is refused, not accepted as covering" \
481
+ '[ "$rc" = 1 ] && case "$out" in *"outside the reviewed scope"*"lane-b/big.txt"*) true;; *) false;; esac'
482
+
483
+ probe_part
484
+ git -C "$PART" rm -q "$PART/specs/round/evidence/closeout-b.json"
485
+ git -C "$PART" commit -qm "one ledger missing"
486
+ out="$(run_part --base "$PART_BASE")"; rc=$?
487
+ check "a manifest with one partition unbound is refused, naming that partition" \
488
+ '[ "$rc" = 1 ] && case "$out" in *"no accepted ledger"*"lane-b"*) true;; *) false;; esac'
489
+
490
+ git -C "$PART" checkout -q -B "landing" "$PART_GOOD"
491
+ out="$(run_part --base "$PART_BASE")"; rc=$?
492
+ check "the passing partition state still passes after the probes" \
493
+ '[ "$rc" = 0 ]'
494
+
310
495
  # The suite above exercises a synthetic repository. The gate must also run against
311
496
  # the real checkout it ships in, or a break in that path passes every test here.
312
497
  REAL_ROOT="$(cd "$DIR/../../.." && pwd -P)"
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "npmPackage": "@ccoalm/ccl-skills",
4
- "version": "0.11.0",
5
- "sourceCommit": "3269aa4739c5336f005292e70cb82cdd4ffb76c7",
4
+ "version": "0.12.0",
5
+ "sourceCommit": "b874e09297205e68e3c5f01b112051655d48f2d7",
6
6
  "sourceState": "clean",
7
7
  "files": [
8
8
  {
@@ -2017,7 +2017,7 @@
2017
2017
  },
2018
2018
  {
2019
2019
  "path": "marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/dual-track-review-gate.md",
2020
- "sha256": "b567749aee88e60a46a1ba7dfb687fe7e22e0898b2fae69f4638d2c07e2a6cca",
2020
+ "sha256": "50f907bd644401adbcdf55f259430eb62c39e656e27c14c1f367654c3da1b630",
2021
2021
  "mode": 420
2022
2022
  },
2023
2023
  {
@@ -2047,7 +2047,7 @@
2047
2047
  },
2048
2048
  {
2049
2049
  "path": "marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-quickstart.md",
2050
- "sha256": "63d62923d961f34765c1ebb832c73764ebd979f981a0f2c086a6f815a6c2b6b8",
2050
+ "sha256": "68b6327538ad1bb99fe9c0dd337bd961f029ddf9929c1ecd7c60c616f59d84de",
2051
2051
  "mode": 420
2052
2052
  },
2053
2053
  {
@@ -2122,7 +2122,7 @@
2122
2122
  },
2123
2123
  {
2124
2124
  "path": "marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md",
2125
- "sha256": "244e155d641b0495a254f765dc8d9d548e0f0bf2f1f3e60916fcbebea2b44ece",
2125
+ "sha256": "5d4cede717c6a88c94b4154b5b2e077c5e610fb450b2d5eaaa4490b1cf826d44",
2126
2126
  "mode": 420
2127
2127
  },
2128
2128
  {
@@ -2252,7 +2252,7 @@
2252
2252
  },
2253
2253
  {
2254
2254
  "path": "marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/review_ledger_binding.py",
2255
- "sha256": "20068bbd9b54f32460d14963391e11d8e9ecf08997bf8ece3c758effeb950f40",
2255
+ "sha256": "a44a65d80dbcb366f667742e160949256c7b191175ab6fb711c38e35c74f11c7",
2256
2256
  "mode": 493
2257
2257
  },
2258
2258
  {
@@ -2482,7 +2482,7 @@
2482
2482
  },
2483
2483
  {
2484
2484
  "path": "marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_review_ledger_binding.sh",
2485
- "sha256": "ce08eca4a5ecd1c578216eaed67cf9af0d02629e2c9cfea54c2a27bfaf8809ae",
2485
+ "sha256": "471362ad4524c7c13367e4cc296d48beff3127bd50974fdc5d4e8d038396855e",
2486
2486
  "mode": 493
2487
2487
  },
2488
2488
  {
@@ -3378,5 +3378,5 @@
3378
3378
  "mode": 420
3379
3379
  }
3380
3380
  ],
3381
- "snapshotHash": "4797cf3d30cf1336e439094f37df74198438083e314e2fb1db66dbfbd3eac0fc"
3381
+ "snapshotHash": "09ad2ee15e60bc0b231e22c816aab9207dc5af912957bfd86d97998eb732d1ea"
3382
3382
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccoalm/ccl-skills",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Reusable workflows that help coding agents plan, build, test, review, and release software — for Claude Code, Codex, and OpenCode",
5
5
  "keywords": ["skills", "agent-skills", "claude", "claude-code", "codex", "opencode", "agent", "ai", "ai-agents", "cli", "anthropic", "developer-tools"],
6
6
  "type": "module",