typeclaw 0.17.0 → 0.19.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 (50) hide show
  1. package/auth.schema.json +0 -5
  2. package/package.json +2 -2
  3. package/secrets.schema.json +0 -5
  4. package/src/agent/index.ts +2 -1
  5. package/src/agent/model-overrides.ts +77 -0
  6. package/src/agent/plugin-tools.ts +53 -4
  7. package/src/agent/tools/grant-role.ts +102 -8
  8. package/src/bundled-plugins/github-cli-auth/gh-command.ts +372 -0
  9. package/src/bundled-plugins/github-cli-auth/index.ts +42 -0
  10. package/src/bundled-plugins/github-cli-auth/token-class.ts +11 -0
  11. package/src/bundled-plugins/reviewer/skills/code-review.ts +18 -1
  12. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +9 -2
  13. package/src/channels/adapters/discord-bot-classify.ts +23 -0
  14. package/src/channels/adapters/discord-bot.ts +22 -4
  15. package/src/channels/adapters/github/auth-app.ts +49 -26
  16. package/src/channels/adapters/github/auth-pat.ts +3 -3
  17. package/src/channels/adapters/github/auth.ts +19 -5
  18. package/src/channels/adapters/github/channel-resolver.ts +3 -2
  19. package/src/channels/adapters/github/history.ts +3 -2
  20. package/src/channels/adapters/github/inbound.ts +30 -55
  21. package/src/channels/adapters/github/index.ts +147 -43
  22. package/src/channels/adapters/github/membership.ts +7 -2
  23. package/src/channels/adapters/github/outbound.ts +6 -2
  24. package/src/channels/adapters/github/team-membership.ts +4 -2
  25. package/src/channels/adapters/github/webhook-register.ts +19 -16
  26. package/src/channels/adapters/slack-bot-slash-commands.ts +78 -1
  27. package/src/channels/adapters/slack-bot.ts +119 -18
  28. package/src/channels/commands.ts +10 -0
  29. package/src/channels/engagement.ts +34 -3
  30. package/src/channels/github-token-bridge.ts +42 -0
  31. package/src/channels/index.ts +6 -0
  32. package/src/channels/manager.ts +6 -0
  33. package/src/channels/membership.ts +9 -0
  34. package/src/channels/router.ts +155 -37
  35. package/src/cli/channel.ts +0 -12
  36. package/src/cli/init.ts +0 -9
  37. package/src/cli/ui.ts +6 -0
  38. package/src/commands/index.ts +54 -4
  39. package/src/init/dockerfile.ts +60 -0
  40. package/src/init/github-webhook-install.ts +1 -2
  41. package/src/init/index.ts +4 -10
  42. package/src/init/validate-api-key.ts +15 -1
  43. package/src/plugin/context.ts +8 -0
  44. package/src/plugin/manager.ts +3 -0
  45. package/src/plugin/types.ts +6 -0
  46. package/src/run/bundled-plugins.ts +9 -0
  47. package/src/run/index.ts +6 -0
  48. package/src/secrets/schema.ts +0 -1
  49. package/src/server/command-runner.ts +14 -0
  50. package/src/skills/typeclaw-channel-github/SKILL.md +70 -43
package/src/init/index.ts CHANGED
@@ -94,7 +94,7 @@ export type GithubInitCredentials = {
94
94
  hostname?: string
95
95
  tokenEnv?: string
96
96
  repos: string[]
97
- auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
97
+ auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string }
98
98
  }
99
99
 
100
100
  export type GithubTunnelProvider = 'cloudflare-quick' | 'cloudflare-named' | 'external' | 'none'
@@ -998,7 +998,7 @@ export type AddChannelOptions = {
998
998
  hostname?: string
999
999
  tokenEnv?: string
1000
1000
  repos: string[]
1001
- auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
1001
+ auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string }
1002
1002
  fetchImpl?: typeof fetch
1003
1003
  }
1004
1004
  )
@@ -1263,9 +1263,6 @@ async function writeGithubChannelForInit(cwd: string, credentials: GithubInitCre
1263
1263
  type: 'app',
1264
1264
  appId: credentials.auth.appId,
1265
1265
  privateKey: { value: credentials.auth.privateKey } satisfies Secret,
1266
- ...(credentials.auth.installationId !== undefined
1267
- ? { installationId: credentials.auth.installationId }
1268
- : {}),
1269
1266
  },
1270
1267
  webhookSecret: { value: credentials.webhookSecret } satisfies Secret,
1271
1268
  }
@@ -1298,7 +1295,6 @@ async function appendGithubSecrets(
1298
1295
  type: 'app',
1299
1296
  appId: options.auth.appId,
1300
1297
  privateKey: { value: options.auth.privateKey } satisfies Secret,
1301
- ...(options.auth.installationId !== undefined ? { installationId: options.auth.installationId } : {}),
1302
1298
  },
