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
@@ -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
 
@@ -85,29 +85,11 @@ A `[dreaming] citation-superset violation: …` warning logs the dropped ids and
85
85
 
86
86
  - **`memory/topics/<slug>.md`** — per-topic shards with YAML frontmatter (`heading`, `cites`, `days`, `lastReinforced`, `tags?`) + body markdown. Runtime owns the frontmatter (recomputed after every dreaming run from the body's citations); dreaming subagent writes body only.
87
87
  - **`memory/streams/yyyy-MM-dd.jsonl`** — daily fragment streams. One event per line, discriminated union of `fragment | watermark | legacy_prose`. Force-committed alongside the shards.
88
- - **`memory/MEMORY.md.pre-shard.bak`** — one-shot pre-migration backup created by the boot migration. Safe to delete after verifying.
88
+ - **`memory/MEMORY.md.pre-shard.bak`** — legacy pre-shard backup left by older TypeClaw versions. Safe to delete after verifying.
89
89
  - **`memory/skills/<name>/SKILL.md`** — muscle memory. Skills the dreaming subagent distills from repeated procedures. Auto-loaded as first-class skills.
90
90
  - **`memory/.dreaming-state.json`** — per-day dreamed-id sets.
91
91
  - **`memory/.retrieval-cache/<sessionId>.md`** — ephemeral retrieval summaries. Written by `memory-retrieval`, read by `loadMemory` on the next prompt of the same session, unlinked on `session.end`.
92
92
 
93
- ## One-shot boot migration
94
-
95
- When the plugin boots against an agent folder with a root `MEMORY.md` and no `memory/topics/`, it runs `runShardingMigration`. Steps:
96
-
97
- 1. Detect prerequisites.
98
- 2. Reset `memory/.migrating/` if a previous run crashed mid-flight.
99
- 3. Run the legacy `.md → .jsonl` daily-stream migration (existing behavior).
100
- 4. Parse `MEMORY.md` via `parseTopicsWithBodies`.
101
- 5. **Stage topic shards** in `memory/.migrating/topics/` (originals untouched).
102
- 6. **Stage streams** by COPY (not rename) to `memory/.migrating/streams/`.
103
- 7. Stage pre-shard backup by COPY.
104
- 8. **Verify staging** via `checkCitationSupersetAcrossShards`. On failure, abort and KEEP `memory/.migrating/` for human inspection. Originals untouched.
105
- 9. **Atomic finalization**: rename three dirs (`topics`, `streams`, `.pre-shard.bak`), then unlink originals.
106
-
107
- Crash-recovery branches at boot: stale `memory/.migrating/` with no `topics/` → cleanup + retry; leftover `memory/.migrating/` alongside complete `topics/` → cleanup only; orphan root `MEMORY.md` or `memory/<date>.jsonl` alongside the new layout → delete orphans.
108
-
109
- The migration is idempotent and crash-safe.
110
-
111
93
  ## How `session.idle` works
112
94
 
113
95
  Core fires `session.idle` immediately after every `session.prompt()` completion. The plugin owns the debounce: a `Map<sessionId, Timeout>` reset on every event. When the timer fires, the plugin spawns `memory-logger` for that session — unless the min-delta gate suppresses the spawn (see below).
@@ -132,9 +114,9 @@ Each `memory-logger` spawn captures the line count of `memory/streams/<today>.js
132
114
 
133
115
  ## Tests
134
116
 
135
- Test files in this directory (kebab-case, `.test.ts` neighbors): `paths`, `slug`, `frontmatter`, `topics`, `shard-snapshot`, `delete-tool`, `citations`, `citation-superset`, `migration`, `load-shards`, `load-memory`, `injection-plan`, `search-tool`, `memory-retrieval`, `memory-logger`, `dreaming`, `index`, `integration`. Plus guard policies in `../guard/policies/`: `memory-topics-delete`, `memory-topics-write`, `memory-retrieval-cache-write`.
117
+ Test files in this directory (kebab-case, `.test.ts` neighbors): `paths`, `slug`, `frontmatter`, `topics`, `shard-snapshot`, `delete-tool`, `citations`, `citation-superset`, `load-shards`, `load-memory`, `injection-plan`, `search-tool`, `memory-retrieval`, `memory-logger`, `dreaming`, `index`, `integration`. Plus guard policies in `../guard/policies/`: `memory-topics-delete`, `memory-topics-write`, `memory-retrieval-cache-write`.
136
118
 
137
- ## Migration notes (from before the plugin existed)
119
+ ## Notes from before the plugin existed
138
120
 
139
121
  - `memory.idleMs` and `memory.dreaming.schedule` already existed in core's `typeclaw.json` schema and moved into this plugin's `configSchema` verbatim.
140
122
  - `memory.dreaming.schedule` is now **restart-required** because plugin config is read once at boot.
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { access, constants as fsConstants, mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises'
2
+ import { access, constants as fsConstants, mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
 
5
5
  import { CronExpressionParser } from 'cron-parser'
@@ -14,7 +14,6 @@ import { buildInjectionPlan, DEFAULT_INJECTION_BUDGET_BYTES, MIN_INJECTION_BUDGE
14
14
  import { loadAllShards } from './load-shards'
15
15
  import { createMemoryLoggerSubagent, type MemoryLoggerPayload } from './memory-logger'
16
16
  import { createMemoryRetrievalSubagent, type MemoryRetrievalPayload } from './memory-retrieval'
17
- import { runMigration, runShardingMigration } from './migration'
18
17
  import { preShardBackupPath, streamFilePath, streamsDir, topicsDir } from './paths'
19
18
  import { memorySearchTool } from './search-tool'
20
19
 
@@ -141,26 +140,6 @@ export default definePlugin({
141
140
  const retrievalSpawnTimeoutMs = ctx.config.retrievalSpawnTimeoutMs
142
141
  const dreamingSchedule = ctx.config.dreaming?.schedule ?? DEFAULT_DREAMING_SCHEDULE
143
142
 
144
- const migrationResult = await runMigration({
145
- agentDir: ctx.agentDir,
146
- logger: ctx.logger,
147
- })
148
- if (migrationResult.migrated.length > 0) {
149
- ctx.logger.info(`[memory] migrated ${migrationResult.migrated.length} daily stream(s) to JSONL`)
150
- }
151
-
152
- const shardingResult = await runShardingMigration({
153
- agentDir: ctx.agentDir,
154
- logger: ctx.logger,
155
- })
156
- if (shardingResult.migrated) {
157
- ctx.logger.info(
158
- `[memory] sharded ${shardingResult.topicCount} topics + ${shardingResult.streamCount} streams (pre-shard backup at memory/MEMORY.md.pre-shard.bak)`,
159
- )
160
- } else if (shardingResult.error !== undefined) {
161
- ctx.logger.warn(`[memory] sharding migration aborted: ${shardingResult.error}`)
162
- }
163
-
164
143
  const idleTimers = new Map<string, ReturnType<typeof setTimeout>>()
165
144
  const lastIdleEvent = new Map<string, { parentTranscriptPath: string | undefined; origin?: SessionOrigin }>()
166
145
  const bytesAtLastRun = new Map<string, number>()
@@ -548,133 +527,6 @@ export default definePlugin({
548
527
  }
549
528
  },
550
529
  },
551
- 'legacy-md-cleanup': {
552
- description: 'Check for legacy .md daily stream files and un-migrated root MEMORY.md',
553
- run: async (dctx) => {
554
- const memoryDir = join(dctx.agentDir, 'memory')
555
- // kept: pre-migration agents may still have a root MEMORY.md.
556
- const rootMemoryPath = join(dctx.agentDir, 'MEMORY.md')
557
- const hasRootMemory = existsSync(rootMemoryPath)
558
- const hasTopicsDir = existsSync(topicsDir(dctx.agentDir))
559
-
560
- let files: string[]
561
- try {
562
- files = await readdir(memoryDir)
563
- } catch {
564
- if (!hasRootMemory) return { status: 'ok', message: 'memory/ does not exist yet' }
565
- return {
566
- status: 'warning',
567
- message: 'root MEMORY.md present but not sharded',
568
- fix: {
569
- description: 'Run sharding migration to convert root MEMORY.md to topic shards',
570
- apply: async (fixCtx) => {
571
- const result = await runShardingMigration({ agentDir: fixCtx.agentDir, logger: fixCtx.logger })
572
- return {
573
- summary: result.migrated
574
- ? `sharded ${result.topicCount} topic(s) and ${result.streamCount} stream(s)`
575
- : `sharding migration did not run${result.error ? `: ${result.error}` : ''}`,
576
- changedPaths: result.migrated
577
- ? [
578
- join('memory', 'topics'),
579
- join('memory', 'streams'),
580
- join('memory', 'MEMORY.md.pre-shard.bak'),
581
- ]
582
- : [],
583
- }
584
- },
585
- },
586
- }
587
- }
588
-
589
- const mdFiles = files.filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f))
590
-
591
- if (hasRootMemory) {
592
- if (!hasTopicsDir) {
593
- const mdMsg = mdFiles.length > 0 ? `; also ${mdFiles.length} legacy .md daily stream(s)` : ''
594
- return {
595
- status: 'warning',
596
- message: `root MEMORY.md present but not sharded${mdMsg}`,
597
- fix: {
598
- description: 'Run sharding migration to convert root MEMORY.md to topic shards',
599
- apply: async (fixCtx) => {
600
- const result = await runShardingMigration({ agentDir: fixCtx.agentDir, logger: fixCtx.logger })
601
- return {
602
- summary: result.migrated
603
- ? `sharded ${result.topicCount} topic(s) and ${result.streamCount} stream(s)`
604
- : `sharding migration did not run${result.error ? `: ${result.error}` : ''}`,
605
- changedPaths: result.migrated
606
- ? [
607
- join('memory', 'topics'),
608
- join('memory', 'streams'),
609
- join('memory', 'MEMORY.md.pre-shard.bak'),
610
- ]
611
- : [],
612
- }
613
- },
614
- },
615
- }
616
- }
617
- const mdMsg = mdFiles.length > 0 ? `; also ${mdFiles.length} legacy .md daily stream(s)` : ''
618
- return {
619
- status: 'warning',
620
- message: `orphaned root MEMORY.md after sharding migration${mdMsg}`,
621
- fix: {
622
- description: 'Delete the orphaned root MEMORY.md file',
623
- apply: async () => {
624
- await unlink(rootMemoryPath)
625
- return { summary: 'deleted orphaned root MEMORY.md', changedPaths: ['MEMORY.md'] }
626
- },
627
- },
628
- }
629
- }
630
-
631
- if (mdFiles.length === 0) return { status: 'ok', message: 'no legacy .md daily streams found' }
632
-
633
- const caseA: string[] = []
634
- const caseB: string[] = []
635
-
636
- for (const mdFile of mdFiles) {
637
- const date = mdFile.replace('.md', '')
638
- const jsonlFile = `${date}.jsonl`
639
- if (files.includes(jsonlFile)) {
640
- caseB.push(date)
641
- } else {
642
- caseA.push(date)
643
- }
644
- }
645
-
646
- if (caseA.length > 0 && caseB.length === 0) {
647
- return {
648
- status: 'warning',
649
- message: `${caseA.length} legacy .md daily stream(s) still present; boot-time migration likely failed`,
650
- fix: {
651
- description: 'Re-run migration to convert .md files to .jsonl',
652
- apply: async (fixCtx) => {
653
- const result = await runMigration({ agentDir: fixCtx.agentDir, logger: fixCtx.logger })
654
- return {
655
- summary: `migrated ${result.migrated.length} legacy .md daily stream(s) to .jsonl`,
656
- changedPaths: result.migrated.map((d) => `memory/${d}.jsonl`),
657
- }
658
- },
659
- },
660
- }
661
- }
662
-
663
- if (caseB.length > 0) {
664
- const allDates = [...caseA, ...caseB]
665
- return {
666
- status: 'warning',
667
- message: `Conflicting .md+.jsonl pair for dates: ${allDates.join(', ')}. Inspect manually: the .jsonl is the authoritative new format; if its contents match or supersede the .md, delete the .md by hand.`,
668
- fix: {
669
- description: 'Manual inspection required. Delete the .md file if the .jsonl is correct.',
670
- // No apply — this is an operator decision
671
- },
672
- }
673
- }
674
-
675
- return { status: 'ok', message: 'no legacy .md daily streams found' }
676
- },
677
- },
678
530
  'pre-shard-backup-age': {
679
531
  description: 'Warn when pre-shard backup is older than 30 days',
680
532
  run: async (dctx) => {
@@ -191,7 +191,7 @@ async function intendedContent(
191
191
  }
192
192
 
193
193
  function parseJobsFromContent(content: string): readonly ParsedCronJob[] | undefined {
194
- const result = parseCronJson(content, { migrate: false })
194
+ const result = parseCronJson(content)
195
195
  if (!result.ok) return undefined
196
196
  return result.file.jobs
197
197
  }
@@ -203,7 +203,7 @@ async function readExistingJobs(targetPath: string): Promise<readonly ParsedCron
203
203
  } catch {
204
204
  return []
205
205
  }
206
- const result = parseCronJson(raw, { migrate: true })
206
+ const result = parseCronJson(raw)
207
207
  if (!result.ok) return []
208
208
  return result.file.jobs
209
209
  }
@@ -8,6 +8,7 @@ import { removeRequestedReviewer } from './decoy-reviewer'
8
8
  import type { DeliveryDedup } from './dedup'
9
9
  import { isGithubEventAllowed } from './event-allowlist'
10
10
  import { encodeGithubReactionRef, type GithubReactionTarget } from './reactions'
11
+ import { listUnresolvedSelfReviewThreads } from './review-thread-resolver'
11
12
 
12
13
  export type GithubInboundLogger = { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }
13
14
 
@@ -80,6 +81,18 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
80
81
  return ok()
81
82
  }
82
83
 
84
+ // A push to an open PR (`synchronize`) is not a message to react to — it is
85
+ // a trigger to re-check whether the new commits addressed the bot's own
86
+ // still-open review threads. The check needs a GraphQL round-trip, so it
87
+ // runs OFF the ACK path (like the decoy-reviewer drop) and only wakes a
88
+ // session when there is at least one such thread. Returning here also keeps
89
+ // synchronize out of the generic awareness-only fallthrough below.
90
+ if (event === 'pull_request' && action === 'synchronize') {
91
+ if (delivery !== '') options.dedup.add(delivery)
92
+ scheduleReviewThreadRecheck({ payload, selfLogin, options })
93
+ return ok()
94
+ }
95
+
83
96
  const teamIsBotMember = await resolveTeamMembership(event, payload, options)
84
97
  const classified = classifyGithubInbound(event, payload, selfLogin, {
85
98
  teamIsBotMember,
@@ -171,6 +184,96 @@ function defaultScheduleBackgroundTask(task: () => Promise<void>): void {
171
184
  void task().catch(() => {})
172
185
  }
173
186
 
187
+ function scheduleReviewThreadRecheck(input: {
188
+ payload: Record<string, unknown>
189
+ selfLogin: string | null
190
+ options: GithubWebhookHandlerOptions
191
+ }): void {
192
+ const { payload, selfLogin, options } = input
193
+ if (selfLogin === null) return
194
+ const authToken = options.authToken
195
+ if (authToken === undefined) return
196
+
197
+ const repository = readRepository(payload)
198
+ const pr = readRecord(payload.pull_request)
199
+ const pullNumber = readNumber(pr, 'number')
200
+ if (repository === null || pullNumber === null) return
201
+ const headSha = readString(readRecord(pr?.head), 'sha')
202
+
203
+ const fetchImpl = options.fetchImpl ?? fetch
204
+ const schedule = options.scheduleBackgroundTask ?? defaultScheduleBackgroundTask
205
+ const target = `${repository.owner}/${repository.name}#${pullNumber}`
206
+ schedule(async () => {
207
+ try {
208
+ const token = await authToken({ repoSlug: `${repository.owner}/${repository.name}` })
209
+ const result = await listUnresolvedSelfReviewThreads({
210
+ token,
211
+ selfLogin,
212
+ owner: repository.owner,
213
+ repo: repository.name,
214
+ prNumber: pullNumber,
215
+ fetchImpl,
216
+ })
217
+ if (!result.ok) {
218
+ options.logger.warn(`[github] review-thread recheck failed for ${target}: ${result.error}`)
219
+ return
220
+ }
221
+ if (result.threads.length === 0) return
222
+ options.route(
223
+ buildRecheckInbound({
224
+ repository,
225
+ pullNumber,
226
+ headSha,
227
+ rootCommentIds: result.threads.map((t) => t.rootCommentId),
228
+ title: readString(pr, 'title'),
229
+ }),
230
+ )
231
+ } catch (err) {
232
+ options.logger.warn(
233
+ `[github] review-thread recheck failed for ${target}: ${err instanceof Error ? err.message : String(err)}`,
234
+ )
235
+ }
236
+ })
237
+ }
238
+
239
+ function buildRecheckInbound(input: {
240
+ repository: { owner: string; name: string }
241
+ pullNumber: number
242
+ headSha: string | null
243
+ rootCommentIds: readonly number[]
244
+ title: string | null
245
+ }): InboundMessage {
246
+ const { repository, pullNumber, headSha, rootCommentIds, title } = input
247
+ const titleSegment = title !== null && title.trim() !== '' ? `: "${title}"` : ''
248
+ const shaSegment = headSha !== null ? ` (now at ${headSha.slice(0, 7)})` : ''
249
+ const idList = rootCommentIds.join(', ')
250
+ const text =
251
+ `PR #${pullNumber}${titleSegment} received new commits${shaSegment}. ` +
252
+ `You have ${rootCommentIds.length} unresolved review thread(s) you authored on this PR ` +
253
+ `(root comment id(s): ${idList}). For each, check whether the new commits addressed your ` +
254
+ `concern. If addressed, reply on that thread via channel_send with a short acknowledgement ` +
255
+ `and resolve_review_thread: true (the thread id is the root comment id). If not addressed, ` +
256
+ `leave it open. If none are addressed, end your turn without replying.`
257
+
258
+ return {
259
+ adapter: 'github',
260
+ workspace: `${repository.owner}/${repository.name}`,
261
+ chat: `pr:${pullNumber}`,
262
+ thread: null,
263
+ text,
264
+ externalMessageId: `pr-${pullNumber}-recheck-${headSha ?? 'unknown'}`,
265
+ authorId: 'github-system',
266
+ authorName: 'github',
267
+ authorIsBot: false,
268
+ isBotMention: true,
269
+ replyToBotMessageId: null,
270
+ mentionsOthers: false,
271
+ replyToOtherMessageId: null,
272
+ isDm: false,
273
+ ts: 0,
274
+ }
275
+ }
276
+
174
277
  export async function verifySignature(body: string, secret: string, sigHeader: string): Promise<boolean> {
175
278
  const expected = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
176
279
  const a = Buffer.from(expected)
@@ -21,6 +21,7 @@ import {
21
21
  parseListHooksPermissionStatus,
22
22
  } from './permission-guidance'
23
23
  import { createGithubReactionCallback, createGithubRemoveReactionCallback } from './reactions'
24
+ import { createGithubReviewStateResolver } from './review-state'
24
25
  import { createGithubReviewThreadResolver } from './review-thread-resolver'
25
26
  import { createTeamMembershipChecker } from './team-membership'
26
27
  import { deregisterGithubWebhooks, registerGithubWebhooks, type WebhookRegistrationResult } from './webhook-register'
@@ -143,6 +144,12 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
143
144
  selfLogin: () => selfLogin,
144
145
  fetchImpl,
145
146
  })
