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.
- package/package.json +2 -1
- package/src/agent/index.ts +55 -1
- package/src/agent/loop-guard.ts +180 -53
- package/src/bundled-plugins/bun-hygiene/README.md +82 -0
- package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
- package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
- package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
- package/src/bundled-plugins/memory/memory-logger.ts +6 -2
- package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
- package/src/channels/adapters/discord-bot.ts +2 -0
- package/src/channels/adapters/github/inbound.ts +23 -1
- package/src/channels/adapters/github/index.ts +1 -0
- package/src/channels/adapters/slack-bot.ts +104 -5
- package/src/channels/manager.ts +8 -0
- package/src/channels/router.ts +68 -15
- package/src/channels/schema.ts +18 -0
- package/src/cli/dreams.ts +2 -1
- package/src/cli/inspect.ts +2 -1
- package/src/cli/ui.ts +34 -0
- package/src/commands/index.ts +5 -2
- package/src/config/config.ts +89 -0
- package/src/mcp/catalog.ts +29 -0
- package/src/mcp/client.ts +236 -0
- package/src/mcp/index.ts +25 -0
- package/src/mcp/manager.ts +156 -0
- package/src/mcp/tools.ts +190 -0
- package/src/permissions/builtins.ts +9 -0
- package/src/reload/format.ts +14 -0
- package/src/reload/index.ts +1 -0
- package/src/run/bundled-plugins.ts +7 -0
- package/src/run/channel-session-factory.ts +3 -0
- package/src/run/index.ts +38 -1
- package/src/server/command-runner.ts +5 -0
- package/src/server/index.ts +4 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +83 -13
- 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
|
})
|
package/src/server/index.ts
CHANGED
|
@@ -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:
|
package/typeclaw.schema.json
CHANGED
|
@@ -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
|
},
|