Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
11c97fb
Add contributor mode: user-token auth and a notifications poller
The-Glassy Oct 17, 2026
d2787d9
Contributor mode: README section, example recipe, changelog fragment
The-Glassy Oct 17, 2026
bf83ada
Contributor mode: cover the new example recipe in the docs and policy…
The-Glassy Oct 17, 2026
f0daf7a
Contributor mode: rework how the notifications poller reads, keeps it…
Oct 18, 2026
55c024e
Contributor mode: read reviews from their last page, keep each cursor…
Oct 18, 2026
5c80bc6
Contributor mode: deliver review requests, look up checks on the user…
The-Glassy Oct 20, 2026
b682a6f
Contributor mode: look up a settled head's checks again only when it …
Oct 20, 2026
a3e9640
Contributor mode: leave a head's checks only once every run finished,…
Oct 20, 2026
8b11949
README: say a pass looks up 20 heads' checks in turns
Oct 20, 2026
23e3c56
Contributor mode: say when a head's checks are left alone, and test t…
Oct 20, 2026
40e6402
Merge, update-branch, workflow runs and review reads, behind opt-in a…
antra-tess Oct 23, 2026
9127a61
Review fixes: update_branch and delete_branch fences, sha prefixes, n…
antra-tess Oct 23, 2026
ef49160
update_branch: write only to a head that is nobody's base
Oct 24, 2026
05cd9db
Test that bad input from the host ends only what it should
Oct 27, 2026
50afa96
Use @animalabs/mcpl-core 0.4.0, and hear the errors it reports
Oct 27, 2026
6d72345
Test on Node 24 in CI too, and check the U+2028 answer is a result
Oct 27, 2026
94b403d
Merge main into the mcpl-core 0.4.0 raise
Oct 28, 2026
3dd5289
Merge main into the mcpl-core 0.4.0 raise
Oct 28, 2026
089ad1f
Merge upstream PR16: MCPL core 0.4 and connection errors
theaspirational Oct 29, 2026
27e4331
Merge upstream PR6: reconcile contributor mode with current polling a…
theaspirational Oct 29, 2026
30b5e01
Test integration boundaries and document shared-account editing limit…
theaspirational Oct 29, 2026
7286add
Launch synthetic lifecycle fixtures through the host Node path
theaspirational Oct 29, 2026
046668a
Retain notification progress when host delivery is unavailable
theaspirational Oct 29, 2026
36918c7
Keep truncated notification listing windows available for retry
theaspirational Oct 29, 2026
fe9dad2
Bound descending review pagination and stop on empty pages
theaspirational Oct 29, 2026
fd8bd81
Document notification retry and saturation limits
theaspirational Oct 29, 2026
ee9878f
Preserve first-page reviews after an empty tail page
theaspirational Oct 29, 2026
cd48ad3
Accept delivery through either granted event egress
theaspirational Oct 29, 2026
3553b44
Pause contributor polling without a host and bound refusal retries
theaspirational Oct 29, 2026
00eaaa2
Document contributor delivery, retry, and truncation contracts
theaspirational Oct 29, 2026
172aba1
Document the separate refused-check delivery schedule
theaspirational Oct 29, 2026
92d941a
Continue bounded review pagination through empty pages
theaspirational Oct 29, 2026
2d39f8b
Clarify thread retry scope and final sibling egress declines
theaspirational Oct 29, 2026
79f745d
Merge upstream PR #11: merge, actions and review reads
theaspirational Oct 29, 2026
718bcfb
Merge upstream PR #14: protect update_branch bases and fork heads
theaspirational Oct 29, 2026
87e27dd
Test layered tools with user tokens instead of installation credentials
theaspirational Oct 29, 2026
d30228c
Document user-token scopes and refusals for merge and Actions
theaspirational Oct 29, 2026
5f33db8
Warn contributors when unscoped write lists span repository owners
theaspirational Oct 29, 2026
df7587b
Explain contributor workflow dispatch and rerun delivery limits
theaspirational Oct 29, 2026
8477506
Exercise contributor merge and Actions through host-granted server calls
theaspirational Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,9 @@ permissions:
contents: read

