typeclaw 0.27.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 (42) 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/tools/channel-fetch-attachment.ts +1 -2
  6. package/src/agent/tools/channel-react.ts +9 -3
  7. package/src/agent/tools/channel-reply.ts +30 -1
  8. package/src/agent/tools/channel-send.ts +94 -1
  9. package/src/bundled-plugins/github-cli-auth/gh-review-detect.ts +175 -0
  10. package/src/bundled-plugins/github-cli-auth/index.ts +4 -0
  11. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +93 -0
  12. package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
  13. package/src/bundled-plugins/memory/README.md +3 -21
  14. package/src/bundled-plugins/memory/index.ts +1 -149
  15. package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
  16. package/src/channels/adapters/github/inbound.ts +103 -0
  17. package/src/channels/adapters/github/review-thread-resolver.ts +65 -5
  18. package/src/channels/github-false-receipt.ts +87 -0
  19. package/src/channels/github-review-claim.ts +91 -0
  20. package/src/channels/github-review-turn-ledger.ts +71 -0
  21. package/src/channels/persistence.ts +4 -102
  22. package/src/channels/router.ts +2 -0
  23. package/src/channels/schema.ts +20 -5
  24. package/src/cli/channel.ts +2 -1
  25. package/src/cli/init.ts +2 -1
  26. package/src/config/config.ts +19 -288
  27. package/src/container/start.ts +0 -2
  28. package/src/cron/index.ts +3 -44
  29. package/src/cron/schema.ts +2 -96
  30. package/src/init/gitignore.ts +1 -2
  31. package/src/secrets/defaults.ts +1 -18
  32. package/src/secrets/index.ts +0 -2
  33. package/src/secrets/schema.ts +4 -90
  34. package/src/secrets/storage.ts +0 -2
  35. package/src/server/index.ts +0 -4
  36. package/src/skills/typeclaw-config/SKILL.md +9 -11
  37. package/src/skills/typeclaw-permissions/SKILL.md +1 -1
  38. package/typeclaw.schema.json +1 -0
  39. package/src/agent/tools/normalize-ref.ts +0 -11
  40. package/src/bundled-plugins/memory/migration.ts +0 -633
  41. package/src/secrets/migrate-kakaotalk.ts +0 -82
  42. 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.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 })
@@ -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
  }
@@ -0,0 +1,93 @@
1
+ import { readFile } from 'node:fs/promises'
2
+
3
+ import { recordReview } from '@/channels/github-review-turn-ledger'
4
+ import type { ContentPart, ToolResult } from '@/plugin'
5
+
6
+ import { detectReviewSubmission, type DetectedReview } from './gh-review-detect'
7
+
8
+ // Bridges the bash `gh` interceptor to the false-receipt ledger: at tool.before
9
+ // we detect a review-submission command (resolving its --input file), stash it by
10
+ // callId, and at tool.after we credit the ledger ONLY if the command actually
11
+ // succeeded. Strict success detection is the safe bias here — wrongly crediting a
12
+ // review that never landed would re-open the false-receipt hole, so an ambiguous
13
+ // result is treated as "not landed" and left uncredited.
14
+
15
+ const pending = new Map<string, DetectedReview>()
16
+
17
+ const MAX_INPUT_BYTES = 1_000_000
18
+
19
+ export async function noteReviewCommand(args: { callId: string; command: string }): Promise<void> {
20
+ const inputFileContents = await readInputFile(args.command)
21
+ const detected = detectReviewSubmission({ command: args.command, inputFileContents })
22
+ if (detected !== null) pending.set(args.callId, detected)
23
+ }
24
+
25
+ export function commitReviewIfSucceeded(args: { sessionId: string; callId: string; result: ToolResult }): void {
26
+ const detected = pending.get(args.callId)
27
+ if (detected === undefined) return
28
+ pending.delete(args.callId)
29
+ if (!looksSucceeded(detected, collectText(args.result.content))) return
30
+ recordReview({
31
+ sessionId: args.sessionId,
32
+ workspace: detected.workspace,
33
+ prNumber: detected.prNumber,
34
+ verdict: detected.verdict,
35
+ })
36
+ }
37
+
38
+ async function readInputFile(command: string): Promise<string | null> {
39
+ const path = inputFilePath(command)
40
+ if (path === null) return null
41
+ try {
42
+ const buf = await readFile(path)
43
+ if (buf.byteLength > MAX_INPUT_BYTES) return null
44
+ return buf.toString('utf8')
45
+ } catch {
46
+ return null
47
+ }
48
+ }
49
+
50
+ function inputFilePath(command: string): string | null {
51
+ const m = /(?:^|\s)--input(?:=|\s+)(\S+)/.exec(command)
52
+ if (m === null) return null
53
+ const raw = m[1] as string
54
+ if (raw === '-') return null
55
+ return stripQuotes(raw)
56
+ }
57
+
58
+ function stripQuotes(value: string): string {
59
+ if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value[value.length - 1] === value[0]) {
60
+ return value.slice(1, -1)
61
+ }
62
+ return value
63
+ }
64
+
65
+ // Success markers are vector-specific. The REST endpoints echo the created
66
+ // review JSON; the `gh pr review` porcelain prints a plain confirmation line
67
+ // ("✓ Approved pull request OWNER/REPO#N" / "+ Requested changes to pull
68
+ // request …", from cli/cli pkg/cmd/pr/review). Matching REST JSON markers
69
+ // against porcelain output left every `gh pr review --approve` uncredited, so a
70
+ // later "Approved" reply in the same turn was wrongly blocked.
71
+ const API_SUCCESS_MARKERS = [
72
+ '"node_id":"PRR_',
73
+ '"state":"APPROVED"',
74
+ '"state":"CHANGES_REQUESTED"',
75
+ '"state": "APPROVED"',
76
+ ]
77
+ const PR_REVIEW_SUCCESS_MARKERS = ['Approved pull request', 'Requested changes to pull request']
78
+ const FAILURE_MARKERS = ['gh: ', 'HTTP 4', 'HTTP 5', 'Bad credentials', 'Not Found', 'Validation Failed']
79
+
80
+ // Require a success marker AND no failure marker, so a partial/garbled capture
81
+ // fails closed (uncredited).
82
+ function looksSucceeded(detected: DetectedReview, text: string): boolean {
83
+ if (FAILURE_MARKERS.some((m) => text.includes(m))) return false
84
+ const markers = detected.source === 'pr-review' ? PR_REVIEW_SUCCESS_MARKERS : API_SUCCESS_MARKERS
85
+ return markers.some((m) => text.includes(m))
86
+ }
87
+
88
+ function collectText(content: readonly ContentPart[]): string {
89
+ return content
90
+ .filter((p): p is ContentPart & { type: 'text' } => p.type === 'text')
91
+ .map((p) => p.text)
92
+ .join('\n')
93
+ }
@@ -71,7 +71,7 @@ function validateManagedContent(file: ManagedFile, content: string): { ok: true
71
71
  const result = parseConfigJson(content, { migrate: false })
72
72
  return result.ok ? { ok: true } : { ok: false, reason: result.reason }
73
73
  }
74
- const result = parseCronJson(content, { migrate: false })
74
+ const result = parseCronJson(content)
75
75
  return result.ok ? { ok: true } : { ok: false, reason: result.reason }
76
76
  }
77
77