147
+ const reviewStateResolver = createGithubReviewStateResolver({
148
+ token: authToken,
149
+ selfLogin: () => selfLogin,
150
+ approve: () => options.configRef().review.approve,
151
+ fetchImpl,
152
+ })
146
153
  const channelNameResolver = createGithubChannelNameResolver({ token: authToken, fetchImpl })
147
154
  // GitHub addresses by `@login`, not the numeric id, so `username` carries
148
155
  // the login the model should type; the id is kept for completeness.
@@ -195,6 +202,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
195
202
  options.router.registerChannelNameResolver('github', channelNameResolver)
196
203
  options.router.registerSelfIdentity('github', selfIdentityResolver)
197
204
  options.router.registerReviewThreadResolver('github', reviewThreadResolver)
205
+ options.router.registerReviewStateResolver('github', reviewStateResolver)
198
206
  options.router.registerFetchAttachment('github', fetchAttachment)
199
207
  try {
200
208
  server = (options.httpListenImpl ?? listenWithBun)(options.configRef().webhookPort, handler)
@@ -210,6 +218,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
210
218
  options.router.unregisterChannelNameResolver('github', channelNameResolver)
211
219
  options.router.unregisterSelfIdentity('github', selfIdentityResolver)
212
220
  options.router.unregisterReviewThreadResolver('github', reviewThreadResolver)
221
+ options.router.unregisterReviewStateResolver('github', reviewStateResolver)
213
222
  options.router.unregisterFetchAttachment('github', fetchAttachment)
214
223
  await auth.dispose()
215
224
  delete process.env.GH_TOKEN
@@ -334,6 +343,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
334
343
  options.router.unregisterChannelNameResolver('github', channelNameResolver)
335
344
  options.router.unregisterSelfIdentity('github', selfIdentityResolver)
336
345
  options.router.unregisterReviewThreadResolver('github', reviewThreadResolver)
346
+ options.router.unregisterReviewStateResolver('github', reviewStateResolver)
337
347
  options.router.unregisterFetchAttachment('github', fetchAttachment)
338
348
  await server?.stop()
339
349
  // Detach hooks AFTER closing the listener so any in-flight deliveries
@@ -0,0 +1,137 @@
1
+ import type { ReviewStateResolver, ReviewStateResult } from '@/channels/types'
2
+
3
+ import type { GithubAuthContext } from './auth'
4
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
5
+
6
+ // Answers the re-review stranding guard's question: is the bot's latest
7
+ // EFFECTIVE formal review on this PR a sticky CHANGES_REQUESTED? GitHub clears a
8
+ // same-reviewer CHANGES_REQUESTED only with a later APPROVED or DISMISSED from
9
+ // the same reviewer — a later COMMENTED review does NOT clear it (the PR #644
10
+ // trap). So we walk the bot's own reviews in chronological order, ignore
11
+ // COMMENTED/PENDING, and read the last decisive one.
12
+ export function createGithubReviewStateResolver(deps: {
13
+ token: (context?: GithubAuthContext) => Promise<string>
14
+ selfLogin: () => string | null
15
+ approve: () => boolean
16
+ fetchImpl?: typeof fetch
17
+ }): ReviewStateResolver {
18
+ const fetchImpl = deps.fetchImpl ?? fetch
19
+ return async (req): Promise<ReviewStateResult> => {
20
+ const approve = deps.approve()
21
+ if (req.adapter !== 'github') {
22
+ return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
23
+ }
24
+ const target = parseTarget(req.workspace, req.chat)
25
+ if (target === null) {
26
+ return { ok: false, error: `unparseable github PR target (chat=${req.chat})`, code: 'transient' }
27
+ }
28
+ const selfLogin = deps.selfLogin()
29
+ if (selfLogin === null) {
30
+ return { ok: false, error: 'github self-identity not resolved; cannot read review state', code: 'transient' }
31
+ }
32
+
33
+ const token = await deps.token({ repoSlug: `${target.owner}/${target.repo}` })
34
+ const reviews = await fetchSelfReviews(fetchImpl, token, target, selfLogin)
35
+ if (!reviews.ok) return { ok: false, error: reviews.error, code: reviews.code }
36
+
37
+ const lastDecisive = reviews.states.filter(isDecisive).at(-1) ?? null
38
+ return { ok: true, selfBlocking: lastDecisive === 'CHANGES_REQUESTED', approve }
39
+ }
40
+ }
41
+
42
+ type Target = { owner: string; repo: string; prNumber: number }
43
+
44
+ function parseTarget(workspace: string, chat: string): Target | null {
45
+ const [owner, repo, ...rest] = workspace.split('/')
46
+ if (owner === undefined || owner === '' || repo === undefined || repo === '' || rest.length > 0) return null
47
+ const m = /^pr:(\d+)$/.exec(chat)
48
+ if (m === null) return null
49
+ const prNumber = Number(m[1])
50
+ if (!Number.isSafeInteger(prNumber) || prNumber <= 0) return null
51
+ return { owner, repo, prNumber }
52
+ }
53
+
54
+ type SelfReviewsResult =
55
+ | { ok: true; states: string[] }
56
+ | { ok: false; error: string; code: 'not-found' | 'permission-denied' | 'transient' }
57
+
58
+ async function fetchSelfReviews(
59
+ fetchImpl: typeof fetch,
60
+ token: string,
61
+ target: Target,
62
+ selfLogin: string,
63
+ ): Promise<SelfReviewsResult> {
64
+ const states: string[] = []
65
+ let url: string | null =
66
+ `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/pulls/${target.prNumber}/reviews?per_page=100`
67
+ while (url !== null) {
68
+ let response: Response
69
+ try {
70
+ response = await fetchImpl(url, { headers: githubJsonHeaders(token) })
71
+ } catch (err) {
72
+ return { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' }
73
+ }
74
+ if (!response.ok) {
75
+ const text = await response.text().catch(() => '')
76
+ return {
77
+ ok: false,
78
+ error: `GitHub reviews ${response.status}${text !== '' ? `: ${text}` : ''}`,
79
+ code: classifyStatus(response.status),
80
+ }
81
+ }
82
+ const page = (await response.json().catch(() => null)) as ReviewRow[] | null
83
+ if (page === null) return { ok: false, error: 'GitHub reviews returned non-JSON', code: 'transient' }
84
+ for (const row of page) {
85
+ if (typeof row.state !== 'string') continue
86
+ const login = row.user?.login ?? null
87
+ if (login === null) continue
88
+ const isBot = row.user?.type === 'Bot'
89
+ if (!isSelfReviewer(login, isBot, selfLogin)) continue
90
+ states.push(row.state)
91
+ }
92
+ url = nextLink(response.headers.get('link'))
93
+ }
94
+ return { ok: true, states }
95
+ }
96
+
97
+ // A formal CHANGES_REQUESTED is sticky until a later APPROVED/DISMISSED; only
98
+ // these three states decide the block. COMMENTED and PENDING are non-deciding
99
+ // noise that must NOT shadow an earlier CHANGES_REQUESTED.
100
+ const DECISIVE = new Set(['CHANGES_REQUESTED', 'APPROVED', 'DISMISSED'])
101
+
102
+ function isDecisive(state: string): boolean {
103
+ return DECISIVE.has(state)
104
+ }
105
+
106
+ // A GitHub App's own login differs across REST (`slug[bot]`) and GraphQL (bare
107
+ // `slug`). The REST reviews endpoint returns `slug[bot]` for the App, but the
108
+ // suffix-strip must be gated on the reviewer actually being a Bot: a human User
109
+ // can own the bare slug as a login, and stripping `[bot]` off the App's
110
+ // selfLogin to match a human would wrongly attribute their review to the bot.
111
+ const BOT_LOGIN_SUFFIX = '[bot]'
112
+
113
+ function isSelfReviewer(login: string, isBot: boolean, selfLogin: string): boolean {
114
+ if (isBot) return normalizeBotLogin(login) === normalizeBotLogin(selfLogin)
115
+ return login === selfLogin
116
+ }
117
+
118
+ function normalizeBotLogin(login: string): string {
119
+ return login.endsWith(BOT_LOGIN_SUFFIX) ? login.slice(0, -BOT_LOGIN_SUFFIX.length) : login
120
+ }
121
+
122
+ function nextLink(linkHeader: string | null): string | null {
123
+ if (linkHeader === null) return null
124
+ for (const part of linkHeader.split(',')) {
125
+ const m = /<([^>]+)>;\s*rel="next"/.exec(part)
126
+ if (m !== null) return m[1] ?? null
127
+ }
128
+ return null
129
+ }
130
+
131
+ function classifyStatus(status: number): 'not-found' | 'permission-denied' | 'transient' {
132
+ if (status === 401 || status === 403) return 'permission-denied'
133
+ if (status === 404) return 'not-found'
134
+ return 'transient'
135
+ }
136
+
137
+ type ReviewRow = { id?: number; state?: unknown; user?: { login?: string; type?: string } }