CtrlK
BlogDocsLog inGet started
Tessl Logo

rebelytics/task-observer

Monitors task execution for skill improvement opportunities. Use during ANY multi-step task, agentic workflow, or work session where the agent uses tools and produces deliverables. Captures patterns, user corrections, workflow insights, and methodology worth preserving as reusable skills. Also triggers in post-task feedback discussions and when the user mentions skill observations, improvements, the observation log, skill taxonomy, or asks the agent to watch for skill opportunities. Also known as "One Skill to Rule Them All" — trigger on this phrase too. IMPORTANT: invoke this skill before the FIRST tool call of any session and before writing or proposing a plan — any turn that will involve a tool call counts, however simple the opener looks. This sentence is the session-start trigger and the only activation layer that survives an unreachable config file; pair it with a CLAUDE.md instruction or a harness session-start hook (references/environments.md) — description matching alone is not enforceable.

66

Quality

83%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

validate-skill-bundle.pyscripts/

#!/usr/bin/env python3
"""
validate-skill-bundle.py — the pre-delivery gate, as assertions.

Checks a staged skill directory (and optionally its packed .skill bundle)
against the criteria the INSTALLER enforces, not against what seems
sensible. Every check compares a measurement to a bound in the same step:
an unasserted metric manufactures confidence that no defect exists.

Usage
-----
  python3 validate-skill-bundle.py <staged-skill-dir> [--bundle file.skill] [--pack out.skill]
  python3 validate-skill-bundle.py --repo-only <repo-dir>

  --pack        writes a well-formed bundle (POSIX separators on any platform)
                after the directory checks pass, then validates it.
  --repo        additionally check the repo's manifests against the skill.
  --repo-only   check ONLY the repo's manifests, skipping the skill checks.

Why --repo-only exists: in a repo whose root IS the skill directory
(`SKILL.md` at the top level), the two check sets contradict each other.
The skill checks require the directory name to equal the frontmatter `name`
and forbid a plugin manifest; the repo is named after the repository, not
the skill, and is the one place the manifest belongs. Running the skill
checks over a repo root therefore reports two failures that are both
correct behaviour. Point the skill checks at the staged skill directory and
`--repo-only` at the repo.

Exit status 0 = every check passed; 1 = at least one failed (all failures
are listed, not just the first).

Limits are stated as numbers and labelled with where they come from, so
the check is implementable without guessing and can be updated when the
consumer changes them.
"""

import pathlib
import re
import struct
import sys
import zipfile

MAX_DESCRIPTION_CHARS = 1024   # installer's documented cap on the folded description
NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")   # kebab-case
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")

