typeclaw 0.26.0 → 0.28.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/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/session-origin.ts +9 -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 +30 -1
- package/src/agent/tools/channel-send.ts +94 -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/reviewer/skills/code-review.ts +3 -1
- package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
- package/src/channels/adapters/github/inbound.ts +155 -9
- package/src/channels/adapters/github/review-thread-resolver.ts +93 -8
- package/src/channels/github-false-receipt.ts +87 -0
- package/src/channels/github-review-claim.ts +91 -0
- package/src/channels/github-review-turn-ledger.ts +71 -0
- package/src/channels/persistence.ts +4 -102
- package/src/channels/router.ts +191 -7
- package/src/channels/schema.ts +20 -5
- package/src/cli/channel.ts +2 -1
- package/src/cli/init.ts +2 -1
- package/src/cli/inspect.ts +216 -36
- package/src/cli/logs.ts +15 -0
- package/src/cli/tui.ts +33 -39
- package/src/compose/logs.ts +1 -1
- package/src/config/config.ts +19 -288
- package/src/container/logs.ts +70 -22
- 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/index.ts +128 -42
- package/src/inspect/item-list.ts +44 -0
- package/src/inspect/item.ts +17 -0
- package/src/inspect/label.ts +1 -1
- package/src/inspect/logs-item.ts +79 -0
- package/src/inspect/loop.ts +74 -3
- package/src/inspect/open-item.ts +100 -0
- package/src/inspect/preview.ts +106 -0
- package/src/inspect/session-list.ts +15 -3
- package/src/inspect/transcript-view.ts +182 -0
- package/src/inspect/tui-item.ts +97 -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 +0 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +3 -1
- package/src/skills/typeclaw-config/SKILL.md +9 -11
- package/src/skills/typeclaw-permissions/SKILL.md +1 -1
- package/src/tui/index.ts +72 -32
- 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 })
|
|
@@ -297,6 +297,7 @@ function renderChannelOrigin(
|
|
|
297
297
|
chat: string
|
|
298
298
|
chatName?: string
|
|
299
299
|
thread: string | null
|
|
300
|
+
reactionRef?: ReactionRef
|
|
300
301
|
participants?: readonly ChannelParticipant[]
|
|
301
302
|
membership?: MembershipCount
|
|
302
303
|
self?: ChannelSelfIdentity
|
|
@@ -354,7 +355,14 @@ function renderChannelOrigin(
|
|
|
354
355
|
const conversationLine = renderConversationLine(origin)
|
|
355
356
|
if (conversationLine !== null) lines.push('', conversationLine)
|
|
356
357
|
|
|
357
|
-
|
|
358
|
+
// Gate on `reactionRef`, not just the static `supportsReactions` platform
|
|
359
|
+
// fact: a turn only has a message to react to when the triggering inbound
|
|
360
|
+
// carried one. Reminder-only turns (restart-resume, subagent-completion,
|
|
361
|
+
// idle/todo continuation) wake the session with no inbound, so
|
|
362
|
+
// `buildLiveOrigin` omits `reactionRef`. Prompting "react like a teammate"
|
|
363
|
+
// there made the model call `channel_react`, which then denied with "this
|
|
364
|
+
// conversation has no message to react to".
|
|
365
|
+
if (platformInfo.supportsReactions && origin.reactionRef !== undefined) {
|
|
358
366
|
lines.push(
|
|
359
367
|
'',
|
|
360
368
|
'**React like a teammate would.** You can drop an emoji on the message that',
|
|
@@ -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,7 @@
|
|
|
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'
|
|
4
5
|
import {
|
|
5
6
|
containsKimiToolDelimiter,
|
|
6
7
|
isNoReplySignal,
|
|
@@ -22,6 +23,10 @@ export type ChannelReplyOrigin = {
|
|
|
22
23
|
export type CreateChannelReplyToolOptions = {
|
|
23
24
|
router: ChannelRouter
|
|
24
25
|
origin: ChannelReplyOrigin
|
|
26
|
+
// Scopes the per-turn false-receipt ledger. Defaults to '' when a caller (e.g.
|
|
27
|
+
// a focused test) has no session; the guard then simply finds no recorded
|
|
28
|
+
// action and falls back to its safe default.
|
|
29
|
+
sessionId?: string
|
|
25
30
|
logger?: ChannelToolLogger
|
|
26
31
|
}
|
|
27
32
|
|
|
@@ -37,6 +42,7 @@ export type CreateChannelReplyToolOptions = {
|
|
|
37
42
|
export function createChannelReplyTool({
|
|
38
43
|
router,
|
|
39
44
|
origin,
|
|
45
|
+
sessionId = '',
|
|
40
46
|
logger = consoleChannelLogger,
|
|
41
47
|
}: CreateChannelReplyToolOptions) {
|
|
42
48
|
return defineTool({
|
|
@@ -131,6 +137,28 @@ export function createChannelReplyTool({
|
|
|
131
137
|
}
|
|
132
138
|
}
|
|
133
139
|
|
|
140
|
+
// False-receipt guard: deny a terminal reply that CLAIMS a PR verdict /
|
|
141
|
+
// thread close-out the agent never actually performed this turn. Warn-tier
|
|
142
|
+
// claims fall through and have their notice appended on success below.
|
|
143
|
+
const falseReceipt = checkFalseReceipt({
|
|
144
|
+
sessionId,
|
|
145
|
+
adapter: origin.adapter,
|
|
146
|
+
workspace: origin.workspace,
|
|
147
|
+
chat: origin.chat,
|
|
148
|
+
thread: origin.thread,
|
|
149
|
+
text,
|
|
150
|
+
isContinue: keepTurnAlive,
|
|
151
|
+
resolveReviewThread: params.resolve_review_thread === true,
|
|
152
|
+
})
|
|
153
|
+
if (falseReceipt.kind === 'block') {
|
|
154
|
+
logger.warn(formatChannelToolFailure('channel_reply', falseReceipt.reason))
|
|
155
|
+
return {
|
|
156
|
+
content: [{ type: 'text' as const, text: `channel_reply denied: ${falseReceipt.reason}` }],
|
|
157
|
+
details: { ok: false, error: falseReceipt.reason },
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const falseReceiptNotice = falseReceipt.kind === 'warn' ? falseReceipt.notice : null
|
|
161
|
+
|
|
134
162
|
// Resolve BEFORE posting: a successful channel_reply ends the turn, so a
|
|
135
163
|
// resolve attempted "after" the ack would never run (the exact bug this
|
|
136
164
|
// flag fixes). Resolve-failure blocks the reply so the agent never posts
|
|
@@ -203,8 +231,9 @@ export function createChannelReplyTool({
|
|
|
203
231
|
// TOOL_RESULT_PREFIX to match the denial branch below. The prefix is
|
|
204
232
|
// intentionally weaker and is safe ONLY because denials carry no echoed
|
|
205
233
|
// prose; the success result does, and the weak prefix let Kimi loop.
|
|
234
|
+
const warnNote = falseReceiptNotice !== null ? fenceRuntimeNotice(falseReceiptNotice) : ''
|
|
206
235
|
return {
|
|
207
|
-
content: [{ type: 'text' as const, text: `${fenceToolResult(receipt)}${hint}` }],
|
|
236
|
+
content: [{ type: 'text' as const, text: `${fenceToolResult(receipt)}${hint}${warnNote}` }],
|
|
208
237
|
details,
|
|
209
238
|
}
|
|
210
239
|
}
|
|
@@ -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 { recordResolvedThread } from '@/channels/github-review-turn-ledger'
|
|
4
6
|
import {
|
|
5
7
|
containsKimiToolDelimiter,
|
|
6
8
|
isNoReplySignal,
|
|
@@ -28,10 +30,19 @@ export type CreateChannelSendToolOptions = {
|
|
|
28
30
|
// the model can self-correct on its next turn. Absent for sessions whose
|
|
29
31
|
// origin isn't a channel (e.g. cron prompts that send to channels).
|
|
30
32
|
origin?: ChannelSendOrigin
|
|
33
|
+
// Scopes the per-turn false-receipt ledger for github resolve close-outs.
|
|
34
|
+
// Defaults to '' when absent (cron / non-channel sessions); the guard then
|
|
35
|
+
// finds no recorded action and falls back to its safe default.
|
|
36
|
+
sessionId?: string
|
|
31
37
|
logger?: ChannelToolLogger
|
|
32
38
|
}
|
|
33
39
|
|
|
34
|
-
export function createChannelSendTool({
|
|
40
|
+
export function createChannelSendTool({
|
|
41
|
+
router,
|
|
42
|
+
origin,
|
|
43
|
+
sessionId = '',
|
|
44
|
+
logger = consoleChannelLogger,
|
|
45
|
+
}: CreateChannelSendToolOptions) {
|
|
35
46
|
return defineTool({
|
|
36
47
|
name: 'channel_send',
|
|
37
48
|
label: 'Channel Send',
|
|
@@ -93,6 +104,15 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
|
|
|
93
104
|
},
|
|
94
105
|
),
|
|
95
106
|
),
|
|
107
|
+
resolve_review_thread: Type.Optional(
|
|
108
|
+
Type.Boolean({
|
|
109
|
+
description:
|
|
110
|
+
'GitHub review threads ONLY — ignored on every other adapter and on a github send that has no `thread`. ' +
|
|
111
|
+
'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. ' +
|
|
112
|
+
'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. ' +
|
|
113
|
+
"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).",
|
|
114
|
+
}),
|
|
115
|
+
),
|
|
96
116
|
}),
|
|
97
117
|
|
|
98
118
|
async execute(_toolCallId, params) {
|
|
@@ -136,6 +156,47 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
|
|
|
136
156
|
}
|
|
137
157
|
}
|
|
138
158
|
|
|
159
|
+
const wantsResolve = params.resolve_review_thread === true
|
|
160
|
+
const falseReceipt = checkFalseReceipt({
|
|
161
|
+
sessionId,
|
|
162
|
+
adapter,
|
|
163
|
+
workspace: params.workspace,
|
|
164
|
+
chat: params.chat,
|
|
165
|
+
thread: params.thread ?? null,
|
|
166
|
+
text: bodyText,
|
|
167
|
+
isContinue: false,
|
|
168
|
+
resolveReviewThread: wantsResolve,
|
|
169
|
+
})
|
|
170
|
+
if (falseReceipt.kind === 'block') {
|
|
171
|
+
logger.warn(formatChannelToolFailure('channel_send', falseReceipt.reason))
|
|
172
|
+
return {
|
|
173
|
+
content: [{ type: 'text' as const, text: `channel_send denied: ${falseReceipt.reason}` }],
|
|
174
|
+
details: { ok: false, error: falseReceipt.reason },
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const falseReceiptNotice = falseReceipt.kind === 'warn' ? falseReceipt.notice : null
|
|
178
|
+
|
|
179
|
+
// Resolve BEFORE posting (mirrors channel_reply): a failed resolve must
|
|
180
|
+
// block the acknowledgement so the bot never posts "addressed — resolving"
|
|
181
|
+
// next to a still-open thread. The router enforces that only the bot's own
|
|
182
|
+
// threads can be resolved.
|
|
183
|
+
if (wantsResolve) {
|
|
184
|
+
const resolveError = await resolveReviewThreadBeforeSend(router, {
|
|
185
|
+
adapter,
|
|
186
|
+
workspace: params.workspace,
|
|
187
|
+
chat: params.chat,
|
|
188
|
+
thread: params.thread ?? null,
|
|
189
|
+
})
|
|
190
|
+
if (resolveError !== null) {
|
|
191
|
+
logger.warn(formatChannelToolFailure('channel_send', resolveError))
|
|
192
|
+
return {
|
|
193
|
+
content: [{ type: 'text' as const, text: `channel_send denied: ${resolveError}` }],
|
|
194
|
+
details: { ok: false, error: resolveError },
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
recordResolvedThreadFromSend(sessionId, params.workspace, params.chat, params.thread ?? null)
|
|
198
|
+
}
|
|
199
|
+
|
|
139
200
|
const result = await router.send({
|
|
140
201
|
adapter,
|
|
141
202
|
workspace: params.workspace,
|
|
@@ -179,6 +240,8 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
|
|
|
179
240
|
})
|
|
180
241
|
if (threadMismatch) hints.push(threadMismatch)
|
|
181
242
|
|
|
243
|
+
if (falseReceiptNotice !== null) hints.push(fenceRuntimeNotice(falseReceiptNotice))
|
|
244
|
+
|
|
182
245
|
return {
|
|
183
246
|
content: [{ type: 'text' as const, text: `${fenceToolResult(receipt)}${hints.join('')}` }],
|
|
184
247
|
details,
|
|
@@ -192,6 +255,36 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
|
|
|
192
255
|
})
|
|
193
256
|
}
|
|
194
257
|
|
|
258
|
+
async function resolveReviewThreadBeforeSend(
|
|
259
|
+
router: ChannelRouter,
|
|
260
|
+
target: { adapter: AdapterId; workspace: string; chat: string; thread: string | null },
|
|
261
|
+
): Promise<string | null> {
|
|
262
|
+
if (target.adapter !== 'github') {
|
|
263
|
+
return 'resolve_review_thread is only supported on github sends.'
|
|
264
|
+
}
|
|
265
|
+
if (target.thread === null) {
|
|
266
|
+
return 'resolve_review_thread requires a `thread` (the review thread root comment id).'
|
|
267
|
+
}
|
|
268
|
+
const result = await router.resolveReviewThread({
|
|
269
|
+
adapter: target.adapter,
|
|
270
|
+
workspace: target.workspace,
|
|
271
|
+
chat: target.chat,
|
|
272
|
+
rootCommentId: target.thread,
|
|
273
|
+
})
|
|
274
|
+
if (result.ok) return null
|
|
275
|
+
if (result.code === 'no-match') return null
|
|
276
|
+
return `could not resolve review thread: ${result.error}`
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function recordResolvedThreadFromSend(sessionId: string, workspace: string, chat: string, thread: string | null): void {
|
|
280
|
+
if (thread === null) return
|
|
281
|
+
const m = /^pr:(\d+)$/.exec(chat)
|
|
282
|
+
if (m === null) return
|
|
283
|
+
const prNumber = Number(m[1])
|
|
284
|
+
if (!Number.isSafeInteger(prNumber) || prNumber <= 0) return
|
|
285
|
+
recordResolvedThread({ sessionId, workspace, prNumber, rootCommentId: thread })
|
|
286
|
+
}
|
|
287
|
+
|
|
195
288
|
// Returns a behavioral hint when the model posted to the SAME conversation
|
|
196
289
|
// as the session's origin (same adapter+workspace+chat) but DROPPED the
|
|
197
290
|
// 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
|
}
|