blob: 72d596ae07790be4d5b1a228b79010fc86d42f06 [file]
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
# https://www.apache.org/licenses/LICENSE-2.0
"""Stamp a reconciliation fingerprint (`surface_hash:`) into every
`skills/*/SKILL.md`.
A running skill needs to answer a question it cannot answer by inspecting
itself: *has my own reconciliation surface moved since this project was last
configured for me?* "Surface" here means the two things an adopter's
configuration actually has to track — the skill's `requires_config:` list
(which project files it reads) and its structural anchors (the step and
golden-rule headings that name the contract the skill executes). A reworded
paragraph is not a surface change; a renamed step or an added config
dependency is.
A skill's anchors are not confined to its own `SKILL.md`. A multi-file
skill (`setup`, `pr-management-triage`, `security-issue-sync`, …) keeps
steps and golden rules in sibling detail files, and an adopter override can
anchor to a heading there just as easily as to one in `SKILL.md` itself. So
the fingerprint spans the skill's whole **directory**: `SKILL.md` plus every
`*.md` file directly inside it (no recursion into subdirectories such as
`guards/` or `fixtures/`), each with the generated pre-flight region
stripped. The file a heading came from is folded into the hashed payload
alongside the heading text — otherwise a heading moving from one detail
file to another, or between `SKILL.md` and a detail file, would leave the
digest unchanged, which is exactly the class of silent drift this
fingerprint exists to catch. `requires_config:` still comes from
`SKILL.md`'s frontmatter alone; detail files carry no frontmatter of their
own.
The skill cannot compute this itself at invocation time. An agent reads a
`SKILL.md` as static instructions — there is no code execution hook on most
harnesses (the same constraint `check-shared-blocks.py` documents), so the
skill has no way to hash its own body and compare it against what the
adopter last reconciled against. The fingerprint has to be computed once,
here, deterministically, and carried in the frontmatter where the skill (or
a future reconciliation check) can read it as plain data.
So: **one generator, one derived field per skill.** `surface_hash(skill_dir)`
folds `requires_config:` (order-independent — a re-sorted list is not a
change) and the sorted set of structural anchors, tagged with the file each
came from (`##` through `####` headings and `**Golden rule ...**` callouts,
markdown decoration stripped so `**Step 1**` and `Step 1` hash the same)
into a short `sha256:` digest, deliberately
excluding the shared pre-flight block that `check-shared-blocks.py`
manages — that block is identical everywhere and moving it is a framework
change, not a project-specific reconciliation event — and deliberately
excluding ordinary prose, which is free to be reworded without telling
every adopter their configuration went stale.
Fourth-level headings count. Twelve shipped skills use `####` for real
structure — `#### Pass B — Security`, `#### Category C — ...`, `#### 4c-ii
— ...` — and an override anchors to one of those exactly as it anchors to
a `##` step. Stopping at `###` left those renames silent, which is the
class of drift this fingerprint exists to catch. `#####` and deeper stay
out: nothing in the catalogue uses them as a contract surface.
Unlike the shared-blocks hook, this script exempts nothing. The `setup`
family is exempt from the pre-flight block because those are the skills
that *perform* setup and would otherwise ask users to set up before setting
up — but a `setup` skill's own configuration surface (its `requires_config:`
list, its steps) can drift just like any other skill's, and a project can
fall out of sync with it the same way. Every skill gets a hash.
**Stated consequence of the exclusion.** A heading that lives *inside* a
generated region — the auto pre-flight block, or any declared block — is
never a structural anchor, on purpose: editing shared framework prose is a
framework change, not a project-specific reconciliation event, and it must
not tell every adopter their own configuration drifted. The trade this
buys is real and deliberate, not an accident of a regex: an override
anchored to a heading that happens to live inside shared text will **not**
be detected as drift by this fingerprint. That is correct for the same
reason the exclusion exists — the shared text is framework-owned, not
project-owned — but it means `surface_hash` alone cannot catch every stale
override; an override anchored inside a shared block relies on the shared
block itself staying stable, not on this fingerprint.
This script does not keep its own copy of what "a generated region" looks
like. It loads `check-shared-blocks.py` — the script that actually writes
every generated region — and reuses its `PREFLIGHT_RE`, `DECLARED_RE`, and
`strip_generated_regions()` directly, so the two scripts' notion of
"generated" cannot drift apart: a `check-shared-blocks.py` marker-format
change is automatically picked up here, rather than requiring a matching
edit to a second regex that happened to also know about markers.
"""
from __future__ import annotations
import argparse
import hashlib
import importlib.util
import re
import sys
from pathlib import Path
from types import ModuleType
def _load_shared_blocks() -> ModuleType:
"""Load `check-shared-blocks.py` as a module so this script can reuse
its marker regexes and `strip_generated_regions()` directly, instead of
keeping a second, driftable definition of "a generated region" here."""
path = Path(__file__).resolve().parent / "check-shared-blocks.py"
spec = importlib.util.spec_from_file_location("check_shared_blocks", path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
_SHARED_BLOCKS = _load_shared_blocks()
# Re-exported, not redefined: the exact regex objects `check-shared-blocks.py`
# compiles, so a marker-format change there is automatically reflected here.
PREFLIGHT_RE = _SHARED_BLOCKS.PREFLIGHT_RE
DECLARED_RE = _SHARED_BLOCKS.DECLARED_RE
strip_generated_regions = _SHARED_BLOCKS.strip_generated_regions
SKILLS = Path("skills")
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.S)
REQUIRES_RE = re.compile(r"^requires_config:\n((?:[ \t]+-[ \t]*\S+\n)+)", re.M)
ITEM_RE = re.compile(r"^[ \t]+-[ \t]*(\S+)[ \t]*$", re.M)
HEADING_RE = re.compile(r"^#{2,4}[ \t]+(.+?)[ \t]*$", re.M)
GOLDEN_RE = re.compile(r"^\*\*(Golden rule[^*]+)\*\*", re.M)
HASH_RE = re.compile(r"^surface_hash:[ \t]*\S+\n", re.M)
def _normalise(text: str) -> str:
"""Anchor text without markdown decoration, so `**Step 1**` == `Step 1`."""
text = re.sub(r"[`*_]", "", text)
return re.sub(r"\s+", " ", text).strip()
def _anchors_in(body: str) -> set[str]:
"""Structural anchors (headings + golden-rule callouts) in one file's body."""
return {_normalise(m) for m in HEADING_RE.findall(body)} | {
_normalise(m) for m in GOLDEN_RE.findall(body)
}
# `SKILL.md` is hashed separately: its frontmatter supplies
# `requires_config`, and its anchors are recorded bare rather than tagged
# with a filename.
EXCLUDED_DETAIL_FILES = frozenset({"SKILL.md"})
def surface_inputs(skill_dir: Path) -> tuple[list[str], list[str]]:
"""Return `(requires_config, anchors)` — the two inputs the hash folds in.
`requires_config` comes from `SKILL.md`'s frontmatter alone.
`anchors` spans `SKILL.md` and every sibling `*.md` file directly inside
`skill_dir` (sorted by filename; no recursion into subdirectories).
`SKILL.md`'s own anchors stay bare, exactly as the pre-widening
algorithm recorded them, so a skill directory with no detail files
hashes identically to before. Each detail file's anchors are recorded
as `"<filename>: <anchor>"`, appended in sorted-filename order after
`SKILL.md`'s — the file an anchor came from is part of the payload, so
a heading moving between files (including into or out of `SKILL.md`)
changes the hash even though the heading text itself did not.
"""
text = (skill_dir / "SKILL.md").read_text()
front = FRONTMATTER_RE.match(text)
body = text[front.end() :] if front else text
body = strip_generated_regions(body)
requires: list[str] = []
block = REQUIRES_RE.search(front.group(1) + "\n") if front else None
if block:
requires = sorted(ITEM_RE.findall(block.group(1)))
anchors = sorted(_anchors_in(body))
detail_files = sorted(
(
p
for p in skill_dir.iterdir()
if p.is_file() and p.suffix == ".md" and p.name not in EXCLUDED_DETAIL_FILES
),
key=lambda p: p.name,
)
for detail in detail_files:
detail_body = strip_generated_regions(detail.read_text())
for anchor in sorted(_anchors_in(detail_body)):
anchors.append(f"{detail.name}: {anchor}")
return requires, anchors
def surface_hash(skill_dir: Path) -> str:
"""The reconciliation fingerprint: `sha256:` plus the first 16 hex characters."""
requires, anchors = surface_inputs(skill_dir)
payload = "\n".join(["requires_config:", *requires, "anchors:", *anchors])
return "sha256:" + hashlib.sha256(payload.encode()).hexdigest()[:16]
def apply(path: Path, digest: str) -> tuple[bool, str | None]:
"""Return (changed, error). Rewrites `path` only when it differs.
Removes any existing `surface_hash:` line from the frontmatter and
re-inserts the given digest immediately before `license:` — a stable
position, and `license:` is required on every skill.
"""
text = path.read_text()
match = FRONTMATTER_RE.match(text)
if not match:
return False, f"{path}: no YAML frontmatter"
lines = [line for line in match.group(1).split("\n") if not HASH_RE.match(line + "\n")]
try:
index = next(i for i, line in enumerate(lines) if line.startswith("license:"))
except StopIteration:
return False, f"{path}: no 'license:' line to anchor surface_hash to"
lines.insert(index, f"surface_hash: {digest}")
updated = text[: match.start()] + "---\n" + "\n".join(lines) + "\n---\n" + text[match.end() :]
if updated == text:
return False, None
path.write_text(updated)
return True, None
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--fix",
action="store_true",
help="write the surface_hash field into every SKILL.md instead of only reporting",
)
args = parser.parse_args()
skills = sorted(SKILLS.glob("*/SKILL.md"))
if not skills:
print(f"{SKILLS}: no SKILL.md files found", file=sys.stderr)
return 1
errors: list[str] = []
changed: list[Path] = []
for path in skills:
text = path.read_text()
digest = surface_hash(path.parent)
if args.fix:
did, err = apply(path, digest)
if err:
errors.append(err)
elif did:
changed.append(path)
else:
if f"surface_hash: {digest}" not in text:
errors.append(f"{path}: surface_hash is missing or stale (expected {digest})")
if errors:
print("Skill surface_hash is out of sync:", file=sys.stderr)
for error in errors:
print(f" - {error}", file=sys.stderr)
if not args.fix:
print(
f"\nRun `python3 {Path(__file__).name} --fix`.",
file=sys.stderr,
)
return 1
if changed:
print(f"Updated surface_hash in {len(changed)} skill(s):")
for path in changed:
print(f" - {path}")
return 1
print(f"surface_hash in sync across {len(skills)} skills.")
return 0
if __name__ == "__main__":
sys.exit(main())