# --- Core-size ratchet -------------------------------------------------------
# The per-session core is loaded in full, every session, by every consumer of
# the skill. It has grown before: a split brought it down and two months of
# ordinary review rounds put it back, because nothing in the loop pushed the
# other way. A guideline that is only prose loses to a hundred individually
# reasonable additions.
#
# CEILING ONLY EVER GOES DOWN. Raising it is not a fix for a failing gate; it
# is the failure. A release that needs more room in the core needs a split
# instead, and the reference files are where the room is. The number below is
# the high-water mark at the release that introduced this check — every later
# release either holds it or lowers it.
#
# TARGET is the size the core is trying to reach. The gap between CEILING and
# TARGET is reported on every run, passing or failing, so the debt stays
# visible rather than being rediscovered by a contributor with a line counter.
# The ceiling is a property of the SKILL, not of this script. It is read from
# the skill under test — `core_max_lines:` in SKILL.md frontmatter, or a
# `.core-ceiling` file beside it holding the number. A skill that declares
# neither has not opted in: the count is reported and nothing is gated. This
# script ships inside task-observer but is run against every skill in a
# library, and a constant that describes one skill must never bound another.
CORE_CEILING_FILE = ".core-ceiling"
CORE_CEILING_KEY = "core_max_lines"
TARGET_CORE_LINES = 500
PATH_RE = re.compile(r"`((?:references|scripts|assets)/[^`\s*?]+\.[A-Za-z0-9]+)`")
BUILD_JUNK = {"__pycache__", ".DS_Store"}
# Edit residue: strings that only ever enter a file through a failed
# replacement, an unresolved template slot or an unfinished merge. The gate
# checks bundle FORM; this is the one CONTENT assertion, because a literal
# backreference passed apply, gate and install once.
SLOT_WHY = "unresolved template slot"
RESIDUE_RES = [
    (re.compile(r"(?m)^\\[0-9]\s*$"), "literal regex backreference on its own line"),
    (re.compile(r"(?<![\w`])\\[1-9](?![\w])"), "literal regex backreference in prose"),
    (re.compile(r"(?m)^(<<<<<<<|=======|>>>>>>>)( |$)"), "merge conflict marker"),
    (re.compile(r"\{\{[A-Za-z_][A-Za-z0-9_ .-]*\}\}"), SLOT_WHY),
    (re.compile(r"\b(TODO|FIXME|XXX)\b: ?(fill|replace|write)", re.I), "placeholder note left in"),
]
# A file that IS a template carries its slots as the deliverable, not as
# residue: exempt it from the SLOT rule only — every other residue rule and
# every other gate check still runs on it, so the exemption never becomes the
# hand-zip that also skips the checks nobody questioned.
TEMPLATE_MARKER = "<!-- template: slots intentional -->"


def slots_are_intentional(path, body):
    return "template" in str(path).lower() or body.lstrip().startswith(TEMPLATE_MARKER)
SECOND_FRONTMATTER_RE = re.compile(r"^---\n.*?\n---\n\s*(---\n|name:|description:)", re.S)


def frontmatter(text):
    m = re.match(r"^---\n(.*?)\n---\n", text, re.S)
    return m.group(1) if m else None


def folded_description(fm):
    """The description as the installer sees it, for both YAML block styles.

    Folded (`>`) joins its lines with spaces; literal (`|`) keeps the line
    breaks. Both accept a chomping indicator (`-`, `+`) and an explicit indent
    digit. Matching only `>` meant a `|` description fell through to the
    single-line branch below, which measured the string "|" — one character,
    inside every cap by construction, so the gate reported a pass having
    measured nothing at all. Any block style must be recognised here or the
    length check silently stops being a length check.
    """
    m = re.search(r"(?ms)^description:\s*([>|])[-+]?\d?\s*\n(.*?)(?=^\S|\Z)", fm)
    if m:
        body = m.group(2)
        if m.group(1) == ">":
            return " ".join(body.split())
        return "\n".join(line.strip() for line in body.strip("\n").splitlines())
    m = re.search(r"(?m)^description:\s*(.+)$", fm)
    return m.group(1).strip().strip('"\'') if m else ""


def check_frontmatter_shape(fm, fails, yaml_available):
    """Structural checks that do not need PyYAML.

    The gate's `frontmatter parses` item degrades to a regex when PyYAML is
    absent, and a regex happily accepts a block a YAML parser rejects. The
    case that got through: a cosmetic re-wrap turned a single-line
    `description:` into a key line followed by UNINDENTED continuation lines.
    The body loaded fine, so the defect surfaced only in the client's skill
    listing, where the description had been replaced by the H1 title.

    So: check the shape here, always, whichever branch ran above — and say
    which branch ran, because `OK` from the regex branch and `OK` from the
    parser branch are not the same assurance.
    """
    in_block = False
    for n, line in enumerate(fm.splitlines(), 1):
        if not line.strip() or line.lstrip().startswith("#"):
            continue
        if re.match(r"^[A-Za-z_][\w.-]*:", line):
            in_block = bool(re.search(r":\s*[>|][-+]?\d?\s*$", line))
            continue
        if line[:1].isspace() or line.lstrip().startswith("- "):
            continue          # indented continuation or list item: fine
        if in_block:
            # A block scalar's body must be indented. An unindented line ends
            # the block, and since it is not a `key:` line either, it is the
            # re-wrap defect.
            fails.append(
                f"frontmatter:{n}: unindented continuation line inside a block "
                f"scalar — YAML ends the block here and rejects the rest "
                f"({line.strip()[:60]!r})")
        else:
            fails.append(
                f"frontmatter:{n}: line is neither `key: value` nor an indented "
                f"continuation ({line.strip()[:60]!r})")
        in_block = False
    if not yaml_available:
        print("note: PyYAML not installed — frontmatter checked structurally, "
              "not parsed. Install PyYAML for the stronger check.")


