typeclaw 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +2 -1
  2. package/src/agent/index.ts +55 -1
  3. package/src/agent/loop-guard.ts +180 -53
  4. package/src/bundled-plugins/bun-hygiene/README.md +82 -0
  5. package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
  6. package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
  7. package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
  8. package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
  9. package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
  10. package/src/bundled-plugins/memory/memory-logger.ts +6 -2
  11. package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
  12. package/src/channels/adapters/discord-bot.ts +2 -0
  13. package/src/channels/adapters/github/inbound.ts +23 -1
  14. package/src/channels/adapters/github/index.ts +1 -0
  15. package/src/channels/adapters/slack-bot.ts +104 -5
  16. package/src/channels/manager.ts +8 -0
  17. package/src/channels/router.ts +68 -15
  18. package/src/channels/schema.ts +18 -0
  19. package/src/cli/dreams.ts +2 -1
  20. package/src/cli/inspect.ts +2 -1
  21. package/src/cli/ui.ts +34 -0
  22. package/src/commands/index.ts +5 -2
  23. package/src/config/config.ts +89 -0
  24. package/src/mcp/catalog.ts +29 -0
  25. package/src/mcp/client.ts +236 -0
  26. package/src/mcp/index.ts +25 -0
  27. package/src/mcp/manager.ts +156 -0
  28. package/src/mcp/tools.ts +190 -0
  29. package/src/permissions/builtins.ts +9 -0
  30. package/src/reload/format.ts +14 -0
  31. package/src/reload/index.ts +1 -0
  32. package/src/run/bundled-plugins.ts +7 -0
  33. package/src/run/channel-session-factory.ts +3 -0
  34. package/src/run/index.ts +38 -1
  35. package/src/server/command-runner.ts +5 -0
  36. package/src/server/index.ts +4 -0
  37. package/src/skills/typeclaw-channel-github/SKILL.md +83 -13
  38. package/typeclaw.schema.json +82 -0
@@ -1,5 +1,6 @@
1
1
  import agentBrowserPlugin from '@/bundled-plugins/agent-browser'
2
2
  import backupPlugin from '@/bundled-plugins/backup'
3
+ import bunHygienePlugin from '@/bundled-plugins/bun-hygiene'
3
4
  import explorerPlugin from '@/bundled-plugins/explorer'
4
5
  import githubCliAuthPlugin from '@/bundled-plugins/github-cli-auth'
5
6
  import guardPlugin from '@/bundled-plugins/guard'
@@ -29,6 +30,11 @@ import type { ResolvedPlugin } from '@/plugin'
29
30
  // Reversing this order would make guard advise on the full oversized payload
30
31
  // and then tool-result-cap would clobber the advice text along with the rest.
31
32
  //
33
+ // `bun-hygiene` is registered after `guard` and guards a disjoint surface
34
+ // (package-manager bash commands: global installs and non-bun managers), so its
35
+ // position relative to security/guard only matters for precedence — keeping it
36
+ // after the two general guards means a security/guard block always wins first.
37
+ //
32
38
  // `github-cli-auth` is registered AFTER `security` so security's `tool.before`
33
39
  // runs its exfil/secret scanners on the bash command first. github-cli-auth
34
40
  // injects the minted token via an env overlay (TYPECLAW_INTERNAL_BASH_ENV), not
@@ -45,6 +51,7 @@ export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
45
51
  { name: 'security', version: undefined, source: '<bundled>', defined: securityPlugin },
46
52
  { name: 'tool-result-cap', version: undefined, source: '<bundled>', defined: toolResultCapPlugin },
47
53
  { name: 'guard', version: undefined, source: '<bundled>', defined: guardPlugin },
54
+ { name: 'bun-hygiene', version: undefined, source: '<bundled>', defined: bunHygienePlugin },
48
55
  { name: 'github-cli-auth', version: undefined, source: '<bundled>', defined: githubCliAuthPlugin },
49
56
  { name: 'memory', version: undefined, source: '<bundled>', defined: memoryPlugin },
50
57
  { name: 'backup', version: undefined, source: '<bundled>', defined: backupPlugin },
@@ -7,6 +7,7 @@ import type { CreateSessionForSubagent, SubagentRegistry } from '@/agent/subagen
7
7
  import { capJsonlFileInPlace } from '@/bundled-plugins/tool-result-cap/cap-jsonl'
8
8
  import type { CapOptions } from '@/bundled-plugins/tool-result-cap/cap-result'
9
9
  import type { CreateSessionForChannel, ChannelRouter } from '@/channels'
10
+ import type { McpManager } from '@/mcp'
10
11
  import type { PermissionService, RolesConfig } from '@/permissions'
11
12
  import type { ReloadRegistry } from '@/reload'
12
13
  import type { SessionFactory } from '@/sessions'
