Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ jobs:
python-version: "3.12"

- name: Install dev deps
run: pip install -r requirements-dev.txt ruff
# ruff is PINNED in requirements-dev.txt (0.15.10, the protoAgent/projectBoard pin) —
# a floating ruff fails `format --check` on correctly-formatted code.
run: pip install -r requirements-dev.txt

- name: Lint
run: ruff check . && ruff format --check .
Expand Down
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@

**Canonical instructions live in [PROTO.md](./PROTO.md).** Read it before editing — it
owns the stack, the lint+test gate, the per-agent gating design (**§4, don't break
it**), what to build (the stubs, §5), and the rules (§6, incl. host-free imports and
plain-string `@tool` docstrings). This file is a thin pointer; edit PROTO.md.
it**), the tool inventory + error classification (§5), the rules (§6, incl. host-free
imports, plain-string `@tool` docstrings, and never-raise tools), and the release
recipe (§7). This file is a thin pointer; edit PROTO.md.
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@

**Canonical instructions live in [PROTO.md](./PROTO.md).** Read it before editing — it
owns the stack, the lint+test gate, the per-agent gating design (**§4, don't break
it**), what to build (the stubs, §5), and the rules (§6, incl. host-free imports and
plain-string `@tool` docstrings). This file is a thin pointer; edit PROTO.md.
it**), the tool inventory + error classification (§5), the rules (§6, incl. host-free
imports, plain-string `@tool` docstrings, and never-raise tools), and the release
recipe (§7). This file is a thin pointer; edit PROTO.md.
134 changes: 108 additions & 26 deletions PROTO.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,40 @@ tools over the `gh` CLI, with **per-agent write gating**. The host loads it via
| Layer | What |
|---|---|
| Runtime | Python ≥ 3.11; `langchain-core` (`@tool`) is provided by the host |
| Auth | `gh` ambient auth, or `GITHUB_TOKEN`/`GH_TOKEN` from the env |
| Auth | a non-empty `github.token` secret (Settings) is injected and wins; else the env passes through and gh's own order applies (`GH_TOKEN` > `GITHUB_TOKEN` > `gh auth login` keyring) |
| Repo | `protoLabsAI/github-plugin`, ships `enabled: false` (install ≠ enable ≠ trust) |

## 2. Commands — the PR gate

These must pass before a PR opens (host-free — no protoAgent needed):

```bash
pip install -r requirements-dev.txt ruff
pip install -r requirements-dev.txt # includes the PINNED ruff==0.15.10
ruff check . && ruff format --check . # lint + format
pytest -q # the suite
```

There is no other runner. `ruff` + `pytest` are the sole gate.
There is no other runner. `ruff` + `pytest` are the sole gate. `ruff` is pinned (the
same `0.15.10` as protoAgent core and projectBoard-plugin) in BOTH `requirements-dev.txt`
and `ci.yml` — a floating ruff fails `format --check` on correctly-formatted code; bump
the pin in both places together.

## 3. Where everything lives

```
protoagent.plugin.yaml # manifest (id: github, config_section: github; write/default_repo/repos)
__init__.py # register() — gating wiring (read always; write iff github.write) + /issue
gh_cli.py # vendored async `gh` runner (run_gh, check_gh_error, bad_repo)
read_tools.py # 8 read tools (6 ported core + read_file/repo_contents)
protoagent.plugin.yaml # manifest (id: github, config_section: github; write/default_repo/repos + the `token` secret)
__init__.py # register() — gating wiring (read always; write iff github.write) + /issue + live-config getters
gh_cli.py # vendored async `gh` runner: binary resolution (PATH + install dirs), token
# injection (config secret > env), check_gh_error CLASSIFICATION, bad_repo
status.py # the first-run probe: compute_status / summarize_status / report_gaps (setup-gap seam)
projects.py # repo sources: host projects: registry (ADR 0095) + checkout `origin` remote parsing
read_tools.py # 12 read tools (6 ported core + file/contents/pr-file/path-exists/pr-diff + github_status)
write_tools.py # 8 write tools (create/edit/merge/close/comment/labels/assignees) — gated
gh_issue.py # /issue chat command logic (user-only; gate-checked; configured repo)
api.py # routers — public PAGES (view/new-issue) + gated data routes (config/issues/prs/issue)
view.py # PAGE = read-only board (Issues/PRs tabs + repo picker); NEW_ISSUE_PAGE = file-an-issue form. --pl-* themed
tests/ # host-free pytest (gating + version coherence + routes via TestClient)
review_tools.py # 3 verdict tools (comment/approve/request-changes, guarded) — gated
gh_issue.py # /issue chat command logic + repo resolution (resolve_repo, default_repo_error)
api.py # routers — public PAGES (view/new-issue) + gated data routes (config/status/issues/prs/issue)
view.py # PAGE = read-only board; NEW_ISSUE_PAGE = file-an-issue form; shared setup card. --pl-* themed
tests/ # host-free pytest (gating + version coherence + routes via TestClient + the no-raise sweep)
```