def read_core_ceiling(skill_md):
    """The skill's own ceiling, or None if it has not declared one.

    Two sources, frontmatter first: `core_max_lines: N` in SKILL.md, else a
    `.core-ceiling` file beside it containing the number. None means the skill
    has not opted in — which is not a failure and must not be treated as one.
    A malformed value is also None, and says so, because silently falling back
    to some default is how one skill's number ends up bounding another.
    """
    text = skill_md.read_text(encoding="utf-8")
    m = re.match(r"(?s)^---\n(.*?)\n---\n", text)
    if m:
        km = re.search(r"(?m)^%s:\s*(\S+)\s*$" % CORE_CEILING_KEY, m.group(1))
        if km:
            if km.group(1).isdigit():
                return int(km.group(1))
            print(f"note: {CORE_CEILING_KEY} is not a whole number "
                  f"({km.group(1)!r}) — core size not gated")
            return None
    f = skill_md.parent / CORE_CEILING_FILE
    if f.exists():
        raw = f.read_text(encoding="utf-8").strip()
        if raw.isdigit():
            return int(raw)
        print(f"note: {CORE_CEILING_FILE} is not a whole number ({raw!r}) — "
              f"core size not gated")
    return None


def check_core_size(skill_md, fails):
    """The ratchet, against the skill's OWN declared ceiling.

    Counted with splitlines() so the number matches `wc -l` on a
    newline-terminated file. Counting newlines and adding one reports 755 for
    a 754-line file, which is a one-line phantom in a check whose whole job is
    to compare a count to a bound.
    """
    lines = len(skill_md.read_text(encoding="utf-8").splitlines())
    over_target = lines - TARGET_CORE_LINES
    ceiling = read_core_ceiling(skill_md)
    if ceiling is None:
        note = f"core SKILL.md {lines} lines (no ceiling declared — not gated"
        if over_target > 0:
            note += f"; {over_target} over the {TARGET_CORE_LINES}-line target"
        print(note + ")")
        return
    if lines > ceiling:
        fails.append(
            f"core SKILL.md {lines} lines > this skill's declared ceiling "
            f"{ceiling}. Move content to a reference file — do not raise the "
            f"ceiling.")
    else:
        headroom = ceiling - lines
        note = f"core SKILL.md {lines} lines (ceiling {ceiling}, {headroom} to spare"
        if over_target > 0:
            note += f"; {over_target} over the {TARGET_CORE_LINES}-line target"
        print(note + ")")


