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.
Files changed (62) 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/session-origin.ts +9 -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 +30 -1
  9. package/src/agent/tools/channel-send.ts +94 -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/reviewer/skills/code-review.ts +3 -1
  17. package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
  18. package/src/channels/adapters/github/inbound.ts +155 -9
  19. package/src/channels/adapters/github/review-thread-resolver.ts +93 -8
  20. package/src/channels/github-false-receipt.ts +87 -0
  21. package/src/channels/github-review-claim.ts +91 -0
  22. package/src/channels/github-review-turn-ledger.ts +71 -0
  23. package/src/channels/persistence.ts +4 -102
  24. package/src/channels/router.ts +191 -7
  25. package/src/channels/schema.ts +20 -5
  26. package/src/cli/channel.ts +2 -1
  27. package/src/cli/init.ts +2 -1
  28. package/src/cli/inspect.ts +216 -36
  29. package/src/cli/logs.ts +15 -0
  30. package/src/cli/tui.ts +33 -39
  31. package/src/compose/logs.ts +1 -1
  32. package/src/config/config.ts +19 -288
  33. package/src/container/logs.ts +70 -22
  34. package/src/container/start.ts +0 -2
  35. package/src/cron/index.ts +3 -44
  36. package/src/cron/schema.ts +2 -96
  37. package/src/init/gitignore.ts +1 -2
  38. package/src/inspect/index.ts +128 -42
  39. package/src/inspect/item-list.ts +44 -0
  40. package/src/inspect/item.ts +17 -0
  41. package/src/inspect/label.ts +1 -1
  42. package/src/inspect/logs-item.ts +79 -0
  43. package/src/inspect/loop.ts +74 -3
  44. package/src/inspect/open-item.ts +100 -0
  45. package/src/inspect/preview.ts +106 -0
  46. package/src/inspect/session-list.ts +15 -3
  47. package/src/inspect/transcript-view.ts +182 -0
  48. package/src/inspect/tui-item.ts +97 -0
  49. package/src/secrets/defaults.ts +1 -18
  50. package/src/secrets/index.ts +0 -2
  51. package/src/secrets/schema.ts +4 -90
  52. package/src/secrets/storage.ts +0 -2
  53. package/src/server/index.ts +0 -4
  54. package/src/skills/typeclaw-channel-github/SKILL.md +3 -1
  55. package/src/skills/typeclaw-config/SKILL.md +9 -11
  56. package/src/skills/typeclaw-permissions/SKILL.md +1 -1
  57. package/src/tui/index.ts +72 -32
  58. package/typeclaw.schema.json +1 -0
  59. package/src/agent/tools/normalize-ref.ts +0 -11
  60. package/src/bundled-plugins/memory/migration.ts +0 -633
  61. package/src/secrets/migrate-kakaotalk.ts +0 -82
  62. package/src/secrets/migrate.ts +0 -96
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
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 })
@@ -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
- if (platformInfo.supportsReactions) {
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 = 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,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({ router, origin, logger = consoleChannelLogger }: CreateChannelSendToolOptions) {
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
  }