typeclaw 0.27.0 → 0.28.1
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/scripts/generate-schema.ts +4 -6
- package/src/agent/index.ts +26 -4
- package/src/agent/multimodal/look-at.ts +1 -2
- package/src/agent/provider-error.ts +33 -1
- package/src/agent/tools/channel-fetch-attachment.ts +1 -2
- package/src/agent/tools/channel-react.ts +9 -3
- package/src/agent/tools/channel-reply.ts +52 -1
- package/src/agent/tools/channel-send.ts +115 -1
- package/src/bundled-plugins/github-cli-auth/gh-review-detect.ts +175 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +4 -0
- package/src/bundled-plugins/github-cli-auth/review-recorder.ts +93 -0
- package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
- package/src/bundled-plugins/memory/README.md +3 -21
- package/src/bundled-plugins/memory/index.ts +1 -149
- package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
- package/src/channels/adapters/github/inbound.ts +103 -0
- package/src/channels/adapters/github/index.ts +10 -0
- package/src/channels/adapters/github/review-state.ts +137 -0
- package/src/channels/adapters/github/review-thread-resolver.ts +65 -5
- package/src/channels/github-false-receipt.ts +87 -0
- package/src/channels/github-rereview-guard.ts +76 -0
- package/src/channels/github-review-claim.ts +92 -0
- package/src/channels/github-review-turn-ledger.ts +71 -0
- package/src/channels/persistence.ts +4 -102
- package/src/channels/router.ts +181 -7
- package/src/channels/schema.ts +20 -5
- package/src/channels/types.ts +31 -0
- package/src/cli/channel.ts +2 -1
- package/src/cli/init.ts +2 -1
- package/src/config/config.ts +19 -288
- package/src/container/start.ts +0 -2
- package/src/cron/index.ts +3 -44
- package/src/cron/schema.ts +2 -96
- package/src/init/gitignore.ts +1 -2
- package/src/inspect/transcript-view.ts +10 -0
- package/src/secrets/defaults.ts +1 -18
- package/src/secrets/index.ts +0 -2
- package/src/secrets/schema.ts +4 -90
- package/src/secrets/storage.ts +0 -2
- package/src/server/index.ts +11 -5
- package/src/shared/protocol.ts +18 -6
- package/src/skills/typeclaw-config/SKILL.md +9 -11
- package/src/skills/typeclaw-permissions/SKILL.md +1 -1
- package/src/tui/format.ts +13 -0
- package/src/tui/index.ts +21 -7
- package/typeclaw.schema.json +1 -0
- package/src/agent/tools/normalize-ref.ts +0 -11
- package/src/bundled-plugins/memory/migration.ts +0 -633
- package/src/secrets/migrate-kakaotalk.ts +0 -82
- package/src/secrets/migrate.ts +0 -96
package/package.json
CHANGED
|
@@ -14,12 +14,10 @@ import { secretsFileSchema } from '../src/secrets/schema'
|
|
|
14
14
|
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
15
15
|
|
|
16
16
|
// auth.schema.json is the permanent compatibility alias for secrets.schema.json.
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// maintenance cost, so we keep it indefinitely rather than coordinating a
|
|
22
|
-
// content-rewrite migration for every old agent folder.
|
|
17
|
+
// Old agent folders may still carry an `auth.json`, or a `secrets.json` whose
|
|
18
|
+
// `$schema` still points at the pre-rename URL; the alias lets those resolve in
|
|
19
|
+
// editors. It is tiny and re-emitting it has no maintenance cost, so we keep it
|
|
20
|
+
// indefinitely rather than rewriting `$schema` in every old agent folder.
|
|
23
21
|
const targets: Array<{ path: string; schema: z.ZodType }> = [
|
|
24
22
|
{ path: join(repoRoot, 'typeclaw.schema.json'), schema: buildConfigSchemaWithBundledPlugins(coreConfigSchema) },
|
|
25
23
|
{ path: join(repoRoot, 'cron.schema.json'), schema: cronFileSchema },
|
package/src/agent/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { AgentSession, ToolDefinition } from '@mariozechner/pi-coding-agent
|
|
|
13
13
|
|
|
14
14
|
import { loadMemory } from '@/bundled-plugins/memory/load-memory'
|
|
15
15
|
import type { ChannelRouter } from '@/channels/router'
|
|
16
|
+
import type { ReactionRef } from '@/channels/types'
|
|
16
17
|
import { getConfig, resolveModel, resolveProfile } from '@/config'
|
|
17
18
|
import { defaultThinkingLevelForRef, providerForModelRef, type KnownModelRef } from '@/config/providers'
|
|
18
19
|
import { renderMcpCatalog } from '@/mcp/catalog'
|
|
@@ -359,7 +360,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
359
360
|
...(options.mcpManager ? buildMcpDispatcherToolDefinitions(options.mcpManager) : []),
|
|
360
361
|
...(options.reloadRegistry ? [createReloadTool({ registry: options.reloadRegistry })] : []),
|
|
361
362
|
...(options.stream ? [createStreamSnapshotTool({ stream: options.stream })] : []),
|
|
362
|
-
...buildChannelTools(options.channelRouter, options.origin, sessionManager.getSessionId()),
|
|
363
|
+
...buildChannelTools(options.channelRouter, options.origin, sessionManager.getSessionId(), getOrigin),
|
|
363
364
|
...(options.containerName
|
|
364
365
|
? [
|
|
365
366
|
createRestartTool({
|
|
@@ -620,6 +621,7 @@ export function buildChannelTools(
|
|
|
620
621
|
channelRouter: ChannelRouter | undefined,
|
|
621
622
|
origin: SessionOrigin | undefined,
|
|
622
623
|
sessionId?: string,
|
|
624
|
+
getOrigin?: () => SessionOrigin | undefined,
|
|
623
625
|
): ToolDefinition[] {
|
|
624
626
|
if (!channelRouter) return []
|
|
625
627
|
const tools: ToolDefinition[] = []
|
|
@@ -630,13 +632,33 @@ export function buildChannelTools(
|
|
|
630
632
|
chat: origin.chat,
|
|
631
633
|
thread: origin.thread,
|
|
632
634
|
}
|
|
633
|
-
tools.push(
|
|
635
|
+
tools.push(
|
|
636
|
+
createChannelReplyTool({
|
|
637
|
+
router: channelRouter,
|
|
638
|
+
origin: channelOrigin,
|
|
639
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
640
|
+
}),
|
|
641
|
+
)
|
|
634
642
|
tools.push(createChannelHistoryTool({ router: channelRouter, origin: channelOrigin }))
|
|
635
|
-
tools.push(
|
|
643
|
+
tools.push(
|
|
644
|
+
createChannelSendTool({
|
|
645
|
+
router: channelRouter,
|
|
646
|
+
origin: channelOrigin,
|
|
647
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
648
|
+
}),
|
|
649
|
+
)
|
|
650
|
+
// Read the live turn origin, falling back to the static snapshot when no
|
|
651
|
+
// getter is wired (composition tests). `reactionRef` is per-turn, so the
|
|
652
|
+
// getter is what makes reactions work outside tests.
|
|
653
|
+
const resolveReactionRef = (): ReactionRef | undefined => {
|
|
654
|
+
const live = getOrigin?.() ?? origin
|
|
655
|
+
return live.kind === 'channel' ? live.reactionRef : undefined
|
|
656
|
+
}
|
|
636
657
|
tools.push(
|
|
637
658
|
createChannelReactTool({
|
|
638
659
|
router: channelRouter,
|
|
639
|
-
origin:
|
|
660
|
+
origin: channelOrigin,
|
|
661
|
+
getReactionRef: resolveReactionRef,
|
|
640
662
|
}),
|
|
641
663
|
)
|
|
642
664
|
tools.push(
|
|
@@ -3,7 +3,6 @@ import type { ImageContent } from '@mariozechner/pi-ai'
|
|
|
3
3
|
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
4
4
|
|
|
5
5
|
import { createSessionWithDispose, type SessionOrigin } from '@/agent'
|
|
6
|
-
import { normalizeRef } from '@/agent/tools/normalize-ref'
|
|
7
6
|
import type { ChannelRouter } from '@/channels/router'
|
|
8
7
|
import type { AdapterId } from '@/channels/schema'
|
|
9
8
|
|
|
@@ -121,7 +120,7 @@ export function createChannelLookAtTool(router: ChannelRouter, origin: ChannelLo
|
|
|
121
120
|
)
|
|
122
121
|
}
|
|
123
122
|
const result = await router.fetchAttachment(origin.adapter, {
|
|
124
|
-
ref:
|
|
123
|
+
ref: found.ref,
|
|
125
124
|
...(found.filename !== undefined ? { filename: found.filename } : {}),
|
|
126
125
|
})
|
|
127
126
|
if (!result.ok) return errorResult(result.error, { count: 0, prompt: params.prompt })
|
|
@@ -13,7 +13,39 @@ import type { AgentSession } from './index'
|
|
|
13
13
|
// not by this helper.
|
|
14
14
|
|
|
15
15
|
export type DetectedProviderError = {
|
|
16
|
+
// Raw provider text. Safe for logs and operator-only surfaces (TUI,
|
|
17
|
+
// `typeclaw logs`), but NOT for channels — see `safeMessage`.
|
|
16
18
|
message: string
|
|
19
|
+
// Redacted, user-facing variant for public/multi-user channels. Known-safe
|
|
20
|
+
// operational classes (rate/usage limit, billing/quota) map to a canonical
|
|
21
|
+
// sentence; everything else (malformed-response SDK dumps, unknown failures)
|
|
22
|
+
// collapses to a generic notice so provider response bodies, URLs, or tokens
|
|
23
|
+
// can never leak to a channel.
|
|
24
|
+
safeMessage: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const GENERIC_SAFE_NOTICE = 'The upstream LLM provider failed. Operators can check `typeclaw logs` for details.'
|
|
28
|
+
|
|
29
|
+
// Each entry pairs a narrow matcher against the raw provider text with the
|
|
30
|
+
// canonical, leak-free sentence shown in channels. Matchers are intentionally
|
|
31
|
+
// specific: a miss falls through to GENERIC_SAFE_NOTICE rather than echoing raw
|
|
32
|
+
// text, so adding a new class is opt-in and never widens what we expose.
|
|
33
|
+
const SAFE_CLASSES: ReadonlyArray<{ match: RegExp; safe: string }> = [
|
|
34
|
+
{
|
|
35
|
+
match: /\b(usage limit|rate limit|rate.?limited|too many requests|429)\b/i,
|
|
36
|
+
safe: 'The upstream LLM provider is rate-limited (usage limit reached). Try again shortly.',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
match: /\b(billing|quota|insufficient.*(credit|fund|balance)|payment|account is not active)\b/i,
|
|
40
|
+
safe: 'The upstream LLM provider rejected the request for a billing/quota reason. Operators can check `typeclaw logs` for details.',
|
|
41
|
+
},
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
function toSafeMessage(raw: string): string {
|
|
45
|
+
for (const { match, safe } of SAFE_CLASSES) {
|
|
46
|
+
if (match.test(raw)) return safe
|
|
47
|
+
}
|
|
48
|
+
return GENERIC_SAFE_NOTICE
|
|
17
49
|
}
|
|
18
50
|
|
|
19
51
|
export function detectProviderError(message: unknown): DetectedProviderError | null {
|
|
@@ -25,7 +57,7 @@ export function detectProviderError(message: unknown): DetectedProviderError | n
|
|
|
25
57
|
// ignore aborts (no surface to render them on).
|
|
26
58
|
if (m.stopReason !== 'error') return null
|
|
27
59
|
const text = typeof m.errorMessage === 'string' && m.errorMessage.length > 0 ? m.errorMessage : 'LLM call failed'
|
|
28
|
-
return { message: text }
|
|
60
|
+
return { message: text, safeMessage: toSafeMessage(text) }
|
|
29
61
|
}
|
|
30
62
|
|
|
31
63
|
export type ProviderErrorListener = (error: DetectedProviderError) => void
|
|
@@ -8,7 +8,6 @@ import type { ChannelRouter } from '@/channels/router'
|
|
|
8
8
|
import type { AdapterId } from '@/channels/schema'
|
|
9
9
|
|
|
10
10
|
import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
|
|
11
|
-
import { normalizeRef } from './normalize-ref'
|
|
12
11
|
|
|
13
12
|
export type ChannelFetchAttachmentOrigin = {
|
|
14
13
|
adapter: AdapterId
|
|
@@ -87,7 +86,7 @@ export function createChannelFetchAttachmentTool({
|
|
|
87
86
|
`attachment #${params.attachment_id} (${found.kind}) has no fetchable ref — likely a sticker or an upstream payload without a public URL. Acknowledge the user but do not promise to view it.`,
|
|
88
87
|
)
|
|
89
88
|
}
|
|
90
|
-
const ref =
|
|
89
|
+
const ref = found.ref
|
|
91
90
|
const filename = params.filename ?? found.filename
|
|
92
91
|
const result = await router.fetchAttachment(adapter, {
|
|
93
92
|
ref,
|
|
@@ -13,18 +13,23 @@ export type ChannelReactOrigin = {
|
|
|
13
13
|
workspace: string
|
|
14
14
|
chat: string
|
|
15
15
|
thread: string | null
|
|
16
|
-
reactionRef?: ReactionRef
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
export type CreateChannelReactToolOptions = {
|
|
20
19
|
router: ChannelRouter
|
|
21
20
|
origin: ChannelReactOrigin
|
|
21
|
+
// Resolved at execute time, not captured: the target is the message that
|
|
22
|
+
// triggered THIS turn. The tool is built once at session creation, whose
|
|
23
|
+
// origin snapshot carries no reactionRef, so a static capture would deny
|
|
24
|
+
// every call.
|
|
25
|
+
getReactionRef: () => ReactionRef | undefined
|
|
22
26
|
logger?: ChannelToolLogger
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export function createChannelReactTool({
|
|
26
30
|
router,
|
|
27
31
|
origin,
|
|
32
|
+
getReactionRef,
|
|
28
33
|
logger = consoleChannelLogger,
|
|
29
34
|
}: CreateChannelReactToolOptions) {
|
|
30
35
|
return defineTool({
|
|
@@ -58,14 +63,15 @@ export function createChannelReactTool({
|
|
|
58
63
|
}
|
|
59
64
|
}
|
|
60
65
|
|
|
61
|
-
|
|
66
|
+
const reactionRef = getReactionRef()
|
|
67
|
+
if (reactionRef === undefined) return deny('this conversation has no message to react to')
|
|
62
68
|
|
|
63
69
|
const result = await router.react({
|
|
64
70
|
adapter: origin.adapter,
|
|
65
71
|
workspace: origin.workspace,
|
|
66
72
|
chat: origin.chat,
|
|
67
73
|
thread: origin.thread,
|
|
68
|
-
reactionRef
|
|
74
|
+
reactionRef,
|
|
69
75
|
emoji: params.emoji,
|
|
70
76
|
})
|
|
71
77
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Type } from '@mariozechner/pi-ai'
|
|
2
2
|
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
3
|
|
|
4
|
+
import { checkFalseReceipt } from '@/channels/github-false-receipt'
|
|
5
|
+
import { evaluateRereviewGuard } from '@/channels/github-rereview-guard'
|
|
4
6
|
import {
|
|
5
7
|
containsKimiToolDelimiter,
|
|
6
8
|
isNoReplySignal,
|
|
@@ -22,6 +24,10 @@ export type ChannelReplyOrigin = {
|
|
|
22
24
|
export type CreateChannelReplyToolOptions = {
|
|
23
25
|
router: ChannelRouter
|
|
24
26
|
origin: ChannelReplyOrigin
|
|
27
|
+
// Scopes the per-turn false-receipt ledger. Defaults to '' when a caller (e.g.
|
|
28
|
+
// a focused test) has no session; the guard then simply finds no recorded
|
|
29
|
+
// action and falls back to its safe default.
|
|
30
|
+
sessionId?: string
|
|
25
31
|
logger?: ChannelToolLogger
|
|
26
32
|
}
|
|
27
33
|
|
|
@@ -37,6 +43,7 @@ export type CreateChannelReplyToolOptions = {
|
|
|
37
43
|
export function createChannelReplyTool({
|
|
38
44
|
router,
|
|
39
45
|
origin,
|
|
46
|
+
sessionId = '',
|
|
40
47
|
logger = consoleChannelLogger,
|
|
41
48
|
}: CreateChannelReplyToolOptions) {
|
|
42
49
|
return defineTool({
|
|
@@ -131,6 +138,49 @@ export function createChannelReplyTool({
|
|
|
131
138
|
}
|
|
132
139
|
}
|
|
133
140
|
|
|
141
|
+
// False-receipt guard: deny a terminal reply that CLAIMS a PR verdict /
|
|
142
|
+
// thread close-out the agent never actually performed this turn. Warn-tier
|
|
143
|
+
// claims fall through and have their notice appended on success below.
|
|
144
|
+
const falseReceipt = checkFalseReceipt({
|
|
145
|
+
sessionId,
|
|
146
|
+
adapter: origin.adapter,
|
|
147
|
+
workspace: origin.workspace,
|
|
148
|
+
chat: origin.chat,
|
|
149
|
+
thread: origin.thread,
|
|
150
|
+
text,
|
|
151
|
+
isContinue: keepTurnAlive,
|
|
152
|
+
resolveReviewThread: params.resolve_review_thread === true,
|
|
153
|
+
})
|
|
154
|
+
if (falseReceipt.kind === 'block') {
|
|
155
|
+
logger.warn(formatChannelToolFailure('channel_reply', falseReceipt.reason))
|
|
156
|
+
return {
|
|
157
|
+
content: [{ type: 'text' as const, text: `channel_reply denied: ${falseReceipt.reason}` }],
|
|
158
|
+
details: { ok: false, error: falseReceipt.reason },
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const falseReceiptNotice = falseReceipt.kind === 'warn' ? falseReceipt.notice : null
|
|
162
|
+
|
|
163
|
+
// Re-review stranding guard: block a thread close-out / verdict ack while
|
|
164
|
+
// the bot still holds its own CHANGES_REQUESTED on this PR, so it can't
|
|
165
|
+
// silently leave the PR blocked (PR #644). Runs before the resolve so a
|
|
166
|
+
// blocked close-out never mutates the thread.
|
|
167
|
+
const rereview = await evaluateRereviewGuard({
|
|
168
|
+
adapter: origin.adapter,
|
|
169
|
+
workspace: origin.workspace,
|
|
170
|
+
chat: origin.chat,
|
|
171
|
+
thread: origin.thread,
|
|
172
|
+
text,
|
|
173
|
+
wantsResolve: params.resolve_review_thread === true,
|
|
174
|
+
getReviewState: (req) => router.getReviewState(req),
|
|
175
|
+
})
|
|
176
|
+
if (rereview.block) {
|
|
177
|
+
logger.warn(formatChannelToolFailure('channel_reply', rereview.reason))
|
|
178
|
+
return {
|
|
179
|
+
content: [{ type: 'text' as const, text: `channel_reply denied: ${rereview.reason}` }],
|
|
180
|
+
details: { ok: false, error: rereview.reason },
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
134
184
|
// Resolve BEFORE posting: a successful channel_reply ends the turn, so a
|
|
135
185
|
// resolve attempted "after" the ack would never run (the exact bug this
|
|
136
186
|
// flag fixes). Resolve-failure blocks the reply so the agent never posts
|
|
@@ -203,8 +253,9 @@ export function createChannelReplyTool({
|
|
|
203
253
|
// TOOL_RESULT_PREFIX to match the denial branch below. The prefix is
|
|
204
254
|
// intentionally weaker and is safe ONLY because denials carry no echoed
|
|
205
255
|
// prose; the success result does, and the weak prefix let Kimi loop.
|
|
256
|
+
const warnNote = falseReceiptNotice !== null ? fenceRuntimeNotice(falseReceiptNotice) : ''
|
|
206
257
|
return {
|
|
207
|
-
content: [{ type: 'text' as const, text: `${fenceToolResult(receipt)}${hint}` }],
|
|
258
|
+
content: [{ type: 'text' as const, text: `${fenceToolResult(receipt)}${hint}${warnNote}` }],
|
|
208
259
|
details,
|
|
209
260
|
}
|
|
210
261
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { Type } from '@mariozechner/pi-ai'
|
|
2
2
|
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
3
|
|
|
4
|
+
import { checkFalseReceipt } from '@/channels/github-false-receipt'
|
|
5
|
+
import { evaluateRereviewGuard } from '@/channels/github-rereview-guard'
|
|
6
|
+
import { recordResolvedThread } from '@/channels/github-review-turn-ledger'
|
|
4
7
|
import {
|
|
5
8
|
containsKimiToolDelimiter,
|
|
6
9
|
isNoReplySignal,
|
|
@@ -28,10 +31,19 @@ export type CreateChannelSendToolOptions = {
|
|
|
28
31
|
// the model can self-correct on its next turn. Absent for sessions whose
|
|
29
32
|
// origin isn't a channel (e.g. cron prompts that send to channels).
|
|
30
33
|
origin?: ChannelSendOrigin
|
|
34
|
+
// Scopes the per-turn false-receipt ledger for github resolve close-outs.
|
|
35
|
+
// Defaults to '' when absent (cron / non-channel sessions); the guard then
|
|
36
|
+
// finds no recorded action and falls back to its safe default.
|
|
37
|
+
sessionId?: string
|
|
31
38
|
logger?: ChannelToolLogger
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
export function createChannelSendTool({
|
|
41
|
+
export function createChannelSendTool({
|
|
42
|
+
router,
|
|
43
|
+
origin,
|
|
44
|
+
sessionId = '',
|
|
45
|
+
logger = consoleChannelLogger,
|
|
46
|
+
}: CreateChannelSendToolOptions) {
|
|
35
47
|
return defineTool({
|
|
36
48
|
name: 'channel_send',
|
|
37
49
|
label: 'Channel Send',
|
|
@@ -93,6 +105,15 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
|
|
|
93
105
|
},
|
|
94
106
|
),
|
|
95
107
|
),
|
|
108
|
+
resolve_review_thread: Type.Optional(
|
|
109
|
+
Type.Boolean({
|
|
110
|
+
description:
|
|
111
|
+
'GitHub review threads ONLY — ignored on every other adapter and on a github send that has no `thread`. ' +
|
|
112
|
+
'Set `true` to close out a review thread you authored once you have confirmed the new commits address your concern: pass the thread\'s root comment id as `thread`, an acknowledgement (e.g. "addressed in <sha> — resolving") as `text`, and this flag. ' +
|
|
113
|
+
'This is the post-push close-out path: a `pull_request.synchronize` recheck lists your unresolved threads, and you call this once per addressed thread. ' +
|
|
114
|
+
"Safe by default — the runtime resolves BEFORE posting and ONLY if the thread's root comment is yours, refusing (and blocking the send) on a human reviewer's thread. Leave it unset to keep the thread open (not addressed, partial fix, disagreement).",
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
96
117
|
}),
|
|
97
118
|
|
|
98
119
|
async execute(_toolCallId, params) {
|
|
@@ -136,6 +157,67 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
|
|
|
136
157
|
}
|
|
137
158
|
}
|
|
138
159
|
|
|
160
|
+
const wantsResolve = params.resolve_review_thread === true
|
|
161
|
+
const falseReceipt = checkFalseReceipt({
|
|
162
|
+
sessionId,
|
|
163
|
+
adapter,
|
|
164
|
+
workspace: params.workspace,
|
|
165
|
+
chat: params.chat,
|
|
166
|
+
thread: params.thread ?? null,
|
|
167
|
+
text: bodyText,
|
|
168
|
+
isContinue: false,
|
|
169
|
+
resolveReviewThread: wantsResolve,
|
|
170
|
+
})
|
|
171
|
+
if (falseReceipt.kind === 'block') {
|
|
172
|
+
logger.warn(formatChannelToolFailure('channel_send', falseReceipt.reason))
|
|
173
|
+
return {
|
|
174
|
+
content: [{ type: 'text' as const, text: `channel_send denied: ${falseReceipt.reason}` }],
|
|
175
|
+
details: { ok: false, error: falseReceipt.reason },
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const falseReceiptNotice = falseReceipt.kind === 'warn' ? falseReceipt.notice : null
|
|
179
|
+
|
|
180
|
+
// Re-review stranding guard (mirrors channel_reply): block a thread
|
|
181
|
+
// close-out / verdict ack while the bot still holds its own
|
|
182
|
+
// CHANGES_REQUESTED on this PR, before the resolve mutates the thread.
|
|
183
|
+
const rereview = await evaluateRereviewGuard({
|
|
184
|
+
adapter,
|
|
185
|
+
workspace: params.workspace,
|
|
186
|
+
chat: params.chat,
|
|
187
|
+
thread: params.thread ?? null,
|
|
188
|
+
text: bodyText,
|
|
189
|
+
wantsResolve,
|
|
190
|
+
getReviewState: (req) => router.getReviewState(req),
|
|
191
|
+
})
|
|
192
|
+
if (rereview.block) {
|
|
193
|
+
logger.warn(formatChannelToolFailure('channel_send', rereview.reason))
|
|
194
|
+
return {
|
|
195
|
+
content: [{ type: 'text' as const, text: `channel_send denied: ${rereview.reason}` }],
|
|
196
|
+
details: { ok: false, error: rereview.reason },
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Resolve BEFORE posting (mirrors channel_reply): a failed resolve must
|
|
201
|
+
// block the acknowledgement so the bot never posts "addressed — resolving"
|
|
202
|
+
// next to a still-open thread. The router enforces that only the bot's own
|
|
203
|
+
// threads can be resolved.
|
|
204
|
+
if (wantsResolve) {
|
|
205
|
+
const resolveError = await resolveReviewThreadBeforeSend(router, {
|
|
206
|
+
adapter,
|
|
207
|
+
workspace: params.workspace,
|
|
208
|
+
chat: params.chat,
|
|
209
|
+
thread: params.thread ?? null,
|
|
210
|
+
})
|
|
211
|
+
if (resolveError !== null) {
|
|
212
|
+
logger.warn(formatChannelToolFailure('channel_send', resolveError))
|
|
213
|
+
return {
|
|
214
|
+
content: [{ type: 'text' as const, text: `channel_send denied: ${resolveError}` }],
|
|
215
|
+
details: { ok: false, error: resolveError },
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
recordResolvedThreadFromSend(sessionId, params.workspace, params.chat, params.thread ?? null)
|
|
219
|
+
}
|
|
220
|
+
|
|
139
221
|
const result = await router.send({
|
|
140
222
|
adapter,
|
|
141
223
|
workspace: params.workspace,
|
|
@@ -179,6 +261,8 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
|
|
|
179
261
|
})
|
|
180
262
|
if (threadMismatch) hints.push(threadMismatch)
|
|
181
263
|
|
|
264
|
+
if (falseReceiptNotice !== null) hints.push(fenceRuntimeNotice(falseReceiptNotice))
|
|
265
|
+
|
|
182
266
|
return {
|
|
183
267
|
content: [{ type: 'text' as const, text: `${fenceToolResult(receipt)}${hints.join('')}` }],
|
|
184
268
|
details,
|
|
@@ -192,6 +276,36 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
|
|
|
192
276
|
})
|
|
193
277
|
}
|
|
194
278
|
|
|
279
|
+
async function resolveReviewThreadBeforeSend(
|
|
280
|
+
router: ChannelRouter,
|
|
281
|
+
target: { adapter: AdapterId; workspace: string; chat: string; thread: string | null },
|
|
282
|
+
): Promise<string | null> {
|
|
283
|
+
if (target.adapter !== 'github') {
|
|
284
|
+
return 'resolve_review_thread is only supported on github sends.'
|
|
285
|
+
}
|
|
286
|
+
if (target.thread === null) {
|
|
287
|
+
return 'resolve_review_thread requires a `thread` (the review thread root comment id).'
|
|
288
|
+
}
|
|
289
|
+
const result = await router.resolveReviewThread({
|
|
290
|
+
adapter: target.adapter,
|
|
291
|
+
workspace: target.workspace,
|
|
292
|
+
chat: target.chat,
|
|
293
|
+
rootCommentId: target.thread,
|
|
294
|
+
})
|
|
295
|
+
if (result.ok) return null
|
|
296
|
+
if (result.code === 'no-match') return null
|
|
297
|
+
return `could not resolve review thread: ${result.error}`
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function recordResolvedThreadFromSend(sessionId: string, workspace: string, chat: string, thread: string | null): void {
|
|
301
|
+
if (thread === null) return
|
|
302
|
+
const m = /^pr:(\d+)$/.exec(chat)
|
|
303
|
+
if (m === null) return
|
|
304
|
+
const prNumber = Number(m[1])
|
|
305
|
+
if (!Number.isSafeInteger(prNumber) || prNumber <= 0) return
|
|
306
|
+
recordResolvedThread({ sessionId, workspace, prNumber, rootCommentId: thread })
|
|
307
|
+
}
|
|
308
|
+
|
|
195
309
|
// Returns a behavioral hint when the model posted to the SAME conversation
|
|
196
310
|
// as the session's origin (same adapter+workspace+chat) but DROPPED the
|
|
197
311
|
// thread. This catches the "model forgot to copy thread verbatim" failure
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { ReviewVerdict } from '@/channels/github-review-turn-ledger'
|
|
2
|
+
|
|
3
|
+
// Extracts the formal-review verdict a successful `gh` command landed, so the
|
|
4
|
+
// false-receipt ledger can credit it. Covers the three vectors the agent uses to
|
|
5
|
+
// post a review: REST create-review via `--input <file>`, REST create-review via
|
|
6
|
+
// inline `-f/-F event=...`, and the `gh pr review` porcelain. Returns null when
|
|
7
|
+
// the command is not a verdict-bearing review submission (incl. COMMENT reviews,
|
|
8
|
+
// which carry no false-receipt risk and are not tracked).
|
|
9
|
+
|
|
10
|
+
// `source` drives success detection downstream: the REST endpoints echo the
|
|
11
|
+
// created review JSON, while the `gh pr review` porcelain prints a plain
|
|
12
|
+
// confirmation line — so each needs its own success markers (see review-recorder).
|
|
13
|
+
export type DetectedReview = {
|
|
14
|
+
workspace: string
|
|
15
|
+
prNumber: number
|
|
16
|
+
verdict: ReviewVerdict
|
|
17
|
+
source: 'api' | 'pr-review'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type GhReviewDetectInput = {
|
|
21
|
+
command: string
|
|
22
|
+
// Contents of the file named by `--input <file>`, when the caller resolved it.
|
|
23
|
+
// Kept as an injected value so this module does no I/O and stays sync+pure.
|
|
24
|
+
inputFileContents?: string | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const REVIEWS_ENDPOINT = /\/repos\/([^/\s]+)\/([^/\s]+)\/pulls\/(\d+)\/reviews\b/
|
|
28
|
+
|
|
29
|
+
export function detectReviewSubmission(input: GhReviewDetectInput): DetectedReview | null {
|
|
30
|
+
const args = splitArgs(input.command)
|
|
31
|
+
if (args[0] !== 'gh') return null
|
|
32
|
+
const sub = args[1]
|
|
33
|
+
if (sub === 'api') return detectApiReview(args, input.inputFileContents ?? null)
|
|
34
|
+
if (sub === 'pr' && args[2] === 'review') return detectPrReview(args)
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function detectApiReview(args: readonly string[], fileContents: string | null): DetectedReview | null {
|
|
39
|
+
const endpoint = args.find((a) => REVIEWS_ENDPOINT.test(a))
|
|
40
|
+
if (endpoint === undefined) return null
|
|
41
|
+
const m = REVIEWS_ENDPOINT.exec(endpoint)
|
|
42
|
+
if (m === null) return null
|
|
43
|
+
const workspace = `${m[1]}/${m[2]}`
|
|
44
|
+
const prNumber = Number(m[3])
|
|
45
|
+
if (!Number.isSafeInteger(prNumber)) return null
|
|
46
|
+
|
|
47
|
+
const verdict = verdictFromInlineFields(args) ?? verdictFromFile(fileContents)
|
|
48
|
+
if (verdict === null) return null
|
|
49
|
+
return { workspace, prNumber, verdict, source: 'api' }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Inline `-f event=APPROVE` / `--field event=REQUEST_CHANGES` (and `-F` raw).
|
|
53
|
+
// gh accepts both `flag value` and `flag=value` shapes; cover both.
|
|
54
|
+
function verdictFromInlineFields(args: readonly string[]): ReviewVerdict | null {
|
|
55
|
+
for (let i = 0; i < args.length; i++) {
|
|
56
|
+
const a = args[i]
|
|
57
|
+
if (a === undefined) continue
|
|
58
|
+
if (a === '-f' || a === '-F' || a === '--field' || a === '--raw-field') {
|
|
59
|
+
const v = parseEventAssignment(args[i + 1])
|
|
60
|
+
if (v !== null) return v
|
|
61
|
+
}
|
|
62
|
+
if (a.startsWith('-f=') || a.startsWith('-F=') || a.startsWith('--field=') || a.startsWith('--raw-field=')) {
|
|
63
|
+
const v = parseEventAssignment(a.slice(a.indexOf('=') + 1))
|
|
64
|
+
if (v !== null) return v
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseEventAssignment(token: string | undefined): ReviewVerdict | null {
|
|
71
|
+
if (token === undefined) return null
|
|
72
|
+
const eq = token.indexOf('=')
|
|
73
|
+
if (eq === -1) return null
|
|
74
|
+
if (token.slice(0, eq).trim().toLowerCase() !== 'event') return null
|
|
75
|
+
return normalizeVerdict(token.slice(eq + 1))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function verdictFromFile(contents: string | null): ReviewVerdict | null {
|
|
79
|
+
if (contents === null || contents === '') return null
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(contents) as unknown
|
|
82
|
+
if (typeof parsed !== 'object' || parsed === null) return null
|
|
83
|
+
const event = (parsed as Record<string, unknown>).event
|
|
84
|
+
return typeof event === 'string' ? normalizeVerdict(event) : null
|
|
85
|
+
} catch {
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function detectPrReview(args: readonly string[]): DetectedReview | null {
|
|
91
|
+
const verdict =
|
|
92
|
+
args.includes('--approve') || args.includes('-a')
|
|
93
|
+
? 'APPROVE'
|
|
94
|
+
: args.includes('--request-changes') || args.includes('-r')
|
|
95
|
+
? 'REQUEST_CHANGES'
|
|
96
|
+
: null
|
|
97
|
+
if (verdict === null) return null
|
|
98
|
+
const workspace = repoFromFlag(args)
|
|
99
|
+
const prNumber = prNumberArg(args)
|
|
100
|
+
if (workspace === null || prNumber === null) return null
|
|
101
|
+
return { workspace, prNumber, verdict, source: 'pr-review' }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function repoFromFlag(args: readonly string[]): string | null {
|
|
105
|
+
for (let i = 0; i < args.length; i++) {
|
|
106
|
+
const a = args[i]
|
|
107
|
+
if (a === undefined) continue
|
|
108
|
+
if ((a === '-R' || a === '--repo') && isRepoSlug(args[i + 1])) return args[i + 1] as string
|
|
109
|
+
if (a.startsWith('--repo=') && isRepoSlug(a.slice('--repo='.length))) return a.slice('--repo='.length)
|
|
110
|
+
if (a.startsWith('-R=') && isRepoSlug(a.slice('-R='.length))) return a.slice('-R='.length)
|
|
111
|
+
}
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function prNumberArg(args: readonly string[]): number | null {
|
|
116
|
+
const start = args.indexOf('review') + 1
|
|
117
|
+
for (let i = start; i < args.length; i++) {
|
|
118
|
+
const a = args[i]
|
|
119
|
+
if (a === undefined) continue
|
|
120
|
+
if (a.startsWith('-')) continue
|
|
121
|
+
if (/^\d+$/.test(a)) {
|
|
122
|
+
const n = Number(a)
|
|
123
|
+
return Number.isSafeInteger(n) ? n : null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizeVerdict(value: string): ReviewVerdict | null {
|
|
130
|
+
const v = value.trim().toUpperCase()
|
|
131
|
+
if (v === 'APPROVE') return 'APPROVE'
|
|
132
|
+
if (v === 'REQUEST_CHANGES') return 'REQUEST_CHANGES'
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isRepoSlug(value: string | undefined): boolean {
|
|
137
|
+
if (value === undefined) return false
|
|
138
|
+
const [owner, name, ...rest] = value.split('/')
|
|
139
|
+
return owner !== undefined && owner !== '' && name !== undefined && name !== '' && rest.length === 0
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Quote-aware whitespace split. The interceptor guarantees a single bare `gh`
|
|
143
|
+
// command before we record (no pipes/substitution), so this only needs to honor
|
|
144
|
+
// quotes, not full shell grammar.
|
|
145
|
+
function splitArgs(command: string): string[] {
|
|
146
|
+
const out: string[] = []
|
|
147
|
+
let cur = ''
|
|
148
|
+
let quote: '"' | "'" | null = null
|
|
149
|
+
let has = false
|
|
150
|
+
for (const ch of command) {
|
|
151
|
+
if (quote !== null) {
|
|
152
|
+
if (ch === quote) quote = null
|
|
153
|
+
else cur += ch
|
|
154
|
+
has = true
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
if (ch === '"' || ch === "'") {
|
|
158
|
+
quote = ch
|
|
159
|
+
has = true
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
if (ch === ' ' || ch === '\t' || ch === '\n') {
|
|
163
|
+
if (has) {
|
|
164
|
+
out.push(cur)
|
|
165
|
+
cur = ''
|
|
166
|
+
has = false
|
|
167
|
+
}
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
cur += ch
|
|
171
|
+
has = true
|
|
172
|
+
}
|
|
173
|
+
if (has) out.push(cur)
|
|
174
|
+
return out
|
|
175
|
+
}
|
|
@@ -3,6 +3,7 @@ import { definePlugin } from '@/plugin'
|
|
|
3
3
|
|
|
4
4
|
import { analyzeGhCommand } from './gh-command'
|
|
5
5
|
import { checkGraphqlAuthNudge } from './graphql-auth-nudge'
|
|
6
|
+
import { commitReviewIfSucceeded, noteReviewCommand } from './review-recorder'
|
|
6
7
|
import { classifyGhToken } from './token-class'
|
|
7
8
|
|
|
8
9
|
export default definePlugin({
|
|
@@ -15,6 +16,8 @@ export default definePlugin({
|
|
|
15
16
|
const command = event.args.command
|
|
16
17
|
if (typeof command !== 'string' || !command.includes('gh')) return
|
|
17
18
|
|
|
19
|
+
await noteReviewCommand({ callId: event.callId, command })
|
|
20
|
+
|
|
18
21
|
const decision = analyzeGhCommand(command)
|
|
19
22
|
if (decision.kind === 'pass-through') return
|
|
20
23
|
|
|
@@ -42,6 +45,7 @@ export default definePlugin({
|
|
|
42
45
|
},
|
|
43
46
|
'tool.after': async (event) => {
|
|
44
47
|
checkGraphqlAuthNudge({ tool: event.tool, result: event.result })
|
|
48
|
+
commitReviewIfSucceeded({ sessionId: event.sessionId, callId: event.callId, result: event.result })
|
|
45
49
|
},
|
|
46
50
|
},
|
|
47
51
|
}
|