def check_reference_indexes(skill_dir, fails):
    """Every heading in a reference file appears in that file's own Contents.

    The per-file `## Contents` index exists so a reader can see what the file
    covers without loading it, and so the core's pointers have something to
    resolve against. Nothing kept it current: an index is edited by hand, in a
    different part of the file from the section being added, and a stale one
    is indistinguishable from a complete one at a glance. Both misses this
    check caught were made while writing the rule that the index matters.

    Pointer targets are checked too — a core pointer naming a heading that
    does not exist sends the reader to a file to find nothing, which is worse
    than no pointer, because they stop looking.
    """
    def norm(s):
        return " ".join(s.split())

    def defence(text):
        """Blank fenced blocks, keeping line count, so a `##` inside a
        template (a summary skeleton, a principles entry, a registry stanza)
        is not mistaken for a section of the file. Every early failure this
        check reported was of exactly that kind."""
        return re.sub(r"(?ms)^```.*?^```[ \t]*$",
                      lambda m: re.sub(r"[^\n]", " ", m.group(0)), text)

    refs = skill_dir / "references"
    if not refs.is_dir():
        return
    for p in sorted(refs.glob("*.md")):
        body = defence(p.read_text(encoding="utf-8"))
        m = re.search(r"(?ms)^## Contents\s*\n(.*?)(?=^## )", body)
        if not m:
            continue          # a file may legitimately have no index
        # Index entries wrap, and legitimately carry a parenthetical the
        # heading does not ("Lean Content (including progressive
        # disclosure…)"). So: join continuation lines into their entry, then
        # match a heading as a PREFIX of an entry, not by equality. Requiring
        # equality reported a correctly-indexed section as missing — the
        # check's own first finding was its own defect.
        entries, cur = [], None
        for line in m.group(1).splitlines():
            if re.match(r"^\s*-\s+", line):
                if cur is not None:
                    entries.append(norm(cur))
                cur = re.sub(r"^\s*-\s+", "", line)
            elif cur is not None and line.strip():
                cur += " " + line.strip()
        if cur is not None:
            entries.append(norm(cur))
        # Headings below the index, excluding the index itself
        after = body[m.end():]
        for h in re.findall(r"(?m)^#{2,3}\s+(.+?)\s*$", after):
            hn = norm(h)
            if not any(e == hn or e.startswith(hn + " ") or e.startswith(hn + "(")
                       for e in entries):
                fails.append(f"{p.name}: heading not in its own Contents index: {h!r}")

    core = (skill_dir / "SKILL.md").read_text(encoding="utf-8")

    # --- Reference shape: reported, never gated -----------------------------
    # The core's size ceiling does not transfer to reference files, because
    # their cost is conditional: a long file loaded once a month is cheap. The
    # signal that matters is how many DISTINCT episodes pull a file in, since
    # every one of them pays for the whole file. Measured once on this bundle,
    # the largest reference (1,105 lines) had a single trigger and was
    # correctly shaped, while a smaller one carried fourteen — so a size rule
    # would have flagged the healthy file and missed the mis-shaped one.
    #
    # Printed rather than enforced on purpose: one snapshot is not enough to
    # site a threshold, and the same measurement already overturned the rule
    # it was meant to support. Let a series accumulate, then decide.
    triggers = {}
    for m in re.finditer(r'`references/([a-z-]+\.md)`[^(]{0,40}\("([^"]+)"\)', core, re.S):
        triggers.setdefault(m.group(1), set()).add(norm(m.group(2)))
    if refs.is_dir():
        rows = []
        for p in sorted(refs.glob("*.md")):
            n = len(p.read_text(encoding="utf-8").splitlines())
            rows.append((p.name, n, len(triggers.get(p.name, ()))))
        worst = max((t for _, _, t in rows), default=0)
        print("reference shape (lines / distinct load triggers; not gated): "
              + ", ".join(f"{n}={ln}/{t}" for n, ln, t in rows))
        if worst >= 10:
            print(f"note: one reference file carries {worst} distinct load triggers — "
                  f"split by episode is indicated when this grows; size is not the signal")

    for m in re.finditer(r'`references/([a-z-]+\.md)`[^(]{0,40}\("([^"]+)"\)', core, re.S):
        fname, heading = m.group(1), norm(m.group(2))
        target = refs / fname
        if not target.is_file():
            fails.append(f"SKILL.md points at a reference that does not exist: {fname}")
            continue
        heads = {norm(x) for x in re.findall(r"(?m)^#{2,4}\s+(.+?)\s*$",
                                            defence(target.read_text(encoding="utf-8")))}
        if heading not in heads:
            fails.append(f"SKILL.md pointer has no target heading: {fname} -> {heading!r}")