@@ -35,6 +36,7 @@ export type BuildChannelSessionFactoryDeps = {
35
36
  // cycle while still ensuring the factory's sessions get the same router
36
37
  // their inbound messages came from.
37
38
  getChannelRouter: () => ChannelRouter
39
+ mcpManager?: McpManager
38
40
  containerName?: string
39
41
  runtimeVersion?: string
40
42
  // When set, rehydrating a session JSONL caps oversized tool results in the
@@ -113,6 +115,7 @@ export function buildChannelSessionFactory(deps: BuildChannelSessionFactoryDeps)
113
115
  sessionManager,
114
116
  stream: deps.stream,
115
117
  channelRouter: deps.getChannelRouter(),
118
+ ...(deps.mcpManager !== undefined ? { mcpManager: deps.mcpManager } : {}),
116
119
  origin,
117
120
  originRef,
118
121
  ...(snap.hasAnyPluginContent
package/src/run/index.ts CHANGED
@@ -3,6 +3,7 @@ import { SessionManager } from '@mariozechner/pi-coding-agent'
3
3
  import { createSession, createSessionWithDispose } from '@/agent'
4
4
  import { LiveSessionRegistry } from '@/agent/live-sessions'
5
5
  import { LiveSubagentRegistry } from '@/agent/live-subagents'
6
+ import { requestContainerRestart } from '@/agent/restart'
6
7
  import type { SessionOrigin } from '@/agent/session-origin'
7
8
  import {
8
9
  awaitWithSubagentTimeout,
@@ -38,11 +39,12 @@ import {
38
39
  type Scheduler,
39
40
  } from '@/cron'
40
41
  import { CLI_VERSION } from '@/init/cli-version'
42
+ import { createMcpManager } from '@/mcp'
41
43
  import { loadPlugins, type LoadPluginsResult, pluginCronJobs, type PluginRegistry, summarizeLoaded } from '@/plugin'
42
44
  import { createPluginLogger } from '@/plugin/context'
43
45
  import type { CronHandlerContext } from '@/plugin/types'
44
46
  import { createContainerBroker, publishForwardResult } from '@/portbroker'
45
- import { ReloadRegistry } from '@/reload'
47
+ import { formatChannelReloadSummary, ReloadRegistry } from '@/reload'
46
48
  import { createClaimController } from '@/role-claim'
47
49
  import {
48
50
  exportClaudeCredentialsFileForAgent,
@@ -140,6 +142,15 @@ export async function startAgent({
140
142
  const pluginConfigsByName = loadPluginConfigsSync(cwd)
141
143
  const cwdConfig = loadConfigSync(cwd)
142
144
  const githubTokenBridge = createGithubTokenBridge()
145
+ const mcpManager =
146
+ cwdConfig.mcpServers.length > 0 ? createMcpManager(cwdConfig.mcpServers, { env: process.env }) : null
147
+ if (mcpManager !== null) {
148
+ const results = await mcpManager.connectAll()
149
+ for (const result of results) {
150
+ if (!result.ok) console.warn(`[mcp] ${result.name} failed to connect: ${result.error.message}`)
151
+ }
152
+ }
153
+ const mcpManagerOpt = mcpManager !== null ? { mcpManager } : {}
143
154
  const pluginsLoaded = await loadPlugins({
144
155
  entries: cwdConfig.plugins,
145
156
  agentDir: cwd,
@@ -255,11 +266,32 @@ export async function startAgent({
255
266
  getCreateSessionForSubagent: () => createSessionForSubagent,
256
267
  ...containerNameOpt,
257
268
  ...runtimeVersionOpt,
269
+ ...mcpManagerOpt,
258
270
  }),
259
271
  permissions: pluginsLoaded.permissions,
260
272
  claimHandler: claimController.claimHandler,
261
273
  githubTokenBridge,
262
274
  stream,
275
+ onReload: async () => {
276
+ const { results } = await reloadRegistry.reloadAll()
277
+ return formatChannelReloadSummary(results)
278
+ },
279
+ // Always registered so /restart's presence in /help, the Slack manifest,
280
+ // and the Discord declarations is environment-independent. When there is no
281
+ // container to bounce (TYPECLAW_CONTAINER_NAME unset — tests, ad-hoc
282
+ // `typeclaw run` outside Docker), the handler reports that instead of the
283
+ // command resolving as unknown, which would make the advertised contract
284
+ // depend on the runtime environment.
285
+ onRestart: async (): Promise<string> => {
286
+ if (containerName === undefined) {
287
+ return 'Restart is unavailable: this agent is not running inside a typeclaw container.'
288
+ }
289
+ // No originatingSessionId/stream/handoff: a channel-invoked restart must
290
+ // not write a resume hint or fire the "I'm back" broadcast that a TUI
291
+ // restart does (issue #291 scoping — only TUI origins resume).
292
+ const result = await requestContainerRestart({ containerName })
293
+ return result.ok ? 'Restart scheduled; the container will bounce shortly.' : `Restart denied: ${result.reason}`
294
+ },
263
295
  })
264
296
 
265
297
  const createSessionForSubagent: import('@/agent/subagents').CreateSessionForSubagent = async (
@@ -376,6 +408,7 @@ export async function startAgent({
376
408
  containerName: containerNameOpt.containerName,
377
409
  sessionFactory,
378
410
  channelRouter: channelManager.router,
411
+ ...mcpManagerOpt,
379
412
  }),
380
413
  subagent: (subName: string, payload?: unknown) =>
381
414
  dispatchSpawnSubagent(subName, payload, {
@@ -424,6 +457,7 @@ export async function startAgent({
424
457
  createSessionForSubagent,
425
458
  ...containerNameOpt,
426
459
  ...runtimeVersionOpt,
460
+ ...mcpManagerOpt,
427
461
  })
428
462
  liveSessionRegistry.register({ sessionId, session })
429
463
  return {
@@ -591,6 +625,7 @@ export async function startAgent({
591
625
  outbound,
592
626
  sessionFactory,
593
627
  channelRouter: channelManager.router,
628
+ ...mcpManagerOpt,
594
629
  })
595
630
 
596
631
  const server = createServer({
@@ -600,6 +635,7 @@ export async function startAgent({
600
635
  sessionFactory,
601
636
  stream,
602
637
  channelRouter: channelManager.router,
638
+ ...mcpManagerOpt,
603
639
  agentDir: cwd,
604
640
  pluginRuntime,
605
641
  claimController,
@@ -634,6 +670,7 @@ export async function startAgent({
634
670
  subagentCompletionBridge.stop()
635
671
  await tunnelManager.stop()
636
672
  await channelManager.stop()
673
+ await mcpManager?.closeAll()
637
674
  uninstallCodexFetchObserver()
638
675
  }
639
676
 
@@ -6,6 +6,7 @@ import {
6
6
  type SessionOrigin,
7
7
  } from '@/agent'
8
8
  import type { ChannelRouter } from '@/channels/router'
9
+ import type { McpManager } from '@/mcp'
9
10
  import type { PermissionService } from '@/permissions'
10
11
  import type {
11
12
  CommandExecResult,
@@ -53,6 +54,7 @@ export type CommandRunnerOptions = {
53
54
  // `channelManager.router` via `createSessionForCron`; this is the matching
54
55
  // wire for the handler/command path.
55
56
  channelRouter: ChannelRouter | undefined
57
+ mcpManager?: McpManager
56
58
  }
57
59
 
58
60
  type CommandHandle = {
@@ -192,6 +194,7 @@ export function createCommandRunner(opts: CommandRunnerOptions): CommandRunner {
192
194
  signal: abortController.signal,
193
195
  sessionFactory: opts.sessionFactory,
194
196
  channelRouter: opts.channelRouter,
197
+ ...(opts.mcpManager !== undefined ? { mcpManager: opts.mcpManager } : {}),
195
198
  }),
196
199
  subagent: (subName, payload) =>
197
200
  opts.spawnSubagent(subName, payload, {
@@ -376,6 +379,7 @@ export async function runPromptForCommand(args: {
376
379
  // See CommandRunnerOptions.channelRouter. Threaded to createSessionWithDispose
377
380
  // so the spawned session exposes `channel_send`.
378
381
  channelRouter?: ChannelRouter
382
+ mcpManager?: McpManager
379
383
  // Test seam for the agent-session boundary. Production passes the real
380
384
  // `createSessionWithDispose`; tests inject a fake to verify wiring
381
385
  // (specifically: the sessionManager handed off must be persisted, not
@@ -402,6 +406,7 @@ export async function runPromptForCommand(args: {
402
406
  agentDir: args.agentDir,
403
407
  },
404
408
  ...(args.channelRouter !== undefined ? { channelRouter: args.channelRouter } : {}),
409
+ ...(args.mcpManager !== undefined ? { mcpManager: args.mcpManager } : {}),
405
410
  ...(args.runtimeVersion !== undefined ? { runtimeVersion: args.runtimeVersion } : {}),
406
411
  ...(args.containerName !== undefined ? { containerName: args.containerName } : {}),
407
412
  })
@@ -19,6 +19,7 @@ import { parseSubagentCompletedPayload, renderSubagentCompletionReminder } from
19
19
  import type { CreateSessionForSubagent } from '@/agent/subagents'
20
20
  import type { ChannelRouter } from '@/channels/router'
21
21
  import { aggregateCronList, type CronListEntry, loadCron } from '@/cron'
22
+ import type { McpManager } from '@/mcp'
22
23
  import type { HookBus } from '@/plugin'
23
24
  import type { BrokerWsData, ContainerBroker } from '@/portbroker'
24
25
  import type { ReloadAllResult, ReloadRegistry } from '@/reload'
@@ -61,6 +62,7 @@ export type ServerOptions = {
61
62
  sessionFactory?: SessionFactory
62
63
  stream?: Stream
63
64
  channelRouter?: ChannelRouter
65
+ mcpManager?: McpManager
64
66
  agentDir?: string
65
67
  pluginRuntime?: PluginRuntime
66
68
  containerName?: string
@@ -221,6 +223,7 @@ export function createServer({
221
223
  sessionFactory,
222
224
  stream,
223
225
  channelRouter,
226
+ mcpManager,
224
227
  agentDir,
225
228
  pluginRuntime,
226
229
  containerName,
@@ -471,6 +474,7 @@ export function createServer({
471
474
  origin,
472
475
  ...(stream ? { stream } : {}),
473
476
  ...(channelRouter ? { channelRouter } : {}),
477
+ ...(mcpManager ? { mcpManager } : {}),
474
478
  ...(pluginsWiring ? { plugins: pluginsWiring } : {}),
475
479
  ...(containerName !== undefined ? { containerName } : {}),
476
480
  ...(runtimeVersion !== undefined ? { runtimeVersion } : {}),
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: typeclaw-channel-github
3
- description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `github`, AND before composing replies to GitHub-originated inbounds, AND before opening new issues or PRs with `gh`, AND ALWAYS when you are asked to review a PR — whether the inbound says "requested your review on PR #N" / "requested a review from team @… on PR #N", or a human asks for a review in plain language in an issue/PR body or comment ("@bot review this", "can you take a look at #123"). On a review request you delegate the analysis to the `reviewer` subagent, which produces line-anchored findings, then you post them as an inline review via `gh api`. GitHub renders **real markdown** — `**bold**`, `## headings`, `| tables |`, fenced code blocks, and `inline code` all render natively. Use rich markdown freely. GitHub cannot send file attachments via API — do not call `channel_send` with attachments on github chats. GitHub has no typing indicator. PR review threads use `thread` keyed on the root comment id; reply to a thread to stay in it, or omit `thread` to post a top-level issue/PR comment. To open new issues or PRs use the `gh` CLI — `GH_TOKEN` is pre-set by the adapter. Read this skill before composing anything on GitHub.
3
+ description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `github`, AND before composing replies to GitHub-originated inbounds, AND before opening new issues or PRs with `gh`, AND ALWAYS when you are asked to review a PR — whether the inbound says "requested your review on PR #N" / "requested a review from team @… on PR #N", or a human asks for a review in plain language in an issue/PR body or comment ("@bot review this", "can you take a look at #123"). On a review request you delegate the analysis to the `reviewer` subagent, which produces line-anchored findings, then you post them as an inline review via `gh api`. GitHub renders **real markdown** — `**bold**`, `## headings`, `| tables |`, fenced code blocks, and `inline code` all render natively. Use rich markdown freely. GitHub cannot send file attachments via API — do not call `channel_send` with attachments on github chats. GitHub has no typing indicator. PR review threads use `thread` keyed on the root comment id; reply to a thread to stay in it, or omit `thread` to post a top-level issue/PR comment. When a review comment **you authored** gets addressed — the author pushed a fix or replied that resolves it — verify the fix at the PR's head SHA and then resolve the thread with the `resolveReviewThread` GraphQL mutation (see "Resolving review threads you authored" below); resolving is the close-out that tells the author the concern is settled. To open new issues or PRs use the `gh` CLI — `GH_TOKEN` is pre-set by the adapter. Read this skill before composing anything on GitHub.
4
4
  ---
5
5
 
6
6
  GitHub renders normal Markdown in issues, PRs, discussions, and review comments. Use headings, lists, tables, fenced code blocks, links, and inline code when they improve clarity.
@@ -8,6 +8,7 @@ GitHub renders normal Markdown in issues, PRs, discussions, and review comments.
8
8
  - Do not send attachments on GitHub chats; the adapter rejects them.
9
9
  - There is no typing indicator.
10
10
  - For PR review threads, keep `thread` set to reply in-place. Omit `thread` for a top-level PR/issue comment.
11
+ - When a review comment **you authored** has been addressed, resolve its thread — see "Resolving review threads you authored" below. The base principle is **whoever opened the thread closes it**: you resolve only the threads you started, never a human's.
11
12
 
12
13
  ## Mid-turn status replies need `continue: true`
13
14
 
@@ -17,15 +18,15 @@ A successful `channel_reply` ends your turn by default — the runtime stops the
17
18
 
18
19
  Every GitHub inbound lands on a `chat` keyed by its subject: `issue:N`, `pr:N`, or `discussion:N`. Pick your action from the kind of thing that arrived. The default action for anything addressed to you is a normal `channel_reply` in that thread; the **PR review flow** below is the one exception that requires delegation.
19
20
 
20
- | Inbound | Looks like | What to do |
21
- | -------------------------------------------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
22
- | **New issue** (`issue:N`) | A freshly opened issue body. | Triage or answer it. `channel_reply` on `issue:N`. Open follow-up issues/PRs with `gh` if needed. |
23
- | **Issue comment** (`issue:N`) | A comment on an issue. | Reply in the issue thread with `channel_reply`. |
24
- | **PR conversation comment** (`pr:N`, no `thread`) | A comment on a PR's main conversation (GitHub models PR comments as issue comments). | Reply on the PR with `channel_reply`. **If the text asks you to review → go to the PR review flow.** |
25
- | **PR review-thread reply** (`pr:N`, `thread` set) | A reply on an existing inline review comment thread. | Stay in the thread: `channel_reply` with `thread` kept as-is. |
26
- | **A submitted review** (`pr:N`) | Someone submitted a formal review (approve / changes / comment) on a PR. | React if a response is warranted (answer a question, acknowledge changes). `channel_reply` on `pr:N`. |
27
- | **New discussion / discussion comment** (`discussion:N`) | A discussion thread or a comment in one. | Reply with `channel_reply` on `discussion:N`. |
28
- | **Review requested** (`pr:N`) | See "When you are being asked to review" below. | **PR review flow.** |
21
+ | Inbound | Looks like | What to do |
22
+ | -------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
23
+ | **New issue** (`issue:N`) | A freshly opened issue body. | Triage or answer it. `channel_reply` on `issue:N`. Open follow-up issues/PRs with `gh` if needed. |
24
+ | **Issue comment** (`issue:N`) | A comment on an issue. | Reply in the issue thread with `channel_reply`. |
25
+ | **PR conversation comment** (`pr:N`, no `thread`) | A comment on a PR's main conversation (GitHub models PR comments as issue comments). | Reply on the PR with `channel_reply`. **If the text asks you to review → go to the PR review flow.** |
26
+ | **PR review-thread reply** (`pr:N`, `thread` set) | A reply on an existing inline review comment thread. | Stay in the thread: `channel_reply` with `thread` kept as-is. **If it addresses a comment you authored → verify and resolve the thread (below).** |
27
+ | **A submitted review** (`pr:N`) | Someone submitted a formal review (approve / changes / comment) on a PR. | React if a response is warranted (answer a question, acknowledge changes). `channel_reply` on `pr:N`. |
28
+ | **New discussion / discussion comment** (`discussion:N`) | A discussion thread or a comment in one. | Reply with `channel_reply` on `discussion:N`. |
29
+ | **Review requested** (`pr:N`) | See "When you are being asked to review" below. | **PR review flow.** |
29
30
 
30
31
  ### When you are being asked to review
31
32
 
@@ -42,14 +43,28 @@ A `review_request_removed` inbound ("removed your review request on PR #N") is t
42
43
 
43
44
  The `reviewer` subagent is the analyst; you are the integration layer between its output and GitHub's review API. It loads the `code-review` skill on demand and returns line-anchored findings inside a `<review>` block. Your job is mechanics: spawn, wait, translate, post.
44
45
 
45
- 1. **Confirm the target.** Capture the PR number, the repo, and the head SHA — you may need the SHA to read files at the revision the reviewer analyzed.
46
+ 1. **Confirm the target, and check whether you already reviewed it.** Capture the PR number, the repo, and the head SHA — you may need the SHA to read files at the revision the reviewer analyzed.
46
47
 
47
48
  ```sh
48
49
  gh pr view <N> --repo owner/repo --json title,body,baseRefName,headRefOid,files
49
50
  ```
50
51
 
52
+ Then check for a **prior review by you** — this is what makes the current request a _re-review_ (the author pushed fixes and re-requested you after you previously blocked the PR):
53
+
54
+ ```sh
55
+ gh api --paginate --slurp /repos/owner/repo/pulls/<N>/reviews --jq 'add | [.[] | select(.user.login == "<your-login>" and (.state == "CHANGES_REQUESTED" or .state == "APPROVED"))] | last | .state'
56
+ ```
57
+
58
+ If that prints `CHANGES_REQUESTED`, treat the current request as a **re-review** and carry that fact into the spawn in step 2; any other output (including empty) means no live block, so handle the request normally. (`<your-login>` is your GitHub App login, typically `name[bot]`.)
59
+
60
+ Two things make this query load-bearing — both are bugs if you simplify it:
61
+ - **Filter to _decision_ states, not the latest review row.** GitHub's sticky block is cleared only by a later `APPROVED` (or a dismissal) from the same reviewer — a later `COMMENTED` review does **not** clear it. So a history of `CHANGES_REQUESTED` → `COMMENTED` is _still blocked_, even though the latest row is `COMMENTED`. Selecting `last` over the raw review list would misread that as "not a re-review". Filtering to `{CHANGES_REQUESTED, APPROVED}` first, then taking `last`, asks the right question: "what is my latest _blocking decision_, ignoring non-deciding comments?" (Dismissed reviews surface as `state: "DISMISSED"`, so they're correctly excluded from the decision set too.)
62
+ - **`--paginate --slurp` is mandatory.** GitHub returns reviews 30 per page; a bot on a long-lived PR can have its blocking `CHANGES_REQUESTED` past the first page. Without paginating, that review is invisible and a genuine re-review silently falls back to the plain-comment path. `--slurp` collects every page into one array of arrays; the `add` concatenates them before filtering.
63
+
51
64
  2. **Spawn the `reviewer` subagent with the PR target.** Use `run_in_background: true` so you stay responsive while the deep model works. Pass the PR URL (or `owner/repo#N`) plus any context the requester gave you (focus areas, specific files, etc.). The reviewer fetches the diff itself (`gh pr diff`, `gh api /repos/.../pulls/<n>`), loads the `code-review` skill, and returns a `<review>` block whose code findings carry `location="path:line"`.
52
65
 
66
+ **If step 1 found a prior `CHANGES_REQUESTED` review, say so in the spawn payload** — e.g. _"This is a re-review: you previously requested changes on this PR (the prior blockers were …). Verify they are resolved and return `approve` or `request-changes` — a re-review must re-decide the blocking state, not return `comment`."_ The reviewer's `code-review` skill enforces the same rule, but telling it the prior verdict is what lets it apply that rule; a fresh reviewer session has no memory of your earlier review.
67
+
53
68
  Do **not** post an "on it" acknowledgement comment before spawning the reviewer — the runtime already adds an :eyes: reaction to the PR the moment it engages, so a "looking into this" comment is redundant noise. Just spawn the reviewer with `run_in_background: true` and keep working; the formal review is your reply. If you want to acknowledge explicitly, use `channel_react({ emoji: "eyes" })`, which reacts without posting a comment.
54
69
 
55
70
  3. **Wait for the completion `<system-reminder>`,** then call `subagent_output({ task_id })` to read the reviewer's final assistant message. The structured payload looks like:
@@ -80,6 +95,19 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
80
95
  | `request-changes` | `REQUEST_CHANGES` |
81
96
  | `comment` | `COMMENT` |
82
97
 
98
+ **Operator approval policy.** If the inbound carries a note that PR approval is disabled (`channels.github.review.approve: false` — the adapter appends "Operator policy: PR approval is disabled for this agent" to the message), you must **not** submit an `APPROVE`. Map an `approve` verdict to `COMMENT` instead: post the same `<summary>` and all inline `comments[]` as a `COMMENT` review, just without the formal approval. `request-changes` and `comment` verdicts are unaffected (they never approve). Absent that note, approval is enabled and the table above applies unchanged.
99
+
100
+ **Re-review.** If step 1 established this is a re-review (your latest blocking decision was `CHANGES_REQUESTED`), the result MUST clear or re-assert that block — never a top-level PR comment. On GitHub, `CHANGES_REQUESTED` is sticky: **only** a fresh `APPROVE` from you, or a dismissal of your prior review, clears it. A plain issue comment does **not** clear it, and — critically — **neither does a `COMMENT` review.** So even if the reviewer returns zero actionable findings, do **not** take the `comment` → top-level-comment branch below for a re-review. The reviewer's skill is instructed not to return `comment` on a re-review; if it does anyway despite a reachable diff, prefer `approve` when the prior blockers are visibly resolved in the diff, otherwise `request-changes` — and say which in your reasoning. Resolve the re-review by verdict:
101
+ - **`request-changes`** — submit a fresh `REQUEST_CHANGES` review (re-asserts the block with the new findings). Straightforward.
102
+ - **`approve`, approval enabled** — submit `APPROVE`. This clears the block.
103
+ - **`approve`, approval disabled (`channels.github.review.approve: false`)** — you cannot `APPROVE`, and a `COMMENT` review will **not** clear the sticky block, so the PR would stay blocked by your stale review. Clear it explicitly by **dismissing your own prior `CHANGES_REQUESTED` review**. Grab that review's `id` by re-running the step-1 query with the trailing filter changed from `| .state` to `| {state, id}` (same `select`), take the entry whose `state` is `CHANGES_REQUESTED`, then:
104
+
105
+ ```sh
106
+ gh api -X PUT /repos/owner/repo/pulls/<N>/reviews/<review_id>/dismissals -f message="Blockers resolved; dismissing my prior changes request per operator approval-disabled policy." -f event=DISMISS
107
+ ```
108
+
109
+ This transitions your review to `DISMISSED` and unblocks the PR without an approval. It needs the bot's installation to have **write** access (or to be on the branch's "who can dismiss reviews" list); if the dismissal returns 403, the block cannot be cleared under this policy — post the `<summary>` as a `COMMENT` review and say plainly in the body that the prior changes-request stands until a human dismisses it, rather than implying the PR is unblocked.
110
+
83
111
  Then submit the review. **Write the JSON payload to a file with the `write` tool, then run a single bare `gh api --input <file>`** — two steps:
84
112
 
85
113
  First write `/tmp/review.json` (via the `write` tool, not bash):
@@ -126,12 +154,54 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
126
154
 
127
155
  A finding is "actionable" if its severity is `blocker`, `concern`, or `nit`. The inline-review post in step 4 applies whenever the actionable count is **at least one**. When the reviewer returns **exactly zero** actionable findings (only `praise`, or none), there is nothing to anchor inline — handle by verdict:
128
156
 
129
- - `approve` → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array).
130
- - `comment` → post the summary as a top-level PR comment via `gh api -X POST /repos/.../issues/<N>/comments` instead of submitting an empty review.
157
+ - `approve` → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array). **If the operator approval policy above disabled approval, submit a `COMMENT` review instead — same `<summary>` as the review body, `event: "COMMENT"`, no `comments[]` array. Keep it a formal review, not a top-level issue comment, so the review metadata and flow are preserved.** (Re-review caveat: a `COMMENT` review does **not** clear a sticky `CHANGES_REQUESTED` block. If this is a re-review under approval-disabled policy, follow the step-4 re-review branch — dismiss your prior review — instead of relying on this `COMMENT`.)
158
+ - `comment` → post the summary as a top-level PR comment via `gh api -X POST /repos/.../issues/<N>/comments` instead of submitting an empty review. **Exception — re-reviews:** if this is a re-review (your latest blocking decision was `CHANGES_REQUESTED`), a top-level comment does not clear the sticky block. Do not use this branch; resolve it via the step-4 re-review branch (`APPROVE` if resolved and approval is enabled, the dismissal endpoint if resolved but approval is disabled, `REQUEST_CHANGES` if not resolved).
131
159
  - `request-changes` → submit `REQUEST_CHANGES` with the `<summary>` as the review body and no `comments[]` array. This combination is rare (the reviewer's contract says `request-changes` requires at least one blocker or load-bearing concern); if it happens, faithfully encode the verdict and trust the reviewer's reasoning is in the summary.
132
160
 
133
161
  The bundled `agent-browser` is **not** for PR reviews — `gh api` is faster and more reliable. Only use the browser when the API genuinely can't reach what you need.
134
162
 
163
+ ## Resolving review threads you authored
164
+
165
+ A review you posted leaves inline comment threads open on the PR. When one of **your** threads is addressed — the author pushed a fix, or replied that they handled it — close it out by **resolving the thread**. Leaving it open after the concern is settled reads as if you never noticed; a resolved thread is the signal that the loop is closed.
166
+
167
+ **The base principle: whoever opened the thread closes it.** Resolve only threads whose root comment **you** authored. Never resolve a human reviewer's thread on your behalf — that erases their open question. The thread you can resolve is the one you started; the inbound that brings you here is a **review-thread reply on `pr:N` with `thread` set**, replying inside a thread you opened.
168
+
169
+ ### When a thread counts as addressed
170
+
171
+ Do not resolve on a bare "done" claim. A reply that says "fixed" is a prompt to check, not proof. Before resolving, **verify the fix at the PR's current head SHA**:
172
+
173
+ 1. Re-read the PR head: `gh pr view <N> --repo owner/repo --json headRefOid` gives you the SHA the author's latest push landed on.
174
+ 2. Read the lines your comment anchored to, at that SHA: `gh api /repos/owner/repo/contents/<path>?ref=<headRefOid>` (or `gh pr diff <N>` to see what the new push changed). Confirm the change actually addresses the concern your comment raised — not a different line, not a partial fix.
175
+ 3. Only when the code at head genuinely resolves the finding do you resolve the thread. If the fix is partial or misses the point, **reply in the thread** explaining what's still open and leave it unresolved.
176
+
177
+ If the author merely **replied** without pushing (e.g. "this is intentional because …") and their reasoning settles it, that is also "addressed" — **resolve first, then optionally leave a one-line acknowledgement.** Order matters: a bare `channel_reply` ends your turn (see "Mid-turn status replies need `continue: true`" above), so acknowledging _before_ you resolve would stop the turn and the `resolveReviewThread` mutation would never run, leaving the thread open. Resolve, then reply. If you genuinely want to acknowledge before resolving, the acknowledgement must use `channel_reply({ …, continue: true })` so the turn survives long enough to resolve. If their reasoning does **not** settle it, keep the thread open and answer.
178
+
179
+ ### How to resolve — `resolveReviewThread` GraphQL mutation
180
+
181
+ There is no REST endpoint for this. Resolution is a GraphQL mutation that takes the thread's **node id** (`PRRT_…`), not the comment's numeric id. Two steps: find the thread id, then resolve it.
182
+
183
+ 1. **Find the node id of the thread you authored.** Query the PR's review threads and pick the one whose root comment is yours and matches the `thread` you're replying in:
184
+
185
+ ```sh
186
+ gh api graphql -f query='query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:1){nodes{databaseId author{login}}}}}}}}' -F owner=OWNER -F name=REPO -F number=N
187
+ ```
188
+
189
+ Match on the root comment: its `comments.nodes[0].databaseId` equals the root comment id (the `thread` value the inbound carried), and `author.login` is you. Skip threads already `isResolved: true`.
190
+
191
+ **Paginate until you find the match — `first:100` is one page, not all threads.** A busy PR can carry more than 100 review threads, and yours may sit past the first page; stopping at page one would silently miss it and leave your thread open. Omit `-F after=…` on the first call, then while `pageInfo.hasNextPage` is true and you have not yet matched the `databaseId`, re-run the same query with `-F after=<endCursor>` from the previous page. Stop the moment the target thread is found (no need to walk the rest) or when `hasNextPage` is false (the thread is genuinely absent — don't fabricate a node id).
192
+
193
+ 2. **Resolve it** with the node id from step 1:
194
+
195
+ ```sh
196
+ gh api graphql -f query='mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{id isResolved}}}' -F threadId=PRRT_xxx
197
+ ```
198
+
199
+ The returned `isResolved: true` is your proof it landed. As with every repo-targeting `gh` call, this is a **single bare `gh` invocation** — no pipes, `;`, `&&`, heredocs, or command substitution (the `github-cli-auth` plugin injects the App token into the command's environment; a pipeline would leak it). `-F` passes the id as a typed variable, so there is no shell-metacharacter hazard for the simple id/number values here.
200
+
201
+ ### Self-loop safety — resolving never wakes you
202
+
203
+ Resolving your own thread is safe from the self-response loop. The `pull_request_review_thread.resolved` webhook that GitHub emits carries **you** as its `sender`, and the inbound classifier maps `pull_request_review_thread` events to their `sender` (not the PR opener) for the self-author drop — so the bot resolving a thread is recognized as self-authored and dropped, exactly like the decoy-reviewer cleanup in the PR review flow. You will not be re-woken by your own resolution. See "Self-loop safety" below.
204
+
135
205
  ## Opening new issues and PRs
136
206
 
137
207
  The `gh` CLI is pre-authenticated via `GH_TOKEN` (injected by the adapter at startup). Use it to open new issues or PRs:
@@ -104,6 +104,76 @@
104
104
  ]
105
105
  }
106
106
  },
107
+ "mcpServers": {
108
+ "type": "array",
109
+ "items": {
110
+ "type": "object",
111
+ "properties": {
112
+ "name": {
113
+ "type": "string",
114
+ "pattern": "^[a-z0-9][a-z0-9-_]*$"
115
+ },
116
+ "description": {
117
+ "type": "string"
118
+ },
119
+ "enabled": {
120
+ "default": true,
121
+ "type": "boolean"
122
+ },
123
+ "timeoutMs": {
124
+ "type": "integer",
125
+ "exclusiveMinimum": 0,
126
+ "maximum": 600000
127
+ },
128
+ "command": {
129
+ "type": "string",
130
+ "minLength": 1
131
+ },
132
+ "args": {
133
+ "default": [],
134
+ "type": "array",
135
+ "items": {
136
+ "type": "string"
137
+ }
138
+ },
139
+ "url": {
140
+ "type": "string",
141
+ "format": "uri"
142
+ },
143
+ "env": {
144
+ "type": "object",
145
+ "propertyNames": {
146
+ "type": "string",
147
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
148
+ },
149
+ "additionalProperties": {
150
+ "anyOf": [
151
+ {
152
+ "type": "string",
153
+ "minLength": 1
154
+ },
155
+ {
156
+ "type": "object",
157
+ "properties": {
158
+ "value": {
159
+ "type": "string",
160
+ "minLength": 1
161
+ },
162
+ "env": {
163
+ "type": "string",
164
+ "minLength": 1
165
+ }
166
+ }
167
+ }
168
+ ]
169
+ }
170
+ }
171
+ },
172
+ "required": [
173
+ "name"
174
+ ]
175
+ }
176
+ },
107
177
  "plugins": {
108
178
  "default": [],
109
179
  "type": "array",
@@ -475,6 +545,18 @@
475
545
  "items": {
476
546
  "type": "string"
477
547
  }
548
+ },
549
+ "review": {
550
+ "default": {
551
+ "approve": true
552
+ },
553
+ "type": "object",
554
+ "properties": {
555
+ "approve": {
556
+ "default": true,
557
+ "type": "boolean"
558
+ }
559
+ }
478
560
  }
479
561
  }
480
562
  },