diff --git a/.github/workflows/prose-guard.yml b/.github/workflows/prose-guard.yml new file mode 100644 index 0000000..4e14e07 --- /dev/null +++ b/.github/workflows/prose-guard.yml @@ -0,0 +1,589 @@ +name: prose-guard + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + guard: + name: prose-guard + runs-on: ubuntu-latest + steps: + - name: Scope gate + id: scope + env: + HEAD_REF: ${{ github.head_ref }} + run: | + set -euo pipefail + if [[ "$HEAD_REF" == claude/prose-* ]]; then + echo "in_scope=true" >> "$GITHUB_OUTPUT" + echo "In scope: head ref is $HEAD_REF" >> "$GITHUB_STEP_SUMMARY" + else + echo "in_scope=false" >> "$GITHUB_OUTPUT" + echo "Out of scope (head ref is not claude/prose-): $HEAD_REF" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Checkout PR head + if: steps.scope.outputs.in_scope == 'true' + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + if: steps.scope.outputs.in_scope == 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install scanner dependencies + if: steps.scope.outputs.in_scope == 'true' + # Pinned literally rather than installed from scripts/requirements.txt: + # the requirements file is PR-controlled and could be swapped for a + # hostile package on a malicious PR. Workflow YAMLs require repo-admin + # permissions to modify, so this pin lives at a trust boundary the + # guard does not need to validate. + run: pip install 'PyYAML>=6,<7' + + - name: Validate prose envelope and skill linked-release rule + if: steps.scope.outputs.in_scope == 'true' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_REF: ${{ github.head_ref }} + run: | + set -euo pipefail + python3 - <<'PY' + import json + import os + import re + import subprocess + import sys + from pathlib import Path + + import yaml + + BASE = os.environ["BASE_SHA"] + HEAD = os.environ["HEAD_SHA"] + HEAD_REF = os.environ["HEAD_REF"] + SUMMARY_PATH = os.environ.get("GITHUB_STEP_SUMMARY") + BRANCH_DATE_RE = re.compile( + r"^claude/prose-(\d{4}-\d{2}-\d{2})(?:-[a-z0-9][a-z0-9-]*)?$" + ) + DOCS_PREFIX = "docs/" + SWEEPS_PREFIX = "planning/sweeps/" + SWEEP_FILE_RE = re.compile(r"^planning/sweeps/(\d{4}-\d{2}-\d{2})\.md$") + VERSION_JSON_PATH = "skills/start/version.json" + SKILL_MD_PATH = "skills/start/SKILL.md" + CHANGELOG_PATH = "skills/start/CHANGELOG.md" + ALLOWED_PATH_PREFIXES = ("docs/", "skills/start/", "planning/sweeps/") + ALLOWED_EXACT_PATHS = { + "skills/start/SKILL.md", + "skills/start/version.json", + "skills/start/CHANGELOG.md", + } + FORBIDDEN_PATH_PREFIXES = ("scripts/", ".github/") + FORBIDDEN_EXACT_PATHS = {"repo-registry.yml", "component-registry.yml"} + # version.json fields that ride with a linked release + RELEASE_VERSION_JSON_KEYS = {"version", "published_date"} + # frontmatter keys that ride with a linked release + RELEASE_SKILL_FRONTMATTER_KEYS = {"version"} + # version.json fields the routine refreshes regardless + REFRESH_VERSION_JSON_KEYS = {"verified_commits"} + # frontmatter fields the routine refreshes regardless + REFRESH_SKILL_FRONTMATTER_KEYS = {"verified_commits", "verified_date"} + SEMVER_RE = re.compile( + r"^(?P\d+)\.(?P\d+)\.(?P\d+)(?P[-+].+)?$" + ) + VERIFICATION_BLOCK_RE = re.compile( + r"", re.DOTALL + ) + BLOCK_KEY_VALUE_RE = re.compile(r"^\s*([a-z_]+)\s*:\s*(\S.*?)\s*$") + DRAFT_SUFFIX = "-draft" + + violations: list[str] = [] + + def fail(loc: str, msg: str) -> None: + violations.append(f"{loc}: {msg}") + + def run(cmd: list[str]) -> str: + return subprocess.check_output(cmd, text=True) + + def show(ref: str, path: str) -> str | None: + try: + return subprocess.check_output( + ["git", "show", f"{ref}:{path}"], + text=True, + stderr=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError: + return None + + name_status = run( + ["git", "diff", "--name-status", "-z", f"{BASE}..{HEAD}"] + ) + # -z output is NUL-separated. For renames the entry is + # "R\0\0". For everything else "\0". + # Both the old and new paths are recorded so the envelope check + # sees forbidden old paths even when a rename lands them under an + # allowed new prefix. + tokens = [t for t in name_status.split("\0") if t] + changes: list[tuple[str, str]] = [] + i = 0 + while i < len(tokens): + status = tokens[i] + if status.startswith(("R", "C")): + if i + 2 >= len(tokens): + fail("git diff", "unexpected -z output truncation") + break + changes.append((status[0], tokens[i + 1])) + changes.append((status[0], tokens[i + 2])) + i += 3 + else: + if i + 1 >= len(tokens): + fail("git diff", "unexpected -z output truncation") + break + changes.append((status, tokens[i + 1])) + i += 2 + + branch_match = BRANCH_DATE_RE.match(HEAD_REF) + if not branch_match: + fail( + HEAD_REF, + "branch name does not match claude/prose-YYYY-MM-DD[-slug]; " + "the guard cannot bind sweep summary files to a date", + ) + branch_date = branch_match.group(1) if branch_match else None + expected_sweep_path = ( + f"planning/sweeps/{branch_date}.md" if branch_date else None + ) + + changed_paths = sorted({path for _status, path in changes}) + + # Positive path allowlist: every changed path must match one of + # docs/**/*.md, skills/start/SKILL.md, skills/start/version.json, + # skills/start/CHANGELOG.md, or planning/sweeps/.md. + # Anything else fails closed. The forbidden-path list below is + # redundant defence-in-depth so the failure message names the + # specific infrastructure category when applicable. + for path in changed_paths: + if any(path.startswith(p) for p in FORBIDDEN_PATH_PREFIXES) or ( + path in FORBIDDEN_EXACT_PATHS + ): + fail( + path, + "infrastructure file outside the prose envelope; " + "the routine never modifies scripts/, .github/, " + "repo-registry.yml, or component-registry.yml", + ) + continue + allowed = False + if path.startswith(DOCS_PREFIX) and path.endswith(".md"): + allowed = True + elif path in ALLOWED_EXACT_PATHS: + allowed = True + elif path.startswith(SWEEPS_PREFIX): + # Sweep summary path validated below; just permit here. + allowed = True + if not allowed: + fail(path, "path outside the prose envelope") + + def parse_frontmatter(text: str) -> tuple[dict, str]: + if not text.startswith("---"): + return {}, text + end = text.find("\n---", 3) + if end < 0: + return {}, text + raw = text[3:end] + try: + loaded = yaml.safe_load(raw) or {} + except yaml.YAMLError as exc: + fail(SKILL_MD_PATH, f"frontmatter YAML parse error: {exc}") + loaded = {} + body = text[end + 4 :] + return loaded, body + + def parse_semver(version: str) -> tuple[int, int, int, str] | None: + match = SEMVER_RE.match(version.strip()) + if not match: + return None + return ( + int(match.group("major")), + int(match.group("minor")), + int(match.group("patch")), + match.group("suffix") or "", + ) + + # Prepare base/head views of the skill files. + base_version_text = show(BASE, VERSION_JSON_PATH) + head_version_text = show(HEAD, VERSION_JSON_PATH) + base_skill_text = show(BASE, SKILL_MD_PATH) + head_skill_text = show(HEAD, SKILL_MD_PATH) + base_changelog_text = show(BASE, CHANGELOG_PATH) or "" + head_changelog_text = show(HEAD, CHANGELOG_PATH) or "" + + base_version_obj: dict = {} + head_version_obj: dict = {} + if base_version_text is not None: + try: + base_version_obj = json.loads(base_version_text) + except json.JSONDecodeError as exc: + fail(VERSION_JSON_PATH, f"base JSON parse error: {exc}") + if head_version_text is not None: + try: + head_version_obj = json.loads(head_version_text) + except json.JSONDecodeError as exc: + fail(VERSION_JSON_PATH, f"head JSON parse error: {exc}") + + base_fm: dict = {} + base_skill_body = "" + head_fm: dict = {} + head_skill_body = "" + if base_skill_text is not None: + base_fm, base_skill_body = parse_frontmatter(base_skill_text) + if head_skill_text is not None: + head_fm, head_skill_body = parse_frontmatter(head_skill_text) + + skill_body_changed = base_skill_body != head_skill_body + version_value_changed = base_version_obj.get("version") != head_version_obj.get("version") + published_date_changed = ( + base_version_obj.get("published_date") + != head_version_obj.get("published_date") + ) + frontmatter_version_changed = base_fm.get("version") != head_fm.get("version") + changelog_changed = base_changelog_text != head_changelog_text + + def block_body_is_target_manifest(body: str) -> bool: + for raw in body.splitlines(): + match = BLOCK_KEY_VALUE_RE.match(raw.strip()) + if match and match.group(1) == "verification_mode": + return match.group(2) == "target-manifest" + return False + + def target_manifest_block_bodies(text: str) -> list[str]: + # Each entry is the literal byte content between the opening + # "" (group 1 of the + # regex). Multiset comparison on this raw form catches any + # byte-level edit, including comments and future fields. + out: list[str] = [] + for match in VERIFICATION_BLOCK_RE.finditer(text): + body = match.group(1) + if block_body_is_target_manifest(body): + out.append(body) + return out + + # 0a) Target-manifest docs blocks must be unchanged. Multiset of + # block bodies on each side must match. Any byte-level diff inside + # a target-manifest block, any flip of verification_mode, or any + # add/remove of a target-manifest block fails closed. + for path in changed_paths: + if not (path.startswith(DOCS_PREFIX) and path.endswith(".md")): + continue + base_text = show(BASE, path) or "" + head_text = show(HEAD, path) or "" + base_tm = sorted(target_manifest_block_bodies(base_text)) + head_tm = sorted(target_manifest_block_bodies(head_text)) + if base_tm != head_tm: + fail( + path, + "target-manifest verification block changed; " + "manual override required", + ) + + # 0b) Target-manifest version.json: if either side has top-level + # verification_mode: target-manifest, both verification_mode and + # verified_commits must be structurally equal between base and + # head. Structural (parsed-object) equality avoids false positives + # from cosmetic re-serialisation but still catches every + # meaningful change. + base_vjson_mode = base_version_obj.get("verification_mode") + head_vjson_mode = head_version_obj.get("verification_mode") + if "target-manifest" in (base_vjson_mode, head_vjson_mode): + if ( + base_vjson_mode != head_vjson_mode + or base_version_obj.get("verified_commits") + != head_version_obj.get("verified_commits") + or base_version_obj.get("verified_date") + != head_version_obj.get("verified_date") + ): + fail( + VERSION_JSON_PATH, + "target-manifest verification block changed; " + "manual override required", + ) + + # 0c) Target-manifest SKILL.md frontmatter: same rule. + base_fm_mode = base_fm.get("verification_mode") + head_fm_mode = head_fm.get("verification_mode") + if "target-manifest" in (base_fm_mode, head_fm_mode): + if ( + base_fm_mode != head_fm_mode + or base_fm.get("verified_commits") + != head_fm.get("verified_commits") + or base_fm.get("verified_date") + != head_fm.get("verified_date") + ): + fail( + SKILL_MD_PATH, + "target-manifest verification block changed; " + "manual override required", + ) + + # 1) verified_commits key set must be locked in both files. + # Read raw values (no `... or {}` coercion) so that null, list, + # string, or any other non-map fails closed before the key-set + # check runs. The routine emits a map only. + for path, base_obj, head_obj in ( + (VERSION_JSON_PATH, base_version_obj, head_version_obj), + (SKILL_MD_PATH, base_fm, head_fm), + ): + if "verified_commits" not in base_obj: + fail(path, "verified_commits missing on base") + continue + if "verified_commits" not in head_obj: + fail(path, "verified_commits missing on head") + continue + base_commits = base_obj["verified_commits"] + head_commits = head_obj["verified_commits"] + if not isinstance(base_commits, dict): + fail( + path, + "verified_commits is not a map/object on base; " + "the routine emits a map only", + ) + continue + if not isinstance(head_commits, dict): + fail( + path, + "verified_commits is not a map/object on head; " + "the routine emits a map only", + ) + continue + if set(base_commits.keys()) != set(head_commits.keys()): + added = sorted(set(head_commits) - set(base_commits)) + removed = sorted(set(base_commits) - set(head_commits)) + detail = [] + if added: + detail.append(f"added: {', '.join(added)}") + if removed: + detail.append(f"removed: {', '.join(removed)}") + fail( + path, + "verified_commits key set changed; the routine may only " + "refresh values, not add or remove repos " + f"({'; '.join(detail)})", + ) + + # 2) version.json forbidden-key check: anything outside the refresh + # set or the release set is forbidden everywhere. status is called + # out specifically because flipping it (e.g., draft -> stable) is a + # promotion event that must happen on a manually-prepared branch + # outside the upstream-sweep namespace. + if base_version_obj or head_version_obj: + all_keys = set(base_version_obj) | set(head_version_obj) + for key in sorted(all_keys): + if base_version_obj.get(key) == head_version_obj.get(key): + continue + if key == "status": + fail( + VERSION_JSON_PATH, + "status changed (immutable in upstream-sweep PRs): " + f"{base_version_obj.get('status')!r} -> " + f"{head_version_obj.get('status')!r}", + ) + continue + if ( + key in REFRESH_VERSION_JSON_KEYS + or key in RELEASE_VERSION_JSON_KEYS + ): + continue + fail( + VERSION_JSON_PATH, + f"forbidden field changed: {key} " + "(only verified_commits, version, and published_date may " + "differ in a prose PR)", + ) + + # 3) frontmatter forbidden-key check: anything outside refresh + release + # set is forbidden. + if base_fm or head_fm: + all_keys = set(base_fm) | set(head_fm) + for key in sorted(all_keys): + if base_fm.get(key) == head_fm.get(key): + continue + if ( + key in REFRESH_SKILL_FRONTMATTER_KEYS + or key in RELEASE_SKILL_FRONTMATTER_KEYS + ): + continue + fail( + SKILL_MD_PATH, + f"forbidden frontmatter key changed: {key} " + "(only verified_commits, verified_date, and version " + "may differ in a prose PR)", + ) + + # 4) Linked-release rule (both directions). + if skill_body_changed: + # All five linked-release fields must move. + missing: list[str] = [] + if not version_value_changed: + missing.append("skills/start/version.json: version (must bump to a new patch)") + if not published_date_changed: + missing.append("skills/start/version.json: published_date (must update)") + if not frontmatter_version_changed: + missing.append("skills/start/SKILL.md frontmatter version: (must match new version.json)") + # verified_date must change too. + if base_fm.get("verified_date") == head_fm.get("verified_date"): + missing.append("skills/start/SKILL.md frontmatter verified_date: (must update)") + if not changelog_changed: + missing.append("skills/start/CHANGELOG.md (must add a new entry matching the new version)") + if missing: + fail( + SKILL_MD_PATH, + "SKILL.md body changed but the linked patch release is " + "incomplete; missing: " + "; ".join(missing), + ) + else: + # Validate patch arithmetic: head version's MAJOR.MINOR match + # base, PATCH = base + 1. + base_v = base_version_obj.get("version", "") + head_v = head_version_obj.get("version", "") + base_parsed = parse_semver(base_v) if isinstance(base_v, str) else None + head_parsed = parse_semver(head_v) if isinstance(head_v, str) else None + if not base_parsed or not head_parsed: + fail( + VERSION_JSON_PATH, + f"version values are not valid semver: " + f"base={base_v!r} head={head_v!r}", + ) + else: + bmaj, bmin, bpat, _ = base_parsed + hmaj, hmin, hpat, _ = head_parsed + if (hmaj, hmin) != (bmaj, bmin) or hpat != bpat + 1: + fail( + VERSION_JSON_PATH, + f"linked release must be a patch bump: " + f"base {base_v} -> head {head_v} (expected " + f"{bmaj}.{bmin}.{bpat + 1})", + ) + # -draft suffix preservation. Promotion off draft is an + # explicit human decision; an upstream-sweep prose PR + # is a routine maintenance event and must not silently + # strip the suffix. Symmetric: a non-draft base may + # not gain -draft on head. + if isinstance(base_v, str) and isinstance(head_v, str): + base_draft = base_v.endswith(DRAFT_SUFFIX) + head_draft = head_v.endswith(DRAFT_SUFFIX) + if base_draft != head_draft: + fail( + VERSION_JSON_PATH, + "version suffix changed (-draft " + f"preservation): {base_v} -> {head_v}", + ) + # published_date and verified_date must match on a linked + # release. The release set is one event; splitting them + # suggests one of the two files was missed. + pub_date = head_version_obj.get("published_date") + ver_date = head_fm.get("verified_date") + if pub_date != ver_date: + fail( + SKILL_MD_PATH, + "published_date and verified_date disagree: " + f"{pub_date!r} vs {ver_date!r}", + ) + # Frontmatter version must match version.json: version. + if head_fm.get("version") != head_version_obj.get("version"): + fail( + SKILL_MD_PATH, + "frontmatter version: must match version.json: version " + f"(frontmatter={head_fm.get('version')!r} vs " + f"version.json={head_version_obj.get('version')!r})", + ) + # CHANGELOG must contain a new header for the new version. + new_version = head_version_obj.get("version", "") + expected_header = f"## [{new_version}]" + if expected_header not in head_changelog_text: + fail( + CHANGELOG_PATH, + f"missing CHANGELOG entry for version {new_version!r} " + f"(expected header line starting with '{expected_header}')", + ) + if expected_header in base_changelog_text: + fail( + CHANGELOG_PATH, + f"CHANGELOG already had an entry for {new_version!r} on " + "base; the linked release must add a new entry, not " + "reuse an existing one", + ) + else: + # Body unchanged: none of the linked-release fields may change. + if version_value_changed: + fail( + VERSION_JSON_PATH, + "version changed but SKILL.md body is unchanged; release " + "metadata may only move when the skill body moves", + ) + if published_date_changed: + fail( + VERSION_JSON_PATH, + "published_date changed but SKILL.md body is unchanged; " + "release metadata may only move when the skill body moves", + ) + if frontmatter_version_changed: + fail( + SKILL_MD_PATH, + "frontmatter version: changed but SKILL.md body is " + "unchanged; release metadata may only move when the body " + "moves", + ) + if changelog_changed: + fail( + CHANGELOG_PATH, + "CHANGELOG.md changed but SKILL.md body is unchanged; " + "the CHANGELOG may only move as part of a linked patch " + "release with a body change", + ) + + # 5) Sweep summary file: optional, but if present must be ADDED + # (not modified), and must match the branch date. + status_by_path: dict[str, str] = {} + for status, path in changes: + status_by_path[path] = status + for path in changed_paths: + if path.startswith(SWEEPS_PREFIX): + match = SWEEP_FILE_RE.match(path) + if not match: + fail(path, "non-sweep file inside planning/sweeps/") + continue + if status_by_path.get(path) != "A": + fail( + path, + "sweep summary must be added (A); " + f"got status {status_by_path.get(path, '?')}", + ) + continue + if expected_sweep_path and path != expected_sweep_path: + fail( + path, + f"sweep summary file date does not match branch; " + f"expected {expected_sweep_path}", + ) + + if violations: + if SUMMARY_PATH: + with open(SUMMARY_PATH, "a", encoding="utf-8") as out: + out.write("## prose-guard failures\n\n") + for v in violations: + out.write(f"- `{v}`\n") + for v in violations: + print(v, file=sys.stderr) + sys.exit(1) + + if SUMMARY_PATH: + with open(SUMMARY_PATH, "a", encoding="utf-8") as out: + out.write("prose-guard: clean envelope.\n") + PY diff --git a/.github/workflows/sweep-guard.yml b/.github/workflows/sweep-guard.yml new file mode 100644 index 0000000..a0ac2b4 --- /dev/null +++ b/.github/workflows/sweep-guard.yml @@ -0,0 +1,510 @@ +name: sweep-guard + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + guard: + name: sweep-guard + runs-on: ubuntu-latest + steps: + - name: Scope gate + id: scope + env: + HEAD_REF: ${{ github.head_ref }} + run: | + set -euo pipefail + if [[ "$HEAD_REF" == claude/sweep-* ]]; then + echo "in_scope=true" >> "$GITHUB_OUTPUT" + echo "In scope: head ref is $HEAD_REF" >> "$GITHUB_STEP_SUMMARY" + else + echo "in_scope=false" >> "$GITHUB_OUTPUT" + echo "Out of scope (head ref is not claude/sweep-): $HEAD_REF" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Checkout PR head + if: steps.scope.outputs.in_scope == 'true' + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + if: steps.scope.outputs.in_scope == 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install scanner dependencies + if: steps.scope.outputs.in_scope == 'true' + # The pin is duplicated in scripts/requirements.txt for the scanner's + # local + routine use, but the workflow must NOT install from that file: + # it is PR-controlled, so a malicious sweep PR could swap it for a + # hostile package and execute arbitrary code in the runner via setup.py. + # Workflow YAMLs require repo-admin permissions to modify (same gate as + # branch protection rules), so this literal pin lives at a trust + # boundary the guard does not need to validate. + run: pip install 'PyYAML>=6,<7' + + - name: Validate metadata-only envelope + if: steps.scope.outputs.in_scope == 'true' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_REF: ${{ github.head_ref }} + run: | + set -euo pipefail + python3 - <<'PY' + import json + import os + import re + import subprocess + import sys + from pathlib import Path + + import yaml + + BASE = os.environ["BASE_SHA"] + HEAD = os.environ["HEAD_SHA"] + HEAD_REF = os.environ["HEAD_REF"] + SUMMARY_PATH = os.environ.get("GITHUB_STEP_SUMMARY") + BRANCH_DATE_RE = re.compile( + r"^claude/sweep-(\d{4}-\d{2}-\d{2})(?:-[a-z0-9][a-z0-9-]*)?$" + ) + DOCS_PREFIX = "docs/" + SWEEPS_PREFIX = "planning/sweeps/" + SWEEP_FILE_RE = re.compile(r"^planning/sweeps/(\d{4}-\d{2}-\d{2})\.md$") + VERIFICATION_BLOCK_RE = re.compile(r"", re.DOTALL) + BLOCK_KEY_VALUE_RE = re.compile(r"^\s*([a-z_]+)\s*:\s*(\S.*?)\s*$") + ALLOWED_DOC_KEYS = ("source_commit:", "verified_date:") + VERSION_JSON_PATH = "skills/start/version.json" + SKILL_MD_PATH = "skills/start/SKILL.md" + CHANGELOG_PATH = "skills/start/CHANGELOG.md" + ALLOWED_VERSION_JSON_KEYS = {"verified_commits"} + ALLOWED_SKILL_FRONTMATTER_KEYS = {"verified_commits", "verified_date"} + + violations: list[str] = [] + + def fail(loc: str, msg: str) -> None: + violations.append(f"{loc}: {msg}") + + def run(cmd: list[str]) -> str: + return subprocess.check_output(cmd, text=True) + + def show(ref: str, path: str) -> str | None: + try: + return subprocess.check_output( + ["git", "show", f"{ref}:{path}"], + text=True, + stderr=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError: + return None + + name_status = run( + ["git", "diff", "--name-status", "-z", f"{BASE}..{HEAD}"] + ) + # -z output is NUL-separated. For renames the entry is + # "R\0\0". For everything else "\0". + tokens = [t for t in name_status.split("\0") if t] + changes: list[tuple[str, str]] = [] + i = 0 + while i < len(tokens): + status = tokens[i] + if status.startswith(("R", "C")): + if i + 2 >= len(tokens): + fail("git diff", "unexpected -z output truncation") + break + changes.append((status[0], tokens[i + 1])) + changes.append((status[0], tokens[i + 2])) + i += 3 + else: + if i + 1 >= len(tokens): + fail("git diff", "unexpected -z output truncation") + break + changes.append((status, tokens[i + 1])) + i += 2 + + # Branch date suffix dictates the expected sweep file. + branch_match = BRANCH_DATE_RE.match(HEAD_REF) + expected_sweep_path = ( + f"planning/sweeps/{branch_match.group(1)}.md" if branch_match else None + ) + if not branch_match: + fail( + HEAD_REF, + "branch name does not match claude/sweep-YYYY-MM-DD[-slug]; " + "the guard cannot verify the new sweep summary file", + ) + + # Guard: every changed file must fit one allowlist slot. + seen_paths: set[str] = set() + sweep_file_seen = False + + def block_intervals(text: str) -> list[tuple[int, int]]: + # Return a list of [start_line, end_line] (1-indexed, inclusive) + # for every block in the file. + intervals: list[tuple[int, int]] = [] + for match in VERIFICATION_BLOCK_RE.finditer(text): + start_line = text.count("\n", 0, match.start()) + 1 + end_line = text.count("\n", 0, match.end()) + 1 + intervals.append((start_line, end_line)) + return intervals + + def line_in_any(intervals: list[tuple[int, int]], line: int) -> bool: + return any(s <= line <= e for s, e in intervals) + + def block_body_is_target_manifest(body: str) -> bool: + for raw in body.splitlines(): + match = BLOCK_KEY_VALUE_RE.match(raw.strip()) + if match and match.group(1) == "verification_mode": + return match.group(2) == "target-manifest" + return False + + def target_manifest_block_bodies(text: str) -> list[str]: + # Literal byte content between ""; + # multiset comparison on this raw form catches any byte-level + # edit, including comments and future fields. + out: list[str] = [] + for match in VERIFICATION_BLOCK_RE.finditer(text): + body = match.group(1) + if block_body_is_target_manifest(body): + out.append(body) + return out + + def validate_docs_md(path: str) -> None: + base_text = show(BASE, path) or "" + head_text = show(HEAD, path) or "" + # Target-manifest protection: a sweep PR must never touch a + # target-manifest block. Multiset of literal block bodies on + # each side must match. The line-prefix check below would + # already permit source_commit:/verified_date: edits inside a + # target-manifest block; this is the rule that closes that + # gap. + base_tm = sorted(target_manifest_block_bodies(base_text)) + head_tm = sorted(target_manifest_block_bodies(head_text)) + if base_tm != head_tm: + fail( + path, + "target-manifest verification block changed; " + "sweep PRs must not touch target-manifest blocks", + ) + # Continue with the line-prefix scan so that any + # additional envelope violations on the same file also + # surface in the same run, but the target-manifest + # failure is enough to red the PR on its own. + base_lines = base_text.splitlines() + head_lines = head_text.splitlines() + base_intervals = block_intervals(base_text) + head_intervals = block_intervals(head_text) + # Use unified diff to identify which lines moved. We + # walk a line-level diff via difflib so we can map back + # to base/head line numbers. + import difflib + + matcher = difflib.SequenceMatcher( + a=base_lines, b=head_lines, autojunk=False + ) + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + continue + # Removed lines (base side) + for offset, base_line in enumerate(base_lines[i1:i2]): + base_line_num = i1 + offset + 1 + if not line_in_any(base_intervals, base_line_num): + fail( + f"{path}:{base_line_num}", + "removed line lies outside any " + " block", + ) + continue + stripped = base_line.lstrip() + if not stripped.startswith(ALLOWED_DOC_KEYS): + fail( + f"{path}:{base_line_num}", + "removed line is not a source_commit: or " + "verified_date: line", + ) + # Added lines (head side) + for offset, head_line in enumerate(head_lines[j1:j2]): + head_line_num = j1 + offset + 1 + if not line_in_any(head_intervals, head_line_num): + fail( + f"{path}:{head_line_num}", + "added line lies outside any " + " block", + ) + continue + stripped = head_line.lstrip() + if not stripped.startswith(ALLOWED_DOC_KEYS): + fail( + f"{path}:{head_line_num}", + "added line is not a source_commit: or " + "verified_date: line", + ) + + def validate_version_json() -> None: + base_text = show(BASE, VERSION_JSON_PATH) + head_text = show(HEAD, VERSION_JSON_PATH) + if base_text is None or head_text is None: + fail( + VERSION_JSON_PATH, + "version.json must exist on both base and head", + ) + return + try: + base_obj = json.loads(base_text) + head_obj = json.loads(head_text) + except json.JSONDecodeError as exc: + fail(VERSION_JSON_PATH, f"JSON parse error: {exc}") + return + # Target-manifest protection: if either side has top-level + # verification_mode: target-manifest, both verification_mode + # and verified_commits must be structurally equal between + # base and head. Structural (parsed-object) equality avoids + # false positives from cosmetic JSON re-serialisation but + # still catches every meaningful change. A silent + # target-manifest -> current-merged-truth flip is also + # caught because the modes themselves differ. + base_mode = base_obj.get("verification_mode") + head_mode = head_obj.get("verification_mode") + if "target-manifest" in (base_mode, head_mode): + if ( + base_mode != head_mode + or base_obj.get("verified_commits") + != head_obj.get("verified_commits") + or base_obj.get("verified_date") + != head_obj.get("verified_date") + ): + fail( + VERSION_JSON_PATH, + "target-manifest verification block changed; " + "sweep PRs must not touch target-manifest blocks", + ) + all_keys = set(base_obj) | set(head_obj) + for key in sorted(all_keys): + if base_obj.get(key) != head_obj.get(key): + if key not in ALLOWED_VERSION_JSON_KEYS: + fail( + VERSION_JSON_PATH, + f"forbidden field changed: {key} " + "(only verified_commits may differ in a sweep)", + ) + # Raw-value type check on verified_commits: do not coerce + # null/list/string to {} before checking. The routine emits + # a map only. + if "verified_commits" not in base_obj: + fail(VERSION_JSON_PATH, "verified_commits missing on base") + return + if "verified_commits" not in head_obj: + fail(VERSION_JSON_PATH, "verified_commits missing on head") + return + base_commits = base_obj["verified_commits"] + head_commits = head_obj["verified_commits"] + if not isinstance(base_commits, dict): + fail( + VERSION_JSON_PATH, + "verified_commits is not a map/object on base; " + "the routine emits a map only", + ) + return + if not isinstance(head_commits, dict): + fail( + VERSION_JSON_PATH, + "verified_commits is not a map/object on head; " + "the routine emits a map only", + ) + return + if set(base_commits.keys()) != set(head_commits.keys()): + added = sorted(set(head_commits) - set(base_commits)) + removed = sorted(set(base_commits) - set(head_commits)) + detail = [] + if added: + detail.append(f"added: {', '.join(added)}") + if removed: + detail.append(f"removed: {', '.join(removed)}") + fail( + VERSION_JSON_PATH, + "verified_commits key set changed; sweeps may only " + "refresh values, not add or remove repos " + f"({'; '.join(detail)})", + ) + + def parse_frontmatter(text: str) -> tuple[dict, str]: + if not text.startswith("---"): + return {}, text + end = text.find("\n---", 3) + if end < 0: + return {}, text + raw = text[3:end] + try: + loaded = yaml.safe_load(raw) or {} + except yaml.YAMLError as exc: + fail(SKILL_MD_PATH, f"frontmatter YAML parse error: {exc}") + loaded = {} + body = text[end + 4 :] + return loaded, body + + def validate_skill_md() -> None: + base_text = show(BASE, SKILL_MD_PATH) + head_text = show(HEAD, SKILL_MD_PATH) + if base_text is None or head_text is None: + fail( + SKILL_MD_PATH, + "SKILL.md must exist on both base and head", + ) + return + base_fm, base_body = parse_frontmatter(base_text) + head_fm, head_body = parse_frontmatter(head_text) + if base_body != head_body: + fail( + SKILL_MD_PATH, + "rendered prose body changed; sweep envelope is " + "frontmatter-only", + ) + # Target-manifest protection: same rule as version.json. + base_mode = base_fm.get("verification_mode") + head_mode = head_fm.get("verification_mode") + if "target-manifest" in (base_mode, head_mode): + if ( + base_mode != head_mode + or base_fm.get("verified_commits") + != head_fm.get("verified_commits") + or base_fm.get("verified_date") + != head_fm.get("verified_date") + ): + fail( + SKILL_MD_PATH, + "target-manifest verification block changed; " + "sweep PRs must not touch target-manifest blocks", + ) + all_keys = set(base_fm) | set(head_fm) + for key in sorted(all_keys): + if base_fm.get(key) != head_fm.get(key): + if key not in ALLOWED_SKILL_FRONTMATTER_KEYS: + fail( + SKILL_MD_PATH, + f"forbidden frontmatter key changed: {key} " + "(only verified_commits and verified_date may differ)", + ) + # Raw-value type check on verified_commits. + if "verified_commits" not in base_fm: + fail(SKILL_MD_PATH, "verified_commits missing on base") + return + if "verified_commits" not in head_fm: + fail(SKILL_MD_PATH, "verified_commits missing on head") + return + base_commits = base_fm["verified_commits"] + head_commits = head_fm["verified_commits"] + if not isinstance(base_commits, dict): + fail( + SKILL_MD_PATH, + "verified_commits is not a map/object on base; " + "the routine emits a map only", + ) + return + if not isinstance(head_commits, dict): + fail( + SKILL_MD_PATH, + "verified_commits is not a map/object on head; " + "the routine emits a map only", + ) + return + if set(base_commits.keys()) != set(head_commits.keys()): + added = sorted(set(head_commits) - set(base_commits)) + removed = sorted(set(base_commits) - set(head_commits)) + detail = [] + if added: + detail.append(f"added: {', '.join(added)}") + if removed: + detail.append(f"removed: {', '.join(removed)}") + fail( + SKILL_MD_PATH, + "verified_commits key set changed; sweeps may only " + "refresh values, not add or remove repos " + f"({'; '.join(detail)})", + ) + + FORBIDDEN_PATH_PREFIXES = ("scripts/", ".github/") + FORBIDDEN_EXACT_PATHS = {"repo-registry.yml", "component-registry.yml"} + + for status, path in changes: + if path in seen_paths: + continue + seen_paths.add(path) + if any(path.startswith(p) for p in FORBIDDEN_PATH_PREFIXES) or ( + path in FORBIDDEN_EXACT_PATHS + ): + fail( + path, + "infrastructure file outside the sweep envelope; " + "the routine never modifies scripts/, .github/, " + "repo-registry.yml, or component-registry.yml", + ) + continue + if path.startswith(DOCS_PREFIX) and path.endswith(".md"): + if status not in ("M",): + fail(path, f"unexpected diff status {status} for docs file") + continue + validate_docs_md(path) + elif path == VERSION_JSON_PATH: + if status != "M": + fail(path, f"unexpected diff status {status}") + continue + validate_version_json() + elif path == SKILL_MD_PATH: + if status != "M": + fail(path, f"unexpected diff status {status}") + continue + validate_skill_md() + elif path == CHANGELOG_PATH: + fail( + path, + "CHANGELOG.md must be byte-identical to base on a sweep PR", + ) + elif path.startswith(SWEEPS_PREFIX): + match = SWEEP_FILE_RE.match(path) + if not match: + fail(path, "non-sweep file inside planning/sweeps/") + continue + if status != "A": + fail( + path, + f"sweep summary must be added (A), got status {status}", + ) + continue + if expected_sweep_path and path != expected_sweep_path: + fail( + path, + f"sweep file date does not match branch; " + f"expected {expected_sweep_path}", + ) + continue + sweep_file_seen = True + else: + fail(path, "file outside the metadata-only allowlist") + + if branch_match and not sweep_file_seen: + fail( + HEAD_REF, + "no planning/sweeps/.md added on this PR", + ) + + if violations: + if SUMMARY_PATH: + with open(SUMMARY_PATH, "a", encoding="utf-8") as out: + out.write("## sweep-guard failures\n\n") + for v in violations: + out.write(f"- `{v}`\n") + for v in violations: + print(v, file=sys.stderr) + sys.exit(1) + + if SUMMARY_PATH: + with open(SUMMARY_PATH, "a", encoding="utf-8") as out: + out.write("sweep-guard: clean envelope.\n") + PY diff --git a/.github/workflows/sweep-sha-reachability.yml b/.github/workflows/sweep-sha-reachability.yml new file mode 100644 index 0000000..bfeea37 --- /dev/null +++ b/.github/workflows/sweep-sha-reachability.yml @@ -0,0 +1,384 @@ +name: sweep-sha-reachability + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + reachability: + name: sweep-sha-reachability + runs-on: ubuntu-latest + steps: + - name: Scope gate + id: scope + env: + HEAD_REF: ${{ github.head_ref }} + run: | + set -euo pipefail + if [[ "$HEAD_REF" == claude/sweep-* || "$HEAD_REF" == claude/prose-* ]]; then + echo "in_scope=true" >> "$GITHUB_OUTPUT" + echo "In scope: head ref is $HEAD_REF" >> "$GITHUB_STEP_SUMMARY" + else + echo "in_scope=false" >> "$GITHUB_OUTPUT" + echo "Out of scope: $HEAD_REF" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Checkout PR head + if: steps.scope.outputs.in_scope == 'true' + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + if: steps.scope.outputs.in_scope == 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install scanner dependencies + if: steps.scope.outputs.in_scope == 'true' + # Pinned literally rather than installed from scripts/requirements.txt: + # the requirements file is PR-controlled and could be swapped for a + # hostile package on a malicious sweep PR. Workflow YAMLs require + # repo-admin permissions to modify, so this pin lives at a trust + # boundary the guard does not need to validate. + run: pip install 'PyYAML>=6,<7' + + - name: Validate SHA reachability + if: steps.scope.outputs.in_scope == 'true' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + python3 - <<'PY' + import json + import os + import re + import subprocess + import sys + import urllib.error + import urllib.parse + import urllib.request + from pathlib import Path + + import yaml + + BASE = os.environ["BASE_SHA"] + HEAD = os.environ["HEAD_SHA"] + TOKEN = os.environ["GITHUB_TOKEN"] + SUMMARY_PATH = os.environ.get("GITHUB_STEP_SUMMARY") + REGISTRY_PATH = "repo-registry.yml" + VERSION_JSON_PATH = "skills/start/version.json" + SKILL_MD_PATH = "skills/start/SKILL.md" + DOCS_PREFIX = "docs/" + VERIFICATION_BLOCK_RE = re.compile( + r"", re.DOTALL + ) + KEY_VALUE_RE = re.compile(r"^\s*([a-z_]+)\s*:\s*(\S.*?)\s*$") + + violations: list[str] = [] + skipped_target_manifest: list[str] = [] + default_branch_cache: dict[str, str] = {} + + def fail(loc: str, msg: str) -> None: + violations.append(f"{loc}: {msg}") + + def skip_tm(loc: str) -> None: + # Ancestry against a moving HEAD is not meaningful for + # target-manifest pins; record an informational summary line + # rather than failing or silently dropping the record. + skipped_target_manifest.append(f"{loc}: skipped (target-manifest pin)") + + def show(ref: str, path: str) -> str | None: + try: + return subprocess.check_output( + ["git", "show", f"{ref}:{path}"], + text=True, + stderr=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError: + return None + + def github_get(path: str) -> tuple[int, dict | None]: + url = f"https://api.github.com{path}" + req = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {TOKEN}", + "User-Agent": "autonomi-developer-docs-sweep-sha-reach", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.status, json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + return exc.code, None + except urllib.error.URLError as exc: + fail(url, f"GitHub API network error: {exc.reason}") + return -1, None + + def parse_owner_repo(url: str) -> tuple[str, str] | None: + parsed = urllib.parse.urlparse(url) + parts = [p for p in parsed.path.strip("/").split("/") if p] + if parsed.netloc != "github.com" or len(parts) < 2: + return None + owner, repo = parts[0], parts[1] + if repo.endswith(".git"): + repo = repo[: -len(".git")] + return owner, repo + + def load_registry() -> dict: + text = show(HEAD, REGISTRY_PATH) or "" + try: + data = yaml.safe_load(text) or {} + except yaml.YAMLError as exc: + fail(REGISTRY_PATH, f"YAML parse error: {exc}") + return {} + return (data or {}).get("repos") or {} + + REGISTRY = load_registry() + + def resolve_default_branch(repo_key: str) -> str | None: + if repo_key in default_branch_cache: + return default_branch_cache[repo_key] + entry = REGISTRY.get(repo_key) + if not entry: + fail(repo_key, "repo not in repo-registry.yml") + return None + parsed = parse_owner_repo(entry["url"]) + if not parsed: + fail(repo_key, f"unparseable url: {entry['url']}") + return None + owner, repo = parsed + status, body = github_get(f"/repos/{owner}/{repo}") + if status != 200 or not body: + fail(repo_key, f"GET /repos/{owner}/{repo} returned {status}") + return None + branch = body.get("default_branch") + if not isinstance(branch, str) or not branch: + fail(repo_key, "default_branch missing in repo metadata") + return None + default_branch_cache[repo_key] = branch + return branch + + def validate_sha( + loc: str, repo_key: str, ref: str, sha: str + ) -> None: + if not isinstance(sha, str) or not sha: + fail( + loc, + "recorded SHA is not a non-empty string: " + f"{sha!r}", + ) + return + entry = REGISTRY.get(repo_key) + if not entry: + fail(loc, f"repo not in repo-registry.yml: {repo_key}") + return + parsed = parse_owner_repo(entry["url"]) + if not parsed: + fail(loc, f"unparseable url: {entry['url']}") + return + owner, repo = parsed + status, _ = github_get(f"/repos/{owner}/{repo}/commits/{sha}") + if status != 200: + fail( + loc, + f"{repo_key}@{sha[:7]} not reachable on github.com " + f"(GET /commits/{sha} -> {status})", + ) + return + encoded_ref = urllib.parse.quote(ref, safe="") + compare_status, body = github_get( + f"/repos/{owner}/{repo}/compare/{sha}...{encoded_ref}" + ) + if compare_status != 200 or not body: + fail( + loc, + f"{repo_key}: compare {sha[:7]}...{ref} returned " + f"{compare_status}", + ) + return + compare_state = body.get("status") + if compare_state not in ("ahead", "identical"): + fail( + loc, + f"{repo_key}: recorded SHA {sha[:7]} is not an " + f"ancestor of {ref} (compare status: {compare_state}); " + "force-push or ref-rewrite suspected", + ) + + def parse_block_fields(body: str) -> dict[str, str]: + fields: dict[str, str] = {} + for raw_line in body.splitlines(): + match = KEY_VALUE_RE.match(raw_line.strip()) + if match: + fields[match.group(1)] = match.group(2) + return fields + + def line_of_offset(text: str, offset: int) -> int: + return text.count("\n", 0, offset) + 1 + + def block_intervals(text: str) -> list[tuple[int, int, str]]: + # (start_line, end_line, body) for each block + out: list[tuple[int, int, str]] = [] + for match in VERIFICATION_BLOCK_RE.finditer(text): + start_line = line_of_offset(text, match.start()) + end_line = line_of_offset(text, match.end()) + out.append((start_line, end_line, match.group(1))) + return out + + # Identify changed verification blocks: any block in HEAD whose + # content (full match text) differs from any block in BASE that + # spans the same lines, OR that has no exact match in BASE. + def changed_blocks_in_doc( + base_text: str, head_text: str + ) -> list[tuple[int, str]]: + base_block_set = { + match.group(0) for match in VERIFICATION_BLOCK_RE.finditer(base_text) + } + changed: list[tuple[int, str]] = [] + for match in VERIFICATION_BLOCK_RE.finditer(head_text): + if match.group(0) in base_block_set: + continue + changed.append( + (line_of_offset(head_text, match.start()), match.group(1)) + ) + return changed + + # 1) docs/**/*.md changed verification blocks + name_status = subprocess.check_output( + ["git", "diff", "--name-status", "-z", f"{BASE}..{HEAD}"], + text=True, + ) + tokens = [t for t in name_status.split("\0") if t] + changed_paths: list[str] = [] + i = 0 + while i < len(tokens): + status = tokens[i] + if status.startswith(("R", "C")): + if i + 2 < len(tokens): + changed_paths.append(tokens[i + 2]) + i += 3 + else: + if i + 1 < len(tokens): + changed_paths.append(tokens[i + 1]) + i += 2 + + docs_changed = [ + p for p in changed_paths if p.startswith(DOCS_PREFIX) and p.endswith(".md") + ] + + for path in sorted(set(docs_changed)): + base_text = show(BASE, path) or "" + head_text = show(HEAD, path) or "" + for start_line, body in changed_blocks_in_doc(base_text, head_text): + fields = parse_block_fields(body) + loc = f"{path}:{start_line}" + required = ("source_repo", "source_ref", "source_commit") + if any(k not in fields for k in required): + fail(loc, "verification block missing required keys") + continue + if fields.get("verification_mode") == "target-manifest": + # SHA reachability against a moving HEAD is not + # meaningful for target-manifest pins. They were + # validated by hand at pin time. + skip_tm(loc) + continue + validate_sha( + loc, + fields["source_repo"], + fields["source_ref"], + fields["source_commit"], + ) + + # 2) version.json post-image verified_commits + if VERSION_JSON_PATH in changed_paths: + head_text = show(HEAD, VERSION_JSON_PATH) or "" + try: + obj = json.loads(head_text) + except json.JSONDecodeError as exc: + fail(VERSION_JSON_PATH, f"JSON parse error: {exc}") + obj = {} + if obj.get("verification_mode") == "target-manifest": + skip_tm(f"{VERSION_JSON_PATH}:verified_commits") + else: + commits = obj.get("verified_commits") + if not isinstance(commits, dict): + fail( + f"{VERSION_JSON_PATH}:verified_commits", + "missing or not a map/object", + ) + commits = {} + for repo_key, sha in sorted(commits.items()): + ref = resolve_default_branch(repo_key) + if ref is None: + continue + loc = f"{VERSION_JSON_PATH}:verified_commits.{repo_key}" + validate_sha(loc, repo_key, ref, sha) + + # 3) SKILL.md post-image frontmatter verified_commits + if SKILL_MD_PATH in changed_paths: + head_text = show(HEAD, SKILL_MD_PATH) or "" + if not head_text.startswith("---"): + fail(SKILL_MD_PATH, "missing YAML frontmatter") + else: + end = head_text.find("\n---", 3) + if end < 0: + fail(SKILL_MD_PATH, "unterminated YAML frontmatter") + else: + raw = head_text[3:end] + try: + fm = yaml.safe_load(raw) or {} + except yaml.YAMLError as exc: + fail(SKILL_MD_PATH, f"frontmatter YAML parse error: {exc}") + fm = {} + if fm.get("verification_mode") == "target-manifest": + skip_tm(f"{SKILL_MD_PATH}:verified_commits") + else: + commits = fm.get("verified_commits") + if not isinstance(commits, dict): + fail( + f"{SKILL_MD_PATH}:verified_commits", + "missing or not a map/object", + ) + commits = {} + for repo_key, sha in sorted(commits.items()): + ref = resolve_default_branch(repo_key) + if ref is None: + continue + loc = ( + f"{SKILL_MD_PATH}:verified_commits.{repo_key}" + ) + validate_sha(loc, repo_key, ref, sha) + + if violations: + if SUMMARY_PATH: + with open(SUMMARY_PATH, "a", encoding="utf-8") as out: + out.write("## sweep-sha-reachability failures\n\n") + for v in violations: + out.write(f"- `{v}`\n") + if skipped_target_manifest: + out.write("\n## skipped (target-manifest)\n\n") + for note in skipped_target_manifest: + out.write(f"- `{note}`\n") + for v in violations: + print(v, file=sys.stderr) + sys.exit(1) + + if SUMMARY_PATH: + with open(SUMMARY_PATH, "a", encoding="utf-8") as out: + out.write("sweep-sha-reachability: all SHAs reachable.\n") + if skipped_target_manifest: + out.write("\n## skipped (target-manifest)\n\n") + for note in skipped_target_manifest: + out.write(f"- `{note}`\n") + PY diff --git a/planning/routines/upstream-sweep-prompt.md b/planning/routines/upstream-sweep-prompt.md new file mode 100644 index 0000000..529e378 --- /dev/null +++ b/planning/routines/upstream-sweep-prompt.md @@ -0,0 +1,310 @@ +# Upstream sweep — routine prompt + +This is the prompt the Claude Desktop Remote routine executes once per day. The behaviour lives in version control so it is reviewable, diffable, and rollback-able. The Claude Desktop routine config references this file by URL or paste-in. Schedule, model selection, and the `GITHUB_TOKEN` secret value live in Claude Desktop, not in this repo. + +## Goal + +Perform the daily upstream-drift sweep per `planning/routines/upstream-sweep.md`. Read that policy doc and `planning/verification-workflow.md` first; they govern this run. + +## Model requirement + +This prompt requires **Opus 4.7 or higher**. The audit/write/verify loop in step 4 assumes the model can: + +- inspect upstream diffs and source at pinned SHAs, +- compare those source changes against affected docs pages and `skills/start/SKILL.md`, +- write actual prose into a draft PR (not suggestions in the PR body), +- run practical verification on the prepared branch (linters, syntax checks, re-runs of the scanner) before opening the PR. + +Do not run this prompt under a smaller model. + +## Inputs available + +- The `Bash` tool, with the docs repo cloned at the working directory. +- The `gh` CLI for all GitHub operations (PR list/create, issue list/create, comment). +- `GITHUB_TOKEN` in the environment, with read access to in-scope upstream repos and write access to `withautonomi/autonomi-developer-docs`. +- `python3` plus the PyYAML pin from `scripts/requirements.txt`. + +Use `gh` consistently for GitHub work. Do not call the GitHub MCP server. + +## Reference rules + +Apply verbatim: + +- `CLAUDE.md` — voice, terminology lockfile, page templates, refusal rules. +- `planning/verification-workflow.md` — source audit -> draft -> verify procedure. +- `planning/routines/upstream-sweep.md` — sweep policy (branch convention, collision handling, named envelopes, fail-closed semantics, Opus audit/write/verify loop, page batching rule, PR body format, audit-diff fetch rule). +- `repo-registry.yml` — `url:` and `topics:` per repo; `topics:` focuses source-artifact selection in step 4. +- `component-registry.yml` — component-to-page map; same role. + +## Steps + +### 0. Bootstrap the rolling status thread + +POSIX shell only. No `mapfile`, no Bash arrays, no bash-only parameter expansions, no `gh issue create --json/--jq` (`gh issue create` does not support those flags). Capture-then-decide is the key invariant: any `gh` failure (auth, network, API) must abort the bootstrap immediately rather than be silently coerced into "label missing" or "no open issue". + +```sh +set -eu + +# (a) labels — capture gh output before deciding so auth/API failure is fatal, +# not silently coerced into "label missing". Both labels are bootstrapped here +# so step 5 can use --label upstream-sweep-manual-review without a 404. +labels=$(gh label list --limit 1000 --json name --jq '.[].name') +if ! printf '%s\n' "$labels" | grep -qx upstream-sweep-status; then + gh label create upstream-sweep-status \ + --description "Rolling status thread for the daily upstream-sweep routine" +fi +if ! printf '%s\n' "$labels" | grep -qx upstream-sweep-manual-review; then + gh label create upstream-sweep-manual-review \ + --description "Ambiguous upstream-sweep records that need a human decision" +fi + +# (b) issue — same capture-then-decide pattern. gh issue create has no +# --json/--jq, so parse the URL it writes to stdout. +first_issue=$(gh issue list --state open --label upstream-sweep-status \ + --limit 1000 --json number --jq 'sort_by(.number) | .[0].number // empty') +if [ -z "$first_issue" ]; then + url=$(gh issue create --title "Upstream sweep status" \ + --label upstream-sweep-status \ + --body "Rolling status thread for the daily upstream-sweep routine.") + STATUS_ISSUE="${url##*/}" +else + STATUS_ISSUE="$first_issue" + count=$(gh issue list --state open --label upstream-sweep-status \ + --limit 1000 --json number --jq 'length') + if [ "$count" -gt 1 ]; then + others=$(gh issue list --state open --label upstream-sweep-status \ + --limit 1000 --json number --jq '[.[].number | tostring] | join(", ")') + gh issue comment "$STATUS_ISSUE" --body \ + "Multiple open issues carry the upstream-sweep-status label: $others. Continuing against #$STATUS_ISSUE (lowest number). Please close the duplicates." + fi +fi +``` + +`gh issue create --label X` fails when the label is missing, so the label step must precede issue creation. Both `upstream-sweep-status` and `upstream-sweep-manual-review` are bootstrapped here because step 5 opens manual-review issues with the second label and would otherwise 404. Capture `$STATUS_ISSUE` for every status post in later steps. + +### 1. Open-PR collision check + +```sh +OPEN_CLAUDE_PRS=$( + gh pr list --state open --limit 1000 --json number,headRefName \ + --jq '.[] | select(.headRefName | startswith("claude/sweep-") or startswith("claude/prose-")) | .number' +) +``` + +If `OPEN_CLAUDE_PRS` is non-empty, post a collision comment to `$STATUS_ISSUE` listing the open PR numbers, and exit without opening anything. Drift is re-detected on the next run. + +`--limit 1000` defeats `gh`'s default 30-row cap so a busy repo cannot hide a still-open prior sweep/prose PR behind pagination. GitHub's `--search` syntax does not reliably match branch prefixes; the JSON list with a client-side prefix filter is the trustworthy form. + +### 2. Run the deterministic scanner + +```sh +python3 scripts/sweep_poll.py > sweep_report.json +``` + +Read `sweep_report.json`. If `status` is `"error"`, post the JSON diagnostics to `$STATUS_ISSUE` and exit. The scanner's fail-closed semantics are documented in `planning/routines/upstream-sweep.md` and in `scripts/sweep_poll.py`. + +If `target_manifest_skipped` is non-empty, include those entries in the run summary at step 6 as "pinned by target-manifest, not bumped" so a human can confirm those pins remain intentional. Never modify `target-manifest` records. + +### 3. Short-circuit on no drift + +If `records` is empty or every record has `drifted: false`, post a no-drift summary table to `$STATUS_ISSUE` (one row per record: location / recorded / HEAD / drifted?) and exit. + +### 4. Opus audit/write/verify loop + +For each drifted record, run the loop below. Treat the deterministic scanner's drift list as a candidate set, not a directive: SHAs are bumped only after this loop confirms the audit succeeds. + +#### 4.1 Fetch both SHAs + +Audit must compare the upstream tree at `head_sha` against the diff from `recorded_sha` to `head_sha`. Both SHAs are required. + +```sh +TMP=$(mktemp -d) +git -C "$TMP" init -q +git -C "$TMP" remote add origin "" +git -C "$TMP" fetch --depth 1 origin "" +git -C "$TMP" fetch --depth 1 origin "" +git -C "$TMP" checkout --detach "" +``` + +The `git -C "$TMP"` form runs each command inside the upstream checkout without changing the current directory of the routine. Every other step in this prompt (`gh` calls, scanner re-runs, file edits in `docs/` and `skills/`, `git add` / `git commit` on the prepared docs branch) assumes the cwd is still the docs repo, so do not `cd` into `$TMP` here. If a follow-up command genuinely needs the upstream tree as cwd, scope it to a subshell: `( cd "$TMP" && )`. + +If either `git fetch` fails (reachable-SHA fetches disabled by the repo, SHA garbage-collected, network failure), fall back to the GitHub compare API: + +```sh +gh api "repos///compare/..." > compare.json +``` + +Inspect `compare.json` for `commits[]`, `files[]`, and the `status` field. + +If both the local fetch and the compare API fail for a record, **fail closed for that record's page**: open a `manual review needed` issue per step 5 and skip the page from both PRs. Never compare against a moving branch name like `main`. + +#### 4.2 Compute the upstream diff + +```sh +git -C "$TMP" log --oneline ".." +git -C "$TMP" diff --stat ".." +git -C "$TMP" diff ".." -- +``` + +Every command in this step targets the upstream checkout at `$TMP`. A bare `git log` or `git diff` here would inspect the docs repo (the cwd of the routine), not the upstream tree, and silently produce misleading audit input. + +Or read `compare.json` `commits[]` and `files[]` when running via the compare-API fallback. + +#### 4.3 Inspect upstream source artifacts at `head_sha` + +Focus on the artifacts the affected docs page or skill cites. Use `repo-registry.yml`'s `topics:` and `component-registry.yml`'s component map to pick artifacts deterministically. + +Common artifacts to read: + +- OpenAPI specs (`antd/openapi.yaml`), +- gRPC `.proto` files (`antd/proto/`), +- CLI source and `--help` output for command-name and flag changes, +- public Rust modules cited by the Direct Rust pages, +- README and docs in the upstream repo when the page references them. + +#### 4.4 Compare against the affected docs pages and `SKILL.md` + +For `scope: "docs"` records, read the page at the recorded `location` path. For `scope: "skill_version_json"` and `scope: "skill_md"` records, read `skills/start/SKILL.md`. + +Identify any rendered claim, code sample, command, endpoint, type, field, or live-reference URL that no longer matches the pinned source. + +#### 4.5 Classify the record + +Pick exactly one: + +- **metadata-only** — internal refactor, test-only changes, dependency bumps, code style. No developer-facing impact. Stamp refresh suffices. +- **prose** — a documented surface changed (new flow, renamed command, removed step, changed payload shape, new error variant that surfaces to users, a moved live-reference URL). Prose changes are required. +- **ambiguous** — evidence is unclear, the page or skill cites something the routine cannot reliably re-derive, or the audit hits a known edge case (deleted file the page referenced, removed surface still mentioned in prose). Defer to a manual-review issue rather than guess. + +#### 4.6 Apply the page batching rule (after all records are classified) + +Apply the five-case rule per page: + +1. **No ambiguous + any prose** → prose PR. All audited non-ambiguous records on the page (prose-impacting **and** metadata-only) are stamped in the same PR. +2. **No ambiguous + all metadata-only** → sweep PR. +3. **Ambiguous + no prose** → manual-review issue, no PR for the page. Metadata-only records on the same page are deferred alongside the ambiguity. +4. **Ambiguous overlapping prose, or unproven independence** → manual-review issue, no PR for the page. Two records overlap when they share any of: claim, page section, code sample, command, endpoint, source artifact, or `` block. +5. **Ambiguous + prose with proven independence** → prose PR for the prose-affected records and any metadata-only records on the same page that are independent of every deferred ambiguity (same overlap dimensions). Ambiguous records and any metadata-only records that overlap a deferred ambiguity stay unstamped: the entire `` block is left byte-identical to base. One manual-review issue per deferred record. + +The two PRs never touch the same page. A prose PR may touch a page with deferred ambiguous records, but must not edit any byte inside those records' verification blocks. + +#### 4.7 Write prose changes directly into the prose PR + +When the page is in the prose batch, write the actual prose edits into the draft `claude/prose-*` PR. Apply `CLAUDE.md`'s voice, terminology lockfile, page templates, and refusal rules. Do not stop at "suggestions in the PR body" — the PR diff must contain the prose edit. + +#### 4.8 Skill-aware prose + +If the audit finds skill impact, include both the human-facing docs change and the `SKILL.md` change in the same prose PR. If the `SKILL.md` body changes, also include the linked patch release set in the same PR: + +- `skills/start/version.json: version` bumped to a new patch version (`MAJOR.MINOR.(PATCH+1)`), +- `skills/start/version.json: published_date` updated, +- `skills/start/SKILL.md` frontmatter `version:` matching the new `version.json: version`, +- `skills/start/SKILL.md` frontmatter `verified_date:` updated, +- `skills/start/CHANGELOG.md` adding one new entry whose header matches the new `version`. + +If the `SKILL.md` body does not change, none of those release fields may change. `prose-guard` enforces both directions. + +#### 4.9 Deferred-record self-check (case 5 only) + +When the page batching rule emits a prose PR with proven-independent ambiguous records held back, confirm — for **every** deferred record on every prose-PR page — that the entire `` block on the prose-PR branch is **byte-identical to base**. Every byte from the opening `` is checked: `source_repo:`, `source_ref:`, `source_commit:`, `verified_date:`, `verification_mode:`, comments, and any other line. Checking only `source_commit:` and `verified_date:` is too narrow; the audit/write step can edit `source_ref:`, change `verification_mode:`, or rewrite a comment inside the block without realising it crossed the deferred-record boundary. + +Confirm the prose PR body contains a `Deferred ambiguous records:` section that links each open `upstream-sweep-manual-review` issue by number for those records. + +The guards cannot enforce this — they have no way to know which records were classified ambiguous — so this self-check is the only thing that catches an accidental edit. **Fail closed on mismatch: do not open the prose PR. Open a manual-review issue describing the slip and continue with the rest of the run.** + +#### 4.10 Practical verification before opening the PR + +Run the checks below on the prepared branch where the toolchain is available. If a check cannot be run (tool missing, environment limit), state that explicitly in the PR body. Never silently skip. + +- repo lint/format checks (`markdownlint`, link checkers — gracefully skip if not configured), +- re-run `python3 scripts/sweep_poll.py` on the prepared branch and confirm `status: "ok"` with no malformed-block errors, +- parse `SKILL.md` frontmatter and `version.json` to confirm the linked-release rule holds when the body changed, +- for changed code samples: language-appropriate syntax check (`python -m py_compile` for Python, `python -m json.tool` for JSON, `node --check` for JavaScript, OpenAPI re-parse as YAML, cURL command shape sanity), each gracefully skipped when the toolchain is unavailable, +- for endpoint/type claims: targeted re-grep against the pinned upstream checkout to confirm the cited endpoint or type still exists. + +Clean up tmp dirs after the loop completes. + +### 5. Open issues, then PRs, then post backlinks + +The opening order is fixed to break the body↔issue mutual-reference cycle. + +**5.1 Manual-review issues first**, before any PR is opened. For each deferred record (case 5) and each whole-page deferral (cases 3 and 4), compute the fingerprint defined in `planning/routines/upstream-sweep.md` `## Manual-review issue format`. Then list open manual-review issues: + +```sh +gh issue list --state open --label upstream-sweep-manual-review \ + --limit 1000 --json number,title,body +``` + +Walk each candidate issue's `body` field client-side and look for a line that, after stripping leading and trailing whitespace, equals the fingerprint string verbatim. Do **not** use `gh issue list --search` — GitHub issue search tokenization does not reliably match a pipe-heavy fingerprint embedded in the body. `--limit 1000` defeats `gh`'s default 30-row cap. + +- On a fingerprint match: reuse the existing issue. Skip `gh issue create` and capture the existing number for the PR body. Do not edit the issue body — reused issues will accumulate a chronological comment trail in step 5.3. +- No fingerprint match: `gh issue create --label upstream-sweep-manual-review` with the body specified in `## Manual-review issue format`. The body **must** contain the fingerprint on its own line, surrounded by blank lines, so the next run's client-side match is unambiguous. Capture the new issue's number from the URL `gh issue create` writes to stdout (`number=${url##*/}`). + +Do **not** include a PR backlink in any issue body at this stage — the PR URL does not exist yet. + +**5.2 Open the draft PR(s)** next. The PR body's `Deferred ambiguous records:` section embeds the manual-review issue numbers captured in 5.1 (a mix of newly-created and reused). Capture each PR URL from `gh pr create` stdout. Sweep PRs are opened ready-for-review; prose PRs are opened as draft. + +**Sweep PR** (only if at least one page has all records classified `metadata-only`, case 2): + +- Branch: `claude/sweep-` from `main`. +- Diff envelope (verbatim from `planning/routines/upstream-sweep.md` `## Sweep PR envelope`): + - update `source_commit:` and `verified_date:` lines inside `` blocks of the affected docs pages, + - update entries in the `verified_commits` map of `skills/start/version.json` for the corresponding repos (key set unchanged), + - update entries in the `verified_commits` map of `skills/start/SKILL.md` frontmatter and refresh the `verified_date:` line (key set unchanged), + - add one new `planning/sweeps/.md` summary file. +- Forbidden: any change to `version`, `published_date`, `skills/start/CHANGELOG.md`, the YAML-frontmatter `version:` line, any rendered prose in `docs/`, any file under `scripts/`, `.github/`, `repo-registry.yml`, or `component-registry.yml`, and any byte inside a `verification_mode: target-manifest` block (sweep PRs must never edit target-manifest pins). +- PR body: see `## PR body format` below. + +**Prose PR** (cases 1 and 5): + +- Branch: `claude/prose--` from `main`. Open as **draft**. +- Includes the prose edits plus the corresponding `source_commit:` / `verified_date:` refreshes for those same pages. +- For case 5, every deferred record's `` block is byte-identical to base (deferred-record self-check from step 4.9). +- If the audit found skill impact, include the `SKILL.md` body edit and the linked patch release set per step 4.8. +- The two PRs never touch the same page. +- PR body: see `## PR body format` below; include a "Why prose changed" section, and (for case 5) a "Deferred ambiguous records" section with the issue numbers captured in 5.1. + +**5.3 Post backlinks last**. For each manual-review issue captured in 5.1 (newly-created **and** reused), comment with the matching prose-PR URL: + +```sh +gh issue comment "$ISSUE_NUMBER" \ + --body "Tracked in $PR_URL. (Run date $(date -u +%Y-%m-%d).)" +``` + +Use a comment, not a body edit, so the original issue body remains an authoritative record of what was deferred and why, and reused issues accumulate a chronological trail of every run that re-encountered them. + +If any of 5.1 / 5.2 / 5.3 errors, post the partial state to `$STATUS_ISSUE` and exit the routine without retrying. + +### 6. Run summary + +Post a single comment to `$STATUS_ISSUE` summarising the run: + +- counts of records scanned, drifted, swept, prose-flagged, manual-review-flagged, and target-manifest-skipped, +- links to the sweep PR, prose PR, and any new issues, +- one-line "no drift" or "skipping; prior PRs open" if those short-circuits fired. + +Link to the prose PR's body for audit detail rather than duplicating it in this comment. + +## PR body format + +Both sweep and prose PR bodies must include: + +- **Upstream**: repo name and SHA range checked, e.g. `ant-sdk: 1cbfb3e → d7652ec`. +- **Source artifacts inspected**: short list, e.g. `antd/openapi.yaml`, `docs/sdk/reference/rest-api.md` upstream. +- **Developer-facing change**: one or two sentences describing what changed for users of the documented surface, or "internal refactor; no developer-facing impact". +- **Files changed in this PR**: bulleted list of docs pages and skill files. +- **Why prose changed** (prose PR only): one or two sentences per page explaining the rendered-text edit and which upstream artifact it tracks. +- **Verification run**: bulleted list of which practical checks ran and their result, or "skipped — " per check. +- **Uncertainties**: one or two sentences calling out any judgment calls or partial evidence the human reviewer should re-check, or "none". + +The body is intentionally concise — no full audit transcript — but every claim is traceable to the pinned `head_sha`. The reviewer should be able to skim the body in under a minute and know exactly what to spot-check. + +## Behaviour rules + +- Use only `gh` for GitHub operations. Do not invoke the GitHub MCP server. +- Bump the skill's `version`, `published_date`, frontmatter `version:`, and `CHANGELOG.md` only when the prose PR's `SKILL.md` body change requires the linked patch release per step 4.8. On `claude/sweep-*` PRs these fields never move. +- Do not modify `target-manifest` verification blocks. They are pinned for launch hardening. +- If a step fails, post the error to `$STATUS_ISSUE` and exit. Do not open partial PRs. +- For per-record fail-closed (e.g., both SHA fetch and compare API failing for one record), open a `manual review needed` issue for that page and continue with the rest of the run. +- Apply the terminology lockfile in `CLAUDE.md` to any prose written in the prose PR or in issue bodies. +- Cite the pinned `head_sha` in audit notes so a reviewer can reproduce the exact tree the audit consulted. diff --git a/planning/routines/upstream-sweep.md b/planning/routines/upstream-sweep.md new file mode 100644 index 0000000..ca4678b --- /dev/null +++ b/planning/routines/upstream-sweep.md @@ -0,0 +1,302 @@ +# Upstream sweep routine + +## Purpose + +Implements the `source audit -> draft -> verify` workflow defined in `planning/verification-workflow.md` on a daily cadence. Every weekday (and weekend) the routine checks every recorded `source_commit` in the docs and the developer skill against the corresponding upstream HEAD, audits any drifted page against the exact pinned SHAs the routine intends to stamp, and opens PRs or issues per the topology below. + +The routine is the hosted-scheduled equivalent of Tier 1 + Tier 2 from `planning/implementation-plan.md` Section 8. It does not replace the eventual `repository_dispatch`-driven path; it sits alongside as an interim path that works without `notify-docs.yml` being installed in any upstream repo. + +## Trigger shape + +- Execution venue: Claude Desktop → Routines → New routine → Remote. +- Schedule: daily at 09:00 UTC. Comfortably above the documented one-hour minimum interval for Remote-routine schedules. +- Routine model: **Opus 4.7 or higher** end-to-end. The audit/write/verify loop in `## Opus audit/write/verify loop` requires the model to inspect upstream diffs and source at pinned SHAs, compare against docs and `SKILL.md`, write actual prose into draft PRs, and run practical verification. No subagent layer. +- Prompt: the committed prompt at `planning/routines/upstream-sweep-prompt.md`. +- Body: the prompt invokes `scripts/sweep_poll.py` for deterministic detection, then runs the audit/write/verify loop against pinned upstream checkouts and opens PRs/issues per the topology. +- Webhook arm (`repository_dispatch` or per-event collation) is explicitly deferred to v2 of the trigger shape. + +## Routine flow + +1. Bootstrap the rolling status thread (label + issue, see `## Rolling status issue`). +2. Run the open-PR collision check. If a prior `claude/sweep-*` or `claude/prose-*` PR is still open, post a collision notice to the rolling issue and exit without opening anything. +3. Run `scripts/sweep_poll.py`. If the JSON status is `error`, post the diagnostics to the rolling issue and exit. +4. If `records` is empty or every record has `drifted: false`, post a no-drift summary to the rolling issue and exit. +5. For each drifted record, run the `## Opus audit/write/verify loop` (fetch both SHAs, compute the upstream diff, inspect source artifacts, compare against docs and skill, classify the record, apply the page batching rule, write prose where required, run the deferred-record self-check, run practical verification). +6. Open at most two PRs (one sweep, one prose draft) plus zero or more `upstream-sweep-manual-review`-labelled issues per the topology in `## Page batching rule`, `## Manual-review issue format`, `## Manual-review issue de-duplication`, and `## PR body format`. Manual-review issues are opened **before** PRs so PR bodies can reference issue numbers; a `Tracked in .` comment is posted on each issue once the corresponding PR exists. Post a run summary with PR/issue links to the rolling issue. + +## Branch convention + +All routine-opened branches use the `claude/` namespace. + +- Metadata-only stamp refreshes: `claude/sweep-` or `claude/sweep--` (the slug is optional for production runs and required for synthetic test PRs that exercise individual envelope rules). +- Prose-impact PRs: `claude/prose--`. Opened as **draft** PRs so the human reviewer promotes them to ready-for-review only after the prose has been read. + +## Rolling status issue + +The routine maintains a single open GitHub issue labelled `upstream-sweep-status` as the chronological log of its behavior. + +Bootstrap, run by every execution before any other GitHub work, in POSIX shell only (no `mapfile`, no Bash arrays, no `gh issue create --json/--jq`). Capture-then-decide is the key invariant: any `gh` failure (auth, network, API) must abort the bootstrap immediately rather than be silently coerced into "label missing" or "no open issue". See `planning/routines/upstream-sweep-prompt.md` Step 0 for the exact script. + +1. Ensure the `upstream-sweep-status` and `upstream-sweep-manual-review` labels both exist. Idempotent: list labels with `--limit 1000`, create either label if missing. Both labels are bootstrapped here because step 5 opens manual-review issues with `--label upstream-sweep-manual-review`, and `gh issue create --label X` fails when the label is missing, so this step must precede any issue creation. +2. Ensure an open issue with that label exists. Idempotent: list open issues with the label sorted by issue number ascending. + - Zero results: create the issue. `gh issue create` has no `--json/--jq` flags, so parse the URL it writes to stdout and take the trailing path component as the issue number. + - Exactly one result: use it. + - More than one result: pick the lowest-numbered issue deterministically and post a one-time warning to that thread asking a human to close the duplicates. The run continues against the chosen issue rather than failing the cadence. + +Every status post (collision skip, error diagnostics, no-drift summaries, run summaries with PR/issue links, target-manifest skip notices) targets the chosen issue. + +## Open-PR collision handling + +Before opening any new PR, list open PRs and filter `headRefName` by prefix client-side: + +```bash +gh pr list --state open --limit 1000 --json number,headRefName \ + --jq '.[] | select(.headRefName | startswith("claude/sweep-") or startswith("claude/prose-"))' +``` + +`--limit 1000` defeats `gh`'s default 30-row cap so a busy repo cannot hide a still-open prior sweep/prose PR behind pagination — without it the routine would see "no results" and open a colliding PR. GitHub's `--search` syntax does not reliably match branch prefixes, so the JSON list with a client-side prefix filter is the trustworthy form. + +If any results come back regardless of date, the routine skips the run, posts a "skipping; prior PR(s) #X #Y still open" comment to the rolling status issue, and exits. Drift is re-detected on the next run after the prior PR(s) merge or close. This forces a clean serial cadence and prevents PR accumulation. + +## Audit-diff fetch rule + +For each drifted record, the routine must inspect the diff between `recorded_sha` and `head_sha` against the upstream tree at `head_sha`. **Both SHAs are required.** + +Default path: fetch both exact SHAs into a fresh tmp dir without changing the routine's cwd. + +```bash +TMP=$(mktemp -d) +git -C "$TMP" init -q +git -C "$TMP" remote add origin +git -C "$TMP" fetch --depth 1 origin +git -C "$TMP" fetch --depth 1 origin +git -C "$TMP" checkout --detach +``` + +Then `git -C "$TMP" log ..` and `git -C "$TMP" diff ..` work against the upstream tree while the routine's cwd remains the docs repo. Every git command in the audit step must carry `-C "$TMP"`; a bare `git log` or `git diff` would inspect the docs repo by accident. See `planning/routines/upstream-sweep-prompt.md` Step 4.1 and Step 4.2 for the executable form. + +Fallback path: if either `git fetch` fails (reachable-SHA fetches disabled by the repo, SHA garbage-collected, network failure), use the GitHub compare API: + +```bash +gh api "repos///compare/..." +``` + +Read `commits[]`, `files[]`, and the `status` field from the response. + +Per-record fail-closed: if **both** the local fetch and the compare API fail for a record, open a `manual review needed` issue for that page and skip the page from both PRs. Continue with the rest of the run. + +**Never compare against a moving branch name** (`main`, `master`, etc.). The audit must reference the exact pinned SHAs the routine plans to stamp. `git clone --depth N` is rejected because an arbitrary `N` may not contain the target SHA. `git fetch ` is rejected because the ref may have moved between the scanner run and the audit run. + +The routine cleans up tmp dirs after each run. + +## Page batching rule + +After every record on every drifted page is classified by the audit loop, apply the five-case rule per page: + +1. **No ambiguous + any prose** → prose PR. All audited non-ambiguous records on that page (prose-impacting **and** metadata-only) are stamped in the same PR. The page never appears on the sweep PR. +2. **No ambiguous + all metadata-only** → sweep PR. +3. **Ambiguous + no prose** → manual-review issue, no PR for that page. Metadata-only records on the same page are deferred alongside the ambiguity rather than spun off into a sweep PR. +4. **Ambiguous overlapping prose, or unproven independence** → manual-review issue, no PR for that page. Two records overlap when they share any of: claim, page section, code sample, command, endpoint, source artifact, or `` block. +5. **Ambiguous + prose with proven independence** → prose PR for the prose-affected records and any metadata-only records on the same page that are also independent of every deferred ambiguity (same overlap dimensions). Ambiguous records and any metadata-only records that share an overlap dimension with a deferred ambiguity stay unstamped: every byte inside the deferred record's `` block (including `source_repo:`, `source_ref:`, `source_commit:`, `verified_date:`, `verification_mode:`, comments, and any other field) stays byte-identical to base. One manual-review issue per deferred record tracks the unresolved evidence. + +The two PRs never touch the same page. A prose PR may touch a page with deferred ambiguous records but must not edit any byte inside those records' verification blocks. The deferred-record self-check at PR-open time confirms the entire block is byte-identical to base; the routine fails closed on mismatch and does not open the prose PR. + +## Manual-review issue format + +Every manual-review issue carries the `upstream-sweep-manual-review` label, distinct from `upstream-sweep-status`. + +- Title for cases 3 and 4: `manual review needed for `. +- Title for case 5 (deferred record from a prose PR with proven independent ambiguity): `manual review needed for deferred record in `. +- Body includes: + - exact record location (`file:line` and the verification block content), + - upstream SHA range (`recorded_sha..head_sha`), + - the ambiguity reason, + - the independence rationale (case 5 only), + - a deterministic `Fingerprint:` line on its own line, surrounded by blank lines, of the form: + - docs records: `Fingerprint: :||..`, + - skill `version.json` records: `Fingerprint: skill_version_json||..`, + - skill `SKILL.md` records: `Fingerprint: skill_md||..`. + +The fingerprint includes the SHA range so a record whose `head_sha` advanced since the previous run is treated as a new event with a new issue, while a record that has not moved reuses the existing issue. + +## Manual-review issue de-duplication + +Deferred records are not stamped, so the next run will rediscover them and would otherwise re-open a duplicate issue every day. Before creating a new manual-review issue, the routine lists open manual-review issues and matches the fingerprint client-side as an exact-line match: + +```bash +gh issue list --state open --label upstream-sweep-manual-review \ + --limit 1000 --json number,title,body +``` + +`gh issue search` is not used: GitHub issue search tokenization does not reliably match a pipe-heavy fingerprint embedded in the body. The match is performed by walking each candidate issue's `body` field and looking for a line that, after stripping leading and trailing whitespace, equals the fingerprint string verbatim — not a substring match. + +On a fingerprint match, the routine reuses the existing issue: it skips `gh issue create`, captures the existing issue's number for the prose PR body, and posts a `Tracked in . (Run date .)` comment on the issue once the PR exists. The original issue body is not edited, so reused issues accumulate a chronological trail of every run that re-encountered them. With no fingerprint match, a new issue is created with the fingerprint embedded in the body. + +## Sweep PR envelope (claude/sweep-*) + +`claude/sweep-*` PRs are metadata-only. + +Allowed: + +- verification-block lines (`source_commit:`, `verified_date:`) in `docs/**/*.md`, +- the `verified_commits` map in `skills/start/version.json` (key set unchanged), +- the `verified_commits` map and the `verified_date:` line in `skills/start/SKILL.md`'s YAML frontmatter (key set unchanged), +- one new `planning/sweeps/.md` whose date suffix matches the head-branch date suffix. + +Forbidden: + +- any change to docs prose (`docs/**/*.md` outside an `` block), +- any change to `skills/start/SKILL.md` body (anything outside the YAML frontmatter), +- any change to the `version:` line in `SKILL.md` frontmatter, +- any change to `version` or `published_date` in `version.json`, +- any change to `skills/start/CHANGELOG.md`, +- any change to `scripts/**`, `.github/**`, `repo-registry.yml`, `component-registry.yml`, +- any add or remove of a key in the `verified_commits` map of `version.json` or `SKILL.md` frontmatter (key sets locked; values may refresh). + +`sweep-guard` enforces this envelope. It runs on `claude/sweep-*` only and green-skips on every other branch. + +## Prose PR envelope (claude/prose-*) + +`claude/prose-*` PRs allow rendered prose changes plus the linked skill patch release when the audit found skill impact. + +Allowed: + +- `docs/**/*.md` — any change. +- `skills/start/SKILL.md` — frontmatter and/or body may change. If body changes, the linked-release rule below applies in full. +- `skills/start/version.json` — `verified_commits` value updates always (key set unchanged); `version` and `published_date` only as part of a linked release. +- `skills/start/CHANGELOG.md` — only as part of a linked release. +- `planning/sweeps/.md` — optional add; date suffix matches `claude/prose--`. + +Forbidden: + +- any change to `scripts/**`, `.github/**`, `repo-registry.yml`, `component-registry.yml` (byte-identical to base), +- any add or remove of a key in the `verified_commits` map of `version.json` or `SKILL.md` frontmatter, +- any change to `version`, `published_date`, frontmatter `version:`, or `CHANGELOG.md` **when the `SKILL.md` body is byte-identical to base** (release metadata may only move with a body change), +- any of the linked-release fields missing **when the `SKILL.md` body has changed**. + +Linked-release rule (both directions): + +If `skills/start/SKILL.md` body bytes differ between base and head, the same PR must include all of: + +- `skills/start/version.json: version` bumped to a new patch version (`MAJOR.MINOR.(PATCH+1)`), +- `skills/start/version.json: published_date` updated, +- `skills/start/SKILL.md` frontmatter `version:` matching the new `version.json: version`, +- `skills/start/SKILL.md` frontmatter `verified_date:` updated, +- `skills/start/CHANGELOG.md` adding one new entry whose header matches the new `version`. + +If the `SKILL.md` body is byte-identical between base and head, **none** of `version`, `published_date`, frontmatter `version:`, or `CHANGELOG.md` may change. + +`prose-guard` enforces this envelope. It runs on `claude/prose-*` only and green-skips on every other branch. + +## Opus audit/write/verify loop + +Required model: **Opus 4.7 or higher**. The deterministic scanner is only the drift detector; audit, prose-writing, and verification are first-class duties of the routine. + +For each drifted record: + +1. **Fetch both SHAs** per `## Audit-diff fetch rule`. If both fetch and compare API fail, fail closed for the page → manual-review issue. +2. **Compute the upstream diff**: `git -C "$TMP" log --oneline ..`, `git -C "$TMP" diff --stat ..`, targeted `git -C "$TMP" diff .. -- `. Every git invocation in this step carries `-C "$TMP"` so it inspects the upstream checkout rather than the docs repo. Or read the compare API response when running via fallback. +3. **Inspect upstream source artifacts** at `head_sha` (OpenAPI specs, `.proto` files, CLI source and `--help` output, public Rust modules, README/docs). Use `repo-registry.yml`'s `topics:` and `component-registry.yml`'s component map to focus on artifacts the affected page actually depends on. +4. **Compare against the affected docs pages** and `skills/start/SKILL.md`. Identify any rendered claim, code sample, command, endpoint, type, field, or live-reference URL that no longer matches the pinned source. +5. **Classify the record** as `metadata-only`, `prose`, or `ambiguous` (see prompt step 4.5 for criteria). +6. **Apply the page batching rule** once every record is classified. +7. **Write prose changes directly into the draft `claude/prose-*` PR** when the page is in the prose batch. Apply `CLAUDE.md`'s voice, terminology lockfile, page templates, and refusal rules. The PR diff must contain the prose edit, not merely a suggestion in the PR body. +8. **Skill-aware prose**: if the audit finds skill impact, include both the docs change and the `SKILL.md` change in the same prose PR. If `SKILL.md` body changes, the same PR must include the linked patch release set per `## Prose PR envelope`. +9. **Deferred-record self-check** (case 5 only — prose PR opened with proven-independent ambiguous records held back). For every deferred record, the routine confirms the entire `` block on the prose-PR branch is byte-identical to base. Every byte from the opening `` is checked, including `source_repo:`, `source_ref:`, `source_commit:`, `verified_date:`, `verification_mode:`, comments, and any other line. Checking only `source_commit:` and `verified_date:` is too narrow: the loop can edit `source_ref:`, change `verification_mode:`, or rewrite a comment inside the block without realising it crossed the deferred-record boundary. The routine also confirms the prose PR body contains a `Deferred ambiguous records:` section that links each open `upstream-sweep-manual-review` issue by number for those records. The guards cannot enforce this — they have no way to know which records were classified ambiguous — so the routine is the only thing that catches an accidental edit. **Fail closed on mismatch: do not open the prose PR.** + +10. **Practical verification before opening a prose PR**, where available: lint/format checks; re-run `scripts/sweep_poll.py` and confirm `status: "ok"`; parse `SKILL.md` frontmatter and `version.json` to confirm the linked-release rule holds when the body changed; language-appropriate syntax checks for changed code samples; targeted re-grep against the pinned upstream checkout for endpoint/type claims. If a check cannot be run, state that explicitly in the PR body — never silently skip. + +## PR body format + +Both sweep and prose PR bodies must include: + +- **Upstream**: repo name and SHA range checked, e.g. `ant-sdk: 1cbfb3e → d7652ec`. +- **Source artifacts inspected**: short list, e.g. `antd/openapi.yaml`, `docs/sdk/reference/rest-api.md` upstream. +- **Developer-facing change**: one or two sentences describing what changed for users of the documented surface, or "internal refactor; no developer-facing impact". +- **Files changed in this PR**: bulleted list of docs pages and skill files. +- **Why prose changed** (prose PR only): one or two sentences per page explaining the rendered-text edit and which upstream artifact it tracks. +- **Verification run**: bulleted list of which practical checks ran and their result, or "skipped — " per check. +- **Uncertainties**: one or two sentences calling out any judgment calls or partial evidence the human reviewer should re-check, or "none". + +The body is intentionally concise — no full audit transcript — but every claim is traceable to the pinned `head_sha`. The reviewer should be able to skim the body in under a minute and know exactly what to spot-check. + +## Credentials and access + +The routine needs: + +- read access to all in-scope upstream repos for GitHub API HEAD lookups and the shallow clones, +- write access to `withautonomi/autonomi-developer-docs` for branch push, PR creation, and comment/issue creation. + +Recommended: a dedicated GitHub App installation token (refreshed per run by Claude Desktop) with `contents: write`, `pull-requests: write`, `issues: write` on the docs repo and `contents: read` on the in-scope upstream repos. + +Acceptable alternative: a fine-grained personal access token with the same scopes, stored as a Claude Desktop routine secret. + +The token is exposed as `GITHUB_TOKEN` in the routine environment. The script and any `gh` calls read from there. The repo file does not contain the secret value, only the policy. + +## Audit gating + +SHAs are bumped only after the per-page audit step succeeds against the pinned upstream checkout. If audit fails for a page (ambiguous evidence, removed surface, deleted file, both SHA-fetch paths failing), the page does not enter any PR — it is surfaced as a `manual review needed` issue. The scanner's drift report is a candidate list, not a directive. + +## Fail-closed semantics + +Scanner fail-closed (whole run aborts): + +- GitHub API auth failure or 4xx/5xx on a HEAD or repo metadata lookup, +- network timeout, +- malformed ` block in docs/**/*.md, every entry in +the verified_commits map of skills/start/version.json, and every entry in the +verified_commits map of the YAML frontmatter of skills/start/SKILL.md. +Resolves each (repo, ref) pair against repo-registry.yml plus a GitHub API +HEAD lookup, and emits a per-record JSON drift report on stdout. + +Read-only. Never modifies files. Exits 0 on success, 2 on fail-closed errors. +See planning/routines/upstream-sweep.md for the policy this implements. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +REGISTRY_PATH = REPO_ROOT / "repo-registry.yml" +DOCS_DIR = REPO_ROOT / "docs" +VERSION_JSON_PATH = REPO_ROOT / "skills" / "start" / "version.json" +SKILL_MD_PATH = REPO_ROOT / "skills" / "start" / "SKILL.md" + +VERIFICATION_BLOCK_RE = re.compile( + r"", + re.DOTALL, +) +KEY_VALUE_RE = re.compile(r"^\s*([a-z_]+)\s*:\s*(\S.*?)\s*$") +ALLOWED_MODES = {"current-merged-truth", "target-manifest"} +REQUIRED_DOC_KEYS = ( + "source_repo", + "source_ref", + "source_commit", + "verification_mode", +) + + +class FailClosed(Exception): + """Raised whenever the scanner cannot make a confident drift judgment. + + Carries one diagnostic dict to embed in the JSON output's `errors` array. + """ + + def __init__(self, diagnostic: dict[str, Any]): + super().__init__(diagnostic.get("message", "fail-closed")) + self.diagnostic = diagnostic + + +def github_request(path: str, token: str) -> dict[str, Any]: + url = f"https://api.github.com{path}" + req = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "User-Agent": "autonomi-developer-docs-sweep-poll", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + raise FailClosed( + { + "kind": "github_api_http_error", + "url": url, + "status": exc.code, + "message": f"GitHub API returned {exc.code} for {path}", + } + ) from exc + except urllib.error.URLError as exc: + raise FailClosed( + { + "kind": "github_api_network_error", + "url": url, + "message": f"GitHub API network error for {path}: {exc.reason}", + } + ) from exc + + +def parse_owner_repo(url: str) -> tuple[str, str]: + parsed = urllib.parse.urlparse(url) + parts = [p for p in parsed.path.strip("/").split("/") if p] + if parsed.netloc != "github.com" or len(parts) < 2: + raise FailClosed( + { + "kind": "registry_url_unparseable", + "url": url, + "message": f"cannot derive / from registry url: {url}", + } + ) + owner, repo = parts[0], parts[1] + if repo.endswith(".git"): + repo = repo[: -len(".git")] + return owner, repo + + +def load_registry() -> dict[str, dict[str, Any]]: + try: + data = yaml.safe_load(REGISTRY_PATH.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise FailClosed( + { + "kind": "registry_yaml_parse_error", + "path": str(REGISTRY_PATH.relative_to(REPO_ROOT)), + "message": f"YAML parse error in repo-registry.yml: {exc}", + } + ) from exc + repos = (data or {}).get("repos") or {} + if not isinstance(repos, dict): + raise FailClosed( + { + "kind": "registry_shape_error", + "path": str(REGISTRY_PATH.relative_to(REPO_ROOT)), + "message": "repo-registry.yml: expected top-level 'repos' map", + } + ) + return repos + + +def parse_verification_block(body: str) -> dict[str, str]: + fields: dict[str, str] = {} + for raw_line in body.splitlines(): + line = raw_line.strip() + if not line: + continue + match = KEY_VALUE_RE.match(line) + if match: + fields[match.group(1)] = match.group(2) + return fields + + +def line_of_offset(text: str, offset: int) -> int: + return text.count("\n", 0, offset) + 1 + + +def walk_docs( + registry: dict[str, dict[str, Any]], + records: list[dict[str, Any]], + target_manifest_skipped: list[dict[str, Any]], + pair_set: set[tuple[str, str]], +) -> None: + if not DOCS_DIR.exists(): + return + for path in sorted(DOCS_DIR.rglob("*.md")): + text = path.read_text(encoding="utf-8") + rel = str(path.relative_to(REPO_ROOT)) + for match in VERIFICATION_BLOCK_RE.finditer(text): + block_line = line_of_offset(text, match.start()) + fields = parse_verification_block(match.group(1)) + missing = [k for k in REQUIRED_DOC_KEYS if k not in fields] + if missing: + raise FailClosed( + { + "kind": "doc_block_missing_required_keys", + "location": f"{rel}:{block_line}", + "missing": missing, + "message": ( + f"verification block at {rel}:{block_line} " + f"missing required keys: {', '.join(missing)}" + ), + } + ) + mode = fields["verification_mode"] + if mode not in ALLOWED_MODES: + raise FailClosed( + { + "kind": "doc_block_unknown_verification_mode", + "location": f"{rel}:{block_line}", + "verification_mode": mode, + "message": ( + f"verification block at {rel}:{block_line} has " + f"unknown verification_mode: {mode!r}" + ), + } + ) + repo_key = fields["source_repo"] + if repo_key not in registry: + raise FailClosed( + { + "kind": "doc_block_unknown_source_repo", + "location": f"{rel}:{block_line}", + "source_repo": repo_key, + "message": ( + f"verification block at {rel}:{block_line} " + f"references repo not in repo-registry.yml: {repo_key}" + ), + } + ) + ref = fields["source_ref"] + recorded = fields["source_commit"] + location = f"{rel}:{block_line}" + if mode == "target-manifest": + target_manifest_skipped.append( + { + "location": location, + "repo": repo_key, + "ref": ref, + "recorded_sha": recorded, + } + ) + continue + records.append( + { + "location": location, + "scope": "docs", + "repo": repo_key, + "ref": ref, + "recorded_sha": recorded, + "head_sha": None, + "drifted": None, + } + ) + pair_set.add((repo_key, ref)) + + +def resolve_skill_default_branch( + repo_key: str, + registry: dict[str, dict[str, Any]], + token: str, + cache: dict[str, str], +) -> str: + if repo_key in cache: + return cache[repo_key] + if repo_key not in registry: + raise FailClosed( + { + "kind": "skill_unknown_source_repo", + "source_repo": repo_key, + "message": ( + f"skill verified_commits references repo not in " + f"repo-registry.yml: {repo_key}" + ), + } + ) + owner, repo = parse_owner_repo(registry[repo_key]["url"]) + meta = github_request(f"/repos/{owner}/{repo}", token) + branch = meta.get("default_branch") + if not isinstance(branch, str) or not branch: + raise FailClosed( + { + "kind": "github_default_branch_missing", + "repo": repo_key, + "message": ( + f"GitHub /repos/{owner}/{repo} returned no default_branch" + ), + } + ) + cache[repo_key] = branch + return branch + + +def walk_version_json( + registry: dict[str, dict[str, Any]], + records: list[dict[str, Any]], + target_manifest_skipped: list[dict[str, Any]], + pair_set: set[tuple[str, str]], + default_branch_cache: dict[str, str], + token: str, +) -> None: + if not VERSION_JSON_PATH.exists(): + return + raw = VERSION_JSON_PATH.read_text(encoding="utf-8") + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise FailClosed( + { + "kind": "version_json_parse_error", + "path": str(VERSION_JSON_PATH.relative_to(REPO_ROOT)), + "message": f"JSON parse error in version.json: {exc}", + } + ) from exc + mode = data.get("verification_mode") + if mode not in ALLOWED_MODES: + raise FailClosed( + { + "kind": "version_json_unknown_verification_mode", + "path": str(VERSION_JSON_PATH.relative_to(REPO_ROOT)), + "verification_mode": mode, + "message": ( + f"version.json has missing or unknown verification_mode: " + f"{mode!r}" + ), + } + ) + if "verified_commits" not in data: + raise FailClosed( + { + "kind": "version_json_shape_error", + "path": str(VERSION_JSON_PATH.relative_to(REPO_ROOT)), + "message": "version.json: verified_commits is missing", + } + ) + commits = data["verified_commits"] + if not isinstance(commits, dict): + raise FailClosed( + { + "kind": "version_json_shape_error", + "path": str(VERSION_JSON_PATH.relative_to(REPO_ROOT)), + "message": "version.json: verified_commits must be an object", + } + ) + rel = str(VERSION_JSON_PATH.relative_to(REPO_ROOT)) + for repo_key, recorded in sorted(commits.items()): + location = f"{rel}:verified_commits.{repo_key}" + if not isinstance(recorded, str) or not recorded: + raise FailClosed( + { + "kind": "version_json_shape_error", + "path": rel, + "message": ( + f"version.json: verified_commits.{repo_key} " + "is not a non-empty string" + ), + } + ) + if mode == "target-manifest": + target_manifest_skipped.append( + { + "location": location, + "repo": repo_key, + "ref": None, + "recorded_sha": recorded, + } + ) + continue + ref = resolve_skill_default_branch( + repo_key, registry, token, default_branch_cache + ) + records.append( + { + "location": location, + "scope": "skill_version_json", + "repo": repo_key, + "ref": ref, + "recorded_sha": recorded, + "head_sha": None, + "drifted": None, + } + ) + pair_set.add((repo_key, ref)) + + +def parse_skill_md_frontmatter() -> dict[str, Any] | None: + """Return frontmatter as a dict, or None if SKILL.md does not exist. + + A present-but-empty or shape-invalid SKILL.md raises FailClosed; only a + truly absent file is reported via the None sentinel so callers can + distinguish that from an empty mapping. + """ + if not SKILL_MD_PATH.exists(): + return None + text = SKILL_MD_PATH.read_text(encoding="utf-8") + if not text.startswith("---"): + raise FailClosed( + { + "kind": "skill_md_missing_frontmatter", + "path": str(SKILL_MD_PATH.relative_to(REPO_ROOT)), + "message": "SKILL.md does not begin with YAML frontmatter", + } + ) + closing = text.find("\n---", 3) + if closing < 0: + raise FailClosed( + { + "kind": "skill_md_unterminated_frontmatter", + "path": str(SKILL_MD_PATH.relative_to(REPO_ROOT)), + "message": "SKILL.md frontmatter is not terminated by '---'", + } + ) + raw = text[3:closing] + try: + loaded = yaml.safe_load(raw) + except yaml.YAMLError as exc: + raise FailClosed( + { + "kind": "skill_md_yaml_parse_error", + "path": str(SKILL_MD_PATH.relative_to(REPO_ROOT)), + "message": f"YAML parse error in SKILL.md frontmatter: {exc}", + } + ) from exc + if loaded is None: + raise FailClosed( + { + "kind": "skill_md_frontmatter_empty", + "path": str(SKILL_MD_PATH.relative_to(REPO_ROOT)), + "message": ( + "SKILL.md frontmatter is empty; verification metadata " + "is required" + ), + } + ) + if not isinstance(loaded, dict): + raise FailClosed( + { + "kind": "skill_md_frontmatter_shape_error", + "path": str(SKILL_MD_PATH.relative_to(REPO_ROOT)), + "message": "SKILL.md frontmatter must be a YAML mapping", + } + ) + return loaded + + +def walk_skill_md( + registry: dict[str, dict[str, Any]], + records: list[dict[str, Any]], + target_manifest_skipped: list[dict[str, Any]], + pair_set: set[tuple[str, str]], + default_branch_cache: dict[str, str], + token: str, +) -> None: + fm = parse_skill_md_frontmatter() + if fm is None: + return + mode = fm.get("verification_mode") + if mode not in ALLOWED_MODES: + raise FailClosed( + { + "kind": "skill_md_unknown_verification_mode", + "path": str(SKILL_MD_PATH.relative_to(REPO_ROOT)), + "verification_mode": mode, + "message": ( + f"SKILL.md frontmatter has missing or unknown " + f"verification_mode: {mode!r}" + ), + } + ) + if "verified_commits" not in fm: + raise FailClosed( + { + "kind": "skill_md_shape_error", + "path": str(SKILL_MD_PATH.relative_to(REPO_ROOT)), + "message": ( + "SKILL.md frontmatter: verified_commits is missing" + ), + } + ) + commits = fm["verified_commits"] + if not isinstance(commits, dict): + raise FailClosed( + { + "kind": "skill_md_shape_error", + "path": str(SKILL_MD_PATH.relative_to(REPO_ROOT)), + "message": ( + "SKILL.md frontmatter: verified_commits must be a mapping" + ), + } + ) + rel = str(SKILL_MD_PATH.relative_to(REPO_ROOT)) + for repo_key, recorded in sorted(commits.items()): + location = f"{rel}:verified_commits.{repo_key}" + if not isinstance(recorded, str) or not recorded: + raise FailClosed( + { + "kind": "skill_md_shape_error", + "path": rel, + "message": ( + f"SKILL.md frontmatter: verified_commits.{repo_key} " + "is not a non-empty string" + ), + } + ) + if mode == "target-manifest": + target_manifest_skipped.append( + { + "location": location, + "repo": repo_key, + "ref": None, + "recorded_sha": recorded, + } + ) + continue + ref = resolve_skill_default_branch( + repo_key, registry, token, default_branch_cache + ) + records.append( + { + "location": location, + "scope": "skill_md", + "repo": repo_key, + "ref": ref, + "recorded_sha": recorded, + "head_sha": None, + "drifted": None, + } + ) + pair_set.add((repo_key, ref)) + + +def resolve_head_shas( + pair_set: set[tuple[str, str]], + registry: dict[str, dict[str, Any]], + token: str, +) -> dict[tuple[str, str], str]: + head_shas: dict[tuple[str, str], str] = {} + for repo_key, ref in sorted(pair_set): + owner, repo = parse_owner_repo(registry[repo_key]["url"]) + encoded_ref = urllib.parse.quote(ref, safe="") + commit = github_request( + f"/repos/{owner}/{repo}/commits/{encoded_ref}", token + ) + sha = commit.get("sha") + if not isinstance(sha, str) or not sha: + raise FailClosed( + { + "kind": "github_head_sha_missing", + "repo": repo_key, + "ref": ref, + "message": ( + f"GitHub /repos/{owner}/{repo}/commits/{ref} returned " + f"no sha" + ), + } + ) + head_shas[(repo_key, ref)] = sha + return head_shas + + +def attach_drift( + records: list[dict[str, Any]], + head_shas: dict[tuple[str, str], str], +) -> None: + for record in records: + head = head_shas[(record["repo"], record["ref"])] + record["head_sha"] = head + record["drifted"] = record["recorded_sha"] != head + + +def main() -> int: + token = os.environ.get("GITHUB_TOKEN", "").strip() + if not token: + diagnostic = { + "kind": "missing_github_token", + "message": "GITHUB_TOKEN environment variable is not set", + } + print( + json.dumps( + { + "status": "error", + "generated_at": datetime.now(timezone.utc).isoformat(), + "records": [], + "target_manifest_skipped": [], + "errors": [diagnostic], + }, + indent=2, + ) + ) + return 2 + + try: + registry = load_registry() + records: list[dict[str, Any]] = [] + target_manifest_skipped: list[dict[str, Any]] = [] + pair_set: set[tuple[str, str]] = set() + default_branch_cache: dict[str, str] = {} + + walk_docs(registry, records, target_manifest_skipped, pair_set) + walk_version_json( + registry, + records, + target_manifest_skipped, + pair_set, + default_branch_cache, + token, + ) + walk_skill_md( + registry, + records, + target_manifest_skipped, + pair_set, + default_branch_cache, + token, + ) + + head_shas = resolve_head_shas(pair_set, registry, token) + attach_drift(records, head_shas) + + except FailClosed as exc: + print( + json.dumps( + { + "status": "error", + "generated_at": datetime.now(timezone.utc).isoformat(), + "records": [], + "target_manifest_skipped": [], + "errors": [exc.diagnostic], + }, + indent=2, + ) + ) + return 2 + + output = { + "status": "ok", + "generated_at": datetime.now(timezone.utc).isoformat(), + "records": records, + "target_manifest_skipped": target_manifest_skipped, + "errors": [], + } + print(json.dumps(output, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/start/MAINTAINING.md b/skills/start/MAINTAINING.md index cb9566f..28bdff7 100644 --- a/skills/start/MAINTAINING.md +++ b/skills/start/MAINTAINING.md @@ -4,12 +4,21 @@ This skill lives in `skills/start/` (slash-invoked as `/developer:start` after i ## Files that move together -Every release or re-verification pass updates these files together: +A **release** (any `version` bump) updates these files together: - `SKILL.md` - `version.json` - `CHANGELOG.md` +A **stamp refresh** (no version bump, pure verification heartbeat) is a different gesture and does not touch CHANGELOG, `version`, or `published_date`. The only fields that move: + +- in `version.json`, the `verified_commits` map (the file has no `verified_date` field), +- in `SKILL.md`'s YAML frontmatter, the `verified_commits` map and the `verified_date:` line. + +Stamp refreshes typically come in via the daily upstream-sweep routine. See `planning/routines/upstream-sweep.md`. + +A skill patch release driven by an upstream-sweep prose PR uses the same envelope as a manual patch release. The prose PR contains the full release set in a single coherent change: `SKILL.md` body and frontmatter, `version.json` (`version` and `published_date`), `CHANGELOG.md` (one new entry whose header matches the new `version`), and the matching `verified_commits` and `verified_date:` refreshes. The `prose-guard` required check enforces this both ways — if the body changes, all release fields must move; if the body is unchanged, none of them may. + ## Current scope This skill is intentionally practical. It covers: @@ -140,7 +149,7 @@ Use Semantic Versioning with these rules: - major: breaking changes to skill loading or manifest shape - minor: new paths, new verified examples, or new operational capabilities -- patch: wording fixes, pointer fixes, or re-verification-only updates +- patch: wording fixes, pointer fixes, or substantive re-verification — for example a SHA refresh that reflects an upstream change to a public surface the skill describes (a new flow, a renamed command, a removed step). Pure stamp refreshes that do not touch any described surface are not patches; they are stamp refreshes (see `## Files that move together`). Keep the `-draft` suffix until the skill has gone through at least one deliberate re-verification pass after landing in the repo.