1303
1299
  webhookSecret: { value: options.webhookSecret } satisfies Secret,
1304
1300
  }
@@ -1461,13 +1457,13 @@ export async function setChannelSecrets(
1461
1457
  // previous auth type, since the two shapes share no fields beyond `type`).
1462
1458
  export type GithubCredentialPatch = {
1463
1459
  webhookSecret?: string
1464
- auth?: { type: 'pat'; pat: string } | { type: 'app'; privateKey: string; appId?: number; installationId?: number }
1460
+ auth?: { type: 'pat'; pat: string } | { type: 'app'; privateKey: string; appId?: number }
1465
1461
  }
1466
1462
 
1467
1463
  // Update one or more credential fields on an already-configured GitHub
1468
1464
  // channel. Like setChannelSecrets, refuses when secrets.json has no
1469
1465
  // existing github entry. Supports both same-type rotation (preserves env
1470
- // bindings, carries appId/installationId forward when not supplied) and
1466
+ // bindings, carries appId forward when not supplied) and
1471
1467
  // auth-type switching (replaces the entire auth block — see
1472
1468
  // `GithubCredentialPatch` above).
1473
1469
  export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch): Promise<SetChannelTokensResult> {
@@ -1503,7 +1499,6 @@ export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch
1503
1499
  } else {
1504
1500
  const existingApp = isSameType && isObjectRecord(existingAuth) ? (existingAuth as Record<string, unknown>) : {}
1505
1501
  const appId = patch.auth.appId ?? (existingApp.appId as number | undefined)
1506
- const installationId = patch.auth.installationId ?? (existingApp.installationId as number | undefined)
1507
1502
  if (typeof appId !== 'number') {
1508
1503
  return {
1509
1504
  result: {
@@ -1518,7 +1513,6 @@ export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch
1518
1513
  type: 'app',
1519
1514
  appId,
1520
1515
  privateKey: rotatedSecret(existingApp.privateKey, patch.auth.privateKey),
1521
- ...(installationId !== undefined ? { installationId } : {}),
1522
1516
  }
1523
1517
  }
1524
1518
  }
@@ -1,3 +1,4 @@
1
+ import { effectiveBaseUrl } from '@/agent/model-overrides'
1
2
  import { KNOWN_PROVIDERS, type KnownProviderId } from '@/config/providers'
2
3
 
3
4
  const PROVIDER_PROBE: Partial<Record<KnownProviderId, { url: string; authHeader: 'bearer' | 'x-api-key' }>> = {
@@ -8,6 +9,19 @@ const PROVIDER_PROBE: Partial<Record<KnownProviderId, { url: string; authHeader:
8
9
  'zai-coding': { url: 'https://api.z.ai/api/coding/paas/v4/models', authHeader: 'bearer' },
9
10
  }
10
11
 
12
+ // When a base-URL override (ANTHROPIC_BASE_URL / OPENAI_BASE_URL) points at a
13
+ // proxy, probe THAT endpoint — validating against the public API would test the
14
+ // wrong gateway (and may reject a proxy-only credential). The path suffix is
15
+ // whatever the hardcoded default probe URL adds on top of the provider's
16
+ // default baseUrl, so providers with different version-segment conventions
17
+ // (anthropic baseUrl omits `/v1`, openai includes it) each keep their own path.
18
+ function probeUrlFor(providerId: KnownProviderId, defaultUrl: string): string {
19
+ const defaultBase = KNOWN_PROVIDERS[providerId].baseUrl
20
+ const base = effectiveBaseUrl(providerId, defaultBase)
21
+ if (base === undefined) return defaultUrl
22
+ return `${base}${defaultUrl.slice(defaultBase.length)}`
23
+ }
24
+
11
25
  export type KeyValidationResult =
12
26
  | { kind: 'ok' }
13
27
  | { kind: 'skipped'; reason: 'no-probe' | 'network-error'; detail?: string }
@@ -36,7 +50,7 @@ export async function validateApiKey(
36
50
  }
37
51
 
38
52
  try {
39
- const res = await fetchImpl(probe.url, {
53
+ const res = await fetchImpl(probeUrlFor(providerId, probe.url), {
40
54
  method: 'GET',
41
55
  headers,
42
56
  signal: AbortSignal.timeout(TIMEOUT_MS),
@@ -1,3 +1,4 @@
1
+ import type { ResolveGithubTokenForRepo } from '@/channels/github-token-bridge'
1
2
  import type { PermissionService } from '@/permissions'
2
3
 
3
4
  import type { PluginContext, PluginLogger, SpawnSubagentOptions } from './types'
@@ -11,10 +12,16 @@ export type CreatePluginContextOptions<TConfig> = {
11
12
  config: TConfig
12
13
  logger: PluginLogger
13
14
  permissions: PermissionService
15
+ resolveGithubTokenForRepo?: ResolveGithubTokenForRepo
14
16
  spawnSubagent: SpawnSubagentFn
15
17
  isBooted: () => boolean
16
18
  }
17
19
 
20
+ const githubTokenUnavailable: ResolveGithubTokenForRepo = async () => ({
21
+ kind: 'unavailable',
22
+ reason: 'GitHub token resolution is not wired in this context.',
23
+ })
24
+
18
25
  export function createPluginContext<TConfig>(opts: CreatePluginContextOptions<TConfig>): PluginContext<TConfig> {
19
26
  return Object.freeze({
20
27
  name: opts.name,
@@ -23,6 +30,7 @@ export function createPluginContext<TConfig>(opts: CreatePluginContextOptions<TC
23
30
  config: opts.config,
24
31
  logger: opts.logger,
25
32
  permissions: opts.permissions,
33
+ github: { resolveTokenForRepo: opts.resolveGithubTokenForRepo ?? githubTokenUnavailable },
26
34
  spawnSubagent: async (name: string, payload?: unknown, options?: SpawnSubagentOptions) => {
27
35
  if (!opts.isBooted()) {
28
36
  throw new Error(
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod'
2
2
 
3
+ import type { ResolveGithubTokenForRepo } from '@/channels/github-token-bridge'
3
4
  import type { CronJob } from '@/cron'
4
5
  import {
5
6
  createPermissionService,
@@ -20,6 +21,7 @@ export type LoadPluginsOptions = {
20
21
  configsByName: Record<string, unknown>
21
22
  loadEntry?: LoadPluginEntryFn
22
23
  roles?: RolesConfig
24
+ resolveGithubTokenForRepo?: ResolveGithubTokenForRepo
23
25
  // Bundled plugins resolved by the runtime (not from typeclaw.json). Loaded
24
26
  // before user-declared `entries` so a config block named after a bundled
25
27
  // plugin (e.g. "memory") is consumed by the bundled plugin, and so plugin-
@@ -101,6 +103,7 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
101
103
  config: validatedConfig as never,
102
104
  logger,
103
105
  permissions,
106
+ resolveGithubTokenForRepo: opts.resolveGithubTokenForRepo,
104
107
  spawnSubagent: (name, payload, options) => spawnSubagentImpl(name, payload, options),
105
108
  isBooted: () => booted,
106
109
  })
@@ -2,6 +2,7 @@ import type { z } from 'zod'
2
2
 
3
3
  import type { SessionOrigin } from '@/agent/session-origin'
4
4
  import type { SubagentShared } from '@/agent/subagents'
5
+ import type { ResolveGithubTokenForRepo } from '@/channels/github-token-bridge'
5
6
  import type { PermissionService } from '@/permissions'
6
7
 
7
8
  export type ContentPart = { type: 'text'; text: string } | { type: 'image'; mimeType: string; data: string }
@@ -273,9 +274,14 @@ export type PluginContext<TConfig = never> = {
273
274
  readonly config: TConfig
274
275
  readonly logger: PluginLogger
275
276
  readonly permissions: PermissionService
277
+ readonly github: PluginGithubServices
276
278
  spawnSubagent: (name: string, payload?: unknown, options?: SpawnSubagentOptions) => Promise<void>
277
279
  }
278
280
 
281
+ export type PluginGithubServices = {
282
+ resolveTokenForRepo: ResolveGithubTokenForRepo
283
+ }
284
+
279
285
  export type PluginExports = {
280
286
  tools?: Record<string, Tool<any>>
281
287
  subagents?: Record<string, Subagent<any>>
@@ -1,6 +1,7 @@
1
1
  import agentBrowserPlugin from '@/bundled-plugins/agent-browser'
2
2
  import backupPlugin from '@/bundled-plugins/backup'
3
3
  import explorerPlugin from '@/bundled-plugins/explorer'
4
+ import githubCliAuthPlugin from '@/bundled-plugins/github-cli-auth'
4
5
  import guardPlugin from '@/bundled-plugins/guard'
5
6
  import memoryPlugin from '@/bundled-plugins/memory'
6
7
  import operatorPlugin from '@/bundled-plugins/operator'
@@ -28,6 +29,13 @@ import type { ResolvedPlugin } from '@/plugin'
28
29
  // Reversing this order would make guard advise on the full oversized payload
29
30
  // and then tool-result-cap would clobber the advice text along with the rest.
30
31
  //
32
+ // `github-cli-auth` is registered AFTER `security` so security's `tool.before`
33
+ // runs its exfil/secret scanners on the bash command first. github-cli-auth
34
+ // injects the minted token via an env overlay (TYPECLAW_INTERNAL_BASH_ENV), not
35
+ // by rewriting the command string, so the token never enters argv or logs — but
36
+ // ordering security first still matters so a blocked command never reaches the
37
+ // mint path at all.
38
+ //
31
39
  // `memory` is registered before `backup` so memory's dreaming commits always
32
40
  // land in the same git index window before backup's commit-and-push cycle.
33
41
  // They commit disjoint paths today (memory/ vs sessions/ + agent changes),
@@ -37,6 +45,7 @@ export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
37
45
  { name: 'security', version: undefined, source: '<bundled>', defined: securityPlugin },
38
46
  { name: 'tool-result-cap', version: undefined, source: '<bundled>', defined: toolResultCapPlugin },
39
47
  { name: 'guard', version: undefined, source: '<bundled>', defined: guardPlugin },
48
+ { name: 'github-cli-auth', version: undefined, source: '<bundled>', defined: githubCliAuthPlugin },
40
49
  { name: 'memory', version: undefined, source: '<bundled>', defined: memoryPlugin },
41
50
  { name: 'backup', version: undefined, source: '<bundled>', defined: backupPlugin },
42
51
  { name: 'agent-browser', version: undefined, source: '<bundled>', defined: agentBrowserPlugin },
package/src/run/index.ts CHANGED
@@ -19,6 +19,7 @@ import { resolveCapOptionsFromConfig } from '@/bundled-plugins/tool-result-cap'
19
19
  import {
20
20
  createChannelManager,
21
21
  createChannelsReloadable,
22
+ createGithubTokenBridge,
22
23
  createSubagentCompletionBridge,
23
24
  type ChannelManager,
24
25
  type SubagentCompletionBridge,
@@ -138,11 +139,13 @@ export async function startAgent({
138
139
 
139
140
  const pluginConfigsByName = loadPluginConfigsSync(cwd)
140
141
  const cwdConfig = loadConfigSync(cwd)
142
+ const githubTokenBridge = createGithubTokenBridge()
141
143
  const pluginsLoaded = await loadPlugins({
142
144
  entries: cwdConfig.plugins,
143
145
  agentDir: cwd,
144
146
  configsByName: pluginConfigsByName,
145
147
  bundled: BUNDLED_PLUGINS,
148
+ resolveGithubTokenForRepo: githubTokenBridge.resolveTokenForRepo,
146
149
  ...(cwdConfig.roles !== undefined ? { roles: cwdConfig.roles } : {}),
147
150
  })
148
151
 
@@ -255,6 +258,7 @@ export async function startAgent({
255
258
  }),
256
259
  permissions: pluginsLoaded.permissions,
257
260
  claimHandler: claimController.claimHandler,
261
+ githubTokenBridge,
258
262
  stream,
259
263
  })
260
264
 
@@ -371,6 +375,7 @@ export async function startAgent({
371
375
  runtimeVersion: runtimeVersionOpt.runtimeVersion,
372
376
  containerName: containerNameOpt.containerName,
373
377
  sessionFactory,
378
+ channelRouter: channelManager.router,
374
379
  }),
375
380
  subagent: (subName: string, payload?: unknown) =>
376
381
  dispatchSpawnSubagent(subName, payload, {
@@ -585,6 +590,7 @@ export async function startAgent({
585
590
  containerName,
586
591
  outbound,
587
592
  sessionFactory,
593
+ channelRouter: channelManager.router,
588
594
  })
589
595
 
590
596
  const server = createServer({
@@ -49,7 +49,6 @@ const githubAppAuthSchema = z.object({
49
49
  type: z.literal('app'),
50
50
  appId: z.number().int().positive(),
51
51
  privateKey: secretFieldSchema,
52
- installationId: z.number().int().positive().optional(),
53
52
  })
54
53
 
55
54
  const githubChannelSchema = z.object({
@@ -5,6 +5,7 @@ import {
5
5
  type CreateSessionResult,
6
6
  type SessionOrigin,
7
7
  } from '@/agent'
8
+ import type { ChannelRouter } from '@/channels/router'
8
9
  import type { PermissionService } from '@/permissions'
9
10
  import type {
10
11
  CommandExecResult,
@@ -44,6 +45,14 @@ export type CommandRunnerOptions = {
44
45
  // `SessionManager.inMemory()` and never persist usage — see
45
46
  // `runPromptForCommand` below.
46
47
  sessionFactory: SessionFactory
48
+ // Channel router threaded into every `ctx.prompt` session so the model can
49
+ // call `channel_send`. Without this, `buildChannelTools` (src/agent/index.ts)
50
+ // receives `undefined` and emits no channel tools — a plugin command or cron
51
+ // handler told to post to a channel then has no tool to do it and falls back
52
+ // to flailing bash loops. The cron `prompt` path already passes
53
+ // `channelManager.router` via `createSessionForCron`; this is the matching
54
+ // wire for the handler/command path.
55
+ channelRouter: ChannelRouter | undefined
47
56
  }
48
57
 
49
58
  type CommandHandle = {
@@ -182,6 +191,7 @@ export function createCommandRunner(opts: CommandRunnerOptions): CommandRunner {
182
191
  permissions: opts.permissions,
183
192
  signal: abortController.signal,
184
193
  sessionFactory: opts.sessionFactory,
194
+ channelRouter: opts.channelRouter,
185
195
  }),
186
196
  subagent: (subName, payload) =>
187
197
  opts.spawnSubagent(subName, payload, {
@@ -363,6 +373,9 @@ export async function runPromptForCommand(args: {
363
373
  // cron `prompt` path uses in src/run/index.ts. Passing in-memory here
364
374
  // regresses `typeclaw usage` (see CommandRunnerOptions.sessionFactory).
365
375
  sessionFactory: SessionFactory
376
+ // See CommandRunnerOptions.channelRouter. Threaded to createSessionWithDispose
377
+ // so the spawned session exposes `channel_send`.
378
+ channelRouter?: ChannelRouter
366
379
  // Test seam for the agent-session boundary. Production passes the real
367
380
  // `createSessionWithDispose`; tests inject a fake to verify wiring
368
381
  // (specifically: the sessionManager handed off must be persisted, not
@@ -388,6 +401,7 @@ export async function runPromptForCommand(args: {
388
401
  sessionId,
389
402
  agentDir: args.agentDir,
390
403
  },
404
+ ...(args.channelRouter !== undefined ? { channelRouter: args.channelRouter } : {}),
391
405
  ...(args.runtimeVersion !== undefined ? { runtimeVersion: args.runtimeVersion } : {}),
392
406
  ...(args.containerName !== undefined ? { containerName: args.containerName } : {}),
393
407
  })
@@ -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 an inbound says "requested your review on PR #N" or "requested a review from team @… on PR #N" (the agent has been assigned as a reviewer and must delegate the analysis to the `reviewer` subagent, then translate its findings into line-by-line comments 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. 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.
@@ -13,39 +13,44 @@ GitHub renders normal Markdown in issues, PRs, discussions, and review comments.
13
13
 
14
14
  A successful `channel_reply` ends your turn by default — the runtime stops the model right after the reply lands. That is correct for a final answer, but it will **silently truncate** a turn that still has work to do. If you post a status line like "Reviewing now, I'll be back with findings" and then expect to keep working (fetch the diff, spawn the reviewer, post the review) in the **same** turn, you must call `channel_reply({ text: "…", continue: true })`. Without `continue: true`, the turn ends at that status reply and the review never runs. Reserve `continue: true` for genuine multi-step turns; the final reply that wraps up the turn omits it.
15
15
 
16
- ## Opening new issues and PRs
16
+ ## What to do, by inbound type
17
17
 
18
- The `gh` CLI is pre-authenticated via `GH_TOKEN` (injected by the adapter at startup). Use it to open new issues or PRs:
18
+ 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
19
 
20
- ```sh
21
- # Open a new issue
22
- gh issue create --repo owner/repo --title "Bug: ..." --body "..."
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.** |
23
29
 
24
- # Open a new PR
25
- gh pr create --repo owner/repo --title "Fix: ..." --head my-branch --base main --body "..."
26
- ```
30
+ ### When you are being asked to review
27
31
 
28
- For App auth, `GH_TOKEN` is an installation access token that refreshes automaticallyit stays current as long as the adapter is running.
32
+ You are being asked to review a PR in **either** of these cases treat them identically:
29
33
 
30
- ## Reviewing pull requests
34
+ - **(A) An explicit review-request inbound.** The message text says **"requested your review on PR #N"** or **"requested a review from team @… on PR #N"**. (You do not need to know how it was triggered — the adapter synthesizes this same text whether a human requested you as a reviewer directly or requested a decoy user account that impersonates you as a GitHub App. From your side it reads the same. See [GitHub decoy reviewer](/docs/internals/github-decoy-reviewer).)
35
+ - **(B) A human asks you to review in plain language** in a PR/issue body or any comment — "@bot review this PR", "can you take a look at #123", "review the changes when you get a chance". There is no synthetic request text here; you recognize the intent from the message.
31
36
 
32
- When an incoming message says **"requested your review on PR #N"** (or "requested a review from team @… on PR #N"), you have been assigned as a reviewer. Do **not** review inline yourself and do **not** just reply in the channel delegate the analysis to the bundled `reviewer` subagent, then translate its findings into line-by-line comments via `gh api`.
37
+ Both run the **PR review flow**. Do not review inline yourself and do not just reply with prose impressions: delegate to the `reviewer` subagent so the analysis runs on the `deep` model, then post its findings as an inline review.
33
38
 
34
- Why delegate: the `reviewer` subagent runs on the `deep` model profile, loads a curated `code-review` skill on demand, and produces a structured `<review>` block with severity-tagged findings. You are the integration layer between that output and GitHub's review API.
39
+ A `review_request_removed` inbound ("removed your review request on PR #N") is the inverse: the requester un-assigned you. Cancel any in-flight reviewer subagent (`subagent_cancel`) and do not post a partial review.
35
40
 
36
- ### Workflow
41
+ ## PR review flow
37
42
 
38
- 1. **Confirm the target.** Capture the PR number, the repo, and the head SHA you'll need the SHA to read files at the revision the reviewer analyzed.
43
+ 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
+ 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.
39
46
 
40
47
  ```sh
41
48
  gh pr view <N> --repo owner/repo --json title,body,baseRefName,headRefOid,files
42
49
  ```
43
50
 
44
- 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.) so the reviewer knows what the requester cares about.
45
-
46
- If you post an "on it" acknowledgement before fetching the diff or spawning the reviewer, it **must** be `channel_reply({ text: "…", continue: true })` — a bare reply ends the turn and the review never starts (see "Mid-turn status replies need `continue: true`" above).
51
+ 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"`.
47
52
 
48
- The reviewer will fetch the diff itself (`gh pr diff`, `gh api /repos/.../pulls/<n>`), load the matching skill (`code-review` for a code PR; `general` for a mixed-format change), and return a `<review>` block.
53
+ If you post an "on it" acknowledgement before spawning the reviewer, it **must** be `channel_reply({ text: "…", continue: true })` a bare reply ends the turn and the review never starts (see "Mid-turn status replies need `continue: true`" above).
49
54
 
50
55
  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:
51
56
 
@@ -63,11 +68,11 @@ Why delegate: the `reviewer` subagent runs on the `deep` model profile, loads a
63
68
  </review>
64
69
  ```
65
70
 
66
- 4. **Translate findings into a `gh api` review payload.** Each `<finding>` with `severity` of `blocker`, `concern`, or `nit` and a `location="path:line"` becomes one entry in `comments[]`. Compose the inline `body` from the reviewer's `<issue>` + `<evidence>` + `<suggestion>` preserve the reviewer's wording, do not paraphrase. Findings whose `location` is `general` (no file:line anchor) go into the top-level review `body` instead. **Skip `praise` findings when building `comments[]`** — they are not actionable, and inline praise comments are exactly the noise the reviewer is supposed to filter out at the source; if you want to surface them, weave them into the top-level review `body` alongside the summary.
71
+ 4. **Translate findings into a `gh api` review payload.** Each `<finding>` with `severity` of `blocker`, `concern`, or `nit` and a `location="path:line"` becomes one entry in `comments[]`. Compose the inline `body` from the reviewer's `<issue>` + `<evidence>` + `<suggestion>` verbatim (modulo markdown). Findings whose `location` is `general` (no file:line anchor) go into the top-level review `body` instead. **Skip `praise` findings when building `comments[]`** — if you want to surface them, weave them into the top-level `body`.
67
72
 
68
- **The verdict and the inline comments are independent. The verdict sets only the `event` field; it never decides whether you post `comments[]`.** Whenever there is at least one actionable finding (`blocker`/`concern`/`nit`) with a `location="path:line"`, you MUST submit a formal review via `POST /pulls/<N>/reviews` carrying those findings in `comments[]` — including when the verdict is `approve`. An `approve` with three nits is still a formal `APPROVE` review with three inline comments, **not** a plain approval and **not** a flattened summary posted as a top-level comment. Collapsing inline findings into a single `channel_reply` or issue comment loses the line anchors the reviewer worked to produce — that is the exact failure mode this step exists to prevent.
73
+ **The verdict and the inline comments are independent. The verdict sets only the `event` field; it never decides whether you post `comments[]`.** Whenever there is at least one actionable finding (`blocker`/`concern`/`nit`) with a `location="path:line"`, you MUST submit a formal review via `POST /pulls/<N>/reviews` carrying those findings in `comments[]` — including when the verdict is `approve`. An `approve` with three nits is still a formal `APPROVE` review with three inline comments, **not** a plain approval and **not** a flattened summary. Collapsing inline findings into a single `channel_reply` or issue comment loses the line anchors the reviewer worked to produce.
69
74
 
70
- Map the reviewer's `<verdict>` to the GitHub `event`:
75
+ Map the reviewer's `<verdict>` to the GitHub `event`, and trust it — do not upgrade `comment` → `APPROVE` to seem agreeable, or downgrade `request-changes` → `COMMENT` to soften the tone:
71
76
 
72
77
  | Reviewer verdict | GitHub `event` |
73
78
  | ----------------- | ----------------- |
@@ -75,22 +80,35 @@ Why delegate: the `reviewer` subagent runs on the `deep` model profile, loads a
75
80
  | `request-changes` | `REQUEST_CHANGES` |
76
81
  | `comment` | `COMMENT` |
77
82
 
78
- Then submit the review in one API call:
83
+ 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:
79
84
 
80
- ```sh
81
- cat <<'JSON' | gh api -X POST /repos/owner/repo/pulls/<N>/reviews --input -
85
+ First write `/tmp/review.json` (via the `write` tool, not bash):
86
+
87
+ ```json
82
88
  {
83
89
  "event": "COMMENT",
84
90
  "body": "<reviewer's <summary> goes here>",
85
91
  "comments": [
86
- { "path": "src/foo.ts", "line": 42, "side": "RIGHT", "body": "<issue + evidence + suggestion from the reviewer's finding>" },
92
+ {
93
+ "path": "src/foo.ts",
94
+ "line": 42,
95
+ "side": "RIGHT",
96
+ "body": "<issue + evidence + suggestion from the reviewer's finding>"
97
+ },
87
98
  { "path": "src/bar.ts", "line": 10, "side": "RIGHT", "body": "..." }
88
99
  ]
89
100
  }
90
- JSON
91
101
  ```
92
102
 
93
- **Always use `--input -` with a quoted heredoc (`<<'JSON'`) for review bodies.** Do **not** use `-f body=...` or `-F 'comments[][body]=...'`: those go through shell argument parsing, so backticks (\`) trigger command substitution and have to be backslash-escaped, which leaks the literal `\` into the rendered comment. The quoted heredoc passes the JSON through untouched — backticks, newlines, and `${...}` all survive verbatim. The same applies to any other `gh api` POST whose body contains backticks, embedded newlines, or shell metacharacters.
103
+ Then post it:
104
+
105
+ ```sh
106
+ gh api -X POST /repos/owner/repo/pulls/<N>/reviews --input /tmp/review.json
107
+ ```
108
+
109
+ **A repo-targeting `gh` command must be a single bare `gh` invocation — no pipes, `;`, `&&`, heredocs, or command substitution.** The `github-cli-auth` plugin injects the GitHub App token into the command's environment, so any sibling/upstream stage in a pipeline would inherit a live token; the runtime blocks those shapes. That is why the old `cat <<'JSON' | gh api --input -` heredoc-pipe no longer works: write the JSON to a file and feed it with `--input <file>` instead. Do **not** use `-f body=...` or `-F 'comments[][body]=...'`: those go through shell argument parsing, so backticks trigger command substitution. The file passes the JSON through untouched — backticks, newlines, and `${...}` all survive verbatim. The same file-then-`--input` pattern applies to any `gh api` POST whose body contains backticks, embedded newlines, or shell metacharacters.
110
+
111
+ Anchor mechanics: `line` is a line number **in the file**, not a position in the diff. `side: RIGHT` is the new revision (default for additions); `side: LEFT` is the old revision (use for comments on removed lines). For multi-line comments, also set `start_line` and `start_side` (same semantics). If you need to read whole files at the PR's head SHA to validate an anchor before posting, use `gh api /repos/owner/repo/contents/<path>?ref=<headRefOid>`.
94
112
 
95
113
  5. **Verify the review actually landed before announcing it.** The `gh api` call can fail silently from the model's perspective — a permission denial, a bad `line` anchor, or a malformed payload returns an error you must not paper over. After submitting, confirm the review exists:
96
114
 
@@ -100,23 +118,32 @@ Why delegate: the `reviewer` subagent runs on the `deep` model profile, loads a
100
118
 
101
119
  The returned `id`/`state` is your proof the formal review posted. If the call errored or the review is absent, do **not** fall back to a top-level `channel_reply` that _claims_ a review was posted — fix the payload (most often a `line` that isn't part of the diff; re-anchor it or move that finding to the top-level `body`) and resubmit. A trace reply that says "Posted review" when no review exists is worse than silence.
102
120
 
103
- 6. **End the turn with `skip_response`, not a trace reply.** The formal review from step 4 already landed _in this PR_ — it carries the summary, the verdict, and the inline comments. A `channel_reply` here does **not** go to a separate operator channel; on GitHub it posts another public comment on the same PR. A one-line "Posted review on PR #N: …" narrated into the PR thread is meta-commentary addressed to a phantom operator, and it reads absurdly next to the review it claims to point at. So once step 5 confirms the review exists, call `skip_response({ reason: "review posted via gh api" })` to close the turn silently. Only fall back to `channel_reply` when there was **no** formal review to post — the zero-actionable-findings branches in Rules below already use `channel_reply`/issue comments _as_ the substantive reply.
121
+ 6. **End the turn with `skip_response`, not a trace reply.** The formal review from step 4 already landed _in this PR_ — it carries the summary, the verdict, and the inline comments. A `channel_reply` here does **not** go to a separate operator channel; on GitHub it posts another public comment on the same PR. A one-line "Posted review on PR #N: …" narrated into the PR thread is meta-commentary addressed to a phantom operator, and it reads absurdly next to the review it claims to point at. So once step 5 confirms the review exists, call `skip_response({ reason: "review posted via gh api" })` to close the turn silently. Only fall back to `channel_reply` when there was **no** formal review to post — the zero-actionable-findings branch below uses `channel_reply`/issue comments _as_ the substantive reply.
122
+
123
+ ### Zero actionable findings
124
+
125
+ 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:
104
126
 
105
- ### Rules
127
+ - `approve` → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array).
128
+ - `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.
129
+ - `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.
106
130
 
107
- - **Always delegate to the `reviewer` subagent.** Do not perform the review craft yourself. The reviewer is the source of truth for severity, evidence quality, and what counts as a finding. Your job is mechanics: spawn, wait, translate, post.
108
- - **Trust the verdict.** Use the GitHub `event` mapped from the reviewer's `<verdict>`. Do not upgrade `comment` → `APPROVE` to seem agreeable, and do not downgrade `request-changes` → `COMMENT` to soften the tone. The reviewer chose deliberately.
109
- - **No actionable findings → no inline review post.** A finding is "actionable" if its severity is `blocker`, `concern`, or `nit`. This branch applies **only when the actionable count is exactly zero** — if there is even one actionable finding with a line anchor, follow step 4 and submit a formal review with `comments[]` regardless of verdict. When the reviewer returns zero actionable findings:
110
- - `approve` verdict → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array).
111
- - `comment` verdict post the summary as a top-level PR comment via `gh api -X POST /repos/.../issues/<N>/comments` instead of submitting an empty review.
112
- - `request-changes` verdict → 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), so if it happens, faithfully encode the verdict and trust the reviewer's reasoning is in the summary.
113
- - **Preserve the reviewer's wording.** Inline comment bodies should reflect the reviewer's `<issue>`, `<evidence>`, and `<suggestion>` verbatim (modulo markdown formatting). Paraphrasing dilutes the analysis — the deep-model reviewer chose those words on purpose.
114
- - `line` is a line number **in the file**, not a position in the diff. `side: RIGHT` is the new revision (default for additions); `side: LEFT` is the old revision (use for comments on removed lines).
115
- - For multi-line comments, also set `start_line` and `start_side` (same semantics).
116
- - If you need to read whole files at the PR's head SHA, use `gh api /repos/owner/repo/contents/<path>?ref=<headRefOid>`. The reviewer can do this itself, but you may need to as well — e.g., when validating a finding's `location` against the actual file before posting.
117
- - 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.
118
- - A `review_request_removed` event means the requester un-assigned you. Cancel any in-flight reviewer subagent (`subagent_cancel`) and do not post a partial review.
131
+ 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.
132
+
133
+ ## Opening new issues and PRs
134
+
135
+ The `gh` CLI is pre-authenticated via `GH_TOKEN` (injected by the adapter at startup). Use it to open new issues or PRs:
136
+
137
+ ```sh
138
+ # Open a new issue
139
+ gh issue create --repo owner/repo --title "Bug: ..." --body "..."
140
+
141
+ # Open a new PR
142
+ gh pr create --repo owner/repo --title "Fix: ..." --head my-branch --base main --body "..."
143
+ ```
144
+
145
+ For App auth, `GH_TOKEN` is an installation access token that refreshes automatically — it stays current as long as the adapter is running.
119
146
 
120
- ### Self-loop safety
147
+ ## Self-loop safety
121
148
 
122
149
  The adapter will **not** wake you when you assign yourself as a reviewer (e.g., via `gh pr edit --add-reviewer`). It will only wake you when someone else requests your review.