jobs:
# The whole suite runs once, at Node 22.12.0. Node 22.8.0 takes npm test's
# --experimental-test-isolation=none, but there every test file fails to
# load with 'Unknown file extension ".ts"'. The install and start check
# below runs on engines' floor, 22.0.0; on Node 24, a maintenance LTS line
# hosts still run; and on the newest Node release.
# The whole suite runs once, at Node 22.12.0, engines' floor. The install
# and start check below runs on Node 24, a maintenance LTS line hosts still
# run, and on the newest Node release.
typecheck-and-test:
name: Typecheck & Test (node 22.12.0)
runs-on: ubuntu-latest
Expand Down Expand Up @@ -52,7 +50,7 @@ jobs:
strategy:
fail-fast: false
matrix:
node: ['22.0.0', '24', 'latest']
node: ['24', 'latest']

steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Expand Down
177 changes: 152 additions & 25 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions app-manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "rayforce-clone",
"description": "An agent living in one GitHub repository: it wakes on issues, comments, pull requests, reviews and check results, and answers in the thread it was spoken to in. Runs as github-mcpl, an MCPL connector. No merge, no release-branch write.",
"description": "An agent living in one GitHub repository: it wakes on issues, comments, pull requests, reviews and check results, and answers in the thread it was spoken to in. Runs as github-mcpl, an MCPL connector. Merges only into the bases its operator allows; writes commits only through GitHub's pull-request merge and update-branch.",
"url": "https://example.com/github-mcpl",
"public": false,
"hook_attributes": {
Expand All @@ -13,7 +13,7 @@
"default_permissions": {
"issues": "write",
"pull_requests": "write",
"contents": "read",
"contents": "write",
"checks": "read",
"metadata": "read"
},
Expand Down
18 changes: 18 additions & 0 deletions changelog.d/contributor-delivery.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
- Submitted-review reads continue through empty pages rather than skipping
earlier pages, with an explicit read cap even for an all-empty walk.
- Contributor notification passes pause without API or state changes when
no connected MCPL host has a granted event egress. An eligible host resumes
polling; existing cursors survive the pause.
- Refused thread-event handoffs retain their cursor without freezing the global
listing watermark or conditional header. After five failed passes, a
persisted terminal retry record prevents the same notification update from
restarting the cycle or repeating its drop notice, even across restart.
A refused `checks.completed` stays on its head/TTL schedule, with no retry
limit and no drop notice.
- Acceptance by either granted event egress counts as delivery. Declining a
push no longer makes the poller resend an accepted channel message (or the
reverse); the sibling egress's decline is warned about and treated as final,
so the message is not re-sent on that egress.
- Contributor documentation now describes retained truncated windows and their
unconditional retries, including the shared-token rate-limit cost when a
window remains saturated.
17 changes: 17 additions & 0 deletions changelog.d/contributor-mode.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
- **Contributor mode** (#5): with `GITHUB_TOKEN_FILE` instead of the App variables, the server authenticates
with a personal access token (classic) and gets its events from `GET /notifications` (the threads its user
takes part in), so a resident can follow its own pull requests and threads in repositories where the App
isn't installed. Notifications from repositories outside `GITHUB_REPOS` are dropped, and `GITHUB_REPOS`
may span several owners. A first start delivers what GitHub notified from one poll interval before it. A
thread the server stops reading after repeated failures gets one `thread.read_failed` event on its channel
(tags `github:read-failed` and `chat:mention`). The webhook listener is refused in this mode. A
notification whose reason is `review_requested` arrives as `pull.review_requested`, and the server looks
up the checks on the heads of the token user's open pull requests, the 100 most recently updated, so a
settled result arrives as `checks.completed`, as in App mode. `GITHUB_BOT_LOGIN` is optional in this mode: unset, it is the token's
login.
- **`search_issues`**, in both modes, drops `user:`, `org:` and `owner:` qualifiers from a query as it already
dropped `repo:`, since GitHub widens a search to each of them, and leaves out any result from another
repository on the page, saying how many.
- **A polled merge** is tagged `github:pr=merged` beside `github:pr=closed`, as a webhook delivery's is, in App
mode's repository poll too: the issues endpoint's row says whether a pull request was merged. Otherwise App
mode is unchanged.
2 changes: 2 additions & 0 deletions changelog.d/mcpl-core-0.4.breaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- **Operators:** github-mcpl needs Node 22.12.0 or later, the floor of
`@animalabs/mcpl-core` 0.4.0, which it now uses.
9 changes: 9 additions & 0 deletions changelog.d/mcpl-core-0.4.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
- A host message whose JSON string holds U+2028 or U+2029 no longer ends the
process: on Node 24 the reader split the line there.
- A malformed line from the host is logged, and its connection stays open. A
stream error, such as a reset TCP connection, is logged and closes that
connection: over TCP the next host can then connect, and under `--stdio` the
process shuts down as it does when the host closes stdin. Before, each of
these ended the process with an unheard error.
- An error the TCP server reports while a host is connected, such as a failed
accept of another connection, is logged.
17 changes: 17 additions & 0 deletions changelog.d/merge-and-actions.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
- `github.merge` feature set: `merge_pull_request` (merge, squash or rebase,
at the head sha the caller read, optionally deleting the merged head branch
when it is nobody's base) and `update_branch`. Both act only on pull
requests into the bases `GITHUB_MERGE_BASE_ALLOW` names, which is empty by
default: granting the feature set alone writes nothing. `merge_pull_request`
refuses a draft. Both refuse a closed or merged pull request, and, given
the `sha` the caller read, a head that has moved since, before GitHub is
asked to write; `update_branch` writes only to a head branch of the pull
request's own repository that is nobody's base.
Branch protection stays GitHub's.
- `github.actions` feature set: `list_workflow_runs`, `dispatch_workflow` and
`rerun_workflow_run`. Dispatch and re-run act only on the workflow files
`GITHUB_WORKFLOW_ALLOW` names (empty by default), and a dispatch runs only
on a base branch. Built for re-requesting a review from a workflow that
ignores comments from bots.
- `list_reviews` (`github.read`): the newest 50 reviews on a pull request and
how many there are in all, ending on the newest verdict.
7 changes: 7 additions & 0 deletions changelog.d/merge-and-actions.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- **Operators:** `app-manifest.json` asks for `contents: write` instead of
`contents: read`. GitHub refuses `resolveReviewThread` to an App that cannot
write contents, so `resolve_thread` and `unresolve_thread` failed with
"Resource not accessible by integration" on an App made from the old
manifest. Raise the permission on the App's **Permissions** page and accept
it on the installation; add `actions: write` and `workflows: write` there
too if you enable `github.actions` or `github.merge`.
15 changes: 13 additions & 2 deletions deploy/github-mcpl.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
# in src/types.ts is what validates them.

# --- runtime ----------------------------------------------------------------
# The absolute path of Node 22+. README step 7 appends it with
# The absolute path of Node 22.12.0 or newer. README step 7 appends it with
# `command -v node`, because a host started by systemd does not see nvm's PATH.
#GITHUB_MCPL_NODE=

Expand All @@ -22,11 +22,22 @@ GITHUB_APP_PRIVATE_KEY_FILE=$HOME/.config/github-mcpl/app-private-key.pem
GITHUB_BOT_LOGIN=REPLACE_WITH_APP_SLUG[bot]

# --- scope ----------------------------------------------------------------
# Comma-separated; several repositories must share one owner.
# Comma-separated; App mode requires one owner. Contributor (user-token) mode
# may include multiple owners, such as your fork and a third party's upstream.
GITHUB_REPOS=REPLACE_WITH_OWNER/REPLACE_WITH_REPO
# `branch` for every repository, `owner/name:branch` for one of them.
# e.g. a repository that merges into dev; the contributor recipe's targets use main.
GITHUB_PR_BASE_ALLOW=dev
# What merge_pull_request (github.merge) may merge into, same format. Empty
# (the default) means no merge at all, whatever the host grants.
# Unscoped merge/workflow entries apply to EVERY repo in GITHUB_REPOS, even
# third-party upstreams. Use owner/name:value to limit each permission. Startup
# warns (does not refuse) about unscoped entries with multiple owners.
#GITHUB_MERGE_BASE_ALLOW=REPLACE_WITH_OWNER/REPLACE_WITH_REPO:dev
# Workflow files dispatch_workflow and rerun_workflow_run (github.actions) may
# start: `review.yml` everywhere, `owner/name:ci.yml` in one repository. Empty
# means none. Never a release or publish workflow.
#GITHUB_WORKFLOW_ALLOW=REPLACE_WITH_OWNER/REPLACE_WITH_REPO:review.yml
GITHUB_MAINTAINERS=REPLACE_WITH_MAINTAINER_LOGIN

# --- ingress: ONE of the two ----------------------------------------------
Expand Down
18 changes: 11 additions & 7 deletions docs/contract-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,18 +214,22 @@ The contract names the variables but not every default or requiredness.
names the branch. An entry is `branch` (every repository) or
`owner/name:branch` (that one); a scoped entry outside `GITHUB_REPOS` is a
startup error.
- `GITHUB_REPOS` took exactly one entry in v1. It now takes several of one
owner: one owner is one App installation and one token, which is what the
adapter holds. With several entries the tools' `repo` argument and the
repository half of `thread` stop defaulting and become required.
- `GITHUB_REPOS` took exactly one entry in v1. With the App it now takes
several of one owner: one owner is one App installation and one token,
which is what the adapter holds. Contributor mode's user token reaches
every owner its user can, so there the list may span owners. With several
entries the tools' `repo` argument and the repository half of `thread`
stop defaulting and become required.
- `GITHUB_STATE_FILE` is optional; absent means the state lives in memory for
the life of the process and `flush()` is a no-op. Checkpoints still work,
they just do not survive a restart.
- `GITHUB_REPOS` set but empty is a startup error, as is more than one entry
(v1). Both are reported as `GITHUB_REPOS: …`.
- `GITHUB_BOT_LOGIN` is required: from-self detection is a correctness
property (without it the server can answer itself), so it is not something
to default.
- `GITHUB_BOT_LOGIN` is required with the App: from-self detection is a
correctness property (without it the server can answer itself), so it is
not something to guess. Contributor mode can leave it unset, since there
`GET /user` says whose the token is: startup takes that login, and stops
when GitHub doesn't answer.

## Not materialized by .1

Expand Down
15 changes: 10 additions & 5 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,16 @@ deliberate choice, not an oversight:
reads any file of any user. A second user there guards against accidents
and costs upkeep (a sudo rule, a wrapper, logs the agent cannot see); it
does not stop an agent that was talked into reading the key.
- **What bounds a leaked key** is the App itself: installed on one
repository, `contents: read` (no push, no new branch), no merge tool, PR
bases checked against `GITHUB_PR_BASE_ALLOW`. The worst case is comments,
issues and reviews under the bot's name on that one repository. Revoke it
by generating a new key in the App's settings and deleting the old one.
- **What bounds a leaked key** is the App itself: the repositories it is
installed on and the permissions it holds — not this server's allow-lists,
which a leaked key does not go through. With the manifest's permissions a
leaked key can write comments, issues and reviews, and — because
`contents: write` is what `resolve_thread` and merging need — push to any
branch that branch protection does not cover. So install the App on the
repositories it serves and no more, and protect every branch that matters
(require a pull request, and restrict who may push) on GitHub itself.
Revoke a key by generating a new one in the App's settings and deleting
the old one.
- **Two agents on one host** each get their own unix user and home. A home is
mode 750 by default on current Ubuntu, so one agent's
`~/.config/github-mcpl` is not readable by the other.
Expand Down
21 changes: 17 additions & 4 deletions docs/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@ capability grant's decision; a tag only influences treatment afterwards.
| `pull_request` (`opened`) | `listIssuesSince` | `pull.opened` | pull |
| `pull_request` (`synchronize`) | — | `pull.synchronize` | pull |
| `pull_request` (`ready_for_review`) | — | `pull.ready_for_review` | pull |
| `pull_request` (`review_requested`) | — | `pull.review_requested` | pull |
| `pull_request` (`review_requested`) | — (contributor mode: a notification whose reason is `review_requested`) | `pull.review_requested` | pull |
| `pull_request` (`closed`) | `listIssuesSince` | `pull.closed` | pull |
| `pull_request_review` (`submitted`) | — | `review.submitted` | pull |
| `pull_request_review_comment` (`created`) | `listReviewCommentsSince` | `review_comment.created` | pull |
| `pull_request_review_comment` (`edited`) | `listReviewCommentsSince` | `review_comment.edited` | pull |
| `check_suite` (`completed`) | `getCheckRuns` on an open PR head | `checks.completed` | pull |
| `check_suite` (`completed`) | `getCheckRuns` on an open PR head (contributor mode: on the head of each open PR the token's user authored, the 100 most recently updated, until a result with every run finished arrives, or for two minutes with no runs, and again when it moves or ten minutes on) | `checks.completed` | pull |
| `push` | — | `push` | *none* |
| — (contributor mode only: the notifications poller stopped reading a thread) | — | `thread.read_failed` | issue or pull |

A `push` event carries no thread, so its channel id, `metadata.number` and
`metadata.kind` are absent — a recipe that routes on a thread must not expect
Expand All @@ -53,13 +54,25 @@ pair**, and then the rows below.
| `pull.opened` | `github:pr=opened` | |
| `pull.synchronize` | `github:pr=synchronize` | |
| `pull.ready_for_review` | `github:pr=ready_for_review` | |
| `pull.review_requested` | `github:pr=review_requested` | Not producible for an App identity (e2e round 3): GitHub does not list an App's bot in the Reviewers picker and drops it from `requested_reviewers` through the API (201, empty list). A human asks this connector for a review with a mention, which wakes `chat:mention`. The kind and tag stay for a reviewer that is a user. |
| `pull.review_requested` | `github:pr=review_requested` | Not producible for an App identity (e2e round 3): GitHub does not list an App's bot in the Reviewers picker and drops it from `requested_reviewers` through the API (201, empty list). A human asks this connector for a review with a mention, which wakes `chat:mention`. The kind and tag stay for a reviewer that is a user, as in contributor mode, where a notification whose reason is `review_requested` arrives as this kind, with the pull request's author as its actor, once per notification update. |
| `pull.closed` | `github:pr=closed`, plus `github:pr=merged` when the caller passes `merged: true` | |
| `review.submitted` | `github:review=approved` \| `=changes_requested` \| `=commented`, from `event.review.state` | |
| `review_comment.created` | `github:review-comment` | `chat:thread` |
| `review_comment.edited` | `github:review-comment` | `chat:thread`, `chat:edited` |
| `checks.completed` | `github:ci=failure`, or `github:ci=success`, or *neither* — see below | |
| `push` | `github:push` | |
| `thread.read_failed` | `github:read-failed` | |

A `thread.read_failed` event is contributor mode's notifications poller saying,
on the thread's own channel, that it stopped reading that thread's new
activity: after five passes in a row that failed, or at once for a thread gone
or out of the token's reach. Its body names the cause. The poller reads the
thread again, from where it left off while its place is among the last 200
threads it read, when the thread next moves on GitHub. The same event says a
thread's backlog was skipped, when it had more new comments or reviews than
one read takes, and that its new activity arrives as usual. Its actor is
`github-mcpl[server]`, a login no GitHub account can have, so never the agent,
and it carries `chat:mention` (§4), since the server addresses it to the agent.

A conversation comment carries no `github:` tag of its own: the core
vocabulary already says everything a recipe routes on (who wrote it, whether
Expand Down Expand Up @@ -102,7 +115,7 @@ are not in the contract's ingress.

| Tag | When |
|---|---|
| `chat:mention` | `event.body` @-mentions the bot — both `@name` and `@name[bot]` spellings, case-insensitively, for a `GITHUB_BOT_LOGIN` of `name[bot]`. Body only: a mention in a title notifies nobody on GitHub. Only on kinds that bring their text — `issue.opened`, `issue.edited`, `pull.opened`, `comment.created`, `comment.edited`, `review.submitted`, `review_comment.created`, `review_comment.edited`: a close, a reopen, new commits or a deleted comment re-carry a body the bot already saw. A thread the bot took part in still wakes it on those through `chat:reply`. |
| `chat:mention` | `event.body` @-mentions the bot — both `@name` and `@name[bot]` spellings, case-insensitively, for a `GITHUB_BOT_LOGIN` of `name[bot]`. Body only: a mention in a title notifies nobody on GitHub. Only on kinds that bring their text — `issue.opened`, `issue.edited`, `pull.opened`, `comment.created`, `comment.edited`, `review.submitted`, `review_comment.created`, `review_comment.edited`: a close, a reopen, new commits or a deleted comment re-carry a body the bot already saw. A thread the bot took part in still wakes it on those through `chat:reply`. On a `thread.read_failed` event, always: there it means addressed to the agent by the server, not a person's @-mention. |
| `chat:reply` | `ctx.isOwnThread` (the bot authored the thread or has commented in it) **or** `ctx.isReplyToSelf` (see §5) |
| `chat:ambient` | neither of the above |

Expand Down
Loading
Loading