def check_plugin_manifest(skill_dir, fails):
    """A skill bundle must NOT contain a plugin manifest.

    The uploader refuses it outright: *"A skill cannot contain a plugin
    manifest (…/.claude-plugin/plugin.json). Remove it, or upload this content
    as a plugin instead."* Skill and plugin are two upload kinds, not one
    artefact wearing both hats.

    This check was originally written the other way round, because the plugin
    reference documents a skills-directory plugin: a folder under a skills
    directory carrying this manifest loads as a plugin in place. That is true
    of a directory copied there by hand, and says nothing about what the
    uploader accepts — a distinction no amount of reading the plugin docs
    surfaces, because the constraint lives in the other product surface. The
    manifest is therefore repo-only, and `check_repo_versions` validates it
    there.

    The practical consequence, worth stating where someone will read it: a
    user who installs the `.skill` gets a skill, not a plugin. Only the
    repo-copy install path yields plugin behaviour.
    """
    manifest = skill_dir / ".claude-plugin" / "plugin.json"
    if manifest.is_file():
        fails.append(
            "a skill bundle must not contain .claude-plugin/plugin.json — the "
            "uploader rejects it ('upload this content as a plugin instead'). "
            "Keep the manifest at the repo root only; check_repo_versions "
            "validates it there.")


def check_dir(skill_dir, fails):
    # Resolve before comparing names: Path('.').name is '' for a relative
    # argument naming the current directory, which false-fails a correct
    # bundle and blames the frontmatter for an argument problem.
    skill_dir = pathlib.Path(skill_dir).resolve()
    skill_md = skill_dir / "SKILL.md"
    if not skill_md.is_file():
        fails.append("SKILL.md missing"); return
    text = skill_md.read_text(encoding="utf-8")
    fm = frontmatter(text)
    if fm is None:
        fails.append("frontmatter: no leading --- block"); return
    # `yaml_available` is about the LIBRARY, not about this file parsing. A
    # parse failure is a defect in the file and is reported as one; only a
    # missing library downgrades the check, and only that case earns the note.
    yaml_available = True
    try:
        import yaml  # optional; fall back to regex checks if absent
        data = yaml.safe_load(fm)
        if not isinstance(data, dict):
            fails.append("frontmatter: does not parse to a mapping")
            data = {}
    except ImportError:
        yaml_available = False
        data = {"name": (re.search(r"(?m)^name:\s*(.+)$", fm) or [None, ""])[1].strip(),
                "description": folded_description(fm)}
    except Exception as e:  # yaml error
        # Name the cause the check can detect, not the first field it then
        # fails to find: an unquoted `: ` inside a description is the common
        # way a whole block stops parsing, and "name missing" sends the
        # author to fix a field that is present.
        fails.append(
            f"frontmatter did not parse (likely an unquoted `: ` in a value — "
            f"double-quote the value or reword it): {e}")
        data = None
    name = str((data or {}).get("name") or "").strip()
    if data is None:
        pass  # already reported above; a missing-name report here would mislead
    elif not name:
        fails.append("frontmatter: `name` missing")
    elif not NAME_RE.match(name):
        fails.append(f"frontmatter: `name` not kebab-case: {name!r}")
    elif name != skill_dir.name:
        fails.append(f"frontmatter: `name` {name!r} != directory {skill_dir.name!r}")
    desc = folded_description(fm)
    if not desc:
        fails.append("frontmatter: `description` missing")
    elif len(desc) > MAX_DESCRIPTION_CHARS:
        fails.append(f"description {len(desc)} chars > cap {MAX_DESCRIPTION_CHARS}")
    elif len(desc) > 900:
        print(f"warn: description {len(desc)} chars (cap {MAX_DESCRIPTION_CHARS}) — near the boundary")
    # Every cited bundled path exists (backticked, real extension — globs in
    # prose are skipped). Ownership is inferred from the shape of the span, so
    # the convention has to make the two cases distinguishable: PATH_RE requires
    # the reserved prefix IMMEDIATELY after the backtick, which means a path
    # qualified with its owning skill's name — `<skill-name>/references/x.md` —
    # does not match and is exempt by construction. That qualified form is how a
    # skill cites a file belonging to a different skill; re-wording a legitimate
    # cross-reference so the backtick no longer starts with the prefix is not.
    # The failure message names the convention, because a false FAIL here is
    # otherwise indistinguishable from a genuinely missing file.
    for rel in sorted(set(PATH_RE.findall(text))):
        if not (skill_dir / rel).is_file():
            fails.append(
                f"cited path missing from staged set: {rel} "
                f"— if this file belongs to another skill, cite it as "
                f"`<skill-name>/{rel}`, which this check exempts")
    # exactly one frontmatter block: a second `---` block (or stray
    # name:/description: lines) directly after the first is a duplicated
    # header that every field check passes by construction
    if SECOND_FRONTMATTER_RE.match(text):
        fails.append("frontmatter: a second frontmatter block follows the first")
    check_frontmatter_shape(fm, fails, yaml_available)
    check_core_size(skill_md, fails)
    check_reference_indexes(skill_dir, fails)
    check_plugin_manifest(skill_dir, fails)
    for p in skill_dir.rglob("*"):
        if p.name in BUILD_JUNK or p.suffix == ".pyc" or p.name.startswith(".~lock"):
            fails.append(f"build artefact in staged tree: {p.relative_to(skill_dir)}")
        # content residue in every text file of the bundle, not only SKILL.md
        if p.is_file() and p.suffix.lower() in (".md", ".txt", ".yml", ".yaml", ".json"):
            body = p.read_text(encoding="utf-8", errors="replace")
            # code is where backreferences legitimately live: blank out
            # fenced blocks and inline spans, keeping line numbers intact
            prose = re.sub(r"(?ms)^```.*?^```[ \t]*$", lambda m: re.sub(r"[^\n]", " ", m.group(0)), body)
            prose = re.sub(r"`[^`\n]*`", lambda m: " " * len(m.group(0)), prose)
            rel = p.relative_to(skill_dir)
            exempt_slots = slots_are_intentional(rel, body)
            for rx, why in RESIDUE_RES:
                if why == SLOT_WHY and exempt_slots:
                    continue
                m = rx.search(prose)
                if m:
                    line = body.count("\n", 0, m.start()) + 1
                    fails.append(f"edit residue in {p.relative_to(skill_dir)}:{line}: {why} ({m.group(0).strip()!r})")


