diff --git a/.agents/skills/rebasing-adapted-skill/SKILL.md b/.agents/skills/rebasing-adapted-skill/SKILL.md new file mode 100644 index 0000000000..10007ed385 --- /dev/null +++ b/.agents/skills/rebasing-adapted-skill/SKILL.md @@ -0,0 +1,113 @@ +--- +name: rebasing-adapted-skill +description: >- + Rebase a locally adapted agent skill onto a newer upstream tip while keeping its intentional deviations, or audit whether those deviations are still honest, driven by the skill's ADAPTATION.md pins. + Use before rebasing an adapted skill, absorbing upstream skill changes, advancing a skill from its attributions, or checking a vendored skill for undeclared drift and stale deviation bullets. +user-invocable: false +metadata: + internal: true +--- + +# Rebasing an adapted skill + +**Rebase** a local skill that was adapted from one or more upstream skill directories onto a newer upstream tip, keeping the intentional deviations. +Point this skill at a **target skill directory**: any path that contains `SKILL.md`. + +Provenance lives only in that directory's sibling `ADAPTATION.md`, never in `SKILL.md`. +Two parts carry it: + +- The front-matter `attributions` **pin** each upstream skill directory to a GitHub tree URL at a full commit SHA. +- The `## Deviations` body lists each intentional difference on an upstream-present path as one natural-language bullet, read as merge **policy**: keep what a bullet protects, and where a bullet is silent, match upstream. + It is a policy ledger, not a changelog, so it never chronicles the upstream changes a rebase absorbs. + +Scaffold pins, validate a directory, or audit it with `scripts/skill-adaptation.py` (read its `--help` for exact commands and exit codes). + +## Audit: is the ledger honest? + +Run this on its own to answer "does my vendored skill still match its declared deviations?", and as the first step of every rebase. + +```bash +scripts/skill-adaptation.py audit +``` + +Audit fetches each pinned base and presents two sides for you to correlate: the deterministic `ours - base` **differences** for paths present in that base, and the declared `## Deviations` bullets. +Those differences include locally modified and removed base files; local-only additions stay local across a rebase and are outside audit. +The script does not match one to the other; you do. +Correlate them against the two rot modes: + +- **Undeclared drift** - a difference that no bullet covers. + The next rebase reads silence as "match upstream" and reverts it, so a local change with no covering bullet is silently lost. + Reconcile by declaring the difference in `## Deviations`, or by dropping the local change to match upstream. +- **Stale deviation** - a bullet that maps to no current difference, usually because upstream later adopted the same change. + Reconcile by retiring the bullet. + +Audit's exit code catches the two provable extremes on its own: differences with zero declared bullets are all undeclared drift, and bullets over zero differences are all stale. +When both differences and bullets are present it cannot prove which covers which, so it exits clean and leaves the pairing to you. + +*Done when every presented difference has a covering bullet and every bullet maps to a live difference.* + +## Rebase + +### 1. Start from an honest ledger + +Confirm the target is adapted, then reconcile it before planning: + +```bash +scripts/skill-adaptation.py validate-skill-dir +``` + +If validation fails, fix `ADAPTATION.md` first (scaffold a fresh one with the `template` subcommand, then edit `## Deviations`). +Then run **Audit** above and reconcile every undeclared drift and stale deviation, so the merge starts from a ledger that tells the truth and cannot silently clobber undeclared local work. + +*Done when `validate-skill-dir` exits 0 and audit shows no undeclared drift or stale deviation.* + +### 2. Choose target SHAs per attribution + +Read the `attributions` pins. +For each attribution the user wants moved this run: + +- If the user already gave a target SHA or tree URL, use it. +- Otherwise investigate GitHub - the default-branch tip at the same path, recent tags and releases, commits that touched that path - and present the options, then wait for the user to pick. + +Leave every attribution with no target this run at its current pin. + +*Done when every attribution either has an approved target SHA or is explicitly skipped.* + +### 3. Plan the merge, and wait + +For each attribution being moved, reconstruct a **3-way merge** per relative path in the upstream skill tree: + +| Side | Source | +| ---------- | --------------------------------------------------- | +| **base** | file contents at the current pinned SHA | +| **ours** | current files in the target skill directory | +| **theirs** | file contents at the chosen target SHA (same path) | + +Hold these rules: + +- Merge every file present in the upstream skill directory at base or target, matched by relative path. +- Read `## Deviations` as policy: keep each intentional difference, and reintroduce upstream text only where no bullet rejects it. +- Keep local-only files local, and propose upstream-only new files as adds. +- Leave `ADAPTATION.md` out of the file merge entirely; its pins advance in step 5, and it is never taken from upstream. + +Show the user a plan: which attributions move (old SHA to new SHA), the file-level outcome for each path, and how each deviation bullet constrained the result. + +*Done when the user approves the plan; write nothing before that approval.* + +### 4. Apply the approved merge + +Write the approved file changes into the target skill directory, and only those. + +*Done when every approved file change is on disk.* + +### 5. Advance pins; refresh deviations only on intent change + +Rewrite each **moved** attribution's tree URL to its new commit SHA, same owner, repo, and path. +Leave skipped attributions untouched. + +Edit `## Deviations` only when the set of intentional differences on upstream-present paths actually changed - a difference added, removed, or reworded. +An absorbed upstream change leaves the ledger alone. + +Re-run `validate-skill-dir` and **Audit**, and confirm both are clean. + +*Done when the moved pins are advanced, the deviations reflect current intent, and validate and audit both pass.* diff --git a/.agents/skills/rebasing-adapted-skill/scripts/skill-adaptation.py b/.agents/skills/rebasing-adapted-skill/scripts/skill-adaptation.py new file mode 100755 index 0000000000..d3991d4ff2 --- /dev/null +++ b/.agents/skills/rebasing-adapted-skill/scripts/skill-adaptation.py @@ -0,0 +1,648 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "pyyaml>=6.0.3,<7", +# ] +# /// +"""Scaffold, validate, and audit skill ADAPTATION.md provenance pins.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import TYPE_CHECKING, NamedTuple + +import yaml + +if TYPE_CHECKING: + from collections.abc import Sequence + +EXIT_OK = 0 +EXIT_INVALID = 1 +EXIT_USAGE = 2 +EXIT_FETCH = 3 +EXIT_BAD_PATH = 128 + +_ADAPTATION_NAME = "ADAPTATION.md" +_SKILL_NAME = "SKILL.md" + +# Environment override: read pinned base files from a local cache tree instead +# of the network, so an audit runs deterministically and air-gapped. Layout: +# /////. Used by the offline tests. +_BASE_DIR_ENV = "SKILL_ADAPTATION_BASE_DIR" + +# Full-SHA GitHub tree URL to a skill directory (no branch/tag names). +_TREE_URL = re.compile( + r"^https://github\.com/" + r"(?P[A-Za-z0-9_.-]+)/" + r"(?P[A-Za-z0-9_.-]+)/" + r"tree/" + r"(?P[0-9a-f]{40})/" + r"(?P.+)$" +) + +_HELP = """\ +Scaffold, validate, and audit ADAPTATION.md for adapted agent skills. + +An adapted skill pins upstream skill directories via GitHub tree URLs (full +commit SHA + path). The skill lives beside this script's own skill directory +(.agents/skills/rebasing-adapted-skill/) for rebasing onto a newer upstream tip. + +Examples: + # Print an ADAPTATION.md stub to stdout (redirect to create the file): + skill-adaptation.py template \\ + 'https://github.com/org/repo/tree/0123456789abcdef0123456789abcdef01234567/skills/foo' \\ + > /path/to/my-skill/ADAPTATION.md + + # Multiple attributions: + skill-adaptation.py template \\ + 'https://github.com/org/a/tree/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/skills/x' \\ + 'https://github.com/org/b/tree/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/skills/y' + + # Validate a skill directory (human-facing; problems on stderr): + skill-adaptation.py validate-skill-dir ~/.claude/skills/my-skill + + # Audit local files against the pinned base and the declared deviations: + skill-adaptation.py audit ~/.claude/skills/my-skill + + # Quiet predicate (git check-ignore -q style): + skill-adaptation.py validate-skill-dir -q /path/to/skill && echo adapted + skill-adaptation.py audit -q /path/to/skill && echo honest + +Side effects: + - template: none (stdout only; does not write files). + - validate-skill-dir: local filesystem read + YAML parse only; no network. + - audit: read-only. Fetches base files at the pinned SHAs (network, or from a + local cache tree named by the SKILL_ADAPTATION_BASE_DIR env var) and reads + local files; never writes. + +What audit does (and does not) decide: + audit computes the deterministic per-path difference `ours - base` for every + pinned attribution and prints it beside the `## Deviations` bullets, so the + agent can correlate the two by hand. It does NOT match bullets to differences + by text. Its exit code therefore flags only the two provable rot states: + - undeclared drift: differences exist but NO deviation is declared, so every + difference is uncovered (the next rebase would silently revert it); + - stale deviations: deviations are declared but NO difference exists, so + every bullet maps to nothing. + When both differences and deviations are present, the script cannot prove + which covers which, so it presents both sides and exits 0; confirming that + each difference has a covering bullet and each bullet a live difference is the + agent's job. + +Exit codes: + 0 success (template), validly adapted (validate), or no provable rot (audit) + 1 invalid tree URL / not validly adapted / provable drift or stale deviation + 2 argparse usage error + 3 audit: could not fetch a pinned base tree (fails loudly, never "clean") + 128 validate/audit: path missing, not a directory, or no SKILL.md +""" + +_TEMPLATE_BODY = """\ +## Deviations + +- +""" + + +class FetchError(Exception): + """A pinned base tree could not be fetched.""" + + +class Attribution(NamedTuple): + """A parsed attribution tree URL.""" + + url: str + owner: str + repo: str + sha: str + path: str + + +class Drift(NamedTuple): + """One path whose local file differs from the pinned base.""" + + attribution: str + relpath: str + kind: str # "modified" or "removed" + + +def normalize_tree_url(url: str) -> str: + """Strip trailing slashes; return the URL unchanged otherwise.""" + return url.rstrip("/") + + +def parse_tree_url(url: str) -> str | None: + """Return an error message if ``url`` is not a valid attribution tree URL.""" + normalized = normalize_tree_url(url) + if _TREE_URL.fullmatch(normalized) is None: + return ( + "must be a GitHub tree URL with a full 40-char commit SHA: " + "https://github.com///tree//" + ) + return None + + +def parse_attribution(url: str) -> Attribution: + """Parse a validated attribution tree URL into its parts.""" + normalized = normalize_tree_url(url) + match = _TREE_URL.fullmatch(normalized) + if match is None: # pragma: no cover - callers validate first + msg = f"not an attribution tree URL: {url}" + raise ValueError(msg) + return Attribution( + url=normalized, + owner=match["owner"], + repo=match["repo"], + sha=match["sha"], + path=match["path"].rstrip("/"), + ) + + +def render_template(urls: Sequence[str]) -> str: + """Render ADAPTATION.md text for the given attribution tree URLs.""" + front: dict[str, list[str]] = { + "attributions": [normalize_tree_url(u) for u in urls], + } + dumped = yaml.safe_dump( + front, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + ) + return f"---\n{dumped}---\n\n{_TEMPLATE_BODY}" + + +def split_front_matter(text: str) -> tuple[str | None, str]: + """Split Markdown into (yaml_text_or_None, body).""" + if not text.startswith("---"): + return None, text + rest = text[3:] + if rest.startswith("\r\n"): + rest = rest[2:] + elif rest.startswith("\n"): + rest = rest[1:] + else: + return None, text + for sep in ("\n---\n", "\n---\r\n", "\r\n---\r\n", "\r\n---\n"): + idx = rest.find(sep) + if idx != -1: + return rest[:idx], rest[idx + len(sep) :] + # Closing --- on final line with no trailing newline + if rest.endswith("\n---"): + return rest[: -len("\n---")], "" + if rest == "---": + return "", "" + return None, text + + +def _load_adaptation_mapping(path: Path) -> dict[str, object] | list[str]: + """Return parsed front-matter mapping, or a one-element problem list.""" + text = path.read_text(encoding="utf-8") + yaml_text, _body = split_front_matter(text) + if yaml_text is None: + return [f"{_ADAPTATION_NAME}: missing YAML front matter (opening ---)"] + + try: + data = yaml.safe_load(yaml_text) + except yaml.YAMLError as exc: + return [f"{_ADAPTATION_NAME}: invalid YAML front matter: {exc}"] + + if not isinstance(data, dict): + return [f"{_ADAPTATION_NAME}: front matter must be a mapping"] + return data + + +def _attribution_item_problems(attributions: list[object]) -> list[str]: + problems: list[str] = [] + for i, item in enumerate(attributions): + if not isinstance(item, str): + problems.append(f"{_ADAPTATION_NAME}: attributions[{i}] must be a string") + continue + err = parse_tree_url(item) + if err is not None: + problems.append(f"{_ADAPTATION_NAME}: attributions[{i}]: {err}") + return problems + + +def adaptation_problems(skill_dir: Path) -> list[str]: + """Return human-readable problems with ADAPTATION.md, or [] if valid. + + Caller must already have established that ``skill_dir`` is a usable skill + directory (exists, is a dir, contains SKILL.md). + """ + path = skill_dir / _ADAPTATION_NAME + if not path.is_file(): + return [f"missing {_ADAPTATION_NAME}"] + + loaded = _load_adaptation_mapping(path) + if isinstance(loaded, list): + return loaded + + if "attributions" not in loaded: + return [f"{_ADAPTATION_NAME}: missing attributions key"] + + attributions = loaded["attributions"] + if not isinstance(attributions, list): + return [f"{_ADAPTATION_NAME}: attributions must be a list"] + if len(attributions) == 0: + return [f"{_ADAPTATION_NAME}: attributions must be non-empty"] + + return _attribution_item_problems(attributions) + + +def usable_skill_dir_error(path: Path) -> str | None: + """Return an error if ``path`` is not a usable skill directory.""" + if not path.exists(): + return f"path does not exist: {path}" + if not path.is_dir(): + return f"not a directory: {path}" + if not (path / _SKILL_NAME).is_file(): + return f"no {_SKILL_NAME} in {path}" + return None + + +def load_attributions(skill_dir: Path) -> list[Attribution]: + """Return the parsed attributions from a valid ADAPTATION.md.""" + loaded = _load_adaptation_mapping(skill_dir / _ADAPTATION_NAME) + if isinstance(loaded, list): # pragma: no cover - callers validate first + msg = "; ".join(loaded) + raise ValueError(msg) # noqa: TRY004 - invalid ledger, not a type error + urls = loaded["attributions"] + return [parse_attribution(url) for url in urls] # type: ignore[union-attr] + + +def parse_deviation_bullets(body: str) -> list[str]: + """Return the ``## Deviations`` bullet texts, minus angle-bracket stubs. + + A stub bullet like ``- `` is the unedited template + placeholder, not a real declaration, so it does not count as a deviation. + """ + bullets: list[str] = [] + in_section = False + for raw in body.splitlines(): + if raw == "## Deviations": + in_section = True + continue + if re.match(r"^#{1,6}(?:\s|$)", raw) is not None: + in_section = False + continue + if not in_section: + continue + item = re.match(r"^[-*]\s+(.*)$", raw.strip()) + if item is None: + continue + text = item.group(1).strip() + if text.startswith("<") and text.endswith(">"): + continue + if text: + bullets.append(text) + return bullets + + +def read_deviation_bullets(skill_dir: Path) -> list[str]: + """Read ``## Deviations`` bullets from a skill directory's ADAPTATION.md.""" + text = (skill_dir / _ADAPTATION_NAME).read_text(encoding="utf-8") + _front, body = split_front_matter(text) + return parse_deviation_bullets(body) + + +def _read_base_from_cache( + base_dir: Path, + attribution: Attribution, +) -> dict[str, bytes]: + """Read a pinned base subtree from a local cache directory.""" + try: + sha_root = ( + base_dir / attribution.owner / attribution.repo / attribution.sha + ).resolve() + decoded_path = Path(urllib.parse.unquote(attribution.path)) + if decoded_path.is_absolute() or ".." in decoded_path.parts: + msg = f"invalid cached base path for {attribution.url}" + raise FetchError(msg) + root = (sha_root / decoded_path).resolve() + if not root.is_relative_to(sha_root): + msg = f"cached base path escapes SHA root for {attribution.url}" + raise FetchError(msg) + if not root.is_dir(): + msg = f"no cached base tree at {root}" + raise FetchError(msg) + files: dict[str, bytes] = {} + for path in sorted(root.rglob("*")): + if path.is_file(): + files[path.relative_to(root).as_posix()] = path.read_bytes() + return files + except OSError as exc: + msg = f"could not read cached base for {attribution.url}: {exc}" + raise FetchError(msg) from exc + + +def _http_get(url: str, *, accept: str) -> bytes: + """Fetch a URL, sending a token from the environment when present.""" + # URLs are built here from https:// literals and pinned SHAs only. + request = urllib.request.Request(url) + request.add_header("Accept", accept) + request.add_header("User-Agent", "skill-adaptation") + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + if token: + request.add_header("Authorization", f"Bearer {token}") + try: + with urllib.request.urlopen(request) as response: + return response.read() + except (urllib.error.URLError, TimeoutError) as exc: + msg = f"fetch failed for {url}: {exc}" + raise FetchError(msg) from exc + + +def _fetch_base_from_github(attribution: Attribution) -> dict[str, bytes]: + """Fetch a pinned base subtree from GitHub at the exact commit SHA.""" + tree_url = ( + f"https://api.github.com/repos/{attribution.owner}/{attribution.repo}" + f"/git/trees/{attribution.sha}?recursive=1" + ) + raw = _http_get(tree_url, accept="application/vnd.github+json") + try: + tree = json.loads(raw) + except json.JSONDecodeError as exc: + msg = f"unreadable tree listing for {attribution.url}: {exc}" + raise FetchError(msg) from exc + if tree.get("truncated"): + msg = f"tree listing truncated for {attribution.url}; cannot audit" + raise FetchError(msg) + + prefix = urllib.parse.unquote(attribution.path) + "/" + files: dict[str, bytes] = {} + for entry in tree.get("tree", []): + if entry.get("type") != "blob": + continue + full = entry.get("path", "") + if not full.startswith(prefix): + continue + relpath = full[len(prefix) :] + encoded_full = urllib.parse.quote(full, safe="/") + content_url = ( + f"https://raw.githubusercontent.com/{attribution.owner}" + f"/{attribution.repo}/{attribution.sha}/{encoded_full}" + ) + files[relpath] = _http_get(content_url, accept="application/octet-stream") + return files + + +def fetch_base_files(attribution: Attribution) -> dict[str, bytes]: + """Return {relpath: bytes} for a pinned attribution's base subtree. + + Reads from the SKILL_ADAPTATION_BASE_DIR cache tree when that env var is + set, otherwise from GitHub. Either way the content is pinned to the exact + commit SHA, so the result is deterministic. + """ + base_dir = os.environ.get(_BASE_DIR_ENV) + if base_dir: + files = _read_base_from_cache(Path(base_dir), attribution) + else: + files = _fetch_base_from_github(attribution) + if _SKILL_NAME not in files: + msg = f"pinned base tree has no {_SKILL_NAME}: {attribution.url}" + raise FetchError(msg) + return files + + +def compute_drift( + attribution: Attribution, + base_files: dict[str, bytes], + skill_dir: Path, +) -> list[Drift]: + """Return the local differences from a pinned base subtree. + + Compares only paths present in the base, because those are exactly the paths + where the next rebase's "silence = match upstream" rule would clobber + undeclared local work: a base file edited locally (modified) or deleted + locally (removed). Local-only additions stay local across a rebase and carry + no such risk, so they are out of scope. ADAPTATION.md is never merged and is + always excluded. + """ + drift: list[Drift] = [] + for relpath, content in base_files.items(): + if relpath == _ADAPTATION_NAME: + continue + local = skill_dir / relpath + if not local.is_file(): + drift.append(Drift(attribution.url, relpath, "removed")) + elif local.read_bytes() != content: + drift.append(Drift(attribution.url, relpath, "modified")) + return sorted(drift) + + +class AuditResult(NamedTuple): + """The deterministic audit of a skill directory.""" + + drift: list[Drift] + bullets: list[str] + + @property + def undeclared_drift(self) -> list[Drift]: + """Differences with provably no covering bullet (no bullet declared).""" + return self.drift if not self.bullets else [] + + @property + def stale_bullets(self) -> list[str]: + """Bullets that provably map to nothing (no difference at all).""" + return self.bullets if not self.drift else [] + + @property + def has_rot(self) -> bool: + """Whether a provable rot state (undeclared drift or stale) exists.""" + return bool(self.undeclared_drift or self.stale_bullets) + + +def audit_skill_dir(skill_dir: Path) -> AuditResult: + """Compute the deterministic audit for a valid adapted skill directory.""" + drift: list[Drift] = [] + for attribution in load_attributions(skill_dir): + base_files = fetch_base_files(attribution) + drift.extend(compute_drift(attribution, base_files, skill_dir)) + bullets = read_deviation_bullets(skill_dir) + return AuditResult(drift=sorted(drift), bullets=bullets) + + +def _render_audit_report(skill_dir: Path, result: AuditResult) -> list[str]: + """Render the both-sides presentation the agent correlates by hand.""" + lines = [f"audit: {skill_dir}", ""] + + lines.append("differences (ours - base):") + if result.drift: + width = max(len(d.kind) for d in result.drift) + for d in result.drift: + lines.append(f" {d.kind.ljust(width)} {d.relpath} [{d.attribution}]") + else: + lines.append(" none") + lines.append("") + + lines.append("declared deviations (## Deviations):") + if result.bullets: + lines.extend(f" - {b}" for b in result.bullets) + else: + lines.append(" none") + lines.append("") + + if result.undeclared_drift: + n = len(result.undeclared_drift) + lines.append( + f"UNDECLARED DRIFT: {n} difference(s) with no declared deviation to " + "cover them - declare each in ## Deviations or drop the local change." + ) + elif result.stale_bullets: + n = len(result.stale_bullets) + lines.append( + f"STALE DEVIATIONS: {n} declared deviation(s) map to no current " + "difference - retire the bullet(s)." + ) + elif result.drift: + lines.append( + f"correlate: {len(result.drift)} difference(s) and " + f"{len(result.bullets)} declared deviation(s). Confirm every " + "difference has a covering bullet (else it is undeclared drift) and " + "every bullet a live difference (else it is stale). The script does " + "not match these automatically." + ) + else: + lines.append("clean: no differences from base and no declared deviations.") + return lines + + +def cmd_template(urls: Sequence[str]) -> int: + errors: list[str] = [] + for i, url in enumerate(urls): + err = parse_tree_url(url) + if err is not None: + errors.append(f"tree-url[{i}]: {err}") + if errors: + for line in errors: + sys.stderr.write(f"skill-adaptation: {line}\n") + return EXIT_INVALID + sys.stdout.write(render_template(urls)) + return EXIT_OK + + +def cmd_validate_skill_dir(skill_dir: Path, *, quiet: bool) -> int: + bad = usable_skill_dir_error(skill_dir) + if bad is not None: + # Hard errors always print (even with -q), matching check-ignore's + # distinction between "no" and "cannot answer". + sys.stderr.write(f"skill-adaptation: {bad}\n") + return EXIT_BAD_PATH + + problems = adaptation_problems(skill_dir) + if not problems: + return EXIT_OK + if not quiet: + for problem in problems: + sys.stderr.write(f"skill-adaptation: {problem}\n") + return EXIT_INVALID + + +def cmd_audit(skill_dir: Path, *, quiet: bool) -> int: + bad = usable_skill_dir_error(skill_dir) + if bad is not None: + sys.stderr.write(f"skill-adaptation: {bad}\n") + return EXIT_BAD_PATH + + problems = adaptation_problems(skill_dir) + if problems: + # Cannot audit against pins that are themselves invalid. + for problem in problems: + sys.stderr.write(f"skill-adaptation: {problem}\n") + return EXIT_INVALID + + try: + result = audit_skill_dir(skill_dir) + except FetchError as exc: + # Fail loudly: never let an unreachable base masquerade as "clean". + sys.stderr.write(f"skill-adaptation: {exc}\n") + return EXIT_FETCH + + if not quiet: + sys.stdout.write("\n".join(_render_audit_report(skill_dir, result)) + "\n") + return EXIT_INVALID if result.has_rot else EXIT_OK + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=_HELP, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + template = subparsers.add_parser( + "template", + help="print an ADAPTATION.md stub to stdout for the given tree URLs", + description=_HELP, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + template.add_argument( + "tree_urls", + nargs="+", + metavar="TREE_URL", + help="GitHub tree URL with full 40-char SHA (one or more)", + ) + + validate = subparsers.add_parser( + "validate-skill-dir", + help="check ADAPTATION.md provenance pins in a skill directory", + description=_HELP, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + validate.add_argument( + "skill_dir", + type=Path, + help="path to a skill directory (must contain SKILL.md)", + ) + validate.add_argument( + "-q", + "--quiet", + action="store_true", + help="no problem lines on exit 0/1; still print hard errors (exit 128)", + ) + + audit = subparsers.add_parser( + "audit", + help="present local differences from pinned base beside declared deviations", + description=_HELP, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + audit.add_argument( + "skill_dir", + type=Path, + help="path to an adapted skill directory (must contain SKILL.md)", + ) + audit.add_argument( + "-q", + "--quiet", + action="store_true", + help="print nothing; exit non-zero on provable drift or stale deviation", + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + if args.command == "template": + return cmd_template(args.tree_urls) + if args.command == "validate-skill-dir": + return cmd_validate_skill_dir(args.skill_dir, quiet=args.quiet) + if args.command == "audit": + return cmd_audit(args.skill_dir, quiet=args.quiet) + parser.error(f"unknown command {args.command!r}") + return EXIT_USAGE # pragma: no cover + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/AGENTS.md b/AGENTS.md index f01bcb723e..3d4bac5806 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -514,6 +514,7 @@ These skills are not captain-invocable; load them only at their precise triggers - `fmx-respond` - load on an `x-mention ` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the X-mode configuration blocker, on a `public-followup ...` `check:` wake or a startup-surfaced public commitment, and on any milestone or terminal wake for an X-mode-linked task before posting its completion follow-up; relevant only when X mode is on. - `firstmate-codexapp` - load before coordinating a visible Codex Desktop thread, evaluating a Codex App backend request, or reconciling Codex Desktop host-tool smoke evidence for Firstmate work. - `firstmate-coding-guidelines` - load before changing firstmate's shared, tracked material, as defined by section 1's list, whether editing directly or briefing a crewmate for a firstmate-repo task. +- `rebasing-adapted-skill` - load before rebasing a vendored (upstream-tracked) skill onto a newer upstream tip, or auditing whether its declared deviations are still honest; the skill owns the rebase workflow, and its colocated `scripts/skill-adaptation.py` owns pin validation and audit mechanics. ## 14. X mode diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index d7d606c85f..26a029164f 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -176,6 +176,10 @@ "path": ".agents/skills/quota-array-dispatch/SKILL.md", "audience": "agent-runtime" }, + { + "path": ".agents/skills/rebasing-adapted-skill/SKILL.md", + "audience": "agent-runtime" + }, { "path": ".agents/skills/secondmate-provisioning/SKILL.md", "audience": "agent-runtime" diff --git a/tests/fm-skill-adaptation.test.sh b/tests/fm-skill-adaptation.test.sh new file mode 100755 index 0000000000..7f7b3a5a9e --- /dev/null +++ b/tests/fm-skill-adaptation.test.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +# tests/fm-skill-adaptation.test.sh - offline behavioral tests for the +# rebasing-adapted-skill provenance script, focused on the read-only `audit` +# subcommand. Every audit run reads its pinned base from a local cache tree +# (SKILL_ADAPTATION_BASE_DIR) instead of the network, so the deterministic +# `ours - base` diff, the two rot classifications, the exit codes, and the -q +# predicate are all exercised through the real executable with no live GitHub. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SCRIPT="$ROOT/.agents/skills/rebasing-adapted-skill/scripts/skill-adaptation.py" + +# The script runs under `uv run`; skip cleanly on a runner without uv. +command -v uv >/dev/null 2>&1 || { echo "skip: uv not found"; exit 0; } + +TMP_ROOT=$(fm_test_tmproot fm-skill-adaptation) +SHA=0123456789abcdef0123456789abcdef01234567 +ATTR="https://github.com/org/repo/tree/$SHA/skills/foo" + +# write_adaptation then bullet lines on stdin. +write_adaptation() { + local sk=$1 + shift + { + printf -- '---\nattributions:\n' + local url + for url in "$@"; do + printf -- '- %s\n' "$url" + done + printf -- '---\n\n## Deviations\n\n' + cat + } >"$sk/ADAPTATION.md" +} + +# fresh_case echoes a self-contained case dir with SKILL.md, an empty +# base cache for the pinned attribution, and a matching upstream SKILL.md. +fresh_case() { + local dir base + dir="$TMP_ROOT/$1/skill" + base="$TMP_ROOT/$1/base/org/repo/$SHA/skills/foo" + mkdir -p "$dir" "$base" + printf 'upstream line\n' >"$base/SKILL.md" + printf 'upstream line\n' >"$dir/SKILL.md" + printf '%s\n' "$dir" +} + +# audit_case [extra args...]: run audit with the fixture's base cache. +audit_case() { + local name=$1 + shift + OUT=$(SKILL_ADAPTATION_BASE_DIR="$TMP_ROOT/$name/base" \ + "$SCRIPT" audit "$TMP_ROOT/$name/skill" "$@" 2>&1) + RC=$? +} + +test_clean() { + local sk + sk=$(fresh_case clean) + write_adaptation "$sk" "$ATTR" "$sk/SKILL.md" + write_adaptation "$sk" "$ATTR" "$base/reference.md" + # ours never had reference.md -> a base file removed locally. + write_adaptation "$sk" "$ATTR" the bullet maps to nothing. + write_adaptation "$sk" "$ATTR" <<<'- keep our stricter opening line' + audit_case stale + expect_code 1 "$RC" "stale bullet fails" + assert_contains "$OUT" "STALE DEVIATIONS" "declared bullet over no diff is stale" + assert_contains "$OUT" "keep our stricter opening line" "the stale bullet is shown" + pass "audit: a bullet with no matching difference is stale (exit 1)" +} + +test_mixed_presents_both_sides() { + local sk + sk=$(fresh_case mixed) + printf 'local edit\n' >"$sk/SKILL.md" + write_adaptation "$sk" "$ATTR" <<<'- we keep a stricter opening line in SKILL.md' + audit_case mixed + # Both a difference and a bullet exist: the script cannot match them, so it + # presents both sides and defers to the agent (exit 0, no provable rot). + expect_code 0 "$RC" "mixed state exits 0 (deferred to agent)" + assert_contains "$OUT" "correlate:" "mixed state asks the agent to correlate" + assert_contains "$OUT" "modified" "mixed state still presents the difference" + assert_contains "$OUT" "we keep a stricter opening line" "mixed state shows the bullet" + assert_not_contains "$OUT" "UNDECLARED DRIFT" "mixed state is not auto-classified as drift" + assert_not_contains "$OUT" "STALE DEVIATIONS" "mixed state is not auto-classified as stale" + pass "audit: differences plus bullets present both sides for the agent (exit 0)" +} + +test_placeholder_bullet_is_not_a_deviation() { + local sk + sk=$(fresh_case stub) + # The unedited template placeholder must not count as a declared deviation. + write_adaptation "$sk" "$ATTR" <<<'- ' + audit_case stub + expect_code 0 "$RC" "placeholder-only ledger over a clean tree is clean" + assert_contains "$OUT" "clean:" "placeholder bullet is ignored as no deviation" + pass "audit: an angle-bracket placeholder bullet is not a declaration" +} + +test_variant_heading_is_not_the_ledger() { + local sk + sk=$(fresh_case variant_heading) + printf 'local edit\n' >"$sk/SKILL.md" + printf -- '---\nattributions:\n- %s\n---\n\n### deviations\n\n- keep the local edit\n' \ + "$ATTR" >"$sk/ADAPTATION.md" + audit_case variant_heading + expect_code 1 "$RC" "variant deviation heading does not declare a deviation" + assert_contains "$OUT" "UNDECLARED DRIFT" "variant heading leaves drift undeclared" + assert_not_contains "$OUT" "keep the local edit" "variant heading bullet is ignored" + pass "audit: only the exact ## Deviations heading identifies the ledger" +} + +test_quiet_predicate() { + local sk + sk=$(fresh_case quiet_ok) + write_adaptation "$sk" "$ATTR" "$bad/SKILL.md" + write_adaptation "$bad" "$ATTR" must fail, not + # silently report "clean". + OUT=$(SKILL_ADAPTATION_BASE_DIR="$TMP_ROOT/does-not-exist" \ + "$SCRIPT" audit "$sk" 2>&1) + RC=$? + expect_code 3 "$RC" "unfetchable base returns the dedicated fetch code" + assert_contains "$OUT" "no cached base tree" "fetch failure names the missing base" + pass "audit: an unreachable base fails loudly (exit 3), never clean" +} + +test_skill_less_base_fails_loudly() { + local sk base + sk=$(fresh_case skill_less) + base="$TMP_ROOT/skill_less/base/org/repo/$SHA/skills/foo" + rm "$base/SKILL.md" + printf 'upstream ref\n' >"$base/reference.md" + write_adaptation "$sk" "$ATTR" "$outside/SKILL.md" + escaped_attr="https://github.com/org/repo/tree/$SHA/skills/%2E%2E/%2E%2E/outside" + write_adaptation "$sk" "$escaped_attr" "$base2/SKILL.md" + printf 'bar upstream\n' >"$base2/EXTRA.md" + printf 'bar local edit\n' >"$sk/EXTRA.md" + write_adaptation "$sk" "$ATTR" "$attr2" &1) + RC=$? + expect_code 128 "$RC" "missing skill dir is a hard path error" + + local sk + sk=$(fresh_case badpin) + printf -- '---\nattributions:\n- https://example.com/not-a-tree-url\n---\n' >"$sk/ADAPTATION.md" + audit_case badpin + expect_code 1 "$RC" "invalid pin cannot be audited" + assert_contains "$OUT" "must be a GitHub tree URL" "invalid pin explains itself" + + # A dir with SKILL.md but no ADAPTATION.md at all. + fresh_case nopin >/dev/null + audit_case nopin + expect_code 1 "$RC" "a non-adapted dir cannot be audited" + assert_contains "$OUT" "missing ADAPTATION.md" "absent provenance is named" + pass "audit: hard path, invalid pin, and missing provenance each fail distinctly" +} + +test_validate_passes_on_the_new_skill_dir() { + # The machinery skill itself is firstmate-own (no ADAPTATION.md), so we + # validate a freshly scaffolded adapted dir to exercise validate-skill-dir. + local sk + sk=$(fresh_case validate) + "$SCRIPT" template "$ATTR" >"$sk/ADAPTATION.md" + "$SCRIPT" validate-skill-dir "$sk" >/dev/null 2>&1 + expect_code 0 "$?" "scaffolded ADAPTATION.md validates" + pass "validate-skill-dir: a scaffolded adapted skill is structurally valid" +} + +test_clean +test_undeclared_modified +test_undeclared_removed +test_stale_bullet +test_mixed_presents_both_sides +test_placeholder_bullet_is_not_a_deviation +test_variant_heading_is_not_the_ledger +test_quiet_predicate +test_fetch_failure_fails_loudly +test_skill_less_base_fails_loudly +test_cache_path_escape_fails_loudly +test_multi_attribution_drift +test_hard_errors +test_validate_passes_on_the_new_skill_dir