**Console surfaces (ADR 0026/0038/0042/0057).** `register()` mounts two routers when the
Expand All @@ -63,22 +70,39 @@ Filing lives in the widget + palette, NOT the board (keep the board read-only).
agent stays read-only; a coding/PM agent gets write. `tests/test_register.py` asserts
both halves — keep it green.

**`/issue` is a user-only chat command, not an agent tool** — creating an issue is a
write the model must not do autonomously, so `register()` registers it via the host's
`register_chat_command` seam (the logic lives in `gh_issue.py`). The call is guarded
by `hasattr(registry, "register_chat_command")`, so on an older host without the seam
`/issue` is skipped and the tools still load (degrade-safe). It routes to the
configured `default_repo`/`repos` (never a silent default). `tests/test_issue_command.py`
asserts both the seam-present and legacy-host paths — keep them green.

## 5. Tools (all implemented)
**`/issue` is the user-only chat path; the `github_create_issue` agent tool exists
behind `github.write`.** A PERSON files from the composer on any agent — including a
read-only one — via `/issue`, registered through the host's `register_chat_command`
seam (the logic lives in `gh_issue.py`); the MODEL files via `github_create_issue`
only when the agent's write gate is on (the PM archetype requires it). Both go
through the same `file_issue` (gate check + `gh issue create`), so they can't
diverge. The seam call is guarded by `hasattr(registry, "register_chat_command")`, so
on an older host without it `/issue` is skipped and the tools still load
(degrade-safe). It routes to the configured `default_repo`/`repos` (never a silent
default). `tests/test_issue_command.py` asserts both the seam-present and
legacy-host paths — keep them green.

**Config is read LIVE.** `register()` builds getters over `registry.live_config` (the
snapshot on an older host) and every consumer — the tools' default repo, `/issue`, the
data routes, the `token` secret — reads through them per call. Never capture a config
value at register time: an `onboard_project` mid-session, or a token pasted in
Settings, must be seen by the very next call.

## 5. Tools (all implemented — 12 read / 8 write / 3 review = 23)

