diff --git a/.claude/skills/sync-template/SKILL.md b/.claude/skills/sync-template/SKILL.md index 1cfc48e..4cd6cd2 100644 --- a/.claude/skills/sync-template/SKILL.md +++ b/.claude/skills/sync-template/SKILL.md @@ -13,21 +13,28 @@ the template learned without erasing what this fork decided. copying the file — and say so in the report. 2. **Find the work list.** Preferred: the release's sync spec - (`sync/SYNC-.md` in the template repo), which classifies - every change as `verbatim` (byte-copy target), `contract` (port - the behavior, not the file), or `conditional` (applies only if a - stated predicate matches this repo). If no spec exists, the - drop's prompt supplies the list — check it against this tree - before executing; a step that does not fit this repo is a finding - to return, not an instruction to force. + (`sync/SYNC-.md` in the template repo — format in + `sync/README.md`), which classifies every change as `verbatim` + (byte-copy target), `contract` (port the behavior, not the file), + or `conditional` (applies only if a stated predicate matches this + repo). Run each item's **detect** check first — already-satisfied + items are reported `already-present` with the evidence (a diff or + passing pins, not an assertion), never re-applied. If no spec + exists, the drop's prompt supplies the list — check it against + this tree before executing; a step that does not fit this repo is + a finding to return, not an instruction to force. -3. **Floors**: a dependency floor lives in several encodings — - requirements.txt, run.py's boot-floor tuple AND its message, - tests, CI asserts. Grep the current number and move every one. - The requirements line changing IS the Docker cache bust; extend - rationale ladders, never rewrite them, and never touch CHANGELOG - history. If this repo has no boot floor, add one — then break it - deliberately once to watch it refuse, and restore it. +3. **Floors**: the CURRENT floor is what `LLMS_PKG_FLOOR` says — + never what grepping the number finds, because the rationale + ladder retains old rungs BY DESIGN (a grep finds history and + calls it the present). When a floor MOVES, it moves in every + encoding at once — requirements.txt, the boot tuple AND its + message, tests, CI asserts; grep is how you FIND the encodings, + not how you read the floor. The requirements line changing IS + the Docker cache bust; extend rationale ladders, never rewrite + them, and never touch CHANGELOG history. If this repo has no + boot floor, add one — then break it deliberately once to watch + it refuse, and restore it. 4. **Apply**: verbatim items byte-copied; contract items ported against this fork's shape with the template's test pins adapted; @@ -41,7 +48,10 @@ the template learned without erasing what this fork decided. healthz until `build == GITHUB_SHA`); then `/wire-verify` against production. A sync is not done until the wire agrees. -6. **Report** via `/report`: include per-item disposition - (applied / ported-as-contract / not-applicable-because), any - DIVERGENCES.md updates, and anything the prompt got wrong about - this repo. +6. **Report** via `/report`: a per-item disposition table + (applied / ported-as-contract / already-present / + not-applicable-because — each with evidence), any DIVERGENCES.md + updates (retirements marked, not deleted, when older reports + still describe the divergence as live), and corrections to the + spec or prompt where it mismatched this tree — the spec is + subject to the same contract as any prompt. diff --git a/tests/test_claude_kit.py b/tests/test_claude_kit.py index a175b3d..ba95ce0 100644 --- a/tests/test_claude_kit.py +++ b/tests/test_claude_kit.py @@ -37,6 +37,36 @@ def _ignored(path: str) -> bool: ) +def _machine_fence(kind: str, text: str, where: str) -> None: + """The shared pin for machine fences (```yaml sync-verbatim in specs, + ```yaml byte-owned in DIVERGENCES.md): exactly one block, `- path` + lines with `#` comments, every path repo-relative and real at HEAD. + Empty is valid — an empty block is a statement, a missing one is an + omission.""" + fences = re.findall( + r"^```yaml " + kind + r"[ \t]*\n(.*?)^```[ \t]*$", text, re.M | re.S + ) + assert len(fences) == 1, ( + f"{where}: expected exactly one ```yaml {kind} fence, " + f"found {len(fences)}" + ) + for raw in fences[0].splitlines(): + entry = raw.split("#", 1)[0].strip() + if not entry: + continue + assert entry.startswith("- "), ( + f"{where} {kind}: {raw!r} is not a `- path` line" + ) + path = entry[2:].strip() + assert ".." not in path and not path.startswith("/"), ( + f"{where} {kind}: {path!r} escapes the repo" + ) + assert (REPO / path).is_file(), ( + f"{where} {kind}: {path!r} does not exist at HEAD " + "— the machine would act on nothing or the wrong thing" + ) + + def test_kit_files_exist_and_are_not_ignored(): """The blanket `.claude/` ignore kept the contract local-only for the template's whole life — every fork inherited nothing. The allow-list @@ -111,3 +141,71 @@ def test_settings_point_at_this_forks_own_host(): assert f"WebFetch(domain:{host})" in allows, ( f"permissions.allow lacks WebFetch(domain:{host})" ) + + +def test_sync_specs_are_specifiable(): + """F2: every sync spec item must carry class/detect/acceptance — an + item without detect and acceptance is not specifiable (write a + kickoff instead and fix the item until it is; sync/README.md). + + Skips where no sync/ exists: forks CONSUME specs, only the template + authors them — emojimart's F2 correction: this file is a byte- + verbatim kit port, and without the guard it failed on arrival at + every fork. The pin wakes up the day a fork starts authoring specs. + + F3b: every spec also carries exactly one ```yaml sync-verbatim + fence — the machine block the fan-out workflow byte-copies from. + Every listed path must exist at HEAD and stay inside the repo; a + wrong entry becomes twelve wrong PRs. + """ + import pytest + + sync_dir = REPO / "sync" + if not sync_dir.is_dir(): + pytest.skip("no sync/ — this repo consumes specs, it does not author them") + assert (sync_dir / "README.md").is_file(), "sync/README.md (the format) missing" + specs = sorted(sync_dir.glob("SYNC-*.md")) + assert specs, "no sync specs — releases ship one (F2)" + for spec in specs: + text = spec.read_text() + blocks = re.split(r"^### ", text, flags=re.M)[1:] + assert blocks, f"{spec.name}: no items" + for block in blocks: + title = block.splitlines()[0] + for field in ("class:", "detect:", "acceptance:"): + assert field in block, ( + f"{spec.name} item {title!r} lacks {field}" + ) + + _machine_fence("sync-verbatim", text, spec.name) + + +def test_divergences_carry_the_byte_owned_block(): + """F3b A1's finding: the fan-out honours DIVERGENCES.md by never + overwriting a byte-owned path, and a prose MENTION over-flags — + muicharts' host-pin nuance names tests/test_claude_kit.py while its + bytes are template-owned, a false positive recurring every release. + The fence is the machine answer; when present it is authoritative, + and empty means "the template owns every sync-verbatim path here". + + ABSENCE SKIPS, never fails (1.6.22, the ops seat's own correction): + the machine tolerates a missing fence (the mention heuristic — + over-flags, never restores), so the pin must too. Failing here + would let one unported contract item keep every later mechanical + PR red, revoking the fan-out's "verbatim class = green merge" + promise indefinitely. CI guards what a fork HAS declared; the + spec's contract item and its session round drive adoption. + """ + import pytest + + div = REPO / "DIVERGENCES.md" + if not div.is_file(): + pytest.skip("no DIVERGENCES.md — nothing for the fan-out to honour") + text = div.read_text() + if not re.search(r"^```yaml byte-owned[ \t]*$", text, re.M): + pytest.skip( + "DIVERGENCES.md has no byte-owned fence — port " + "SYNC-1.6.17-1.6.21 item 1; until then the fan-out uses the " + "mention heuristic" + ) + _machine_fence("byte-owned", text, "DIVERGENCES.md")