typeclaw 0.25.0 → 0.26.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 +1 -1
- package/src/agent/session-origin.ts +28 -5
- package/src/agent/subagent-completion-reminder.ts +16 -1
- package/src/agent/tools/channel-react.ts +11 -4
- package/src/channels/adapters/discord-bot-classify.ts +3 -0
- package/src/channels/adapters/discord-bot-reactions.ts +164 -0
- package/src/channels/adapters/discord-bot.ts +23 -0
- package/src/channels/adapters/github/inbound.ts +8 -4
- package/src/channels/adapters/slack-bot-classify.ts +2 -0
- package/src/channels/adapters/slack-bot-reactions.ts +167 -0
- package/src/channels/adapters/slack-bot.ts +24 -0
- package/src/channels/router.ts +2 -0
- package/src/channels/schema.ts +41 -0
- package/src/config/config.ts +43 -2
- package/src/init/index.ts +3 -3
- package/src/skills/typeclaw-channel-github/SKILL.md +1 -1
- package/typeclaw.schema.json +1 -0
package/package.json
CHANGED
|
@@ -123,14 +123,20 @@ export const PARTICIPANTS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
|
|
123
123
|
type PlatformInfo = {
|
|
124
124
|
displayName: string
|
|
125
125
|
mentionMode: 'angle-id' | 'at-username' | 'alias'
|
|
126
|
+
// Whether this adapter registers a ReactionCallback, i.e. whether
|
|
127
|
+
// `channel_react` actually does anything here. Gates the proactive-reaction
|
|
128
|
+
// prompt guidance so we never tell a KakaoTalk/Telegram agent to react when
|
|
129
|
+
// the call would no-op. Keep in sync with the adapters that call
|
|
130
|
+
// `router.registerReaction` (github, slack-bot, discord-bot today).
|
|
131
|
+
supportsReactions: boolean
|
|
126
132
|
}
|
|
127
133
|
|
|
128
134
|
const PLATFORM_INFO: Record<AdapterId, PlatformInfo> = {
|
|
129
|
-
'slack-bot': { displayName: 'Slack', mentionMode: 'angle-id' },
|
|
130
|
-
'discord-bot': { displayName: 'Discord', mentionMode: 'angle-id' },
|
|
131
|
-
github: { displayName: 'GitHub', mentionMode: 'at-username' },
|
|
132
|
-
'telegram-bot': { displayName: 'Telegram', mentionMode: 'at-username' },
|
|
133
|
-
kakaotalk: { displayName: 'KakaoTalk', mentionMode: 'alias' },
|
|
135
|
+
'slack-bot': { displayName: 'Slack', mentionMode: 'angle-id', supportsReactions: true },
|
|
136
|
+
'discord-bot': { displayName: 'Discord', mentionMode: 'angle-id', supportsReactions: true },
|
|
137
|
+
github: { displayName: 'GitHub', mentionMode: 'at-username', supportsReactions: true },
|
|
138
|
+
'telegram-bot': { displayName: 'Telegram', mentionMode: 'at-username', supportsReactions: false },
|
|
139
|
+
kakaotalk: { displayName: 'KakaoTalk', mentionMode: 'alias', supportsReactions: false },
|
|
134
140
|
}
|
|
135
141
|
|
|
136
142
|
function getPlatformInfo(adapter: AdapterId): PlatformInfo {
|
|
@@ -348,6 +354,23 @@ function renderChannelOrigin(
|
|
|
348
354
|
const conversationLine = renderConversationLine(origin)
|
|
349
355
|
if (conversationLine !== null) lines.push('', conversationLine)
|
|
350
356
|
|
|
357
|
+
if (platformInfo.supportsReactions) {
|
|
358
|
+
lines.push(
|
|
359
|
+
'',
|
|
360
|
+
'**React like a teammate would.** You can drop an emoji on the message that',
|
|
361
|
+
'triggered this turn with `channel_react({ emoji })` — it posts no comment,',
|
|
362
|
+
'just a reaction. Read the message and pick what genuinely fits its tone:',
|
|
363
|
+
'`+1` to agree or approve, `rocket` for something shipping or exciting,',
|
|
364
|
+
'`tada` to celebrate, `heart` to show appreciation, `laugh` for something',
|
|
365
|
+
'funny, `eyes` to signal you are looking. Reach for it when a reaction adds',
|
|
366
|
+
'real warmth or signal — not on every message, and not just because you can.',
|
|
367
|
+
'A reaction does NOT satisfy the reply obligation below: when the message',
|
|
368
|
+
'needs a substantive answer, still send it via `channel_reply`. Think of',
|
|
369
|
+
'reactions as the lightweight, human layer on top of your words, not a',
|
|
370
|
+
'replacement for them.',
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
|
|
351
374
|
lines.push(
|
|
352
375
|
'',
|
|
353
376
|
'**For every user message in this session, you MUST call `channel_reply`',
|
|
@@ -16,6 +16,7 @@ export type CompletionReminderArgs = {
|
|
|
16
16
|
durationMs: number
|
|
17
17
|
error?: string
|
|
18
18
|
channel?: boolean
|
|
19
|
+
adapter?: string
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
const CHANNEL_REPLY_NUDGE =
|
|
@@ -28,9 +29,23 @@ const CHANNEL_REPLY_NUDGE =
|
|
|
28
29
|
'can see why the post-completion turn was silent. `NO_REPLY` is the legacy fallback only when ' +
|
|
29
30
|
'`skip_response` is unavailable.'
|
|
30
31
|
|
|
32
|
+
// Conditional carve-out for github channel sessions. The base nudge above
|
|
33
|
+
// steers EVERY completion to `channel_reply`, which on github is a plain PR
|
|
34
|
+
// comment — fine for most subagents, but wrong for a finished `reviewer`:
|
|
35
|
+
// a verdict delivered as a comment leaves the PR "awaiting review" with no
|
|
36
|
+
// formal approval. This reminder cannot tell a review turn from any other
|
|
37
|
+
// completion, so the carve-out is phrased conditionally ("if this was a PR
|
|
38
|
+
// review") to redirect only the review case without misleading the rest.
|
|
39
|
+
const GITHUB_REVIEW_NUDGE =
|
|
40
|
+
'If this was a PR review, the verdict is a formal review, not a `channel_reply`: ' +
|
|
41
|
+
'post it via `gh api -X POST /repos/owner/repo/pulls/<N>/reviews` (APPROVE/REQUEST_CHANGES/COMMENT) ' +
|
|
42
|
+
'and end the turn with `skip_response`. A `channel_reply` that merely says "Approved" posts a ' +
|
|
43
|
+
'comment and leaves the PR awaiting review.'
|
|
44
|
+
|
|
31
45
|
export function renderSubagentCompletionReminder(args: CompletionReminderArgs): string {
|
|
32
46
|
const durationStr = formatReminderDuration(args.durationMs)
|
|
33
|
-
const
|
|
47
|
+
const githubTail = args.channel === true && args.adapter === 'github' ? ` ${GITHUB_REVIEW_NUDGE}` : ''
|
|
48
|
+
const channelTail = args.channel === true ? ` ${CHANNEL_REPLY_NUDGE}${githubTail}` : ''
|
|
34
49
|
if (args.ok) {
|
|
35
50
|
return (
|
|
36
51
|
`<system-reminder>\n` +
|
|
@@ -31,12 +31,19 @@ export function createChannelReactTool({
|
|
|
31
31
|
name: 'channel_react',
|
|
32
32
|
label: 'Channel React',
|
|
33
33
|
description:
|
|
34
|
-
'
|
|
35
|
-
'
|
|
36
|
-
'
|
|
34
|
+
'React to the message that triggered this turn with an emoji that fits its content or tone — a lightweight, ' +
|
|
35
|
+
'human touch that posts no comment. Works on GitHub (issue/PR/comment), Slack, and Discord. ' +
|
|
36
|
+
'Pick the reaction a thoughtful teammate would leave: :+1: to agree or approve, :rocket: for something ' +
|
|
37
|
+
'shipping or exciting, :tada: to celebrate, :heart: to show appreciation, :eyes: to signal "I am looking at this", ' +
|
|
38
|
+
':laugh: for something funny. Use it when a reaction adds genuine social signal — not on every message. ' +
|
|
39
|
+
'A reaction does NOT replace `channel_reply`: when the message needs a substantive answer, still send one. ' +
|
|
40
|
+
'If a reaction alone is the whole response, call `skip_response` afterward so the turn ends cleanly. ' +
|
|
41
|
+
'Pass the bare emoji name, no colons.',
|
|
37
42
|
parameters: Type.Object({
|
|
38
43
|
emoji: Type.String({
|
|
39
|
-
description:
|
|
44
|
+
description:
|
|
45
|
+
'Bare emoji name, no surrounding colons. Choose one that matches the message: ' +
|
|
46
|
+
'e.g. "+1", "rocket", "tada", "heart", "eyes", "laugh".',
|
|
40
47
|
minLength: 1,
|
|
41
48
|
}),
|
|
42
49
|
}),
|
|
@@ -8,6 +8,8 @@ import type {
|
|
|
8
8
|
import type { ChannelAdapterConfig } from '@/channels/schema'
|
|
9
9
|
import type { InboundAttachment, InboundMessage } from '@/channels/types'
|
|
10
10
|
|
|
11
|
+
import { encodeDiscordReactionRef } from './discord-bot-reactions'
|
|
12
|
+
|
|
11
13
|
export type InboundDropReason =
|
|
12
14
|
| 'self_author' // event.author.id === botUserId; we never route our own messages back to ourselves
|
|
13
15
|
| 'empty_content' // SDK delivered content: '' — usually missing MessageContent intent
|
|
@@ -87,6 +89,7 @@ export function classifyInbound(
|
|
|
87
89
|
text,
|
|
88
90
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
89
91
|
externalMessageId: event.id,
|
|
92
|
+
reactionRef: encodeDiscordReactionRef({ channel: event.channel_id, message: event.id }),
|
|
90
93
|
authorId: event.author.id,
|
|
91
94
|
// Discord's post-2023 username system allows pure-numeric handles (e.g.
|
|
92
95
|
// "1411531"); the human-facing display name lives on `global_name`. The
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { DiscordBotClient } from 'agent-messenger/discordbot'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ReactionCallback,
|
|
5
|
+
ReactionErrorCode,
|
|
6
|
+
ReactionRef,
|
|
7
|
+
ReactionResult,
|
|
8
|
+
RemoveReactionCallback,
|
|
9
|
+
} from '@/channels/types'
|
|
10
|
+
|
|
11
|
+
// The reactable target on Discord: a message is addressed by its channel id
|
|
12
|
+
// plus the message id. The classifier stamps this because both values are on
|
|
13
|
+
// the gateway event but `chat`/`externalMessageId` are the only ones that
|
|
14
|
+
// survive into the routed payload — keeping them paired in an opaque ref means
|
|
15
|
+
// the router/tool never have to reassemble Discord's addressing.
|
|
16
|
+
export type DiscordReactionTarget = { channel: string; message: string }
|
|
17
|
+
|
|
18
|
+
// `RemoveReactionRequest` carries no emoji (the router only round-trips the
|
|
19
|
+
// success ref), so removal needs the resolved unicode folded into the ref:
|
|
20
|
+
// Discord's DELETE is keyed by (channel, message, emoji). Mirrors Slack's
|
|
21
|
+
// removal-ref pattern; GitHub instead carries a per-reaction id.
|
|
22
|
+
export type DiscordReactionRemovalTarget = { channel: string; message: string; emoji: string }
|
|
23
|
+
|
|
24
|
+
export function encodeDiscordReactionRef(target: DiscordReactionTarget): ReactionRef {
|
|
25
|
+
return { adapter: 'discord-bot', value: JSON.stringify(target) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function decodeDiscordReactionRef(ref: ReactionRef): DiscordReactionTarget | null {
|
|
29
|
+
if (ref.adapter !== 'discord-bot') return null
|
|
30
|
+
const parsed = parseRecord(ref.value)
|
|
31
|
+
if (parsed === null || parsed.op !== undefined) return null
|
|
32
|
+
const channel = typeof parsed.channel === 'string' ? parsed.channel : null
|
|
33
|
+
const message = typeof parsed.message === 'string' ? parsed.message : null
|
|
34
|
+
if (channel === null || message === null) return null
|
|
35
|
+
return { channel, message }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function encodeDiscordRemovalRef(target: DiscordReactionRemovalTarget): ReactionRef {
|
|
39
|
+
return { adapter: 'discord-bot', value: JSON.stringify({ op: 'remove', ...target }) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function decodeDiscordRemovalRef(ref: ReactionRef): DiscordReactionRemovalTarget | null {
|
|
43
|
+
if (ref.adapter !== 'discord-bot') return null
|
|
44
|
+
const parsed = parseRecord(ref.value)
|
|
45
|
+
if (parsed === null || parsed.op !== 'remove') return null
|
|
46
|
+
const channel = typeof parsed.channel === 'string' ? parsed.channel : null
|
|
47
|
+
const message = typeof parsed.message === 'string' ? parsed.message : null
|
|
48
|
+
const emoji = typeof parsed.emoji === 'string' ? parsed.emoji : null
|
|
49
|
+
if (channel === null || message === null || emoji === null) return null
|
|
50
|
+
return { channel, message, emoji }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Discord's reaction endpoint takes the literal unicode emoji (URL-encoded by
|
|
54
|
+
// the SDK), NOT a `:name:` shortcode — the agent passes adapter-generic bare
|
|
55
|
+
// names (`+1`, `rocket`), so we translate here. The set mirrors GitHub's fixed
|
|
56
|
+
// reaction vocabulary so the same `channel_react({ emoji })` call works on both
|
|
57
|
+
// platforms, plus a few extra chat-native acks. An unmapped name is reported as
|
|
58
|
+
// `unsupported` rather than forwarded, so a typo gets a clear signal instead of
|
|
59
|
+
// Discord's opaque `10014 Unknown Emoji`.
|
|
60
|
+
const EMOJI_UNICODE: Record<string, string> = {
|
|
61
|
+
eyes: '👀',
|
|
62
|
+
'+1': '👍',
|
|
63
|
+
thumbsup: '👍',
|
|
64
|
+
'-1': '👎',
|
|
65
|
+
thumbsdown: '👎',
|
|
66
|
+
laugh: '😄',
|
|
67
|
+
hooray: '🎉',
|
|
68
|
+
tada: '🎉',
|
|
69
|
+
confused: '😕',
|
|
70
|
+
heart: '❤️',
|
|
71
|
+
rocket: '🚀',
|
|
72
|
+
white_check_mark: '✅',
|
|
73
|
+
'white-check-mark': '✅',
|
|
74
|
+
check: '✅',
|
|
75
|
+
fire: '🔥',
|
|
76
|
+
eye: '👁️',
|
|
77
|
+
raised_hands: '🙌',
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function resolveEmoji(emoji: string): string | null {
|
|
81
|
+
const name = emoji.replace(/^:|:$/g, '')
|
|
82
|
+
return EMOJI_UNICODE[name] ?? null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createDiscordReactionCallback(deps: {
|
|
86
|
+
client: Pick<DiscordBotClient, 'addReaction'>
|
|
87
|
+
}): ReactionCallback {
|
|
88
|
+
return async (req): Promise<ReactionResult> => {
|
|
89
|
+
if (req.adapter !== 'discord-bot') {
|
|
90
|
+
return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
|
|
91
|
+
}
|
|
92
|
+
const unicode = resolveEmoji(req.emoji)
|
|
93
|
+
if (unicode === null) {
|
|
94
|
+
return { ok: false, error: `discord does not support reaction "${req.emoji}"`, code: 'unsupported' }
|
|
95
|
+
}
|
|
96
|
+
const target = decodeDiscordReactionRef(req.reactionRef)
|
|
97
|
+
if (target === null) return { ok: false, error: 'unparseable discord reaction ref', code: 'unsupported' }
|
|
98
|
+
try {
|
|
99
|
+
await deps.client.addReaction(target.channel, target.message, unicode)
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return { ok: false, error: describe(err), code: classifyDiscordError(err) }
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, reactionRef: encodeDiscordRemovalRef({ ...target, emoji: unicode }) }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createDiscordRemoveReactionCallback(deps: {
|
|
108
|
+
client: Pick<DiscordBotClient, 'removeReaction'>
|
|
109
|
+
}): RemoveReactionCallback {
|
|
110
|
+
return async (req): Promise<ReactionResult> => {
|
|
111
|
+
if (req.adapter !== 'discord-bot') {
|
|
112
|
+
return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
|
|
113
|
+
}
|
|
114
|
+
const target = decodeDiscordRemovalRef(req.reactionRef)
|
|
115
|
+
if (target === null) return { ok: false, error: 'unparseable discord reaction removal ref', code: 'unsupported' }
|
|
116
|
+
try {
|
|
117
|
+
await deps.client.removeReaction(target.channel, target.message, target.emoji)
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return { ok: false, error: describe(err), code: classifyDiscordError(err) }
|
|
120
|
+
}
|
|
121
|
+
return { ok: true }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// DiscordBotError exposes the Discord error code on `.code` as a string. We
|
|
126
|
+
// read it structurally so a wrapped/re-thrown error still classifies. The
|
|
127
|
+
// numeric codes are Discord's documented JSON error codes; `http_4xx` is the
|
|
128
|
+
// SDK's fallback when no JSON code is present.
|
|
129
|
+
function classifyDiscordError(err: unknown): ReactionErrorCode {
|
|
130
|
+
const code = typeof err === 'object' && err !== null && 'code' in err ? String((err as { code: unknown }).code) : ''
|
|
131
|
+
switch (code) {
|
|
132
|
+
case '10003': // Unknown Channel
|
|
133
|
+
case '10008': // Unknown Message
|
|
134
|
+
case 'http_404':
|
|
135
|
+
return 'not-found'
|
|
136
|
+
case '50001': // Missing Access
|
|
137
|
+
case '50013': // Missing Permissions
|
|
138
|
+
case 'http_403':
|
|
139
|
+
case 'http_401':
|
|
140
|
+
return 'permission-denied'
|
|
141
|
+
case '10014': // Unknown Emoji
|
|
142
|
+
return 'unsupported'
|
|
143
|
+
case 'http_429':
|
|
144
|
+
return 'rate-limited'
|
|
145
|
+
default:
|
|
146
|
+
return 'transient'
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseRecord(value: string): Record<string, unknown> | null {
|
|
151
|
+
let parsed: unknown
|
|
152
|
+
try {
|
|
153
|
+
parsed = JSON.parse(value)
|
|
154
|
+
} catch {
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
158
|
+
? (parsed as Record<string, unknown>)
|
|
159
|
+
: null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function describe(err: unknown): string {
|
|
163
|
+
return err instanceof Error ? err.message : String(err)
|
|
164
|
+
}
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
type InboundDropReason,
|
|
40
40
|
renderPlaceholder,
|
|
41
41
|
} from './discord-bot-classify'
|
|
42
|
+
import { createDiscordReactionCallback, createDiscordRemoveReactionCallback } from './discord-bot-reactions'
|
|
42
43
|
import { enrichDiscordMessageReferences } from './discord-bot-reference'
|
|
43
44
|
import {
|
|
44
45
|
ackInteraction,
|
|
@@ -863,6 +864,9 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
|
|
|
863
864
|
|
|
864
865
|
const fetchAttachmentCallback = createFetchAttachmentCallback({ token: options.token, logger })
|
|
865
866
|
|
|
867
|
+
const reactionCallback = createDiscordReactionCallback({ client })
|
|
868
|
+
const removeReactionCallback = createDiscordRemoveReactionCallback({ client })
|
|
869
|
+
|
|
866
870
|
const interactionHandler = createInteractionHandler({
|
|
867
871
|
router: options.router,
|
|
868
872
|
knownCommandNames: SLASH_COMMAND_NAMES,
|
|
@@ -999,6 +1003,8 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
|
|
|
999
1003
|
})
|
|
1000
1004
|
|
|
1001
1005
|
options.router.registerOutbound('discord-bot', outboundCallback)
|
|
1006
|
+
options.router.registerReaction('discord-bot', reactionCallback)
|
|
1007
|
+
options.router.registerRemoveReaction('discord-bot', removeReactionCallback)
|
|
1002
1008
|
options.router.registerTyping('discord-bot', typingCallback)
|
|
1003
1009
|
options.router.registerChannelNameResolver('discord-bot', channelResolver)
|
|
1004
1010
|
options.router.registerSelfIdentity('discord-bot', selfIdentityResolver)
|
|
@@ -1009,6 +1015,21 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
|
|
|
1009
1015
|
try {
|
|
1010
1016
|
await listener.start()
|
|
1011
1017
|
} catch (err) {
|
|
1018
|
+
// Listener failed after registration — roll back every callback so a
|
|
1019
|
+
// failed start leaves no router state behind (stop() returns early on
|
|
1020
|
+
// !started and would otherwise skip cleanup), mirroring the github
|
|
1021
|
+
// adapter's rollback path.
|
|
1022
|
+
options.router.unregisterOutbound('discord-bot', outboundCallback)
|
|
1023
|
+
options.router.unregisterReaction('discord-bot', reactionCallback)
|
|
1024
|
+
options.router.unregisterRemoveReaction('discord-bot', removeReactionCallback)
|
|
1025
|
+
options.router.unregisterTyping('discord-bot', typingCallback)
|
|
1026
|
+
options.router.unregisterChannelNameResolver('discord-bot', channelResolver)
|
|
1027
|
+
options.router.unregisterSelfIdentity('discord-bot', selfIdentityResolver)
|
|
1028
|
+
options.router.unregisterHistory('discord-bot', historyCallback)
|
|
1029
|
+
options.router.unregisterFetchAttachment('discord-bot', fetchAttachmentCallback)
|
|
1030
|
+
options.router.unregisterMembership('discord-bot', membershipResolver)
|
|
1031
|
+
listener = null
|
|
1032
|
+
botUserId = null
|
|
1012
1033
|
started = false
|
|
1013
1034
|
logger.error(`[discord-bot] listener start failed: ${describe(err)}`)
|
|
1014
1035
|
throw err
|
|
@@ -1019,6 +1040,8 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
|
|
|
1019
1040
|
if (!started) return
|
|
1020
1041
|
started = false
|
|
1021
1042
|
options.router.unregisterOutbound('discord-bot', outboundCallback)
|
|
1043
|
+
options.router.unregisterReaction('discord-bot', reactionCallback)
|
|
1044
|
+
options.router.unregisterRemoveReaction('discord-bot', removeReactionCallback)
|
|
1022
1045
|
options.router.unregisterTyping('discord-bot', typingCallback)
|
|
1023
1046
|
options.router.unregisterChannelNameResolver('discord-bot', channelResolver)
|
|
1024
1047
|
options.router.unregisterSelfIdentity('discord-bot', selfIdentityResolver)
|
|
@@ -301,7 +301,12 @@ export function classifyGithubInbound(
|
|
|
301
301
|
teamIsBotMember: options?.teamIsBotMember,
|
|
302
302
|
})
|
|
303
303
|
}
|
|
304
|
-
|
|
304
|
+
// `ready_for_review` (draft→ready) is a fresh review opportunity, so it
|
|
305
|
+
// reuses the `opened` paths: same review trigger, same title-only awareness
|
|
306
|
+
// text. The draft skip in classifyOpenedReviewTrigger is a no-op here since
|
|
307
|
+
// the PR is non-draft once ready — preserving "review when no longer draft".
|
|
308
|
+
const isOpenLike = action === 'opened' || action === 'ready_for_review'
|
|
309
|
+
if (isOpenLike && reviewOn === 'opened') {
|
|
305
310
|
const trigger = classifyOpenedReviewTrigger({
|
|
306
311
|
payload,
|
|
307
312
|
pr,
|
|
@@ -314,8 +319,7 @@ export function classifyGithubInbound(
|
|
|
314
319
|
}
|
|
315
320
|
const opener = readUser(pr.user)
|
|
316
321
|
const hasBody = readString(pr, 'body')?.trim() ? true : false
|
|
317
|
-
const prText =
|
|
318
|
-
action === 'opened' ? bodyOrOpenedTitle(pr.body, opener, 'PR', number, readString(pr, 'title')) : pr.body
|
|
322
|
+
const prText = isOpenLike ? bodyOrOpenedTitle(pr.body, opener, 'PR', number, readString(pr, 'title')) : pr.body
|
|
319
323
|
return buildInbound(
|
|
320
324
|
{ ...base, chat: `pr:${number}`, thread: null },
|
|
321
325
|
prText,
|
|
@@ -324,7 +328,7 @@ export function classifyGithubInbound(
|
|
|
324
328
|
selfLogin,
|
|
325
329
|
pr.created_at,
|
|
326
330
|
{ kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
|
|
327
|
-
|
|
331
|
+
isOpenLike && !hasBody,
|
|
328
332
|
)
|
|
329
333
|
}
|
|
330
334
|
|
|
@@ -4,6 +4,7 @@ import { matchesAnyAlias } from '@/channels/engagement'
|
|
|
4
4
|
import type { ChannelAdapterConfig } from '@/channels/schema'
|
|
5
5
|
import type { InboundAttachment, InboundMessage } from '@/channels/types'
|
|
6
6
|
|
|
7
|
+
import { encodeSlackReactionRef } from './slack-bot-reactions'
|
|
7
8
|
import { hasSlackMessageShareAttachments } from './slack-bot-reference'
|
|
8
9
|
import { slackTsToMillis } from './slack-bot-time'
|
|
9
10
|
|
|
@@ -141,6 +142,7 @@ export function classifyInbound(
|
|
|
141
142
|
text,
|
|
142
143
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
143
144
|
externalMessageId: event.ts,
|
|
145
|
+
reactionRef: encodeSlackReactionRef({ channel: event.channel, ts: event.ts }),
|
|
144
146
|
authorId: event.user,
|
|
145
147
|
authorName: event.user,
|
|
146
148
|
authorIsBot,
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { SlackBotClient } from 'agent-messenger/slackbot'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ReactionCallback,
|
|
5
|
+
ReactionErrorCode,
|
|
6
|
+
ReactionRef,
|
|
7
|
+
ReactionResult,
|
|
8
|
+
RemoveReactionCallback,
|
|
9
|
+
} from '@/channels/types'
|
|
10
|
+
|
|
11
|
+
// The reactable target on Slack: a message is addressed by its channel id plus
|
|
12
|
+
// the message `ts`. The classifier stamps this because `ts` is the inbound's
|
|
13
|
+
// own message timestamp — the same value that becomes `externalMessageId`
|
|
14
|
+
// downstream, but kept in an opaque ref so the router/tool never have to know
|
|
15
|
+
// Slack's addressing. Mirrors the GitHub `GithubReactionTarget` precedent.
|
|
16
|
+
export type SlackReactionTarget = { channel: string; ts: string }
|
|
17
|
+
|
|
18
|
+
// Removal needs the emoji name too: Slack's `reactions.remove` is keyed by
|
|
19
|
+
// (channel, ts, name), unlike GitHub's per-reaction id. We fold the emoji that
|
|
20
|
+
// was added into the removal ref so `RemoveReactionCallback` can reconstruct
|
|
21
|
+
// the exact call without the caller tracking it.
|
|
22
|
+
export type SlackReactionRemovalTarget = { channel: string; ts: string; emoji: string }
|
|
23
|
+
|
|
24
|
+
export function encodeSlackReactionRef(target: SlackReactionTarget): ReactionRef {
|
|
25
|
+
return { adapter: 'slack-bot', value: JSON.stringify(target) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function decodeSlackReactionRef(ref: ReactionRef): SlackReactionTarget | null {
|
|
29
|
+
if (ref.adapter !== 'slack-bot') return null
|
|
30
|
+
const parsed = parseRecord(ref.value)
|
|
31
|
+
if (parsed === null) return null
|
|
32
|
+
if (parsed.op !== undefined) return null
|
|
33
|
+
const channel = typeof parsed.channel === 'string' ? parsed.channel : null
|
|
34
|
+
const ts = typeof parsed.ts === 'string' ? parsed.ts : null
|
|
35
|
+
if (channel === null || ts === null) return null
|
|
36
|
+
return { channel, ts }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function encodeSlackRemovalRef(target: SlackReactionRemovalTarget): ReactionRef {
|
|
40
|
+
return { adapter: 'slack-bot', value: JSON.stringify({ op: 'remove', ...target }) }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function decodeSlackRemovalRef(ref: ReactionRef): SlackReactionRemovalTarget | null {
|
|
44
|
+
if (ref.adapter !== 'slack-bot') return null
|
|
45
|
+
const parsed = parseRecord(ref.value)
|
|
46
|
+
if (parsed === null || parsed.op !== 'remove') return null
|
|
47
|
+
const channel = typeof parsed.channel === 'string' ? parsed.channel : null
|
|
48
|
+
const ts = typeof parsed.ts === 'string' ? parsed.ts : null
|
|
49
|
+
const emoji = typeof parsed.emoji === 'string' ? parsed.emoji : null
|
|
50
|
+
if (channel === null || ts === null || emoji === null) return null
|
|
51
|
+
return { channel, ts, emoji }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Slack accepts any custom-emoji name the workspace has, so unlike GitHub there
|
|
55
|
+
// is no fixed allow-list to validate against up front — an unknown name comes
|
|
56
|
+
// back as `invalid_name` from the API, which we map to `unsupported`. We only
|
|
57
|
+
// strip surrounding colons here; the SDK does the same, but normalizing first
|
|
58
|
+
// keeps the removal ref's stored name canonical.
|
|
59
|
+
function normalizeEmoji(emoji: string): string {
|
|
60
|
+
return emoji.replace(/^:|:$/g, '')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createSlackReactionCallback(deps: { client: Pick<SlackBotClient, 'addReaction'> }): ReactionCallback {
|
|
64
|
+
return async (req): Promise<ReactionResult> => {
|
|
65
|
+
if (req.adapter !== 'slack-bot') {
|
|
66
|
+
return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
|
|
67
|
+
}
|
|
68
|
+
const target = decodeSlackReactionRef(req.reactionRef)
|
|
69
|
+
if (target === null) return { ok: false, error: 'unparseable slack reaction ref', code: 'unsupported' }
|
|
70
|
+
const emoji = normalizeEmoji(req.emoji)
|
|
71
|
+
try {
|
|
72
|
+
await deps.client.addReaction(target.channel, target.ts, emoji)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// `already_reacted` is the desired end state, not a failure: a duplicate
|
|
75
|
+
// engage (or a retried tool call) that re-adds the same emoji must read
|
|
76
|
+
// as success so the model/runtime don't surface a spurious error.
|
|
77
|
+
const code = slackErrorCode(err)
|
|
78
|
+
if (code === 'already_reacted') {
|
|
79
|
+
return { ok: true, reactionRef: encodeSlackRemovalRef({ ...target, emoji }) }
|
|
80
|
+
}
|
|
81
|
+
return { ok: false, error: withScopeHint(code, describe(err)), code: classifySlackError(code) }
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, reactionRef: encodeSlackRemovalRef({ ...target, emoji }) }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createSlackRemoveReactionCallback(deps: {
|
|
88
|
+
client: Pick<SlackBotClient, 'removeReaction'>
|
|
89
|
+
}): RemoveReactionCallback {
|
|
90
|
+
return async (req): Promise<ReactionResult> => {
|
|
91
|
+
if (req.adapter !== 'slack-bot') {
|
|
92
|
+
return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
|
|
93
|
+
}
|
|
94
|
+
const target = decodeSlackRemovalRef(req.reactionRef)
|
|
95
|
+
if (target === null) return { ok: false, error: 'unparseable slack reaction removal ref', code: 'unsupported' }
|
|
96
|
+
try {
|
|
97
|
+
await deps.client.removeReaction(target.channel, target.ts, target.emoji)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
// `no_reaction` means the reaction is already gone — the desired end
|
|
100
|
+
// state for a removal, so treat it as success (idempotent), mirroring the
|
|
101
|
+
// `already_reacted` handling on the add path.
|
|
102
|
+
const code = slackErrorCode(err)
|
|
103
|
+
if (code === 'no_reaction') return { ok: true }
|
|
104
|
+
return { ok: false, error: describe(err), code: classifySlackError(code) }
|
|
105
|
+
}
|
|
106
|
+
return { ok: true }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// SlackBotError carries the raw Slack API error string on `.code`. We read it
|
|
111
|
+
// structurally (not by instanceof) so a re-thrown or wrapped error still maps
|
|
112
|
+
// correctly, falling back to the message when no code is present.
|
|
113
|
+
function slackErrorCode(err: unknown): string | null {
|
|
114
|
+
if (typeof err === 'object' && err !== null && 'code' in err) {
|
|
115
|
+
const code = (err as { code: unknown }).code
|
|
116
|
+
if (typeof code === 'string') return code
|
|
117
|
+
}
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// `reactions:write` is the scope the bot token needs for both add and remove.
|
|
122
|
+
// On `missing_scope` the bare Slack error is uninformative, so append the
|
|
123
|
+
// concrete operator fix — mirroring GitHub's permission-guidance precedent —
|
|
124
|
+
// since autoReactOnEngage surfaces this to host logs on every engaged inbound
|
|
125
|
+
// until the scope is granted.
|
|
126
|
+
function withScopeHint(code: string | null, error: string): string {
|
|
127
|
+
if (code !== 'missing_scope') return error
|
|
128
|
+
return `${error} (Slack bot token needs the \`reactions:write\` scope; reinstall/reauthorize the app with that scope.)`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function classifySlackError(code: string | null): ReactionErrorCode {
|
|
132
|
+
switch (code) {
|
|
133
|
+
case 'invalid_name':
|
|
134
|
+
case 'no_item_specified':
|
|
135
|
+
return 'unsupported'
|
|
136
|
+
case 'missing_scope':
|
|
137
|
+
case 'not_in_channel':
|
|
138
|
+
case 'is_archived':
|
|
139
|
+
case 'not_authed':
|
|
140
|
+
case 'invalid_auth':
|
|
141
|
+
return 'permission-denied'
|
|
142
|
+
case 'message_not_found':
|
|
143
|
+
case 'channel_not_found':
|
|
144
|
+
return 'not-found'
|
|
145
|
+
case 'ratelimited':
|
|
146
|
+
case 'rate_limited':
|
|
147
|
+
return 'rate-limited'
|
|
148
|
+
default:
|
|
149
|
+
return 'transient'
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseRecord(value: string): Record<string, unknown> | null {
|
|
154
|
+
let parsed: unknown
|
|
155
|
+
try {
|
|
156
|
+
parsed = JSON.parse(value)
|
|
157
|
+
} catch {
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
161
|
+
? (parsed as Record<string, unknown>)
|
|
162
|
+
: null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function describe(err: unknown): string {
|
|
166
|
+
return err instanceof Error ? err.message : String(err)
|
|
167
|
+
}
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
type SlackInboundMessageEvent,
|
|
44
44
|
} from './slack-bot-classify'
|
|
45
45
|
import { createSlackDedupe } from './slack-bot-dedupe'
|
|
46
|
+
import { createSlackReactionCallback, createSlackRemoveReactionCallback } from './slack-bot-reactions'
|
|
46
47
|
import { enrichSlackReferenceContext } from './slack-bot-reference'
|
|
47
48
|
import {
|
|
48
49
|
buildSlashAckPayload,
|
|
@@ -997,6 +998,9 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
997
998
|
|
|
998
999
|
const fetchAttachmentCallback = createFetchAttachmentCallback({ client, logger })
|
|
999
1000
|
|
|
1001
|
+
const reactionCallback = createSlackReactionCallback({ client })
|
|
1002
|
+
const removeReactionCallback = createSlackRemoveReactionCallback({ client })
|
|
1003
|
+
|
|
1000
1004
|
const dedupe = createSlackDedupe()
|
|
1001
1005
|
|
|
1002
1006
|
const handleSlashCommand = createSlashCommandHandler({
|
|
@@ -1206,6 +1210,8 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
1206
1210
|
})
|
|
1207
1211
|
|
|
1208
1212
|
options.router.registerOutbound('slack-bot', outboundCallback)
|
|
1213
|
+
options.router.registerReaction('slack-bot', reactionCallback)
|
|
1214
|
+
options.router.registerRemoveReaction('slack-bot', removeReactionCallback)
|
|
1209
1215
|
options.router.registerTyping('slack-bot', typingCallback)
|
|
1210
1216
|
options.router.registerChannelNameResolver('slack-bot', channelResolver)
|
|
1211
1217
|
options.router.registerSelfIdentity('slack-bot', selfIdentityResolver)
|
|
@@ -1216,6 +1222,22 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
1216
1222
|
try {
|
|
1217
1223
|
await listener.start()
|
|
1218
1224
|
} catch (err) {
|
|
1225
|
+
// Listener failed after registration — roll back every callback so a
|
|
1226
|
+
// failed start leaves no router state behind (stop() returns early on
|
|
1227
|
+
// !started and would otherwise skip cleanup), mirroring the github
|
|
1228
|
+
// adapter's rollback path.
|
|
1229
|
+
options.router.unregisterOutbound('slack-bot', outboundCallback)
|
|
1230
|
+
options.router.unregisterReaction('slack-bot', reactionCallback)
|
|
1231
|
+
options.router.unregisterRemoveReaction('slack-bot', removeReactionCallback)
|
|
1232
|
+
options.router.unregisterTyping('slack-bot', typingCallback)
|
|
1233
|
+
options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
|
|
1234
|
+
options.router.unregisterSelfIdentity('slack-bot', selfIdentityResolver)
|
|
1235
|
+
options.router.unregisterHistory('slack-bot', historyCallback)
|
|
1236
|
+
options.router.unregisterFetchAttachment('slack-bot', fetchAttachmentCallback)
|
|
1237
|
+
options.router.unregisterMembership('slack-bot', membershipResolver)
|
|
1238
|
+
listener = null
|
|
1239
|
+
botUserId = null
|
|
1240
|
+
teamId = null
|
|
1219
1241
|
started = false
|
|
1220
1242
|
logger.error(`[slack-bot] listener start failed: ${describe(err)}`)
|
|
1221
1243
|
throw err
|
|
@@ -1226,6 +1248,8 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
1226
1248
|
if (!started) return
|
|
1227
1249
|
started = false
|
|
1228
1250
|
options.router.unregisterOutbound('slack-bot', outboundCallback)
|
|
1251
|
+
options.router.unregisterReaction('slack-bot', reactionCallback)
|
|
1252
|
+
options.router.unregisterRemoveReaction('slack-bot', removeReactionCallback)
|
|
1229
1253
|
options.router.unregisterTyping('slack-bot', typingCallback)
|
|
1230
1254
|
options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
|
|
1231
1255
|
options.router.unregisterSelfIdentity('slack-bot', selfIdentityResolver)
|
package/src/channels/router.ts
CHANGED
|
@@ -3233,6 +3233,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3233
3233
|
error?: string
|
|
3234
3234
|
},
|
|
3235
3235
|
): { kind: 'delivered'; keyId: string } => {
|
|
3236
|
+
const adapter = live.keyId.split(':', 1)[0] ?? ''
|
|
3236
3237
|
const text = renderSubagentCompletionReminder({
|
|
3237
3238
|
subagent: args.subagent,
|
|
3238
3239
|
taskId: args.taskId,
|
|
@@ -3240,6 +3241,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3240
3241
|
durationMs: args.durationMs,
|
|
3241
3242
|
...(args.error !== undefined ? { error: args.error } : {}),
|
|
3242
3243
|
channel: true,
|
|
3244
|
+
adapter,
|
|
3243
3245
|
})
|
|
3244
3246
|
live.pendingSystemReminders.push(text)
|
|
3245
3247
|
// The reminder tells the agent to fetch this result now; clear the
|
package/src/channels/schema.ts
CHANGED
|
@@ -125,12 +125,53 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
|
|
|
125
125
|
'discussion_comment.created',
|
|
126
126
|
'issues.opened',
|
|
127
127
|
'pull_request.opened',
|
|
128
|
+
'pull_request.ready_for_review',
|
|
128
129
|
'pull_request.review_requested',
|
|
129
130
|
'pull_request.review_request_removed',
|
|
130
131
|
'discussion.created',
|
|
131
132
|
'pull_request_review.submitted',
|
|
132
133
|
] as const
|
|
133
134
|
|
|
135
|
+
// Prior values of DEFAULT_GITHUB_EVENT_ALLOWLIST that shipped in releases and
|
|
136
|
+
// were seeded verbatim into typeclaw.json. Kept as historical record so the
|
|
137
|
+
// migration can recognize and unfreeze configs created by those versions.
|
|
138
|
+
// NEVER edit these in place — they are snapshots of what was on disk.
|
|
139
|
+
// - v1: 7-event default, shipped 0.5.1–0.10.0 (commit fe4f3a8)
|
|
140
|
+
const GITHUB_EVENT_ALLOWLIST_V1 = [
|
|
141
|
+
'issue_comment.created',
|
|
142
|
+
'pull_request_review_comment.created',
|
|
143
|
+
'discussion_comment.created',
|
|
144
|
+
'issues.opened',
|
|
145
|
+
'pull_request.opened',
|
|
146
|
+
'discussion.created',
|
|
147
|
+
'pull_request_review.submitted',
|
|
148
|
+
] as const
|
|
149
|
+
// - v2: added review_requested + review_request_removed, shipped 0.11.0+ (commit 4f365ce)
|
|
150
|
+
const GITHUB_EVENT_ALLOWLIST_V2 = [
|
|
151
|
+
'issue_comment.created',
|
|
152
|
+
'pull_request_review_comment.created',
|
|
153
|
+
'discussion_comment.created',
|
|
154
|
+
'issues.opened',
|
|
155
|
+
'pull_request.opened',
|
|
156
|
+
'pull_request.review_requested',
|
|
157
|
+
'pull_request.review_request_removed',
|
|
158
|
+
'discussion.created',
|
|
159
|
+
'pull_request_review.submitted',
|
|
160
|
+
] as const
|
|
161
|
+
|
|
162
|
+
// Every event-allowlist that `channel add` / `init` has ever seeded verbatim
|
|
163
|
+
// into typeclaw.json, oldest first, current default last. The legacy-shape
|
|
164
|
+
// migration uses this to tell a seeded default (safe to strip so the config
|
|
165
|
+
// re-tracks the shipped default) from a user's deliberate customization (must
|
|
166
|
+
// be preserved). Append the prior array here — never edit in place — whenever
|
|
167
|
+
// DEFAULT_GITHUB_EVENT_ALLOWLIST changes, or configs from the old version stay
|
|
168
|
+
// frozen and the migration starts eating user edits.
|
|
169
|
+
export const SEEDED_GITHUB_EVENT_ALLOWLISTS: readonly (readonly string[])[] = [
|
|
170
|
+
GITHUB_EVENT_ALLOWLIST_V1,
|
|
171
|
+
GITHUB_EVENT_ALLOWLIST_V2,
|
|
172
|
+
DEFAULT_GITHUB_EVENT_ALLOWLIST,
|
|
173
|
+
]
|
|
174
|
+
|
|
134
175
|
// Which pull_request webhook action triggers an agent code review. The two
|
|
135
176
|
// event values are GitHub's bare PR action names (the `pull_request.` event
|
|
136
177
|
// prefix is implied by this field living under the review config); `off` is the
|
package/src/config/config.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { isAbsolute, join, resolve } from 'node:path'
|
|
|
5
5
|
import type { Model } from '@mariozechner/pi-ai'
|
|
6
6
|
import { z } from 'zod'
|
|
7
7
|
|
|
8
|
-
import { channelsSchema } from '@/channels/schema'
|
|
8
|
+
import { channelsSchema, SEEDED_GITHUB_EVENT_ALLOWLISTS } from '@/channels/schema'
|
|
9
9
|
import { commitSystemFileSync } from '@/git/system-commit'
|
|
10
10
|
import { rolesConfigSchema } from '@/permissions/schema'
|
|
11
11
|
import { secretFieldSchema } from '@/secrets/resolve'
|
|
@@ -810,6 +810,7 @@ export type MigrationStep =
|
|
|
810
810
|
| { kind: 'strip-permissions-gate-channel-respond' }
|
|
811
811
|
| { kind: 'model-to-models'; ref: string }
|
|
812
812
|
| { kind: 'drop-stale-model'; ref: string }
|
|
813
|
+
| { kind: 'drop-github-seeded-event-allowlist' }
|
|
813
814
|
|
|
814
815
|
export type MigrationResult = { json: unknown; changed: boolean; applied: MigrationStep[] }
|
|
815
816
|
|
|
@@ -830,13 +831,15 @@ export function migrateLegacyConfigShape(json: unknown): MigrationResult {
|
|
|
830
831
|
// silently — same precedence rule as the dockerfile/gitignore migrations.
|
|
831
832
|
const hasLegacyModel = 'model' in obj && !('models' in obj) && typeof obj.model === 'string'
|
|
832
833
|
const hasStaleModelAlongsideModels = 'model' in obj && 'models' in obj
|
|
834
|
+
const hasSeededGithubEventAllowlist = isSeededGithubEventAllowlist(obj)
|
|
833
835
|
if (
|
|
834
836
|
!hasLegacyDockerfile &&
|
|
835
837
|
!hasLegacyGitignore &&
|
|
836
838
|
!channelsAllowMigration.found &&
|
|
837
839
|
!hasLegacyGateChannelRespond &&
|
|
838
840
|
!hasLegacyModel &&
|
|
839
|
-
!hasStaleModelAlongsideModels
|
|
841
|
+
!hasStaleModelAlongsideModels &&
|
|
842
|
+
!hasSeededGithubEventAllowlist
|
|
840
843
|
) {
|
|
841
844
|
return { json, changed: false, applied: [] }
|
|
842
845
|
}
|
|
@@ -897,9 +900,43 @@ export function migrateLegacyConfigShape(json: unknown): MigrationResult {
|
|
|
897
900
|
delete next.model
|
|
898
901
|
applied.push({ kind: 'drop-stale-model', ref })
|
|
899
902
|
}
|
|
903
|
+
if (hasSeededGithubEventAllowlist) {
|
|
904
|
+
dropSeededGithubEventAllowlist(next)
|
|
905
|
+
applied.push({ kind: 'drop-github-seeded-event-allowlist' })
|
|
906
|
+
}
|
|
900
907
|
return { json: next, changed: true, applied }
|
|
901
908
|
}
|
|
902
909
|
|
|
910
|
+
// True when channels.github.eventAllowlist deep-equals an allowlist that
|
|
911
|
+
// `channel add` / `init` has previously seeded verbatim. Such a value is
|
|
912
|
+
// indistinguishable from "the default at that time", so stripping it lets the
|
|
913
|
+
// config re-track the shipped default. A user who hand-edited to any other set
|
|
914
|
+
// (added/removed/reordered an event) fails this check and is preserved.
|
|
915
|
+
function isSeededGithubEventAllowlist(obj: Record<string, unknown>): boolean {
|
|
916
|
+
const github = isPlainObject(obj.channels) ? obj.channels.github : undefined
|
|
917
|
+
if (!isPlainObject(github)) return false
|
|
918
|
+
const list = github.eventAllowlist
|
|
919
|
+
if (!Array.isArray(list)) return false
|
|
920
|
+
return SEEDED_GITHUB_EVENT_ALLOWLISTS.some((seeded) => arraysEqual(list, seeded))
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function dropSeededGithubEventAllowlist(next: Record<string, unknown>): void {
|
|
924
|
+
const channels = next.channels
|
|
925
|
+
if (!isPlainObject(channels)) return
|
|
926
|
+
const github = channels.github
|
|
927
|
+
if (!isPlainObject(github)) return
|
|
928
|
+
const { eventAllowlist: _dropped, ...rest } = github
|
|
929
|
+
next.channels = { ...channels, github: rest }
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function arraysEqual(a: readonly unknown[], b: readonly unknown[]): boolean {
|
|
933
|
+
if (a.length !== b.length) return false
|
|
934
|
+
for (let i = 0; i < a.length; i++) {
|
|
935
|
+
if (a[i] !== b[i]) return false
|
|
936
|
+
}
|
|
937
|
+
return true
|
|
938
|
+
}
|
|
939
|
+
|
|
903
940
|
// Builds a meaningful one-line git commit subject for a typeclaw.json
|
|
904
941
|
// migration. Single-step migrations get a specific subject; multi-step ones
|
|
905
942
|
// fall back to a stable summary subject with the count. The body (after the
|
|
@@ -949,6 +986,8 @@ function shortStepLabel(step: MigrationStep): string {
|
|
|
949
986
|
return 'lift model → models.default'
|
|
950
987
|
case 'drop-stale-model':
|
|
951
988
|
return 'drop stale legacy model alongside models'
|
|
989
|
+
case 'drop-github-seeded-event-allowlist':
|
|
990
|
+
return 'drop seeded channels.github.eventAllowlist'
|
|
952
991
|
}
|
|
953
992
|
}
|
|
954
993
|
|
|
@@ -972,6 +1011,8 @@ function describeStep(step: MigrationStep): string {
|
|
|
972
1011
|
return step.ref !== ''
|
|
973
1012
|
? `drop stale top-level model (${step.ref}) — models block takes precedence`
|
|
974
1013
|
: 'drop stale top-level model — models block takes precedence'
|
|
1014
|
+
case 'drop-github-seeded-event-allowlist':
|
|
1015
|
+
return 'drop seeded channels.github.eventAllowlist so it re-tracks the shipped default'
|
|
975
1016
|
}
|
|
976
1017
|
}
|
|
977
1018
|
|
package/src/init/index.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
|
3
3
|
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
|
|
6
|
-
import { DEFAULT_GITHUB_EVENT_ALLOWLIST } from '@/channels/schema'
|
|
7
6
|
import { config, configSchema, migrateLegacyConfigShape, type Config } from '@/config'
|
|
8
7
|
import {
|
|
9
8
|
DEFAULT_MODEL_REF,
|
|
@@ -1167,10 +1166,12 @@ async function mergeChannelIntoConfig(cwd: string, options: AddChannelOptions):
|
|
|
1167
1166
|
}
|
|
1168
1167
|
|
|
1169
1168
|
function buildGithubChannelConfig(options: Extract<AddChannelOptions, { channel: 'github' }>): Record<string, unknown> {
|
|
1169
|
+
// Do NOT write eventAllowlist: the schema defaults it at parse time, so
|
|
1170
|
+
// omitting it lets the user's config track the shipped default across
|
|
1171
|
+
// releases instead of freezing the list captured at `channel add` time.
|
|
1170
1172
|
return {
|
|
1171
1173
|
...(options.webhookUrl !== undefined ? { webhookUrl: options.webhookUrl } : {}),
|
|
1172
1174
|
webhookPort: options.webhookPort ?? 8975,
|
|
1173
|
-
eventAllowlist: [...DEFAULT_GITHUB_EVENT_ALLOWLIST],
|
|
1174
1175
|
repos: options.repos,
|
|
1175
1176
|
}
|
|
1176
1177
|
}
|
|
@@ -1246,7 +1247,6 @@ async function writeGithubChannelForInit(cwd: string, credentials: GithubInitCre
|
|
|
1246
1247
|
existingChannels.github = {
|
|
1247
1248
|
...(credentials.webhookUrl !== undefined ? { webhookUrl: credentials.webhookUrl } : {}),
|
|
1248
1249
|
webhookPort: credentials.webhookPort ?? 8975,
|
|
1249
|
-
eventAllowlist: [...DEFAULT_GITHUB_EVENT_ALLOWLIST],
|
|
1250
1250
|
repos: credentials.repos,
|
|
1251
1251
|
}
|
|
1252
1252
|
parsed.channels = existingChannels
|
|
@@ -189,7 +189,7 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
|
|
|
189
189
|
|
|
190
190
|
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:
|
|
191
191
|
|
|
192
|
-
- `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`.)
|
|
192
|
+
- `approve` → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array). **This is still a formal review via `POST /pulls/<N>/reviews`, NOT a `channel_reply`.** A zero-findings approval is the single most common place this goes wrong: with nothing to anchor inline, the model is tempted to just `channel_reply({ text: "Approved …" })` and end the turn. That posts a plain PR comment and leaves the PR **"awaiting review"** with no approval — the verdict never reaches GitHub's review API. Never start a top-level `channel_reply` on a `pr:N` with "Approved" / "LGTM" / "Request changes": those are verdicts, and verdicts are always formal reviews. Submit the `APPROVE` via `gh api`, confirm it landed (step 5), then `skip_response`. **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`.)
|
|
193
193
|
- `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 (you have an unresolved blocking obligation — a formal `CHANGES_REQUESTED` **or** an unretracted flat-comment blocker), a top-level comment discharges neither. 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 a formal block is resolved but approval is disabled, `REQUEST_CHANGES` if not resolved).
|
|
194
194
|
- `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.
|
|
195
195
|
|
package/typeclaw.schema.json
CHANGED