diff --git a/.github/scripts/digest-prompt.md b/.github/scripts/digest-prompt.md new file mode 100644 index 00000000..359c6764 --- /dev/null +++ b/.github/scripts/digest-prompt.md @@ -0,0 +1,17 @@ +You summarise changes to pstack, Lauren Tan's plugin of agent skills, agents and playbooks for Cursor. The reader is garyj. He installs pstack into Claude Code and Codex through a fork of Michael Ramos's mirror, and he reads these summaries to learn what Lauren is doing and how she works. + +The user message is a JSON list of the merged pull requests in this update, oldest first. Each has a number, URL, title, author, description and the pstack files it changed. + +Write the body of a GitHub issue in Markdown: + +1. Open with two or three sentences on the changes that matter most and why. +2. Group the changes under `##` headings by theme, such as new skills, changes to existing skills, principles, playbooks and scripts, agents, fixes, and docs. Leave out empty groups. Each bullet says what changed and why, and ends with the PR link as `[#123](url)`. Combine PRs that changed the same thing into one bullet. +3. Add a `## How she did it` section with the methods the descriptions report: evals and their results, measurements, token or word cuts, and community PRs she reworked and credited. Leave the section out if the descriptions report none. +4. Add a `## Cursor-only` section for changes that only affect Cursor, such as Cursor manifests, Cursor or Grok Bot behaviour, or the Origin forge, so they don't change anything in Claude Code or Codex. Leave it out if there are none. + +Rules: + +- Use only facts from the descriptions and file lists. When a description gives no reason, don't invent one. +- Name skills as `/name` and principles by their folder name. +- Use plain words and short sentences. No em dashes, no hype, no closing summary. +- When an author other than poteto wrote a PR, say so in its bullet. diff --git a/.github/scripts/digest.py b/.github/scripts/digest.py new file mode 100644 index 00000000..95f7c9d6 --- /dev/null +++ b/.github/scripts/digest.py @@ -0,0 +1,98 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = ["anthropic>=1.15"] +# /// +"""Summarise the cursor/plugins PRs that changed pstack between two commits.""" + +import base64 +import json +import re +import subprocess +import sys +from pathlib import Path + +import anthropic + +REPO = 'cursor/plugins' +MODEL = 'claude-sonnet-5-5' +PROMPT = Path(__file__).with_name('digest-prompt.md').read_text() + + +def gh(path: str) -> list | dict: + out = subprocess.run(['gh', 'api', '--paginate', '--slurp', path], capture_output=True, text=True, check=True) + pages = json.loads(out.stdout) + return [item for page in pages for item in page] if isinstance(pages[0], list) else pages[0] + + +def version(sha: str) -> str: + manifest = gh(f'repos/{REPO}/contents/pstack/.cursor-plugin/plugin.json?ref={sha}') + return json.loads(base64.b64decode(manifest['content']))['version'] + + +def commits_between(old: str, new: str) -> list[str]: + shas = [] + for commit in gh(f'repos/{REPO}/commits?path=pstack&sha={new}&per_page=100'): + if commit['sha'].startswith(old): + return shas[::-1] + shas.append(commit['sha']) + sys.exit(f'{old} is not an ancestor of {new} in {REPO}') + + +def clean(body: str | None) -> str: + body = re.sub(r'.*?', '', body or '', flags=re.DOTALL) + body = re.sub(r'', '', body, flags=re.DOTALL) + return re.sub(r'
list[dict]: + prs = {} + for sha in shas: + for pr in gh(f'repos/{REPO}/commits/{sha}/pulls'): + if pr['merged_at'] and not pr['title'].startswith('chore(pstack): bump'): + prs[pr['number']] = pr + return [ + { + 'number': pr['number'], + 'url': pr['html_url'], + 'title': pr['title'], + 'author': pr['user']['login'], + 'description': clean(pr['body']), + 'files': [ + f"{f['filename']} (+{f['additions']} -{f['deletions']})" + for f in gh(f"repos/{REPO}/pulls/{pr['number']}/files?per_page=100") + if f['filename'].startswith('pstack/') + ], + } + for pr in sorted(prs.values(), key=lambda pr: pr['merged_at']) + ] + + +def summarise(prs: list[dict]) -> str: + response = anthropic.Anthropic().beta.messages.create( + model=MODEL, + max_tokens=16000, + betas=['server-side-fallback-2026-07-01'], + fallbacks='default', + system=PROMPT, + messages=[{'role': 'user', 'content': json.dumps(prs, indent=2)}], + ) + if response.stop_reason != 'end_turn': + sys.exit(f'Claude stopped with {response.stop_reason} (request {response._request_id})') + return ''.join(block.text for block in response.content if block.type == 'text') + + +def main(old: str, new: str, out_dir: Path) -> None: + prs = pull_requests(commits_between(old, new)) + if not prs: + sys.exit(f'No merged pstack PRs between {old} and {new}') + old_version, new_version = version(old), version(new) + (out_dir / 'title.txt').write_text(f'pstack {old_version} → {new_version}: what Lauren changed') + (out_dir / 'body.md').write_text( + f'{summarise(prs)}\n\n---\n\n' + f'[Cursor diff {old[:7]}...{new[:7]}](https://github.com/{REPO}/compare/{old}...{new}) · ' + f'{len(prs)} PRs · summarised by `{MODEL}`\n' + ) + + +if __name__ == '__main__': + main(sys.argv[1], sys.argv[2], Path(sys.argv[3])) diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index 0eff216f..edbfd520 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -4,9 +4,17 @@ on: schedule: - cron: "17 3 * * *" workflow_dispatch: + inputs: + from: + description: Cursor commit to summarise from (skips the sync) + required: false + to: + description: Cursor commit to summarise to + required: false permissions: contents: write + issues: write concurrency: group: sync @@ -14,7 +22,12 @@ concurrency: jobs: sync: + if: ${{ !inputs.from }} runs-on: ubuntu-latest + outputs: + from: ${{ steps.range.outputs.from }} + to: ${{ steps.range.outputs.to }} + moved: ${{ steps.upstream.outputs.moved }} steps: - uses: actions/checkout@v7 with: @@ -26,8 +39,12 @@ jobs: git remote add michael https://github.com/backnotprop/pstack.git git fetch michael main upstream - - name: Set upstream to michael/upstream - run: git push --force origin refs/remotes/michael/upstream:refs/heads/upstream + - name: Record the Cursor commits this sync spans + id: range + run: | + cursor_sha() { git log -1 --format=%s "$1" | sed -n 's|^upstream: cursor/plugins/pstack @ ||p'; } + echo "from=$(cursor_sha origin/upstream)" >> "$GITHUB_OUTPUT" + echo "to=$(cursor_sha michael/upstream)" >> "$GITHUB_OUTPUT" - name: Merge michael/main run: | @@ -48,6 +65,12 @@ jobs: - name: Push run: git push origin HEAD:main + - name: Set upstream to michael/upstream + id: upstream + run: | + git push --force origin refs/remotes/michael/upstream:refs/heads/upstream + echo "moved=true" >> "$GITHUB_OUTPUT" + - name: Check .apm/ covers everything pstack ships run: | diff -r agents .apm/agents @@ -60,3 +83,26 @@ jobs: echo "::error::New top-level entries that .apm/ may not cover: $unknown" exit 1 fi + + digest: + needs: sync + if: ${{ always() && (inputs.from || (needs.sync.outputs.moved == 'true' && needs.sync.outputs.from != needs.sync.outputs.to)) }} + runs-on: ubuntu-latest + env: + FROM: ${{ inputs.from || needs.sync.outputs.from }} + TO: ${{ inputs.to || needs.sync.outputs.to }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v7 + + - uses: astral-sh/setup-uv@v10.2.0 + with: + enable-cache: false + + - name: Summarise Lauren's changes + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: uv run .github/scripts/digest.py "$FROM" "$TO" "$RUNNER_TEMP" + + - name: Open the digest issue + run: gh issue create -R "$GITHUB_REPOSITORY" --title "$(cat "$RUNNER_TEMP/title.txt")" --body-file "$RUNNER_TEMP/body.md"