Each tool mocks `run_gh` in its test and asserts the exact argv + readable errors.
`tests/test_no_raise_sweep.py` additionally invokes EVERY registered tool against a
`run_gh` stub returning a dict / a list / garbage / an error and asserts a `str` comes
back — a tool that raises kills the whole agent turn, so no tool may. Add a tool ⇒ the
sweep covers it automatically (it enumerates `register()`'s output).

**Read (always on)** — 6 ported core tools (`github_get_pr`, `github_get_issue`,
`github_list_issues`, `github_get_commit_diff`, `github_ci_runs`, `github_run_failure`)
plus `github_read_file` (`gh api .../contents/{path}`, raw) and `github_repo_contents`
(directory listing).
`github_list_issues`, `github_get_commit_diff`, `github_ci_runs`, `github_run_failure`),
`github_pr_diff`, the content readers (`github_read_file` — raw file; `github_read_pr_file`
— a file at a PR's head; `github_repo_contents` — directory listing, says "is a file —
use github_read_file" on a file; `github_path_exists` — the EXISTS/MISSING probe), and
`github_status` — is `gh` installed / authenticated / as whom / which default repo, the
self-diagnosis tool the model calls when another tool errors (no `write` gate).

**Write (gated on `github.write`)** —
`github_create_issue` / `github_comment` / `github_create_pr` (return the new URL),
Expand All @@ -87,23 +111,81 @@ plus `github_read_file` (`gh api .../contents/{path}`, raw) and `github_repo_con
`github_close` (close/reopen issue|pr), and `github_set_labels` / `github_set_assignees`
(`gh {issue,pr} edit --add/--remove-{label,assignee}`). Issue-vs-PR ops take `kind`.

**Review (gated on `github.write`)** — `github_review_comment` (always allowed),
`github_review_approve` / `github_review_request_changes` (refused while CI is pending
or unreadable, and on the agent's own PR — guards live in the tool, below the model).

New write op? Validate `bad_repo()`, build argv, `run_gh()`, degrade to `Error: ...`,
add it to `get_write_tools()`'s return list **and** `WRITE_TOOLS` in `test_register.py`,
and mirror an existing test. Anything irreversible (merge) must be `confirm`-guarded.

**Errors are classified** (`gh_cli.check_gh_error`, categories from `error_kind`): `gh`
exit 4 / "gh auth login" ⇒ `Error: GitHub CLI is not authenticated — …` with the hint
branched on `token_source()` (a rejected env/config token says "replace/unset it", not
"run gh auth login"); GraphQL "Could not resolve to a Repository"
⇒ `Error: repo 'o/n' not found or not accessible`; HTTP 404 ⇒ not found (with the repo
named when known); 403/429 + "rate limit" ⇒ `Error: GitHub API rate limit hit — retry …`;
binary missing ⇒ `Error: gh CLI is not installed or not on PATH (looked in …)`. Anything
else keeps the `Error (gh exit N): <stderr>` shape. Add a category here, not in a tool.

**First-run status** (`status.py`): `GET /api/plugins/github/status` → `{gh_path,
gh_version, authenticated, login, host, token_source, error, default_repo, repos,
default_repo_error}` from `gh --version` + `gh auth status --json hosts` (text fallback
for an older `gh`); never raises. The views render a setup card from it; the
`github_status` tool returns `summarize_status()`. **Every status computation reports**
to the host's `report_setup_gap` seam (guarded — no seam, no-op): `probe_in_background()`
at register time (a daemon thread, off the boot path) and `compute_and_report()` from
`/status` and the tool, edge-triggered (a failing key gets its message, a passing key
gets `None`) so the operator banner clears on the very Re-check that sees the recovery.
Each probe drops the cached `gh` path first — a miss is never cached — so a `gh`
installed after boot is found by the next call.
**Never block the loop on the picker**: `effective_default_repo` takes the picker as a
getter and short-circuits when `default_repo` is set; the routes and `github_status`
run the resolution via `asyncio.to_thread`; checkout remotes are cached 10 min.

## 6. Rules

- **Host-free.** NEVER import `graph.*` / `plugins.*` at module top — the suite runs
with only `requirements-dev.txt`. Keep any host imports lazy (inside functions).
- **`@tool` docstrings must be PLAIN string literals** — an f-string docstring makes
`__doc__` None and the tool ships with no description (the model can't see it).
- **Every tool requires an explicit `owner/name` repo** — validate with `bad_repo()`;
there is no silent default (a forgotten repo must error, not hit the wrong repo).
- **Every tool requires an `owner/name` repo** — validate with `bad_repo()`. The
fallback when the arg is omitted is the LIVE configured default (`default_repo` >
first picker entry; the picker = `github.repos` ∪ the host's `projects:` registry ∪
the `origin` remote of each registered checkout / `project_board.repo`), then the
`GITHUB_DEFAULT_REPO` / `GH_REPO` env **logged at INFO when it fires**. No repo
anywhere ⇒ an error, never a guess. A malformed `default_repo` is the NAMED
`default_repo_error` (#23) on the plugin's `GET /config` / `/status`, the issue route,
the `github_status` tool and the views' setup card — i.e. on READ; the host's Settings
save itself is not gated (the plugin has no save route).
- **Never raise out of a tool.** A tool that raises kills the agent's whole turn. Every
`gh` result is typed-checked before use (the contents API returns a dict for a file,
a list for a directory); the no-raise sweep test enforces this for every tool.
- **DO NOT FABRICATE.** Use real `gh` invocations; verify the actual `gh api` shape
before relying on it. No placeholder/guessed command flags.
- **Don't add runtime pip deps.** Test-only deps go in `requirements-dev.txt`; real
runtime deps would go in the manifest's `requires_pip` (operator-installed).

## 7. Agent-scratch
## 7. Release

A release is a version bump + a tag + a GitHub release; hosts pin the tag in
`plugins.lock`. `tests/test_version.py` asserts the manifest and `pyproject.toml`
versions match — bump BOTH.

```bash
# 1. bump `version:` in protoagent.plugin.yaml AND `version =` in pyproject.toml (lockstep)
# 2. land the PR on main (the gates above must be green)
# 3. tag + release from the merged main
git checkout main && git pull
git tag -a vX.Y.Z -m "vX.Y.Z — <one-line summary>"
git push origin vX.Y.Z
gh release create vX.Y.Z --title "vX.Y.Z" --notes "<the PR body / changelog>"
```

There is no CHANGELOG file — the PR body is the changelog; paste it into the release
notes. Installed hosts pick the new version up with
`python -m server plugin update github` (or by re-pinning `plugins.lock`).

## 8. Agent-scratch

`.proto/` is the coding agent's own scratch — gitignored, never commit.
49 changes: 39 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,29 @@ config sets `github.write: true`** — so a research/Lead agent stays read-only
coding/PM agent gets write, purely by its own per-instance config. Supersedes the
read-only in-tree `github` plugin.

## Tools
## Tools (all implemented)

**Read** (always): `github_get_pr`, `github_get_issue`, `github_list_issues`,
`github_get_commit_diff`, `github_ci_runs`, `github_run_failure`, `github_read_file`*,
`github_repo_contents`*.
**Read** (always, 12): `github_get_pr`, `github_get_issue`, `github_list_issues`,
`github_get_commit_diff`, `github_pr_diff`, `github_ci_runs`, `github_run_failure`,
`github_read_file`, `github_read_pr_file`, `github_repo_contents`, `github_path_exists`,
and `github_status` (is `gh` installed / signed in, as whom, which default repo — the
self-diagnosis probe the model calls when another tool errors).

**Write** (only when `github.write: true`): `github_create_issue`*, `github_comment`*,
`github_create_pr`*.
**Write** (only when `github.write: true`, 8): `github_create_issue`, `github_comment`,
`github_create_pr`, `github_edit_pr`, `github_merge_pr` (`confirm`-guarded),
`github_close`, `github_set_labels`, `github_set_assignees`.

*\* stubbed — being built out; see [PROTO.md §5](./PROTO.md).*
**Review** (also behind `github.write`, 3): `github_review_comment`,
`github_review_approve`, `github_review_request_changes` — the formal verdict tools,
with the CI-terminal and self-review guards enforced inside the tool (ADR 0078).

Plus the **user-only `/issue` chat command** (file an issue from the composer on any
agent, without the model) and two console views — the read-only Issues/PRs **board**
and the **New issue** form — both of which show a **setup card** when `gh` is missing
or not signed in.

Every tool returns a readable, classified error instead of raw `gh` stderr: not
authenticated (with the fix), repo not found, API rate limit, `gh` not installed.

## Install & enable

Expand All @@ -31,15 +44,31 @@ Install ≠ enable. To turn it on, add to `config/langgraph-config.yaml`:
plugins:
enabled: [github]
github:
write: false # read-only; set true ONLY for agents that should mutate GitHub
write: false # read-only; set true ONLY for agents that should mutate GitHub
default_repo: "" # owner/name — the repo tools and /issue use when none is passed
repos: [] # repo picker list; the host's projects: registry is added automatically
```

Auth: `gh` uses its own ambient auth (`gh auth login`) or `GITHUB_TOKEN`/`GH_TOKEN`.
**Auth**: a non-empty `github.token` secret (Settings ▸ GitHub — a personal access token
with `repo` scope) is injected into every `gh` run and wins. Otherwise the environment is
passed through untouched and `gh`'s own precedence applies: `GH_TOKEN` > `GITHUB_TOKEN` >
the `gh auth login` keyring. Public-repo reads need none at low volume. When auth fails,
the error says which of those was rejected and what to do about it.
`gh` is found on PATH or in the usual install dirs (`/opt/homebrew/bin`, `/usr/local/bin`,
`~/.local/bin`, `/usr/bin`) — a desktop build launched without a shell PATH still works.

**Repos**: a tool's omitted `repo` falls back to `default_repo`, else the first picker
entry. The picker is `github.repos` ∪ the host's managed-projects registry (ADR 0095,
`projects:` entries with a `github:` binding) ∪ — last resort — the `owner/name` parsed
from the `origin` remote of each registered checkout (and `project_board.repo`). All of
it is read live: a Settings edit or an `onboard_project` mid-session is seen by the
next call. A malformed `default_repo` is a named error on the plugin's `GET /config` and
in the views' setup card (not on Settings save), never fed to `gh`.

## Develop

```bash
pip install -r requirements-dev.txt ruff
pip install -r requirements-dev.txt # pins ruff==0.15.10
ruff check . && ruff format --check . && pytest -q
```

Expand Down
Loading
Loading