From be1402ee9ca565b8459adfdb048d2d9b81c2ec2a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 17:59:03 +0000 Subject: [PATCH 1/5] prm: wait for CI by being woken, not by polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /prm step 3 polled every ~90s, which on mobile is one turn — and one context re-read — per poll, for as long as CI runs. This harness can be woken instead: subscribe_pr_activity delivers the check-suite rollup as an event, so the wait costs one turn per event and the phone can be put down between them. The wake path is offered first where the tools exist; the poll stays for a local CLI, which has no wake mechanism. Two details are load-bearing and written down rather than left to be rediscovered: webhooks are best-effort and a *successful* rollup is the event most likely to go missing (hence the send_later check-in), and an event is a reason to re-judge from step 2, never evidence of green. Step 4 now drops the subscription once the PR reads MERGED, so a live subscription cannot outlive the run and wake later sessions. GITHUB_ACCESS.md carries the mapping row: this is the one operation where the MCP surface beats gh rather than merely matching it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1SLkLDW6aEZCVf95XXeu --- skills/GITHUB_ACCESS.md | 11 +++++++++++ skills/prm/prm.md | 22 +++++++++++++++++++--- tests/test_gh_surface.py | 22 ++++++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/skills/GITHUB_ACCESS.md b/skills/GITHUB_ACCESS.md index eae08858..1c2504ac 100644 --- a/skills/GITHUB_ACCESS.md +++ b/skills/GITHUB_ACCESS.md @@ -113,10 +113,21 @@ leave it that way. | Create a branch | `gh api -X POST .../git/refs` | `create_branch` | | Releases | `gh release view/list` | `get_latest_release`, `list_releases`, `get_release_by_tag` | | Who am I | `gh api user` | `get_me` | +| Be woken by CI / comments on a PR | *(no equivalent — a CLI polls)* | `subscribe_pr_activity`, `unsubscribe_pr_activity` | Tool names are given unprefixed; the harness exposes them as `mcp__github__`. If a name is not loaded, `ToolSearch` fetches its schema. +That last row is the one place the MCP surface **beats** `gh` rather than +merely matching it. A CLI can only ask again; a subscribed session is woken by +the check-suite rollup, so waiting for CI costs one turn per event instead of +one turn per poll. Take it wherever a run would otherwise sit in a loop +(`/prm` step 3), and drop the subscription when the PR merges. Webhooks are +best-effort — a *successful* rollup is the event most likely to go missing — so +pair it with a `send_later` check-in (claude-code-remote) where that tool +exists, and re-read the PR's real state on every wake rather than trusting the +event to be the whole story. + ## What the MCP surface cannot do - **Delete a remote branch.** There is no MCP tool for it, and in a proxied web diff --git a/skills/prm/prm.md b/skills/prm/prm.md index 889c56e6..8446fb3e 100644 --- a/skills/prm/prm.md +++ b/skills/prm/prm.md @@ -64,9 +64,21 @@ every run for the sha and every job in it, and treat anything not `completed` as ## 3. Wait, or stop -- **Pending** — poll every ~90s, one compact line per poll (`3/4 legs done`), - capped at ~30 min; then report where it stands rather than spinning. Under - `--no-wait`, skip the loop entirely: report and stop. +- **Pending** — wait by the cheapest mechanism the surface has. Under + `--no-wait`, do neither: report and stop. + + - **Be woken (mobile, web — preferred there).** Where the harness exposes + `subscribe_pr_activity`, call it once per target PR and **end the turn**. + CI completions and review comments then wake the session: one turn per + event instead of one turn per 90s, and the phone can be put down between + them. Pair it with a `send_later` check-in ~15 min out where that tool + exists — a *successful* rollup is the event most likely to arrive late or + not at all, and the check-in is what stops a green PR sitting unmerged. + On every wake re-judge from step 2 (an event is a reason to look, never + evidence of green) and re-arm the check-in until the merge lands. + - **Poll (local CLI, or no wake tools).** Every ~90s, one compact line per + poll (`3/4 legs done`), capped at ~30 min; then report where it stands + rather than spinning. - **Red** — **fetch the failing job's log immediately** (GitHub purges the blob; once purged the failure is unnameable forever), quote the failing step, and stop. Do not merge, do not re-run, do not "wait for the flake to pass". Offer @@ -84,6 +96,10 @@ Green on every leg → merge, in this order: 2. `gh pr merge --merge` per PR (`-R owner/repo` when you have no checkout), then confirm the state is `MERGED` — queued or auto-merge is not merged. Never `--delete-branch`: it takes the local branch too, orphaning a task worktree. +3. **Drop any subscription step 3 took.** Once every target PR reads `MERGED`, + `unsubscribe_pr_activity` each one and cancel the `send_later` check-in. A + subscription outliving its merge wakes later sessions on a PR nobody is + driving. Never force, never override a protection, never rewrite history. If a merge is refused by GitHub, report the reason verbatim and stop. diff --git a/tests/test_gh_surface.py b/tests/test_gh_surface.py index 3df6e309..216b12ae 100644 --- a/tests/test_gh_surface.py +++ b/tests/test_gh_surface.py @@ -139,3 +139,25 @@ def test_helper_locates_itself_without_readlink(tmp_path): capture_output=True, text=True, env=env, timeout=60) assert "command not found" not in r.stderr, r.stderr assert "GITHUB_ACCESS.md" in r.stderr + + +def test_waiting_for_ci_prefers_being_woken_over_polling(): + """A poll costs a turn every 90s; a wake costs one per event. + + The MCP surface is usually a *reduced* `gh`, so the one operation where it + is strictly better is easy to leave unused. `/prm` is where a run would + otherwise sit in a loop, so its wait step must offer the subscription + first, and the mapping page must carry the tool that provides it. + """ + access = ACCESS_PAGE.read_text() + for tool in ("subscribe_pr_activity", "unsubscribe_pr_activity"): + assert tool in access, f"{tool} is unmapped" + + prm = (SKILLS / "prm" / "prm.md").read_text() + wait = prm.split("## 3. Wait, or stop")[1].split("## 4.")[0] + assert "subscribe_pr_activity" in wait, "the wait step never mentions waking" + assert wait.index("subscribe_pr_activity") < wait.index("~90s"), ( + "polling is offered before the cheaper wake path") + # And the subscription must be dropped, or later sessions wake on a PR + # nobody is driving. + assert "unsubscribe_pr_activity" in prm.split("## 4.")[1] From 5da05710e48f66e8730fe6a4bee8c8a795c99ddb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 18:01:35 +0000 Subject: [PATCH 2/5] agents: bootstrap unconditionally, before the first test command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remote-session block told a session to bootstrap if pytest misbehaved. That trigger stopped firing: measured 2026-08-27, the container ships Python 3.12 with pytest, mypy, ruff and flake8 on it, so the session looks correct while `pytest -n auto` still dies on `unrecognized arguments: -n` — which reads like a bad flag, not a stale environment. Reordered so the bootstrap comes before the parallel-test line it supplies xdist for. The probe defect behind it is fixed in PyAutoMind. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1SLkLDW6aEZCVf95XXeu --- AGENTS.md | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bca2b108..8f5effe3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,16 +164,21 @@ it resolves the sibling `pyauto-heart` and `autohands` binaries from PATH or the Measured in a web/mobile container, where this file is loaded and little else is. -- **Tests run 3.5x faster in parallel** — 4 cores, subprocess-heavy suites, no - single slow test: this repo's 554 tests are 96s on one core and 28s on four. - Use `python3 -m pytest -q -n auto`. -- **A session holding several organs registers no SessionStart hook.** Claude - Code reads project hooks from the project directory, which in that layout is - the repos' *parent*, not a repo. So knock on the door yourself in the first - turn: `bash PyAutoMind/scripts/session_bootstrap.sh` (add `--check` to report - only). `bin/pyauto-brain` already calls it before every verb. The symptom of - skipping it is a pytest that is not this workspace's — collection - `ImportError`s naming `yaml`, or `No module named pytest`. +- **Bootstrap in the first turn, unconditionally.** A session holding several + organs registers no SessionStart hook — Claude Code reads project hooks from + the project directory, which in that layout is the repos' *parent*, not a + repo. So knock on the door yourself: `bash + PyAutoMind/scripts/session_bootstrap.sh` (add `--check` to report only). + `bin/pyauto-brain` calls it before every verb. This used to be advice keyed to + a symptom — a pytest that is not this workspace's, collection `ImportError`s + naming `yaml`. That trigger stopped firing: measured 2026-08-27, the container + ships 3.12 with the linters on it, so the session looks right while + `pytest -n auto` still dies on `unrecognized arguments: -n`, which reads like + a bad flag. ~10s cold, ~1s warm — cheaper than the diagnosis it replaces. +- **Then tests run 3.5x faster in parallel** — 4 cores, subprocess-heavy suites, + no single slow test: this repo's 554 tests are 96s on one core and 28s on + four. `python3 -m pytest -q -n auto`, with `pytest-xdist` from the bootstrap + above. - **There is no `gh`, and installing one does not help.** A remote session reaches GitHub through the `mcp__github__*` tools, already scoped to the session's repos. `gh` itself is two seconds away (`apt-get install gh`) and From d3ec868d9363399e0af2453a3c35e56fa7f6af5b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 18:02:56 +0000 Subject: [PATCH 3/5] prm: a shallow clone makes the merge proof lie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close-out step 1 proves each branch merged with `merge-base --is-ancestor`. A remote session clones shallow (measured 2026-08-27: PyAutoMind at 78 of 4478 commits), and that command reports "not an ancestor" for a commit whose ancestry is merely absent — so a merged branch reads UNMERGED and the close-out stops on nothing. The bootstrap unshallows; the reference now says so at the step that acts on the answer, with `git rev-parse --is-shallow-repository` as the check for a session that skipped it. Same line added to the always-loaded remote-session block, which previously named only the pytest consequences. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1SLkLDW6aEZCVf95XXeu --- AGENTS.md | 5 +++++ skills/prm/reference.md | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 8f5effe3..0e336741 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,6 +175,11 @@ Measured in a web/mobile container, where this file is loaded and little else is ships 3.12 with the linters on it, so the session looks right while `pytest -n auto` still dies on `unrecognized arguments: -n`, which reads like a bad flag. ~10s cold, ~1s warm — cheaper than the diagnosis it replaces. + It also **unshallows the clones**, which is the leg with teeth: a remote + session clones shallow (measured 2026-08-27: PyAutoMind at 78 of 4478 + commits), and `git merge-base --is-ancestor` then answers "not an ancestor" + for a commit whose ancestry is merely absent — the answer `/ship_*` and + `/prm` act on when proving a branch merged. - **Then tests run 3.5x faster in parallel** — 4 cores, subprocess-heavy suites, no single slow test: this repo's 554 tests are 96s on one core and 28s on four. `python3 -m pytest -q -n auto`, with `pytest-xdist` from the bootstrap diff --git a/skills/prm/reference.md b/skills/prm/reference.md index f1c49e9f..4b71d6b4 100644 --- a/skills/prm/reference.md +++ b/skills/prm/reference.md @@ -123,6 +123,14 @@ git rev-list --count origin/main..origin/feature/ # want 0 `UNMERGED` with no open PR → stop the close-out and report which repo. Squash merges break `--is-ancestor`; fall back to the PR's `state=MERGED`. +**On a shallow clone this answer is a lie, not a verdict.** A remote session +clones shallow (measured 2026-08-27: PyAutoMind arrived with 78 of 4478 +commits), and `--is-ancestor` reports "not an ancestor" for a commit whose +ancestry is merely *absent* — so a merged branch reads `UNMERGED` and the +close-out stops on nothing. `session_bootstrap.sh` unshallows every checkout, +which is one more reason it is the first thing a session runs; if you did not +run it, `git rev-parse --is-shallow-repository` before trusting this step. + ### 2. Issue: comment, then close `gh issue close` prints its usage string and exits non-zero in this gh (2.4.0) — From 401e612b9f948ba69d7fce85eed884a5136ce949 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 18:04:27 +0000 Subject: [PATCH 4/5] prm: carry the mcp lane inline, so a mobile close-out reads one page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /prm is ~4k words of gh mechanics. A run without gh loaded those AND GITHUB_ACCESS.md's mapping table, then translated step by step — paying twice for the surface that is now the common one. The close-out's GitHub calls are few and stable, so they are named inline: one row per step, with the parameters a run would otherwise re-derive. Two of them are traps rather than lookups, and cost turns: - `actions_list list_workflow_runs` has NO head_sha filter — workflow_runs_filter is actor/branch/event/status only. Filter by branch and match head_sha yourself, or judge a stale run green. - `issue_write update` with state closed and no state_reason leaves the issue reading "closed as not planned". GITHUB_ACCESS.md stays the index for everything else; it is no longer a required second read for this command. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1SLkLDW6aEZCVf95XXeu --- skills/prm/prm.md | 21 +++++++++++++++++++++ tests/test_gh_surface.py | 19 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/skills/prm/prm.md b/skills/prm/prm.md index 8446fb3e..9e2eae44 100644 --- a/skills/prm/prm.md +++ b/skills/prm/prm.md @@ -43,6 +43,27 @@ command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 \ `/prm` **deletes no remote branch on any surface** (step 5.6), so nothing here needs the second probe that step used to carry. +### The `mcp` lane, step by step + +Every GitHub call a close-out makes, so a mobile run reads this page and the +mapping page only if something here is missing. Names take the +`mcp__github__` prefix (the wake tools are the harness's own); `ToolSearch` +fetches a schema that is not loaded. + +| Step | Call | +|---|---| +| 1 resolve | `list_pull_requests` (state `open`), or `pull_request_read` `get` for a known number. Mind's `active.md` via `get_file_contents` | +| 2 judge CI | `pull_request_read` `get_check_runs` — every check on the head commit, in one call. Then `pull_request_read` `get` for `mergeable` / `merge_state_status` | +| 2 by workflow | `actions_list` `list_workflow_runs` **has no `head_sha` filter** (`workflow_runs_filter` is actor/branch/event/status only): filter by `branch`, then match `head_sha` yourself, or you will judge a stale run. Legs: `actions_list` `list_workflow_jobs`, `resource_id` = run id | +| 3 wait | `subscribe_pr_activity` (owner, repo, pullNumber), then end the turn; `send_later` for the fallback check-in | +| 3 red | `get_job_logs` with `run_id`, `failed_only: true`, `return_content: true` — **before** anything else, the blob is purged | +| 4 merge | `merge_pull_request` (`merge_method: "merge"`), then `pull_request_read` `get` to confirm `MERGED`; `unsubscribe_pr_activity` | +| 5.2 issue | `add_issue_comment`, then `issue_write` `update` with `state: "closed"` and `state_reason: "completed"` — an unset reason is what leaves an issue reading "closed as not planned" | +| 5.1 proof | git, not GitHub — and `--is-ancestor` needs an unshallowed clone (`reference.md` §1) | + +Steps 5.3-5.7 touch no GitHub API at all; what they need is a checkout, and the +note at the foot of step 5 says what to do without one. + ## 1. Resolve the target PR(s) In order: explicit argument → current branch (`gh pr view --json`) → the claimed diff --git a/tests/test_gh_surface.py b/tests/test_gh_surface.py index 216b12ae..a22a2073 100644 --- a/tests/test_gh_surface.py +++ b/tests/test_gh_surface.py @@ -161,3 +161,22 @@ def test_waiting_for_ci_prefers_being_woken_over_polling(): # And the subscription must be dropped, or later sessions wake on a PR # nobody is driving. assert "unsubscribe_pr_activity" in prm.split("## 4.")[1] + + +def test_the_close_out_carries_its_own_mcp_calls(): + """The mobile lane must not cost two pages to read. + + `/prm` is ~4k words of `gh` mechanics; a run without `gh` used to load them + AND the mapping page, then translate step by step. The calls that close a + task out are few and stable enough to name inline, so the mapping page is + the index, not a required second read. + """ + prm = (SKILLS / "prm" / "prm.md").read_text() + lane = prm.split("### The `mcp` lane")[1].split("## 1.")[0] + for call in ("pull_request_read", "actions_list", "get_job_logs", + "merge_pull_request", "add_issue_comment", "issue_write", + "subscribe_pr_activity", "get_file_contents"): + assert call in lane, f"the mcp lane never names {call}" + # The two traps that cost a run its turns, not just a lookup. + assert "head_sha" in lane, "nothing warns that runs cannot be filtered by sha" + assert "state_reason" in lane, "closing an issue without a reason mislabels it" From 8b3d44f9ef078b4082c72335611829ed1fa6e463 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 18:14:33 +0000 Subject: [PATCH 5/5] agents: take the remote-session block from PyAutoMind's generated source The block is now single-sourced in PyAutoMind/policy/remote_sessions.md and written here by repos_sync.py --write, with a drift check. Content is unchanged apart from losing this repo's test count and timings, which rot and now live in the commit record instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1SLkLDW6aEZCVf95XXeu --- AGENTS.md | 79 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 44 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0e336741..63ba2aef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,43 +160,52 @@ Like the other PyAuto repos, PyAutoBrain runs from its checkout (no pip install) it resolves the sibling `pyauto-heart` and `autohands` binaries from PATH or the `~/Code/PyAutoLabs/` checkouts. -## Remote sessions: three lines that pay for themselves - -Measured in a web/mobile container, where this file is loaded and little else is. - -- **Bootstrap in the first turn, unconditionally.** A session holding several - organs registers no SessionStart hook — Claude Code reads project hooks from - the project directory, which in that layout is the repos' *parent*, not a - repo. So knock on the door yourself: `bash - PyAutoMind/scripts/session_bootstrap.sh` (add `--check` to report only). - `bin/pyauto-brain` calls it before every verb. This used to be advice keyed to - a symptom — a pytest that is not this workspace's, collection `ImportError`s - naming `yaml`. That trigger stopped firing: measured 2026-08-27, the container - ships 3.12 with the linters on it, so the session looks right while - `pytest -n auto` still dies on `unrecognized arguments: -n`, which reads like - a bad flag. ~10s cold, ~1s warm — cheaper than the diagnosis it replaces. - It also **unshallows the clones**, which is the leg with teeth: a remote - session clones shallow (measured 2026-08-27: PyAutoMind at 78 of 4478 - commits), and `git merge-base --is-ancestor` then answers "not an ancestor" - for a commit whose ancestry is merely absent — the answer `/ship_*` and - `/prm` act on when proving a branch merged. -- **Then tests run 3.5x faster in parallel** — 4 cores, subprocess-heavy suites, - no single slow test: this repo's 554 tests are 96s on one core and 28s on - four. `python3 -m pytest -q -n auto`, with `pytest-xdist` from the bootstrap - above. + +## Remote sessions (Claude Code on web and mobile) + +Three facts, all measured in a web/mobile container, where this file is loaded +and little else is. They ride in every organ because a session may hold any +subset of them — and the session that needs this most is the one holding +several, which is exactly the session no hook fires in. + +- **Bootstrap in the first turn, unconditionally** — before the first test + command, not as a remedy once something looks wrong: + + ``` + bash PyAutoMind/scripts/session_bootstrap.sh # ~10s cold, ~1s warm + bash PyAutoMind/scripts/session_bootstrap.sh --check # report only + ``` + + A session holding several organs registers no SessionStart hook — Claude Code + reads project hooks from the project directory, which in that layout is the + repos' *parent*, not a repo — so nothing has set this session up. It was once + phrased as a remedy keyed to `No module named pytest` or collection + `ImportError`s naming `yaml`; that symptom stopped appearing when the + container image moved to Python 3.12, while the environment is still wrong in + ways that read like a bad command rather than a stale session (`pytest -n + auto` → `unrecognized arguments: -n`). The bootstrap also **unshallows the + clones**: a remote session clones shallow, and `git merge-base --is-ancestor` + then answers "not an ancestor" for a commit whose ancestry is merely absent — + the answer the ship and close-out procedures act on when proving a branch + merged. + +- **Then run the suite in parallel.** 4 cores, subprocess-heavy suites, no + single slow test: about 3.5x. `python3 -m pytest -q -n auto`, with + `pytest-xdist` supplied by the bootstrap above. + - **There is no `gh`, and installing one does not help.** A remote session reaches GitHub through the `mcp__github__*` tools, already scoped to the - session's repos. `gh` itself is two seconds away (`apt-get install gh`) and - is a trap: it authenticates — `gh api user` returns your login — and then - 403s every repo-scoped call, because the egress proxy serves neither the - REST repo paths nor GraphQL beyond a pinned set of PR-review operations. So - `gh pr list`, `gh issue list`, `gh repo view` and `gh api repos/...` all - fail *after* the install, which is worse than the honest `command not - found`. Read [`PyAutoBrain/skills/GITHUB_ACCESS.md`](skills/GITHUB_ACCESS.md) - at the top of any run that touches GitHub; it maps each `gh` operation onto - its MCP tool. Spell that path from the workspace root, as written: a session - holding several organs is cwd'd at the repos' *parent*, so a bare `skills/…` - reads as a missing file rather than a missing repo prefix. + session's repos. `gh` installs in two seconds and is a trap: it authenticates, + then 403s every repo-scoped call, because the egress proxy serves neither the + REST repo paths nor GraphQL beyond a pinned set of PR-review operations — a + binary that looks healthy and fails everything that matters. It also defeats + the surface probe, which keys off `gh auth status`. Read + `PyAutoBrain/skills/GITHUB_ACCESS.md` at the top of any run that touches + GitHub; it maps each `gh` operation onto its MCP tool. Spell that path from + the workspace root, as written: a multi-organ session is cwd'd at the repos' + *parent*, so a bare `skills/…` reads as a missing file rather than a missing + repo prefix. + ## The command surface (Brain implicit)