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.
- package/auth.schema.json +0 -5
- package/package.json +2 -2
- package/secrets.schema.json +0 -5
- package/src/agent/index.ts +2 -1
- package/src/agent/model-overrides.ts +77 -0
- package/src/agent/plugin-tools.ts +53 -4
- package/src/agent/tools/grant-role.ts +102 -8
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +372 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +42 -0
- package/src/bundled-plugins/github-cli-auth/token-class.ts +11 -0
- package/src/bundled-plugins/reviewer/skills/code-review.ts +18 -1
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +9 -2
- package/src/channels/adapters/discord-bot-classify.ts +23 -0
- package/src/channels/adapters/discord-bot.ts +22 -4
- package/src/channels/adapters/github/auth-app.ts +49 -26
- package/src/channels/adapters/github/auth-pat.ts +3 -3
- package/src/channels/adapters/github/auth.ts +19 -5
- package/src/channels/adapters/github/channel-resolver.ts +3 -2
- package/src/channels/adapters/github/history.ts +3 -2
- package/src/channels/adapters/github/inbound.ts +30 -55
- package/src/channels/adapters/github/index.ts +147 -43
- package/src/channels/adapters/github/membership.ts +7 -2
- package/src/channels/adapters/github/outbound.ts +6 -2
- package/src/channels/adapters/github/team-membership.ts +4 -2
- package/src/channels/adapters/github/webhook-register.ts +19 -16
- package/src/channels/adapters/slack-bot-slash-commands.ts +78 -1
- package/src/channels/adapters/slack-bot.ts +119 -18
- package/src/channels/commands.ts +10 -0
- package/src/channels/engagement.ts +34 -3
- package/src/channels/github-token-bridge.ts +42 -0
- package/src/channels/index.ts +6 -0
- package/src/channels/manager.ts +6 -0
- package/src/channels/membership.ts +9 -0
- package/src/channels/router.ts +155 -37
- package/src/cli/channel.ts +0 -12
- package/src/cli/init.ts +0 -9
- package/src/cli/ui.ts +6 -0
- package/src/commands/index.ts +54 -4
- package/src/init/dockerfile.ts +60 -0
- package/src/init/github-webhook-install.ts +1 -2
- package/src/init/index.ts +4 -10
- package/src/init/validate-api-key.ts +15 -1
- package/src/plugin/context.ts +8 -0
- package/src/plugin/manager.ts +3 -0
- package/src/plugin/types.ts +6 -0
- package/src/run/bundled-plugins.ts +9 -0
- package/src/run/index.ts +6 -0
- package/src/secrets/schema.ts +0 -1
- package/src/server/command-runner.ts +14 -0
- 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
|
|
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
|
|
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
|
|
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
|
|
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),
|
package/src/plugin/context.ts
CHANGED
|
@@ -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(
|
package/src/plugin/manager.ts
CHANGED
|
@@ -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
|
})
|
package/src/plugin/types.ts
CHANGED
|
@@ -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({
|
package/src/secrets/schema.ts
CHANGED
|
@@ -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
|
|
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
|
-
##
|
|
16
|
+
## What to do, by inbound type
|
|
17
17
|
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
+
You are being asked to review a PR in **either** of these cases — treat them identically:
|
|
29
33
|
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
+
## PR review flow
|
|
37
42
|
|
|
38
|
-
|
|
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.)
|
|
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
|
-
|
|
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>`
|
|
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
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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.
|