diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 886686c234..4383b7b49e 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -542,6 +542,10 @@ pub fn bundled_manifests() -> Vec { "../../../src/bundled/changesets/manifest.json" )) .expect("changesets manifest"), + serde_json::from_str(include_str!( + "../../../src/bundled/review-configuration/manifest.json" + )) + .expect("review configuration manifest"), ] } /// New bundles must opt in to the default-on policy. diff --git a/crates/plugin-manager/tests/management.rs b/crates/plugin-manager/tests/management.rs index c570282057..9aaadb9eff 100644 --- a/crates/plugin-manager/tests/management.rs +++ b/crates/plugin-manager/tests/management.rs @@ -1384,6 +1384,7 @@ fn bundled_defaults_preserve_saved_choices_and_only_channels_is_required() { | "buzz.drafts-and-sent" | "buzz.changesets" | "buzz.charts" + | "buzz.review-configuration" ); assert_eq!(plugin.enabled, default, "{id}"); if id == "buzz.channels" { diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 28848960ec..8cff3fde78 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -1278,6 +1278,53 @@ tab, asks **Discard changes?** first; **Keep editing** keeps them. local installs. Upgrades preserve their previous state, including Bestie off when no choice was saved. Enable or disable them in **Manage plugins**; saved choices persist. A deliberate plugin-settings reset uses the current defaults. +- **Review Configuration** is optional and starts off. Enable it in **Manage + plugins** to add separate **Review Configuration** and **Pull requests** dock icons. Register a repository in a private review channel you belong to, + then save named lanes with reviewer agents and + instructions. Each lane supports multiple reviewers. Saved + repositories appear in a list; open one to manage its lanes. **Reload** + reads the latest saved versions. Settings live on the community relay and are + visible to channel members; members who can post can edit them. A conflicting + edit preserves your draft: copy your changes before cancelling and reloading. + **Review Configuration** has matching **Register repository**, **Submit PR**, and **Pull requests** actions at the top, followed by **Repositories**, grouped with their channel configurations. Set a personal default channel per repository; Submit PR uses it and allows an override. Defaults are saved on this device per identity/community. If access to a default is lost, choose another configuration. Only current members can use a private review channel; existing open-channel configurations are excluded and left unchanged. **Edit lane** + opens one lane; **Save lane** persists it before **Add lane** starts another. + **Back to lanes** cancels the editor; **Back to repositories** returns to the list. + Every new or edited lane requires at least one all/any routing rule matching + title text, labels, changed-file counts, or paths such as `src/**/*.tsx`. + Earlier rule-free lanes still match every PR until edited. + Each repository also has **Admission checks**, all off by default: require a + non-draft PR, green CI, no merge conflicts, and/or agent triage. Save selected + checkboxes before leaving. CI requires all reported statuses/checks to pass + (neutral/skipped count as passing); missing/pending results and unknown merge + conflicts block submission with a reason. Agent triage requires an assigned + owned agent and instructions; **Refresh agents** updates choices. Basic checks + run first. Triage gets its own thread and **Awaiting triage** status. Its agent + calls `pr_triage_decision` with `admit` or `reject` and a message; Buzz supplies + the target. Admit rechecks the PR and starts lane routing automatically; + reject shows the reason without closing the GitHub PR. Head/base changes during + triage require deleting and resubmitting. Keep the submitting device connected + with this plugin enabled; continuation works with the page closed and resumes + after reconnect. Submit again on that device to retry a failed continuation. + Decisions are final; delete and resubmit to request fresh triage. Saved admission + settings apply to new submissions. Pending/rejected triage cannot use Remind. + The **Submit PR** page has a full GitHub PR URL field and a + Submit button. It finds the repository in the selected review channel, reads + current PR details, and assigns the first configured reviewer in each matching + lane. Missing configuration or zero matching lanes sends nothing. Results show + assigned agents and their thread buttons. Choose the **Pull requests** dock icon or the matching action on the configuration page to see one compact row per PR with its overall status, lane-status summary, and update marker. Click a row or activate its PR button to open full details; **Back to Pull requests** returns to the list. Back/Forward and reload restore the selected PR after confirming access. The Pull requests page also has a **Submit PR** button. The table shows GitHub status (Open, Draft, Closed, or Merged); Reload refreshes it. Remind is disabled for closed, merged, loading, or unavailable PRs and rechecks GitHub before sending. The PR detail table separates **Lane threads**, **Lane status**, and **Lane last message**, with one row per lane. **Full message** is an underlined link in **Lane last message** and opens a popup; Back/Forward preserves it, while Close dismisses it. The original submitter can click **Remind** on each review lane to notify its reviewer in the existing thread, with an optional message included in the reminder; Admission Triage has no reminder. Only the reminded lane resets to Needs review. Finish an interrupted reminder in that lane before starting another. The submitter can also confirm **Delete** to remove a submission from the shared list; its conversations and GitHub PR remain. Deleting does not stop active reviews. Resubmitting a deleted PR applies current routing and creates new reviewer threads. + Submitting an existing PR reuses its saved reviewers and threads and mentions + them again in those threads. Incomplete initial delivery resumes from the original + browser profile. An interrupted notification retries its saved messages. + Internal **Review status** is separate from GitHub status: Needs review, Reviewing, Changes requested, Looks good · not approved, or Approved. Reviewers must first call `pr_review_status_update` with `reviewing` to acknowledge acceptance, even for acknowledgment-only requests; the status tool refuses outcomes until Reviewing is saved for that request and posts a tagged correction for a skipped step. A reminder requires a new Reviewing acknowledgment. Requests also ask reviewers to add their configured GitHub account to the PR’s requested reviewers when permitted; shared GitHub accounts do not create distinct reviewer identities. After reviewing, they report `request_changes`, `lgtm_not_approved`, or `approved` through `pr_review_status_update`; Buzz supplies the review thread. Each lane has **Allow automatic approval**, unchecked by default. When off, reviewers must not approve on GitHub and the tool refuses `approved`. When on, reviewers may approve after review and report `approved` only after GitHub succeeds. The tool records the reviewer’s report; it does not submit or independently verify GitHub approval. Lane settings apply to new submissions. Older Done reports appear as Completed · outcome unspecified; they do not imply approval. Requests put **Lane instructions** first; reminders highlight **Your reminder message** above them. Compact steps come from the status tool. Remind resets its selected lane to Needs review; resubmitting an existing PR resets all review lanes. Reviews of a PR run sequentially in the same lane threads. Incoming reports update the table; Reload catches missed updates. Reporting requires native Codex thread sessions, a compatible shared harness, or a standalone MCP process bound to that thread by its host. An unsupported host refuses the update; a chat reply alone does not record a result. + Completed outcomes are green, pending review is amber, and changes requested are red; labels still distinguish approval from completion. **Submitted** shows the original submission date (older records say **Not recorded**). **Last reviewed** shows the latest result, including previous reminder rounds; accepting a review does not count. Dates use your local timezone. + The **Pull requests** dock badge counts PRs with unseen status updates in the selected review channel. Opening Pull requests clears the badge for the loaded updates. Affected PRs and lanes keep an **Updated** marker during that visit, including list → PR details, so you can see what changed. Live changes while the page is open are highlighted too. Each lane shows **Last updated** from its latest status report or thread message, including reminders. Seen state persists on this device for your identity, community, and channel; later changes mark the PR again. + The separate Submit PR form has a **Back to Review Configuration** button. All pages remember the selected + review channel. Reviewers must have a verified owner who belongs to the selected channel, and already + belong to the channel. A connected agent runtime is needed for replies; + delivery alone does not mean a review started. Desktop uses signed-in GitHub + access; browser metadata reads support public PRs and may hit rate limits. + PRs above 3,000 changed files or 100 labels are unsupported. This does not post + GitHub comments, track verdicts, pin reviewer checkouts, or schedule reminders. - A plugin's details in Manage plugins link to the Settings sections that hold its settings: sections it adds, such as GitHub comment authors, and Decisions API for a decision provider, such as TypeSafe. Only sections the Settings @@ -1499,4 +1546,3 @@ occurs. It contains operation metadata, with no draft text or secrets. See ## Plugin account actions Plugins can add actions to your avatar menu. Profile-related actions may appear just above Settings; other plugin actions appear below it. - diff --git a/docs/plugin-authoring-guide.md b/docs/plugin-authoring-guide.md index e4c2f2da2c..f608dd061c 100644 --- a/docs/plugin-authoring-guide.md +++ b/docs/plugin-authoring-guide.md @@ -73,6 +73,15 @@ export function apply(ctx) { | `subjectLift` (optional) | Lift a message image's subject | `const lift = ctx.reflect.get("subjectLift")`. `lift?.supports(source)`, then `await lift.lift(source, signal?)` resolves a transparent PNG `File` ready for `session.emoji.upload`, or rejects with a reason. `source` is `session.media(attachment.url)`. macOS 14+ only; see the plugin architecture's Subject lift contract. | | `pluginInstaller` (optional) | Ask the owner to install another plugin, check installation status, or access retained update reviews | `requestInstall(...)` resolves `"installed" \| "updated" \| "cancelled" \| "unavailable"` and never installs silently. Status methods are described below. `updates` is an optional, host-retained capability; see [Plugin architecture](plugin-architecture.md#plugin-installer). | +Plugins can read the current documentation for a built-in or active plugin tool +with `ctx.agents.describeTool(name)`. It returns a detached +`{ name, description, inputSchema }`, or `undefined` when unavailable, and never +returns the handler. Use this to compose agent instructions from the actual tool +definition instead of maintaining a second copy. Read it when composing each +message; already sent messages keep their original text. Feature-check the method +on older hosts. Describing a tool does not grant access to call it or install it +on another agent's runtime. + The installation-status methods may be missing on older hosts even when `pluginInstaller` is available, so feature-check each method before calling it. `getInstallationStatus` returns `"installed"` when the manifest ID is in the ready catalog, `"absent"` when it is not, and `"unavailable"` while the catalog is not ready. Installed status is independent of whether the plugin is enabled, contributes UI, or came from a particular install source. A subscription reports later status changes; subscribe first, then read the current status to avoid missing a change between the read and subscription. Own the subscription with `ctx.effect`: ```js diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index 78423ebbc7..049880837c 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -94,6 +94,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", "buzz.focus", "buzz.charts", "buzz.drafts-and-sent", + "buzz.review-configuration", "buzz.changesets", ].includes(plugin.manifest.id), plugin.manifest.id, @@ -103,12 +104,70 @@ test("the app runtime exposes ready bundled pages and removes them on disable", "buzz.reminders", "buzz.workflows", "buzz.drafts-and-sent", + "buzz.review-configuration", ]) { assert.equal( services.pages.snapshot().some((page) => page.pluginId === id), false, ); } + await services.plugins.change("enable", "buzz.review-configuration"); + await vi.waitFor(() => + assert.ok( + services.pages + .snapshot() + .some( + (page) => page.key === "buzz.review-configuration/configuration", + ), + ), + ); + const reviewConfiguration = services.pages + .snapshot() + .find((page) => page.pluginId === "buzz.review-configuration"); + assert.equal(reviewConfiguration.title, "Review Configuration"); + assert.equal(reviewConfiguration.primary, true); + assert.equal(reviewConfiguration.placement, "dock"); + assert.match( + renderToStaticMarkup(createElement(reviewConfiguration.component)), + /Connect to a community to configure reviews/, + ); + const submitPr = services.pages + .snapshot() + .find((page) => page.key === "buzz.review-configuration/submit"); + assert.ok(submitPr); + assert.equal(submitPr.title, "Submit PR"); + assert.equal(submitPr.primary, false); + assert.equal(submitPr.placement, "sidebar"); + assert.match( + renderToStaticMarkup(createElement(submitPr.component)), + /Connect to a community to submit PRs/, + ); + const allPrs = services.pages + .snapshot() + .find((page) => page.key === "buzz.review-configuration/all"); + assert.ok(allPrs); + assert.equal(allPrs.title, "Pull requests"); + assert.equal(allPrs.primary, true); + assert.equal(allPrs.placement, "dock"); + const detail = { channel: "reviews", submission: "submission" }; + assert.equal(allPrs.route.validate(detail), true); + assert.equal( + allPrs.route.validate({ ...detail, message: "a".repeat(64) }), + true, + ); + assert.equal( + allPrs.route.validate({ ...detail, submission: "../invalid" }), + false, + ); + assert.equal(submitPr.route.validate(detail), false); + assert.equal(reviewConfiguration.route.validate(detail), false); + await services.plugins.change("disable", "buzz.review-configuration"); + assert.equal( + services.pages + .snapshot() + .some((page) => page.pluginId === "buzz.review-configuration"), + false, + ); await services.plugins.change("enable", "buzz.reminders"); await vi.waitFor(() => assert.ok( diff --git a/src/app/shell/presentation.ts b/src/app/shell/presentation.ts index e0505dd48f..9c8561d1b0 100644 --- a/src/app/shell/presentation.ts +++ b/src/app/shell/presentation.ts @@ -6,6 +6,8 @@ import { NoteIcon, GearIcon, LightningIcon, + ListChecksIcon, + GitPullRequestIcon, RobotIcon, SendIcon, } from "../../shared/design-system/icons/index"; @@ -58,6 +60,8 @@ const bundledIcons = new Map([ ["buzz.reminders/reminders", AlarmIcon], ["buzz.agents/agents", RobotIcon], ["buzz.workflows/workflows", LightningIcon], + ["buzz.review-configuration/configuration", ListChecksIcon], + ["buzz.review-configuration/all", GitPullRequestIcon], ["buzz.relay-notes/notes", NoteIcon], ]); /** Resolve host navigation policy once, independently of icons and registrations. */ diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index 3dd23e4284..1e9d7232e1 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -328,6 +328,7 @@ function setup( session: vi.fn(async () => {}), register: vi.fn(), addTool: vi.fn(), + describeTool: vi.fn(), createToolOperation: vi.fn(() => { throw new Error("No operation provider in this fixture"); }), diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index 09a2427367..9d96524b4d 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -4880,7 +4880,7 @@ it("offers the tools plugins add, runs them as the agent here, and starts a new expect(JSON.stringify(reply?.result)).toContain("created"); expect(call).toHaveBeenCalledWith( expect.objectContaining({ pubkey }), - { channel: "channel", root }, + { channel: "channel", root, review: { channel: "channel", root } }, { title: "Ship it" }, ); await f.complete(); @@ -7377,6 +7377,47 @@ it("neither lists nor runs the retired owner-read tools, while its own reads wor await f.complete(); }); +it("supplies an immutable review thread outside tool arguments and omits it for channel-wide sessions", async () => { + const f = fixture(); + const contexts: unknown[] = []; + f.added.push({ + name: "inspect_review_context", + description: "Inspect host context.", + inputSchema: { type: "object", properties: {} }, + call: async (_client, context) => { + contexts.push(context.review); + return "inspected"; + }, + }); + await f.runtime.run(f.delivery("review")); + await vi.waitFor(() => expect(f.starts()).toHaveLength(1)); + await f.tool("inspect_review_context", { + review: { channel: "wrong", root: "wrong" }, + }); + await f.runtime.run(f.delivery("follow-up")); + await vi.waitFor(() => + expect(f.sent.some((wire) => wire.method === "turn/steer")).toBe(true), + ); + await f.tool("inspect_review_context", {}); + expect(contexts).toEqual([ + { channel: "channel", root }, + { channel: "channel", root }, + ]); + await f.complete(); + await vi.waitFor(() => + expect(f.runtime.sessions(pubkey)[0]?.status).toBe("Idle"), + ); + const next = f.delivery("channel scope", "d".repeat(64)); + await f.runtime.run({ + ...next, + config: { ...(next.config as object), scope: "channel" }, + }); + await vi.waitFor(() => expect(f.starts()).toHaveLength(2)); + await f.tool("inspect_review_context", {}, 1); + expect(contexts[2]).toBeUndefined(); + await f.complete(1); +}); + it("supplies an operation proof only while the native Codex call is active", async () => { const f = fixture(); const provider = createToolOperation("example/proposal", () => ({ diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index e8d8a88930..3fb9c249ab 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -153,6 +153,7 @@ type Saved = { protection?: string; }; type Active = { + readonly review?: Context["review"]; /** The server this turn runs on; steering must reach the same process. */ rpc: AppServer; threadId?: string; @@ -2324,6 +2325,7 @@ export class CodexRuntime { // A watch or timer turn has no default destination. const context: Context = request ? { + ...(active?.review ? { review: active.review } : {}), ...(request.session ? { session: request.session } : {}), channel: request.conversation.channelId, ...(request.toolRoot ? { root: request.toolRoot } : {}), @@ -2774,6 +2776,14 @@ export class CodexRuntime { ...(adopted && lane.voice ? [lane.voice.signal] : []), ]); const active: Active = { + ...(request.conversation.root && !request.wake + ? { + review: { + channel: request.conversation.channelId, + root: request.conversation.root, + }, + } + : {}), rpc: server.rpc, ...(adopted && lane.voice ? { threadId: lane.voice.loaded.threadId, turnId: adopted } diff --git a/src/bundled/index.ts b/src/bundled/index.ts index 57122af5c8..4486ab0b63 100644 --- a/src/bundled/index.ts +++ b/src/bundled/index.ts @@ -62,6 +62,8 @@ import changesetsManifest from "./changesets/manifest.json"; import * as changesets from "./changesets"; import workflowsManifest from "./workflows/manifest.json"; import * as workflows from "./workflows"; +import reviewConfigurationManifest from "./review-configuration/manifest.json"; +import * as reviewConfiguration from "./review-configuration"; import type { BundledPlugin } from "../plugins/manager"; import linksManifest from "./links/manifest.json"; import * as links from "./links"; @@ -258,6 +260,11 @@ export const bundledPlugins: readonly BundledPlugin[] = [ module: workflows, enabledByDefault: false, }, + { + manifest: { ...reviewConfigurationManifest, apiVersion: 1 }, + module: reviewConfiguration, + enabledByDefault: false, + }, { manifest: { ...moderationManifest, apiVersion: 1 }, module: moderation, diff --git a/src/bundled/navigation-owner.test.ts b/src/bundled/navigation-owner.test.ts index 964da081b0..5674115bff 100644 --- a/src/bundled/navigation-owner.test.ts +++ b/src/bundled/navigation-owner.test.ts @@ -48,6 +48,9 @@ it("gives every bundled page to the app whose navigation lists it", async () => "buzz.drafts-and-sent/drafts-sent": messages, "buzz.inbox/inbox": messages, // Dock apps own themselves. + "buzz.review-configuration/configuration": + "buzz.review-configuration/configuration", + "buzz.review-configuration/all": "buzz.review-configuration/all", "buzz.bestie/bestie": "buzz.bestie/bestie", "buzz.reminders/reminders": "buzz.reminders/reminders", "buzz.agents/agents": "buzz.agents/agents", diff --git a/src/bundled/review-configuration/AdmissionEditor.tsx b/src/bundled/review-configuration/AdmissionEditor.tsx new file mode 100644 index 0000000000..eca148f164 --- /dev/null +++ b/src/bundled/review-configuration/AdmissionEditor.tsx @@ -0,0 +1,192 @@ +import { useEffect, useRef, useState } from "react"; +import { useReviewAgents } from "./use-review-agents"; +import type { RelaySession } from "../../features/relay/session"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Checkbox } from "../../shared/design-system/ui/Checkbox"; +import { Field } from "../../shared/design-system/ui/Field"; +import { InlineHeader } from "../../shared/design-system/ui/Header"; +import { Select } from "../../shared/design-system/ui/Select"; +import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup"; +import { Textarea } from "../../shared/design-system/ui/Textarea"; +import { + DEFAULT_ADMISSION, + saveConfiguration, + type SavedReviewConfig, +} from "./configuration"; + +export function AdmissionEditor({ + session, + channel, + previous, + writable, + onSaved, + onEditingChange, +}: { + session: RelaySession; + channel: string; + previous: SavedReviewConfig; + writable: boolean; + onSaved: (value: SavedReviewConfig) => void; + onEditingChange: (editing: boolean) => void; +}) { + const choices = useReviewAgents(session, channel); + const [draft, setDraft] = useState( + previous.config.admission ?? DEFAULT_ADMISSION, + ); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const operation = useRef(undefined); + const dirty = + JSON.stringify(draft) !== + JSON.stringify(previous.config.admission ?? DEFAULT_ADMISSION); + useEffect(() => { + setDraft(previous.config.admission ?? DEFAULT_ADMISSION); + setError(""); + }, [previous]); + useEffect(() => { + onEditingChange(dirty || busy); + return () => onEditingChange(false); + }, [dirty, busy, onEditingChange]); + useEffect(() => () => operation.current?.abort(), []); + async function save() { + if (!writable || operation.current) return; + const controller = new AbortController(); + operation.current = controller; + setBusy(true); + setError(""); + try { + const next = await saveConfiguration( + session, + channel, + { ...previous.config, admission: draft }, + previous, + AbortSignal.any([controller.signal, AbortSignal.timeout(20_000)]), + ); + if (!controller.signal.aborted) onSaved(next); + } catch (reason) { + if (!controller.signal.aborted) + setError( + `${reason instanceof Error ? reason.message : "Could not save admission checks."} Your draft is preserved. If settings changed elsewhere, copy it before reloading.`, + ); + } finally { + operation.current = undefined; + if (!controller.signal.aborted) setBusy(false); + } + } + return ( +
+ + + {( + [ + ["ready", "Require ready for review (non-draft)"], + ["ci", "Require green CI"], + ["conflicts", "Require no merge conflicts"], + ["triage", "Enable agent triage"], + ] as const + ).map(([key, label]) => ( + + setDraft({ ...draft, [key]: checked }) + } + /> + ))} + {draft.ci && ( +

+ All reported checks must be successful, neutral, or skipped. Pending + or missing CI blocks submission. +

+ )} + {draft.triage && ( + <> +