def check_repo_versions(repo_dir, fails):
    """Every manifest in the repo carries the same version as the skill's own.

    `.claude-plugin/plugin.json` is canonical — it is the one the installer
    reads, and it is the one that ships inside the skill bundle. Every other
    site is a copy, and a copy that drifts is worse than no copy: the
    installer's update check is keyed on the version, so a manifest left at
    the old number means `/plugin update` reports "already at the latest
    version" while serving stale files.

    Deliberately NOT a site: `marketplace.json` has no `version` field. The
    plugin's own manifest wins where both are set, so leaving it out removes
    a copy rather than synchronising one.
    """
    import json
    repo_dir = pathlib.Path(repo_dir)
    canonical_path = repo_dir / ".claude-plugin" / "plugin.json"
    if not canonical_path.is_file():
        fails.append("repo: .claude-plugin/plugin.json missing (the canonical version site)")
        return
    try:
        canonical = str(json.loads(canonical_path.read_text(encoding="utf-8")).get("version", "")).strip()
    except Exception as e:
        fails.append(f"repo: .claude-plugin/plugin.json does not parse: {e}")
        return
    if not SEMVER_RE.match(canonical):
        fails.append(f"repo: canonical version is not semver: {canonical!r}")
        return
    name = str(json.loads(canonical_path.read_text(encoding="utf-8")).get("name") or "").strip()
    if not NAME_RE.match(name):
        fails.append(f"repo: .claude-plugin/plugin.json `name` not kebab-case: {name!r}")
    if "description" in json.loads(canonical_path.read_text(encoding="utf-8")):
        fails.append(
            "repo: .claude-plugin/plugin.json carries `description`. Remove it — "
            "the skill's own frontmatter description drives invocation, and a "
            "second copy here is unread by the loader and drifts from the first.")
    print(f"repo: canonical version {canonical}")
    for rel in (".tessl-plugin/plugin.json",):
        p = repo_dir / rel
        if not p.is_file():
            continue
        try:
            v = str(json.loads(p.read_text(encoding="utf-8")).get("version", "")).strip()
        except Exception as e:
            fails.append(f"repo: {rel} does not parse: {e}")
            continue
        if v != canonical:
            fails.append(f"repo: {rel} version {v!r} != canonical {canonical!r}")
    mp = repo_dir / ".claude-plugin" / "marketplace.json"
    if mp.is_file():
        try:
            data = json.loads(mp.read_text(encoding="utf-8"))
        except Exception as e:
            fails.append(f"repo: .claude-plugin/marketplace.json does not parse: {e}")
            data = {}
        stray = [k for k in ("version",) if k in data]
        stray += [f"plugins[{i}].version" for i, e in enumerate(data.get("plugins", []))
                  if isinstance(e, dict) and "version" in e]
        if stray:
            fails.append(
                f"repo: marketplace.json carries a version ({', '.join(stray)}). "
                f"Remove it — plugin.json wins where both are set, so this is a "
                f"copy to keep in sync for no benefit.")


