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.
Files changed (51) hide show
  1. package/package.json +1 -1
  2. package/scripts/generate-schema.ts +4 -6
  3. package/src/agent/index.ts +26 -4
  4. package/src/agent/multimodal/look-at.ts +1 -2
  5. package/src/agent/provider-error.ts +33 -1
  6. package/src/agent/tools/channel-fetch-attachment.ts +1 -2
  7. package/src/agent/tools/channel-react.ts +9 -3
  8. package/src/agent/tools/channel-reply.ts +52 -1
  9. package/src/agent/tools/channel-send.ts +115 -1
  10. package/src/bundled-plugins/github-cli-auth/gh-review-detect.ts +175 -0
  11. package/src/bundled-plugins/github-cli-auth/index.ts +4 -0
  12. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +93 -0
  13. package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
  14. package/src/bundled-plugins/memory/README.md +3 -21
  15. package/src/bundled-plugins/memory/index.ts +1 -149
  16. package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
  17. package/src/channels/adapters/github/inbound.ts +103 -0
  18. package/src/channels/adapters/github/index.ts +10 -0
  19. package/src/channels/adapters/github/review-state.ts +137 -0
  20. package/src/channels/adapters/github/review-thread-resolver.ts +65 -5
  21. package/src/channels/github-false-receipt.ts +87 -0
  22. package/src/channels/github-rereview-guard.ts +76 -0
  23. package/src/channels/github-review-claim.ts +92 -0
  24. package/src/channels/github-review-turn-ledger.ts +71 -0
  25. package/src/channels/persistence.ts +4 -102
  26. package/src/channels/router.ts +181 -7
  27. package/src/channels/schema.ts +20 -5
  28. package/src/channels/types.ts +31 -0
  29. package/src/cli/channel.ts +2 -1
  30. package/src/cli/init.ts +2 -1
  31. package/src/config/config.ts +19 -288
  32. package/src/container/start.ts +0 -2
  33. package/src/cron/index.ts +3 -44
  34. package/src/cron/schema.ts +2 -96
  35. package/src/init/gitignore.ts +1 -2
  36. package/src/inspect/transcript-view.ts +10 -0
  37. package/src/secrets/defaults.ts +1 -18
  38. package/src/secrets/index.ts +0 -2
  39. package/src/secrets/schema.ts +4 -90
  40. package/src/secrets/storage.ts +0 -2
  41. package/src/server/index.ts +11 -5
  42. package/src/shared/protocol.ts +18 -6
  43. package/src/skills/typeclaw-config/SKILL.md +9 -11
  44. package/src/skills/typeclaw-permissions/SKILL.md +1 -1
  45. package/src/tui/format.ts +13 -0
  46. package/src/tui/index.ts +21 -7
  47. package/typeclaw.schema.json +1 -0
  48. package/src/agent/tools/normalize-ref.ts +0 -11
  49. package/src/bundled-plugins/memory/migration.ts +0 -633
  50. package/src/secrets/migrate-kakaotalk.ts +0 -82
  51. package/src/secrets/migrate.ts +0 -96
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.27.0",
3
+ "version": "0.28.1",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -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
- // Pre-rename `auth.json` files (or migrated `secrets.json` files that still
18
- // carry the legacy `$schema` URL `mergeLlmIntoEnvelope` preserves an
19
- // existing `$schema`, so the legacy pointer survives the file rename) need
20
- // it to resolve in editors. The alias is tiny and re-emitting it has no
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 },
@@ -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(createChannelReplyTool({ router: channelRouter, origin: channelOrigin }))
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(createChannelSendTool({ router: channelRouter, origin: channelOrigin }))
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: { ...channelOrigin, ...(origin.reactionRef !== undefined ? { reactionRef: origin.reactionRef } : {}) },
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: normalizeRef(found.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 = normalizeRef(found.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
- if (origin.reactionRef === undefined) return deny('this conversation has no message to react to')
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: origin.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({ router, origin, logger = consoleChannelLogger }: CreateChannelSendToolOptions) {
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
  }