From a6bce6be0e85199d1e070dde67d3883125e04da9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 14:55:35 +0000 Subject: [PATCH 1/6] Add daily upstream-sweep routine: scanner, prompt, policy, required checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the daily upstream-drift sweep routine described in planning/routines/upstream-sweep.md. The routine is hosted on Claude Desktop (Remote variant), runs once per day, calls a deterministic Python scanner to detect drift against pinned upstream SHAs, audits each affected page against the exact head_sha the scanner found, and opens metadata-only sweep PRs or prose draft PRs per the documented topology. Scanner (scripts/sweep_poll.py): - walks every block in docs/**/*.md, every entry in skills/start/version.json's verified_commits, and every entry in SKILL.md frontmatter's verified_commits - resolves (repo, ref) via repo-registry.yml + GitHub default_branch - emits a per-record JSON drift report on stdout - fail-closed on auth / network / parse / shape / unknown-mode errors - target-manifest blocks deliberately skipped (separate JSON array) Required checks for branch protection: - sweep-guard: validates the metadata-only envelope on claude/sweep-* branches; verifies docs diffs lie inside verification blocks and only touch source_commit / verified_date lines, version.json only changes verified_commits, SKILL.md frontmatter only changes verified_commits and verified_date, CHANGELOG is untouched, and exactly one planning/sweeps/.md is added. - sweep-sha-reachability: validates every recorded SHA against the upstream ref via the GitHub commits + compare APIs; runs on both claude/sweep-* and claude/prose-* branches; widens the scope to every changed verification block (catches date-only refreshes whose existing SHA was previously broken). Both workflows use a real gate step (id: scope) plus an `if:` on every later step — exit-0 from an early step does not skip later steps in GitHub Actions. skills/start/MAINTAINING.md: tighten the patch-bump rule. Releases (version bumps) move SKILL.md + version.json + CHANGELOG together; stamp refreshes are a separate gesture that touches only verified_commits in both files plus verified_date in SKILL.md. Auto-merge is deferred to v1.5; v1 ships scheduled detection, per-page audit, and PR/issue creation only. --- .github/workflows/sweep-guard.yml | 336 +++++++++++ .github/workflows/sweep-sha-reachability.yml | 344 ++++++++++++ planning/routines/upstream-sweep-prompt.md | 159 ++++++ planning/routines/upstream-sweep.md | 189 +++++++ scripts/requirements.txt | 1 + scripts/sweep_poll.py | 561 +++++++++++++++++++ skills/start/MAINTAINING.md | 11 +- 7 files changed, 1599 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/sweep-guard.yml create mode 100644 .github/workflows/sweep-sha-reachability.yml create mode 100644 planning/routines/upstream-sweep-prompt.md create mode 100644 planning/routines/upstream-sweep.md create mode 100644 scripts/requirements.txt create mode 100755 scripts/sweep_poll.py diff --git a/.github/workflows/sweep-guard.yml b/.github/workflows/sweep-guard.yml new file mode 100644 index 0000000..b203cd8 --- /dev/null +++ b/.github/workflows/sweep-guard.yml @@ -0,0 +1,336 @@ +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' + run: pip install -r scripts/requirements.txt + + - 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})$") + 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) + 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; " + "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 validate_docs_md(path: str) -> None: + base_text = show(BASE, path) or "" + head_text = show(HEAD, path) or "" + 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 + 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)", + ) + + 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", + ) + 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)", + ) + + for status, path in changes: + if path in seen_paths: + continue + seen_paths.add(path) + 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..ef804ef --- /dev/null +++ b/.github/workflows/sweep-sha-reachability.yml @@ -0,0 +1,344 @@ +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' + run: pip install -r scripts/requirements.txt + + - 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] = [] + default_branch_cache: dict[str, str] = {} + + def fail(loc: str, msg: str) -> None: + violations.append(f"{loc}: {msg}") + + 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: + 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. + 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": + pass + else: + commits = obj.get("verified_commits") or {} + 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": + pass + else: + commits = fm.get("verified_commits") or {} + 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") + 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") + PY diff --git a/planning/routines/upstream-sweep-prompt.md b/planning/routines/upstream-sweep-prompt.md new file mode 100644 index 0000000..b4a3f82 --- /dev/null +++ b/planning/routines/upstream-sweep-prompt.md @@ -0,0 +1,159 @@ +# 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. + +## 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 `scripts/requirements.txt` (PyYAML). + +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, allowlist, fail-closed semantics, PR body format, audit gating). + +## Steps + +### 0. Bootstrap the rolling status thread + +Ensure the `upstream-sweep-status` label exists, then ensure exactly one open issue carries it. Both steps are idempotent. + +```bash +# (a) label +if [ -z "$(gh label list --json name --jq '.[] | select(.name=="upstream-sweep-status") | .name')" ]; then + gh label create upstream-sweep-status \ + --description "Rolling status thread for the daily upstream-sweep routine" +fi + +# (b) issue +mapfile -t open_issues < <( + gh issue list --state open --label upstream-sweep-status \ + --json number --jq 'sort_by(.number) | .[].number' +) +case "${#open_issues[@]}" in + 0) STATUS_ISSUE=$(gh issue create \ + --title "Upstream sweep status" \ + --label upstream-sweep-status \ + --body "Rolling status thread for the daily upstream-sweep routine." \ + --json number --jq '.number') ;; + 1) STATUS_ISSUE="${open_issues[0]}" ;; + *) STATUS_ISSUE="${open_issues[0]}" + # Warn once: more than one open status issue, asking a human to close the duplicates. + gh issue comment "$STATUS_ISSUE" --body \ + "Multiple open issues carry the upstream-sweep-status label: ${open_issues[*]}. Continuing against #${STATUS_ISSUE} (lowest number). Please close the duplicates." ;; +esac +``` + +`gh issue create --label X` fails if the label is missing, so the label step must precede issue creation. + +Capture `$STATUS_ISSUE` for every status post in later steps. + +### 1. Open-PR collision check + +```bash +OPEN_CLAUDE_PRS=$( + gh pr list --state open --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 will be re-detected on the next run. + +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 + +```bash +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. Per-page audit against the pinned upstream SHA + +Group the drifted records by `(repo, head_sha)`. For each unique `(repo, head_sha)`: + +```bash +TMP=$(mktemp -d) +cd "$TMP" +git init +git remote add origin "" +git fetch --depth 1 origin "" +git checkout --detach "" +``` + +If the SHA fetch fails (reachable-SHA fetches disabled, SHA garbage-collected, network failure), fail closed: post the error to `$STATUS_ISSUE` and exit. Audit must never run against a moving `main`. + +Audit each affected docs page against the pinned tree per `planning/verification-workflow.md`: + +- For `scope: "docs"` records, the affected page is at the recorded `location` path. +- For `scope: "skill_version_json"` and `scope: "skill_md"` records, the affected page is the relevant skill file. + +Classify per-page outcome into one of three batches: + +- **sweep batch** — audit confirms the page's claims still hold at `head_sha`; only the metadata stamp needs to move. +- **prose batch** — audit identifies prose impact; the page needs human-reviewed prose edits alongside its SHA stamp refresh. +- **issue batch** — audit cannot make a confident judgment (ambiguous evidence, removed surface, deleted file, etc.). The page does not enter any PR. + +Clean up tmp dirs after this step. + +### 5. Open PRs and issues per topology + +At most one sweep PR and one prose draft PR per run, and zero or more `manual review needed` issues. + +**Sweep PR** (if the sweep batch is non-empty): + +- Branch: `claude/sweep-` from `main`. +- Diff envelope (verbatim allowlist from `planning/routines/upstream-sweep.md`): + - 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, + - update entries in the `verified_commits` map of `skills/start/SKILL.md`'s YAML frontmatter and refresh its `verified_date:` line, + - add one new `planning/sweeps/.md` summary file. +- Forbidden in this PR: any change to `version`, `published_date`, `skills/start/CHANGELOG.md`, the YAML-frontmatter `version:` line, or rendered prose in `docs/`. +- PR body uses the format in `planning/routines/upstream-sweep.md` `## PR body format`. + +**Prose PR** (if the prose batch is non-empty): + +- Branch: `claude/prose--` from `main`. Open as **draft**. +- Includes the prose changes plus the corresponding `source_commit:` / `verified_date:` refreshes for those same pages. The two PRs never touch the same page. +- The same `version` / `published_date` / CHANGELOG / frontmatter-`version` forbidding still applies; version bumps are not the routine's job. +- PR body lists which pages have prose changes versus which pages on this PR are metadata-only-on-this-PR so the reviewer knows where to focus. + +**Issues** (one per page in the issue batch): + +- Title: `Upstream sweep: manual review needed for `. +- Body: the page path, the recorded SHA, the upstream HEAD SHA, the audit failure mode (ambiguous evidence, removed surface, deleted file, etc.), and a snippet of the relevant upstream diff. + +### 6. Run summary + +Post a single comment to `$STATUS_ISSUE` summarising the run: + +- counts of records scanned, drifted, swept, prose-flagged, issue-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. + +## Behaviour rules + +- Use only `gh` for GitHub operations. Do not invoke the GitHub MCP server. +- Do not bump the skill's `version`, `published_date`, or `CHANGELOG.md`. Stamp refreshes are not releases (per `skills/start/MAINTAINING.md`). +- 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. +- 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..91ddf62 --- /dev/null +++ b/planning/routines/upstream-sweep.md @@ -0,0 +1,189 @@ +# 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 SHA 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 end-to-end. 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-draft-verify workflow 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, group by `(repo, head_sha)` and check out the pinned SHA in a fresh tmp dir (see `## Source checkout for audit`). Audit each affected docs page against the pinned tree per `planning/verification-workflow.md`. Classify per-page outcome into one of three batches: + - **sweep batch** — audit confirms metadata-only refresh suffices. + - **prose batch** — audit identifies prose impact; the page needs human-reviewed edits alongside its SHA stamp refresh. + - **issue batch** — audit cannot make a confident judgment (ambiguous evidence, removed surface, deleted file). Page does not enter any PR. +6. Open at most two PRs (one sweep, one prose draft) plus zero or more `manual review needed` issues per the topology in `## PR body format`. 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-`. +- 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: + +1. Ensure the `upstream-sweep-status` label exists. Idempotent: list the label, create it if missing (`gh label list` → `gh label create` with `-f` for re-runs). `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. + - 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 --json number,headRefName \ + --jq '.[] | select(.headRefName | startswith("claude/sweep-") or startswith("claude/prose-"))' +``` + +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. + +## Source checkout for audit + +For each unique `(repo, head_sha)` across drifted records, the routine creates a fresh tmp dir and runs: + +```bash +git init +git remote add origin +git fetch --depth 1 origin +git checkout --detach +``` + +Fetching the exact SHA matches the pinned-audit principle: the SHA being stamped is the SHA being audited, independent of any subsequent ref movement. + +If the SHA fetch fails (GitHub does not allow reachable-SHA fetches for that repo, the SHA has been garbage-collected, etc.), the routine fails closed: post error diagnostics to the rolling status issue and exit without opening any PR. Audit never runs against a moving `main`. + +`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. + +## 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 v1 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. + +## PR body format + +Both sweep and prose PRs open with the same three-part body: + +1. Per-page audit note. One or two sentences per affected page citing the pinned `head_sha` the audit consulted. +2. Summary table: + + | Page | Repo | Recorded SHA | HEAD SHA | + | --- | --- | --- | --- | + | docs/sdk/install.md | ant-sdk | `abc1234` | `def5678` | + +3. Link to the run summary on the rolling status issue. + +Sweep PRs add: "metadata-only envelope; the structural guard runs and must be green." + +Prose PRs add a list of pages with prose changes versus pages whose only change in this PR is a metadata bump, so the reviewer knows where to focus. + +## Metadata-only allowlist + +Verbatim envelope for `claude/sweep-` PRs: + +- verification-block lines (`source_commit:`, `verified_date:`) in `docs/**/*.md`, +- the `verified_commits` map in `skills/start/version.json`, +- the `verified_commits` map and the `verified_date:` line in the YAML frontmatter of `skills/start/SKILL.md`, +- one new `planning/sweeps/.md`. + +Forbidden on `claude/sweep-` PRs: + +- `version` and `published_date` keys in `version.json`, +- the YAML-frontmatter `version:` line in `SKILL.md`, +- any change to `skills/start/CHANGELOG.md`, +- any rendered prose change in `docs/`. + +`claude/prose-` PRs allow rendered prose changes in `docs/`. The `version` / `published_date` / CHANGELOG / frontmatter-`version` forbidding still applies because version bumps are not the routine's job: a release happens on a separate, manual concept-edit PR. + +## 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, etc.), 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 + +The scanner exits non-zero on any of: + +- 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}" + ), + } + ) + commits = data.get("verified_commits") or {} + 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 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]: + if not SKILL_MD_PATH.exists(): + return {} + 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) or {} + 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 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 not fm: + 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}" + ), + } + ) + commits = fm.get("verified_commits") or {} + 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 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..fed7018 100644 --- a/skills/start/MAINTAINING.md +++ b/skills/start/MAINTAINING.md @@ -4,12 +4,19 @@ 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`. + ## Current scope This skill is intentionally practical. It covers: @@ -140,7 +147,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. From e5869c5ed3707dbd4fec58749d346a848827c4d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 19:26:15 +0000 Subject: [PATCH 2/6] Harden upstream-sweep routine for v1.1: prose-guard, pinned deps, locked key sets, POSIX bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add prose-guard.yml enforcing the claude/prose-* envelope and the SKILL.md linked-release rule (skill body changes ⇒ all five release fields must move; body unchanged ⇒ none of them may). - Pin PyYAML literally in sweep-guard.yml, sweep-sha-reachability.yml, and prose-guard.yml; the requirements file is PR-controlled and cannot be the install source. - Lock verified_commits key sets in version.json and SKILL.md frontmatter on both sweep-guard and prose-guard so the routine can refresh values but never silently add or remove repos. - Forbid scripts/, .github/, repo-registry.yml, and component-registry.yml on both guards so a hand-rolled PR cannot widen the routine's surface. - Rewrite Step 0 of the routine prompt in POSIX shell with capture-then-decide (no mapfile, no Bash arrays, no gh issue create --json/--jq). - Expand Step 4 of the prompt and the policy doc into the explicit nine-step Opus 4.7+ audit/write/verify loop, the page batching rule, the audit-diff fetch rule (fetch both recorded_sha and head_sha; fall back to the compare API; never compare against a moving branch), and a concise PR body format. - Note the linked-release coupling in MAINTAINING.md so a manual reviewer knows the prose PR carries the full skill release set. --- .github/workflows/prose-guard.yml | 413 +++++++++++++++++++ .github/workflows/sweep-guard.yml | 56 ++- .github/workflows/sweep-sha-reachability.yml | 7 +- planning/routines/upstream-sweep-prompt.md | 224 +++++++--- planning/routines/upstream-sweep.md | 187 ++++++--- scripts/requirements.txt | 6 + skills/start/MAINTAINING.md | 2 + 7 files changed, 780 insertions(+), 115 deletions(-) create mode 100644 .github/workflows/prose-guard.yml diff --git a/.github/workflows/prose-guard.yml b/.github/workflows/prose-guard.yml new file mode 100644 index 0000000..64878e2 --- /dev/null +++ b/.github/workflows/prose-guard.yml @@ -0,0 +1,413 @@ +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" + 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[-+].+)?$" + ) + + 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}"] + ) + 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 + 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}) + + # Reject infrastructure paths up front. + 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", + ) + + 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 + + # 1) verified_commits key set must be locked in both files. + for path, base_obj, head_obj in ( + (VERSION_JSON_PATH, base_version_obj, head_version_obj), + (SKILL_MD_PATH, base_fm, head_fm), + ): + base_commits = base_obj.get("verified_commits") or {} + head_commits = head_obj.get("verified_commits") or {} + if not isinstance(base_commits, dict) or not isinstance(head_commits, dict): + 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. + 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 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})", + ) + # 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 match the + # branch date. + 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 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 index b203cd8..0dabb2a 100644 --- a/.github/workflows/sweep-guard.yml +++ b/.github/workflows/sweep-guard.yml @@ -41,7 +41,14 @@ jobs: - name: Install scanner dependencies if: steps.scope.outputs.in_scope == 'true' - run: pip install -r scripts/requirements.txt + # 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' @@ -225,6 +232,23 @@ jobs: f"forbidden field changed: {key} " "(only verified_commits may differ in a sweep)", ) + base_commits = base_obj.get("verified_commits") or {} + head_commits = head_obj.get("verified_commits") or {} + if isinstance(base_commits, dict) and isinstance(head_commits, dict): + 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("---"): @@ -267,11 +291,41 @@ jobs: f"forbidden frontmatter key changed: {key} " "(only verified_commits and verified_date may differ)", ) + base_commits = base_fm.get("verified_commits") or {} + head_commits = head_fm.get("verified_commits") or {} + if isinstance(base_commits, dict) and isinstance(head_commits, dict): + 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") diff --git a/.github/workflows/sweep-sha-reachability.yml b/.github/workflows/sweep-sha-reachability.yml index ef804ef..3414e5a 100644 --- a/.github/workflows/sweep-sha-reachability.yml +++ b/.github/workflows/sweep-sha-reachability.yml @@ -41,7 +41,12 @@ jobs: - name: Install scanner dependencies if: steps.scope.outputs.in_scope == 'true' - run: pip install -r scripts/requirements.txt + # 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' diff --git a/planning/routines/upstream-sweep-prompt.md b/planning/routines/upstream-sweep-prompt.md index b4a3f82..957bba3 100644 --- a/planning/routines/upstream-sweep-prompt.md +++ b/planning/routines/upstream-sweep-prompt.md @@ -6,12 +6,23 @@ This is the prompt the Claude Desktop Remote routine executes once per day. The 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 `scripts/requirements.txt` (PyYAML). +- `python3` plus the PyYAML pin from `scripts/requirements.txt`. Use `gh` consistently for GitHub work. Do not call the GitHub MCP server. @@ -21,60 +32,67 @@ 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, allowlist, fail-closed semantics, PR body format, audit gating). +- `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 -Ensure the `upstream-sweep-status` label exists, then ensure exactly one open issue carries it. Both steps are idempotent. +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 -```bash -# (a) label -if [ -z "$(gh label list --json name --jq '.[] | select(.name=="upstream-sweep-status") | .name')" ]; then +# (a) label — capture gh output before deciding so auth/API failure is fatal, +# not silently coerced into "label missing". +labels=$(gh label list --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 -# (b) issue -mapfile -t open_issues < <( - gh issue list --state open --label upstream-sweep-status \ - --json number --jq 'sort_by(.number) | .[].number' -) -case "${#open_issues[@]}" in - 0) STATUS_ISSUE=$(gh issue create \ - --title "Upstream sweep status" \ - --label upstream-sweep-status \ - --body "Rolling status thread for the daily upstream-sweep routine." \ - --json number --jq '.number') ;; - 1) STATUS_ISSUE="${open_issues[0]}" ;; - *) STATUS_ISSUE="${open_issues[0]}" - # Warn once: more than one open status issue, asking a human to close the duplicates. - gh issue comment "$STATUS_ISSUE" --body \ - "Multiple open issues carry the upstream-sweep-status label: ${open_issues[*]}. Continuing against #${STATUS_ISSUE} (lowest number). Please close the duplicates." ;; -esac +# (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 \ + --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 \ + --json number --jq 'length') + if [ "$count" -gt 1 ]; then + others=$(gh issue list --state open --label upstream-sweep-status \ + --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 if the label is missing, so the label step must precede issue creation. - -Capture `$STATUS_ISSUE` for every status post in later steps. +`gh issue create --label X` fails when the label is missing, so the label step must precede issue creation. Capture `$STATUS_ISSUE` for every status post in later steps. ### 1. Open-PR collision check -```bash +```sh OPEN_CLAUDE_PRS=$( gh pr list --state open --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 will be re-detected on the next run. +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. 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 -```bash +```sh python3 scripts/sweep_poll.py > sweep_report.json ``` @@ -86,74 +104,164 @@ If `target_manifest_skipped` is non-empty, include those entries in the run summ 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. Per-page audit against the pinned upstream SHA +### 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. -Group the drifted records by `(repo, head_sha)`. For each unique `(repo, head_sha)`: +#### 4.1 Fetch both SHAs -```bash +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) cd "$TMP" -git init +git init -q git remote add origin "" +git fetch --depth 1 origin "" git fetch --depth 1 origin "" git checkout --detach "" ``` -If the SHA fetch fails (reachable-SHA fetches disabled, SHA garbage-collected, network failure), fail closed: post the error to `$STATUS_ISSUE` and exit. Audit must never run against a moving `main`. +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 log --oneline ".." +git diff --stat ".." +git diff ".." -- +``` + +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: -Audit each affected docs page against the pinned tree per `planning/verification-workflow.md`: +- 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. -- For `scope: "docs"` records, the affected page is at the recorded `location` path. -- For `scope: "skill_version_json"` and `scope: "skill_md"` records, the affected page is the relevant skill file. +#### 4.4 Compare against the affected docs pages and `SKILL.md` -Classify per-page outcome into one of three batches: +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`. -- **sweep batch** — audit confirms the page's claims still hold at `head_sha`; only the metadata stamp needs to move. -- **prose batch** — audit identifies prose impact; the page needs human-reviewed prose edits alongside its SHA stamp refresh. -- **issue batch** — audit cannot make a confident judgment (ambiguous evidence, removed surface, deleted file, etc.). The page does not enter any PR. +Identify any rendered claim, code sample, command, endpoint, type, field, or live-reference URL that no longer matches the pinned source. -Clean up tmp dirs after this step. +#### 4.5 Classify the record -### 5. Open PRs and issues per topology +Pick exactly one: -At most one sweep PR and one prose draft PR per run, and zero or more `manual review needed` issues. +- **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. -**Sweep PR** (if the sweep batch is non-empty): +#### 4.6 Apply the page batching rule (after all records are classified) + +- If **any** record on a page is classified `prose`, the **whole page** goes to the prose PR. All metadata-only records on that page ride along on the same prose PR. +- If **any** record on a page is classified `ambiguous`, the **whole page** is held back into a manual-review issue. The page does not enter any PR. +- Only pages where **all** records are classified `metadata-only` go to the sweep PR. + +The two PRs never touch the same page. + +#### 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 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 PRs and issues per the page batching rule + +At most one sweep PR and one prose draft PR per run, plus zero or more `manual review needed` issues. + +**Sweep PR** (only if at least one page has all records classified `metadata-only`): - Branch: `claude/sweep-` from `main`. -- Diff envelope (verbatim allowlist from `planning/routines/upstream-sweep.md`): +- 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, - - update entries in the `verified_commits` map of `skills/start/SKILL.md`'s YAML frontmatter and refresh its `verified_date:` line, + - 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 in this PR: any change to `version`, `published_date`, `skills/start/CHANGELOG.md`, the YAML-frontmatter `version:` line, or rendered prose in `docs/`. -- PR body uses the format in `planning/routines/upstream-sweep.md` `## PR body format`. +- Forbidden: any change to `version`, `published_date`, `skills/start/CHANGELOG.md`, the YAML-frontmatter `version:` line, any rendered prose in `docs/`, or any file under `scripts/`, `.github/`, `repo-registry.yml`, or `component-registry.yml`. +- PR body: see `## PR body format` below. -**Prose PR** (if the prose batch is non-empty): +**Prose PR** (only if at least one page has any record classified `prose`): - Branch: `claude/prose--` from `main`. Open as **draft**. -- Includes the prose changes plus the corresponding `source_commit:` / `verified_date:` refreshes for those same pages. The two PRs never touch the same page. -- The same `version` / `published_date` / CHANGELOG / frontmatter-`version` forbidding still applies; version bumps are not the routine's job. -- PR body lists which pages have prose changes versus which pages on this PR are metadata-only-on-this-PR so the reviewer knows where to focus. +- Includes the prose edits plus the corresponding `source_commit:` / `verified_date:` refreshes for those same pages. +- 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. -**Issues** (one per page in the issue batch): +**Issues** (one per page in the manual-review-issue batch): - Title: `Upstream sweep: manual review needed for `. -- Body: the page path, the recorded SHA, the upstream HEAD SHA, the audit failure mode (ambiguous evidence, removed surface, deleted file, etc.), and a snippet of the relevant upstream diff. +- Body: the page path, the recorded SHA, the upstream HEAD SHA, the audit failure mode (ambiguous evidence, removed surface, deleted file, SHA-fetch failure, etc.), and a snippet of the relevant upstream diff. ### 6. Run summary Post a single comment to `$STATUS_ISSUE` summarising the run: -- counts of records scanned, drifted, swept, prose-flagged, issue-flagged, and target-manifest-skipped, +- 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. -- Do not bump the skill's `version`, `published_date`, or `CHANGELOG.md`. Stamp refreshes are not releases (per `skills/start/MAINTAINING.md`). +- 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 index 91ddf62..27179ca 100644 --- a/planning/routines/upstream-sweep.md +++ b/planning/routines/upstream-sweep.md @@ -2,7 +2,7 @@ ## 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 SHA the routine intends to stamp, and opens PRs or issues per the topology below. +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. @@ -10,9 +10,9 @@ The routine is the hosted-scheduled equivalent of Tier 1 + Tier 2 from `planning - 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 end-to-end. No subagent layer. +- 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-draft-verify workflow against pinned upstream checkouts and opens PRs/issues per the topology. +- 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 @@ -21,11 +21,8 @@ The routine is the hosted-scheduled equivalent of Tier 1 + Tier 2 from `planning 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, group by `(repo, head_sha)` and check out the pinned SHA in a fresh tmp dir (see `## Source checkout for audit`). Audit each affected docs page against the pinned tree per `planning/verification-workflow.md`. Classify per-page outcome into one of three batches: - - **sweep batch** — audit confirms metadata-only refresh suffices. - - **prose batch** — audit identifies prose impact; the page needs human-reviewed edits alongside its SHA stamp refresh. - - **issue batch** — audit cannot make a confident judgment (ambiguous evidence, removed surface, deleted file). Page does not enter any PR. -6. Open at most two PRs (one sweep, one prose draft) plus zero or more `manual review needed` issues per the topology in `## PR body format`. Post a run summary with PR/issue links to the rolling issue. +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 practical verification). +6. Open at most two PRs (one sweep, one prose draft) plus zero or more `manual review needed` issues per the topology in `## Page batching rule` and `## PR body format`. Post a run summary with PR/issue links to the rolling issue. ## Branch convention @@ -38,11 +35,11 @@ All routine-opened branches use the `claude/` namespace. 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: +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` label exists. Idempotent: list the label, create it if missing (`gh label list` → `gh label create` with `-f` for re-runs). `gh issue create --label X` fails when the label is missing, so this step must precede any issue creation. +1. Ensure the `upstream-sweep-status` label exists. Idempotent: list the label, create it if missing. `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. + - 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. @@ -61,84 +58,157 @@ GitHub's `--search` syntax does not reliably match branch prefixes, so the JSON 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. -## Source checkout for audit +## Audit-diff fetch rule -For each unique `(repo, head_sha)` across drifted records, the routine creates a fresh tmp dir and runs: +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. ```bash -git init +git init -q git remote add origin +git fetch --depth 1 origin git fetch --depth 1 origin git checkout --detach ``` -Fetching the exact SHA matches the pinned-audit principle: the SHA being stamped is the SHA being audited, independent of any subsequent ref movement. +Then `git log ..` and `git diff ..` work locally. + +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. -If the SHA fetch fails (GitHub does not allow reachable-SHA fetches for that repo, the SHA has been garbage-collected, etc.), the routine fails closed: post error diagnostics to the rolling status issue and exit without opening any PR. Audit never runs against a moving `main`. +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. -`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. +**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. -## Credentials and access +## Page batching rule -The routine needs: +After every record on every drifted page is classified by the audit loop: -- 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. +- If **any** record on a page is classified `prose` (developer-facing surface changed), the **whole page** goes to the prose PR. All metadata-only records on that page ride along on the same prose PR; the page never appears on the sweep PR. +- If **any** record on a page is classified `ambiguous`, the **whole page** is held back into a manual-review issue. The page does not enter any PR. Other records on the same page are also withheld; they will be reconsidered on the next run. +- Only pages where **all** records are classified `metadata-only` (no developer-facing impact, no ambiguity) go to the sweep PR. -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. +The two PRs never touch the same page. The sweep PR reviewer never sees prose-affected or ambiguous pages; the prose PR reviewer never sees pages whose only changes are metadata bumps for unaffected pages. -Acceptable v1 alternative: a fine-grained personal access token with the same scopes, stored as a Claude Desktop routine secret. +## Sweep PR envelope (claude/sweep-*) -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. +`claude/sweep-*` PRs are metadata-only. -## PR body format +Allowed: -Both sweep and prose PRs open with the same three-part body: +- 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. -1. Per-page audit note. One or two sentences per affected page citing the pinned `head_sha` the audit consulted. -2. Summary table: +Forbidden: - | Page | Repo | Recorded SHA | HEAD SHA | - | --- | --- | --- | --- | - | docs/sdk/install.md | ant-sdk | `abc1234` | `def5678` | +- 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). -3. Link to the run summary on the rolling status issue. +`sweep-guard` enforces this envelope. It runs on `claude/sweep-*` only and green-skips on every other branch. -Sweep PRs add: "metadata-only envelope; the structural guard runs and must be green." +## Prose PR envelope (claude/prose-*) -Prose PRs add a list of pages with prose changes versus pages whose only change in this PR is a metadata bump, so the reviewer knows where to focus. +`claude/prose-*` PRs allow rendered prose changes plus the linked skill patch release when the audit found skill impact. -## Metadata-only allowlist +Allowed: -Verbatim envelope for `claude/sweep-` PRs: +- `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--`. -- verification-block lines (`source_commit:`, `verified_date:`) in `docs/**/*.md`, -- the `verified_commits` map in `skills/start/version.json`, -- the `verified_commits` map and the `verified_date:` line in the YAML frontmatter of `skills/start/SKILL.md`, -- one new `planning/sweeps/.md`. +Forbidden: -Forbidden on `claude/sweep-` PRs: +- 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**. -- `version` and `published_date` keys in `version.json`, -- the YAML-frontmatter `version:` line in `SKILL.md`, -- any change to `skills/start/CHANGELOG.md`, -- any rendered prose change in `docs/`. +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 -`claude/prose-` PRs allow rendered prose changes in `docs/`. The `version` / `published_date` / CHANGELOG / frontmatter-`version` forbidding still applies because version bumps are not the routine's job: a release happens on a separate, manual concept-edit PR. +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 log --oneline ..`, `git diff --stat ..`, targeted `git diff .. -- `. 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. **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, etc.), 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. +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 -The scanner exits non-zero on any of: +Scanner fail-closed (whole run aborts): - GitHub API auth failure or 4xx/5xx on a HEAD or repo metadata lookup, - network timeout, - malformed `", re.DOTALL + ) + BLOCK_KEY_VALUE_RE = re.compile(r"^\s*([a-z_]+)\s*:\s*(\S.*?)\s*$") + DRAFT_SUFFIX = "-draft" violations: list[str] = [] @@ -146,7 +157,12 @@ jobs: changed_paths = sorted({path for _status, path in changes}) - # Reject infrastructure paths up front. + # 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 @@ -157,6 +173,17 @@ jobs: "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("---"): @@ -223,14 +250,107 @@ jobs: 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") + ): + 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") + ): + 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), ): - base_commits = base_obj.get("verified_commits") or {} - head_commits = head_obj.get("verified_commits") or {} - if not isinstance(base_commits, dict) or not isinstance(head_commits, dict): + 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)) @@ -248,12 +368,23 @@ jobs: ) # 2) version.json forbidden-key check: anything outside the refresh - # set or the release set is forbidden everywhere. + # 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 @@ -329,6 +460,31 @@ jobs: 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( @@ -382,14 +538,24 @@ jobs: "release with a body change", ) - # 5) Sweep summary file: optional, but if present must match the - # branch date. + # 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, diff --git a/.github/workflows/sweep-guard.yml b/.github/workflows/sweep-guard.yml index 0dabb2a..6153170 100644 --- a/.github/workflows/sweep-guard.yml +++ b/.github/workflows/sweep-guard.yml @@ -72,11 +72,14 @@ jobs: 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})$") + 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" @@ -134,7 +137,7 @@ jobs: if not branch_match: fail( HEAD_REF, - "branch name does not match claude/sweep-YYYY-MM-DD; " + "branch name does not match claude/sweep-YYYY-MM-DD[-slug]; " "the guard cannot verify the new sweep summary file", ) @@ -155,9 +158,45 @@ jobs: 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) @@ -223,6 +262,27 @@ jobs: 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") + ): + 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): @@ -232,23 +292,45 @@ jobs: f"forbidden field changed: {key} " "(only verified_commits may differ in a sweep)", ) - base_commits = base_obj.get("verified_commits") or {} - head_commits = head_obj.get("verified_commits") or {} - if isinstance(base_commits, dict) and isinstance(head_commits, dict): - 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)})", - ) + # 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("---"): @@ -282,6 +364,20 @@ jobs: "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") + ): + 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): @@ -291,23 +387,43 @@ jobs: f"forbidden frontmatter key changed: {key} " "(only verified_commits and verified_date may differ)", ) - base_commits = base_fm.get("verified_commits") or {} - head_commits = head_fm.get("verified_commits") or {} - if isinstance(base_commits, dict) and isinstance(head_commits, dict): - 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)})", - ) + # 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"} diff --git a/.github/workflows/sweep-sha-reachability.yml b/.github/workflows/sweep-sha-reachability.yml index 3414e5a..ec922dc 100644 --- a/.github/workflows/sweep-sha-reachability.yml +++ b/.github/workflows/sweep-sha-reachability.yml @@ -83,11 +83,18 @@ jobs: 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( @@ -277,6 +284,7 @@ jobs: # 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, @@ -294,7 +302,7 @@ jobs: fail(VERSION_JSON_PATH, f"JSON parse error: {exc}") obj = {} if obj.get("verification_mode") == "target-manifest": - pass + skip_tm(f"{VERSION_JSON_PATH}:verified_commits") else: commits = obj.get("verified_commits") or {} for repo_key, sha in sorted(commits.items()): @@ -321,7 +329,7 @@ jobs: fail(SKILL_MD_PATH, f"frontmatter YAML parse error: {exc}") fm = {} if fm.get("verification_mode") == "target-manifest": - pass + skip_tm(f"{SKILL_MD_PATH}:verified_commits") else: commits = fm.get("verified_commits") or {} for repo_key, sha in sorted(commits.items()): @@ -339,6 +347,10 @@ jobs: 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) @@ -346,4 +358,8 @@ jobs: 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 index 957bba3..8e2e434 100644 --- a/planning/routines/upstream-sweep-prompt.md +++ b/planning/routines/upstream-sweep-prompt.md @@ -81,14 +81,14 @@ fi ```sh OPEN_CLAUDE_PRS=$( - gh pr list --state open --json number,headRefName \ + 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. -GitHub's `--search` syntax does not reliably match branch prefixes; the JSON list with a client-side prefix filter is the trustworthy form. +`--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 @@ -170,11 +170,15 @@ Pick exactly one: #### 4.6 Apply the page batching rule (after all records are classified) -- If **any** record on a page is classified `prose`, the **whole page** goes to the prose PR. All metadata-only records on that page ride along on the same prose PR. -- If **any** record on a page is classified `ambiguous`, the **whole page** is held back into a manual-review issue. The page does not enter any PR. -- Only pages where **all** records are classified `metadata-only` go to the sweep PR. +Apply the five-case rule per page: -The two PRs never touch the same 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 @@ -192,7 +196,15 @@ If the audit finds skill impact, include both the human-facing docs change and t If the `SKILL.md` body does not change, none of those release fields may change. `prose-guard` enforces both directions. -#### 4.9 Practical verification before opening the PR +#### 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. @@ -204,11 +216,27 @@ Run the checks below on the prepared branch where the toolchain is available. If Clean up tmp dirs after the loop completes. -### 5. Open PRs and issues per the page batching rule +### 5. Open issues, then PRs, then post backlinks -At most one sweep PR and one prose draft PR per run, plus zero or more `manual review needed` issues. +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 +``` -**Sweep PR** (only if at least one page has all records classified `metadata-only`): +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`): @@ -216,21 +244,28 @@ At most one sweep PR and one prose draft PR per run, plus zero or more `manual r - 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/`, or any file under `scripts/`, `.github/`, `repo-registry.yml`, or `component-registry.yml`. +- 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** (only if at least one page has any record classified `prose`): +**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. +- 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).)" +``` -**Issues** (one per page in the manual-review-issue batch): +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. -- Title: `Upstream sweep: manual review needed for `. -- Body: the page path, the recorded SHA, the upstream HEAD SHA, the audit failure mode (ambiguous evidence, removed surface, deleted file, SHA-fetch failure, etc.), and a snippet of the relevant upstream diff. +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 diff --git a/planning/routines/upstream-sweep.md b/planning/routines/upstream-sweep.md index 27179ca..7246233 100644 --- a/planning/routines/upstream-sweep.md +++ b/planning/routines/upstream-sweep.md @@ -21,14 +21,14 @@ The routine is the hosted-scheduled equivalent of Tier 1 + Tier 2 from `planning 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 practical verification). -6. Open at most two PRs (one sweep, one prose draft) plus zero or more `manual review needed` issues per the topology in `## Page batching rule` and `## PR body format`. Post a run summary with PR/issue links to the rolling issue. +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-`. +- 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 @@ -50,11 +50,11 @@ Every status post (collision skip, error diagnostics, no-drift summaries, run su Before opening any new PR, list open PRs and filter `headRefName` by prefix client-side: ```bash -gh pr list --state open --json number,headRefName \ +gh pr list --state open --limit 1000 --json number,headRefName \ --jq '.[] | select(.headRefName | startswith("claude/sweep-") or startswith("claude/prose-"))' ``` -GitHub's `--search` syntax does not reliably match branch prefixes, so the JSON list with a client-side prefix filter is the trustworthy form. +`--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. @@ -90,13 +90,46 @@ 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: +After every record on every drifted page is classified by the audit loop, apply the five-case rule per page: -- If **any** record on a page is classified `prose` (developer-facing surface changed), the **whole page** goes to the prose PR. All metadata-only records on that page ride along on the same prose PR; the page never appears on the sweep PR. -- If **any** record on a page is classified `ambiguous`, the **whole page** is held back into a manual-review issue. The page does not enter any PR. Other records on the same page are also withheld; they will be reconsidered on the next run. -- Only pages where **all** records are classified `metadata-only` (no developer-facing impact, no ambiguity) go to the sweep PR. +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. The sweep PR reviewer never sees prose-affected or ambiguous pages; the prose PR reviewer never sees pages whose only changes are metadata bumps for unaffected pages. +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-*) @@ -168,7 +201,9 @@ For each drifted record: 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. **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. +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 From ddeb45a3574112b3163e804e320f662159339cea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 08:33:24 +0000 Subject: [PATCH 4/6] Harden upstream-sweep guards and routine Five defects found during synthetic verification: 1. sweep-sha-reachability.yml crashed with AttributeError when verified_commits was a list (no .items()). Added explicit raw type checks before iteration in both call sites so the workflow fails closed with a clean message instead of a Python traceback. 2. sweep_poll.py used `data.get("verified_commits") or {}` which silently coerced missing/null/empty-list values to an empty map, skipping skill verification records. Replaced with explicit presence + isinstance(dict) checks at both call sites. 3. upstream-sweep-prompt.md Step 0 only created the upstream-sweep-status label. Step 5 then opened manual-review issues with --label upstream-sweep-manual-review and would 404 on first run. Bootstrap now creates both labels. Added --limit 1000 to the gh label list call too. 4. prose-guard.yml only recorded the new path for rename/copy diffs, so a claude/prose-* PR could rename a forbidden old path into an allowed new one and slip past the envelope check. Now records both old and new paths, mirroring sweep-guard. 5. Both guards protected target-manifest verification_mode and verified_commits but not verified_date. Added verified_date to all three target-manifest immutability checks (version.json in prose-guard, SKILL.md in prose-guard, SKILL.md and version.json in sweep-guard). --- .github/workflows/prose-guard.yml | 10 +++++++++ .github/workflows/sweep-guard.yml | 4 ++++ .github/workflows/sweep-sha-reachability.yml | 16 ++++++++++++-- planning/routines/upstream-sweep-prompt.md | 19 ++++++++++------- planning/routines/upstream-sweep.md | 2 +- scripts/sweep_poll.py | 22 ++++++++++++++++++-- 6 files changed, 61 insertions(+), 12 deletions(-) diff --git a/.github/workflows/prose-guard.yml b/.github/workflows/prose-guard.yml index 077ce6b..4e14e07 100644 --- a/.github/workflows/prose-guard.yml +++ b/.github/workflows/prose-guard.yml @@ -125,6 +125,11 @@ jobs: 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 @@ -134,6 +139,7 @@ jobs: 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: @@ -300,6 +306,8 @@ jobs: 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, @@ -315,6 +323,8 @@ jobs: 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, diff --git a/.github/workflows/sweep-guard.yml b/.github/workflows/sweep-guard.yml index 6153170..a0ac2b4 100644 --- a/.github/workflows/sweep-guard.yml +++ b/.github/workflows/sweep-guard.yml @@ -277,6 +277,8 @@ jobs: 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, @@ -372,6 +374,8 @@ jobs: 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, diff --git a/.github/workflows/sweep-sha-reachability.yml b/.github/workflows/sweep-sha-reachability.yml index ec922dc..c4d5d17 100644 --- a/.github/workflows/sweep-sha-reachability.yml +++ b/.github/workflows/sweep-sha-reachability.yml @@ -304,7 +304,13 @@ jobs: if obj.get("verification_mode") == "target-manifest": skip_tm(f"{VERSION_JSON_PATH}:verified_commits") else: - commits = obj.get("verified_commits") or {} + 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: @@ -331,7 +337,13 @@ jobs: if fm.get("verification_mode") == "target-manifest": skip_tm(f"{SKILL_MD_PATH}:verified_commits") else: - commits = fm.get("verified_commits") or {} + 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: diff --git a/planning/routines/upstream-sweep-prompt.md b/planning/routines/upstream-sweep-prompt.md index 8e2e434..a5a0a43 100644 --- a/planning/routines/upstream-sweep-prompt.md +++ b/planning/routines/upstream-sweep-prompt.md @@ -45,18 +45,23 @@ POSIX shell only. No `mapfile`, no Bash arrays, no bash-only parameter expansion ```sh set -eu -# (a) label — capture gh output before deciding so auth/API failure is fatal, -# not silently coerced into "label missing". -labels=$(gh label list --json name --jq '.[].name') +# (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 \ - --json number --jq 'sort_by(.number) | .[0].number // empty') + --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 \ @@ -65,17 +70,17 @@ if [ -z "$first_issue" ]; then else STATUS_ISSUE="$first_issue" count=$(gh issue list --state open --label upstream-sweep-status \ - --json number --jq 'length') + --limit 1000 --json number --jq 'length') if [ "$count" -gt 1 ]; then others=$(gh issue list --state open --label upstream-sweep-status \ - --json number --jq '[.[].number | tostring] | join(", ")') + --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. Capture `$STATUS_ISSUE` for every status post in later steps. +`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 diff --git a/planning/routines/upstream-sweep.md b/planning/routines/upstream-sweep.md index 7246233..074871d 100644 --- a/planning/routines/upstream-sweep.md +++ b/planning/routines/upstream-sweep.md @@ -37,7 +37,7 @@ The routine maintains a single open GitHub issue labelled `upstream-sweep-status 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` label exists. Idempotent: list the label, create it if missing. `gh issue create --label X` fails when the label is missing, so this step must precede any issue creation. +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. diff --git a/scripts/sweep_poll.py b/scripts/sweep_poll.py index fb7cd23..40f1ecc 100755 --- a/scripts/sweep_poll.py +++ b/scripts/sweep_poll.py @@ -296,7 +296,15 @@ def walk_version_json( ), } ) - commits = data.get("verified_commits") or {} + 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( { @@ -402,7 +410,17 @@ def walk_skill_md( ), } ) - commits = fm.get("verified_commits") or {} + 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( { From 8c541eb49f44fb68901d4246300a5280b7d34bb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 13:44:40 +0000 Subject: [PATCH 5/6] Tighten upstream-sweep type and shape checks (review pass 4) Three review findings on top of ddeb45a: 1. validate_sha() in sweep-sha-reachability.yml could still hit sha[:7] when an entry of verified_commits had a non-string value (null, int, list). Added an isinstance(sha, str) guard at the top of validate_sha so all three call sites (docs blocks, version.json, SKILL.md) fail closed with a clean message instead of a traceback. Mirrored with per-entry checks in sweep_poll.py walk_version_json and walk_skill_md. 2. parse_skill_md_frontmatter used `yaml.safe_load(raw) or {}` which coerced a present-but-empty (or null) frontmatter into {}, then walk_skill_md returned early on `if not fm`, silently skipping SKILL.md verification. Now: parse returns None only for an absent file and raises FailClosed for any present-but-shape-invalid case. walk_skill_md uses `fm is None` instead of falsiness, so an explicitly empty {} frontmatter falls through to the existing verification_mode / verified_commits checks and fails closed. 3. The audit fetch step in upstream-sweep-prompt.md used cd "$TMP" without a return, so a tired or literal agent could continue later PR-writing steps from inside the upstream checkout. Changed to git -C "$TMP" so cwd never moves, with explicit guidance to wrap any genuinely-cwd-needing follow-up in a subshell. --- .github/workflows/sweep-sha-reachability.yml | 7 +++ planning/routines/upstream-sweep-prompt.md | 13 +++--- scripts/sweep_poll.py | 47 ++++++++++++++++++-- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/.github/workflows/sweep-sha-reachability.yml b/.github/workflows/sweep-sha-reachability.yml index c4d5d17..bfeea37 100644 --- a/.github/workflows/sweep-sha-reachability.yml +++ b/.github/workflows/sweep-sha-reachability.yml @@ -172,6 +172,13 @@ jobs: 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}") diff --git a/planning/routines/upstream-sweep-prompt.md b/planning/routines/upstream-sweep-prompt.md index a5a0a43..3269f63 100644 --- a/planning/routines/upstream-sweep-prompt.md +++ b/planning/routines/upstream-sweep-prompt.md @@ -119,14 +119,15 @@ Audit must compare the upstream tree at `head_sha` against the diff from `record ```sh TMP=$(mktemp -d) -cd "$TMP" -git init -q -git remote add origin "" -git fetch --depth 1 origin "" -git fetch --depth 1 origin "" -git checkout --detach "" +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 diff --git a/scripts/sweep_poll.py b/scripts/sweep_poll.py index 40f1ecc..e1cb7a4 100755 --- a/scripts/sweep_poll.py +++ b/scripts/sweep_poll.py @@ -316,6 +316,17 @@ def walk_version_json( 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( { @@ -343,9 +354,15 @@ def walk_version_json( pair_set.add((repo_key, ref)) -def parse_skill_md_frontmatter() -> dict[str, Any]: +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 {} + return None text = SKILL_MD_PATH.read_text(encoding="utf-8") if not text.startswith("---"): raise FailClosed( @@ -366,7 +383,7 @@ def parse_skill_md_frontmatter() -> dict[str, Any]: ) raw = text[3:closing] try: - loaded = yaml.safe_load(raw) or {} + loaded = yaml.safe_load(raw) except yaml.YAMLError as exc: raise FailClosed( { @@ -375,6 +392,17 @@ def parse_skill_md_frontmatter() -> dict[str, Any]: "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( { @@ -395,7 +423,7 @@ def walk_skill_md( token: str, ) -> None: fm = parse_skill_md_frontmatter() - if not fm: + if fm is None: return mode = fm.get("verification_mode") if mode not in ALLOWED_MODES: @@ -434,6 +462,17 @@ def walk_skill_md( 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( { From d56e87886780f9a46071d1edff9af1a8afedbf18 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 07:13:06 +0000 Subject: [PATCH 6/6] Scope every audit git command to $TMP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review pass 5: the F3 fix in 8c541eb fixed Step 4.1 but left Step 4.2 and the policy doc's fetch block running bare git commands. A literal routine run would have inspected the docs repo (the cwd of the routine) instead of the upstream checkout, silently producing misleading audit input. Now every git command in the audit step carries -C "$TMP": - planning/routines/upstream-sweep-prompt.md §4.2: log/diff/diff - planning/routines/upstream-sweep.md §Audit-diff fetch rule: full fetch block + the inline mention of log/diff - planning/routines/upstream-sweep.md §Audit/write/verify summary L198: mirrors the prompt's command form Added an explicit anti-pattern note in the prompt: a bare git log or git diff here would inspect the docs repo, not the upstream tree. --- planning/routines/upstream-sweep-prompt.md | 8 +++++--- planning/routines/upstream-sweep.md | 17 +++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/planning/routines/upstream-sweep-prompt.md b/planning/routines/upstream-sweep-prompt.md index 3269f63..529e378 100644 --- a/planning/routines/upstream-sweep-prompt.md +++ b/planning/routines/upstream-sweep-prompt.md @@ -141,11 +141,13 @@ If both the local fetch and the compare API fail for a record, **fail closed for #### 4.2 Compute the upstream diff ```sh -git log --oneline ".." -git diff --stat ".." -git diff ".." -- +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` diff --git a/planning/routines/upstream-sweep.md b/planning/routines/upstream-sweep.md index 074871d..ca4678b 100644 --- a/planning/routines/upstream-sweep.md +++ b/planning/routines/upstream-sweep.md @@ -62,17 +62,18 @@ If any results come back regardless of date, the routine skips the run, posts a 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. +Default path: fetch both exact SHAs into a fresh tmp dir without changing the routine's cwd. ```bash -git init -q -git remote add origin -git fetch --depth 1 origin -git fetch --depth 1 origin -git checkout --detach +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 log ..` and `git diff ..` work locally. +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: @@ -194,7 +195,7 @@ Required model: **Opus 4.7 or higher**. The deterministic scanner is only the dr 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 log --oneline ..`, `git diff --stat ..`, targeted `git diff .. -- `. Or read the compare API response when running via fallback. +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).