def pack(src, out):
    """Always writes POSIX separators, on any platform."""
    src = pathlib.Path(src)
    with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
        for f in sorted(p for p in src.rglob("*") if p.is_file()):
            arc = f"{src.name}/{f.relative_to(src).as_posix()}"
            assert "\\" not in arc, arc
            z.write(f, arcname=arc)


def check_bundle(path, fails):
    """Central directory as raw bytes: a convenience reader (zipfile.namelist)
    rewrites 0x5C to '/' and would report a malformed archive as clean."""
    data, i, n_members = pathlib.Path(path).read_bytes(), 0, 0
    while True:
        i = data.find(b"PK\x01\x02", i)
        if i < 0:
            break
        n, m, k = (struct.unpack_from("<H", data, i + o)[0] for o in (28, 30, 32))
        name = data[i + 46:i + 46 + n]
        n_members += 1
        if b"\x5c" in name:
            fails.append(f"bundle: backslash in member path {name!r} (installer rejects it)")
        # The uploader rejects a skill bundle carrying a plugin manifest. The
        # release workflow happens to copy three paths by name rather than the
        # whole tree, so it never picked this up — an accident, not a design.
        # Assert it here so the property holds however the bundle is built.
        if b".claude-plugin/" in name:
            fails.append(
                f"bundle: contains a plugin manifest ({name.decode(errors='replace')}) — "
                f"the uploader rejects a skill bundle that carries one")
        i += 46 + n + m + k
    if n_members == 0:
        fails.append("bundle: no members found")


def main(argv):
    if len(argv) < 2:
        print(__doc__); return 2
    if "--repo-only" in argv:
        fails = []
        check_repo_versions(argv[argv.index("--repo-only") + 1], fails)
        if fails:
            print("FAIL:")
            for f in fails:
                print("  -", f)
            return 1
        print("OK: repo manifest checks passed")
        return 0
    skill_dir = argv[1]
    bundle = pack_to = None
    if "--bundle" in argv:
        bundle = argv[argv.index("--bundle") + 1]
    if "--pack" in argv:
        pack_to = argv[argv.index("--pack") + 1]
    repo = argv[argv.index("--repo") + 1] if "--repo" in argv else None
    fails = []
    check_dir(skill_dir, fails)
    if repo:
        check_repo_versions(repo, fails)
    if pack_to and not fails:
        pack(skill_dir, pack_to); bundle = pack_to
        print(f"packed {pack_to}")
    if bundle:
        check_bundle(bundle, fails)
    if fails:
        print("FAIL:")
        for f in fails:
            print("  -", f)
        return 1
    print("OK: all gate checks passed")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))

CONTRIBUTING.md

LICENSE.txt

README.md

SKILL.md

tile.json

USER-GUIDE.md