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.
- package/package.json +1 -1
- package/scripts/generate-schema.ts +4 -6
- package/src/agent/index.ts +26 -4
- package/src/agent/multimodal/look-at.ts +1 -2
- package/src/agent/tools/channel-fetch-attachment.ts +1 -2
- package/src/agent/tools/channel-react.ts +9 -3
- package/src/agent/tools/channel-reply.ts +30 -1
- package/src/agent/tools/channel-send.ts +94 -1
- package/src/bundled-plugins/github-cli-auth/gh-review-detect.ts +175 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +4 -0
- package/src/bundled-plugins/github-cli-auth/review-recorder.ts +93 -0
- package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
- package/src/bundled-plugins/memory/README.md +3 -21
- package/src/bundled-plugins/memory/index.ts +1 -149
- package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
- package/src/channels/adapters/github/inbound.ts +103 -0
- package/src/channels/adapters/github/review-thread-resolver.ts +65 -5
- package/src/channels/github-false-receipt.ts +87 -0
- package/src/channels/github-review-claim.ts +91 -0
- package/src/channels/github-review-turn-ledger.ts +71 -0
- package/src/channels/persistence.ts +4 -102
- package/src/channels/router.ts +2 -0
- package/src/channels/schema.ts +20 -5
- package/src/cli/channel.ts +2 -1
- package/src/cli/init.ts +2 -1
- package/src/config/config.ts +19 -288
- package/src/container/start.ts +0 -2
- package/src/cron/index.ts +3 -44
- package/src/cron/schema.ts +2 -96
- package/src/init/gitignore.ts +1 -2
- package/src/secrets/defaults.ts +1 -18
- package/src/secrets/index.ts +0 -2
- package/src/secrets/schema.ts +4 -90
- package/src/secrets/storage.ts +0 -2
- package/src/server/index.ts +0 -4
- package/src/skills/typeclaw-config/SKILL.md +9 -11
- package/src/skills/typeclaw-permissions/SKILL.md +1 -1
- package/typeclaw.schema.json +1 -0
- package/src/agent/tools/normalize-ref.ts +0 -11
- package/src/bundled-plugins/memory/migration.ts +0 -633
- package/src/secrets/migrate-kakaotalk.ts +0 -82
- package/src/secrets/migrate.ts +0 -96
|
@@ -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`** —
|
|
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`, `
|
|
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
|
-
##
|
|
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,
|
|
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
|
|
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
|
|
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)
|
|
@@ -128,6 +128,69 @@ function parseDecimalId(value: string | undefined): number | null {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
async function findThread(fetchImpl: typeof fetch, token: string, target: ResolveTarget): Promise<ThreadLookup> {
|
|
131
|
+
let lookup: ThreadLookup = { kind: 'absent' }
|
|
132
|
+
const outcome = await walkThreadPages(
|
|
133
|
+
fetchImpl,
|
|
134
|
+
token,
|
|
135
|
+
{ owner: target.owner, repo: target.repo, prNumber: target.prNumber },
|
|
136
|
+
(nodes) => {
|
|
137
|
+
for (const node of nodes) {
|
|
138
|
+
if (node.rootCommentId === target.rootCommentId) {
|
|
139
|
+
lookup = { kind: 'found', thread: node }
|
|
140
|
+
return 'stop'
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return 'continue'
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
if (outcome.kind === 'error') return { kind: 'error', result: outcome.result }
|
|
147
|
+
return lookup
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type UnresolvedSelfReviewThread = { threadId: string; rootCommentId: number }
|
|
151
|
+
|
|
152
|
+
export type ListUnresolvedSelfReviewThreadsResult =
|
|
153
|
+
| { ok: true; threads: UnresolvedSelfReviewThread[] }
|
|
154
|
+
| { ok: false; error: string }
|
|
155
|
+
|
|
156
|
+
// Reuses the same page-walk and authorship guard (`isSelfAuthor`) as the
|
|
157
|
+
// single-thread resolver so the post-push "did my comments get addressed?"
|
|
158
|
+
// sweep can never surface a human reviewer's thread as a resolve candidate.
|
|
159
|
+
export async function listUnresolvedSelfReviewThreads(deps: {
|
|
160
|
+
token: string
|
|
161
|
+
selfLogin: string
|
|
162
|
+
owner: string
|
|
163
|
+
repo: string
|
|
164
|
+
prNumber: number
|
|
165
|
+
fetchImpl?: typeof fetch
|
|
166
|
+
}): Promise<ListUnresolvedSelfReviewThreadsResult> {
|
|
167
|
+
const fetchImpl = deps.fetchImpl ?? fetch
|
|
168
|
+
const threads: UnresolvedSelfReviewThread[] = []
|
|
169
|
+
const outcome = await walkThreadPages(
|
|
170
|
+
fetchImpl,
|
|
171
|
+
deps.token,
|
|
172
|
+
{ owner: deps.owner, repo: deps.repo, prNumber: deps.prNumber },
|
|
173
|
+
(nodes) => {
|
|
174
|
+
for (const node of nodes) {
|
|
175
|
+
if (node.isResolved || node.rootCommentId === null) continue
|
|
176
|
+
if (!isSelfAuthor(node, deps.selfLogin)) continue
|
|
177
|
+
threads.push({ threadId: node.id, rootCommentId: node.rootCommentId })
|
|
178
|
+
}
|
|
179
|
+
return 'continue'
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
if (outcome.kind === 'error') return { ok: false, error: outcome.result.error }
|
|
183
|
+
return { ok: true, threads }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
type WalkOutcome = { kind: 'done' } | { kind: 'error'; result: ReviewThreadResolveResult & { ok: false } }
|
|
187
|
+
|
|
188
|
+
async function walkThreadPages(
|
|
189
|
+
fetchImpl: typeof fetch,
|
|
190
|
+
token: string,
|
|
191
|
+
target: { owner: string; repo: string; prNumber: number },
|
|
192
|
+
onPage: (nodes: ReviewThreadNode[]) => 'stop' | 'continue',
|
|
193
|
+
): Promise<WalkOutcome> {
|
|
131
194
|
let after: string | null = null
|
|
132
195
|
for (;;) {
|
|
133
196
|
let response: Response
|
|
@@ -148,11 +211,8 @@ async function findThread(fetchImpl: typeof fetch, token: string, target: Resolv
|
|
|
148
211
|
}
|
|
149
212
|
const parsed = await parseThreadsPage(response)
|
|
150
213
|
if (parsed.kind === 'error') return { kind: 'error', result: parsed.result }
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (node.rootCommentId === target.rootCommentId) return { kind: 'found', thread: node }
|
|
154
|
-
}
|
|
155
|
-
if (!parsed.hasNextPage || parsed.endCursor === null) return { kind: 'absent' }
|
|
214
|
+
if (onPage(parsed.nodes) === 'stop') return { kind: 'done' }
|
|
215
|
+
if (!parsed.hasNextPage || parsed.endCursor === null) return { kind: 'done' }
|
|
156
216
|
after = parsed.endCursor
|
|
157
217
|
}
|
|
158
218
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { classifyReviewClaim } from './github-review-claim'
|
|
2
|
+
import { hasResolvedThread, hasReview } from './github-review-turn-ledger'
|
|
3
|
+
|
|
4
|
+
// Decides whether a github PR reply is a false receipt: prose that CLAIMS a
|
|
5
|
+
// formal verdict / thread close-out the agent never actually performed this turn.
|
|
6
|
+
// Pure except for the ledger reads (module singletons); returns the action the
|
|
7
|
+
// channel_reply tool should take. Block only the black-and-white cases; warn on
|
|
8
|
+
// soft signals so casual chatter is never hard-denied.
|
|
9
|
+
|
|
10
|
+
export type FalseReceiptDecision =
|
|
11
|
+
| { kind: 'allow' }
|
|
12
|
+
| { kind: 'block'; reason: string }
|
|
13
|
+
| { kind: 'warn'; notice: string }
|
|
14
|
+
|
|
15
|
+
export type FalseReceiptInput = {
|
|
16
|
+
sessionId: string
|
|
17
|
+
adapter: string
|
|
18
|
+
workspace: string
|
|
19
|
+
chat: string
|
|
20
|
+
thread: string | null
|
|
21
|
+
text: string | undefined
|
|
22
|
+
isContinue: boolean
|
|
23
|
+
resolveReviewThread: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function checkFalseReceipt(input: FalseReceiptInput): FalseReceiptDecision {
|
|
27
|
+
if (input.adapter !== 'github') return { kind: 'allow' }
|
|
28
|
+
const prNumber = prNumberFromChat(input.chat)
|
|
29
|
+
if (prNumber === null) return { kind: 'allow' }
|
|
30
|
+
|
|
31
|
+
const claim = classifyReviewClaim(input.text ?? '')
|
|
32
|
+
if (claim === 'ignore') return { kind: 'allow' }
|
|
33
|
+
if (claim === 'warn') return { kind: 'warn', notice: SOFT_NOTICE }
|
|
34
|
+
|
|
35
|
+
// A turn the agent explicitly keeps alive (continue:true) is not yet a receipt
|
|
36
|
+
// — the real action may still be coming. Never block; nudge instead.
|
|
37
|
+
if (input.isContinue) return { kind: 'warn', notice: SOFT_NOTICE }
|
|
38
|
+
|
|
39
|
+
if (claim === 'block-resolve') {
|
|
40
|
+
if (input.thread === null) return { kind: 'allow' }
|
|
41
|
+
if (input.resolveReviewThread) return { kind: 'allow' }
|
|
42
|
+
if (
|
|
43
|
+
hasResolvedThread({
|
|
44
|
+
sessionId: input.sessionId,
|
|
45
|
+
workspace: input.workspace,
|
|
46
|
+
prNumber,
|
|
47
|
+
rootCommentId: input.thread,
|
|
48
|
+
})
|
|
49
|
+
) {
|
|
50
|
+
return { kind: 'allow' }
|
|
51
|
+
}
|
|
52
|
+
return { kind: 'block', reason: RESOLVE_REASON }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const verdict = claim === 'block-approve' ? 'APPROVE' : 'REQUEST_CHANGES'
|
|
56
|
+
if (hasReview({ sessionId: input.sessionId, workspace: input.workspace, prNumber, verdict })) {
|
|
57
|
+
return { kind: 'allow' }
|
|
58
|
+
}
|
|
59
|
+
return { kind: 'block', reason: verdict === 'APPROVE' ? APPROVE_REASON : REQUEST_CHANGES_REASON }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function prNumberFromChat(chat: string): number | null {
|
|
63
|
+
const m = /^pr:(\d+)$/.exec(chat)
|
|
64
|
+
if (m === null) return null
|
|
65
|
+
const n = Number(m[1])
|
|
66
|
+
return Number.isSafeInteger(n) && n > 0 ? n : null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const APPROVE_REASON =
|
|
70
|
+
'This reply reads as a formal approval, but no APPROVE review was submitted on this PR this turn. ' +
|
|
71
|
+
'A chat comment is not a GitHub review — submit the formal review via `gh api -X POST /repos/<owner>/<repo>/pulls/<N>/reviews` ' +
|
|
72
|
+
'(event: APPROVE) first, then narrate if needed. If you are not actually approving, reword the reply.'
|
|
73
|
+
|
|
74
|
+
const REQUEST_CHANGES_REASON =
|
|
75
|
+
'This reply reads as a formal "request changes", but no REQUEST_CHANGES review was submitted on this PR this turn. ' +
|
|
76
|
+
'Submit the formal review via `gh api -X POST /repos/<owner>/<repo>/pulls/<N>/reviews` (event: REQUEST_CHANGES) first. ' +
|
|
77
|
+
'If you are not actually requesting changes, reword the reply.'
|
|
78
|
+
|
|
79
|
+
const RESOLVE_REASON =
|
|
80
|
+
'This reply reads as closing out a review thread, but `resolve_review_thread: true` was not set and the thread ' +
|
|
81
|
+
'was not resolved this turn. Pass `resolve_review_thread: true` on this reply to actually resolve it, ' +
|
|
82
|
+
'or reword if the thread should stay open.'
|
|
83
|
+
|
|
84
|
+
const SOFT_NOTICE =
|
|
85
|
+
'Note: a chat comment does not create a formal GitHub review or resolve a thread. ' +
|
|
86
|
+
'If you mean to approve / request changes, submit a formal review via `gh api`; ' +
|
|
87
|
+
'to close a thread you authored, set `resolve_review_thread: true`.'
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Deterministic phrase classifier for the false-receipt guard (channel-reply.ts):
|
|
2
|
+
// how strongly does a github PR reply CLAIM a formal verdict/close-out it may not
|
|
3
|
+
// have actually performed? The taxonomy errs toward WARN over BLOCK on purpose —
|
|
4
|
+
// a false block breaks a legitimate reply; a missed soft-fake only loses a nudge.
|
|
5
|
+
|
|
6
|
+
export type ReviewClaim = 'block-approve' | 'block-request-changes' | 'block-resolve' | 'warn' | 'ignore'
|
|
7
|
+
|
|
8
|
+
// Word-boundary anchored so "approved" never fires inside "unapproved".
|
|
9
|
+
const BLOCK_APPROVE: readonly RegExp[] = [
|
|
10
|
+
/\bapproved\b/,
|
|
11
|
+
/\bapproving\b/,
|
|
12
|
+
/\bi approve\b/,
|
|
13
|
+
/\bapproval (submitted|sent|posted)\b/,
|
|
14
|
+
/\bsubmitting (the )?approval\b/,
|
|
15
|
+
/\bformal approval\b/,
|
|
16
|
+
/\blgtm,? approved\b/,
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
const BLOCK_REQUEST_CHANGES: readonly RegExp[] = [
|
|
20
|
+
/\brequest(ing|ed)? changes\b/,
|
|
21
|
+
/\bchanges requested\b/,
|
|
22
|
+
/\bi request changes\b/,
|
|
23
|
+
/\bblocking (this|the|merge)\b/,
|
|
24
|
+
/\bthis is blocked\b/,
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
// Only consulted by the caller when thread!=null (a review thread). Bare
|
|
28
|
+
// "resolved" is intentionally NOT here — it collides with the warn-tier "looks
|
|
29
|
+
// resolved?"; resolve claims must carry a definite marker (marked/that/this/
|
|
30
|
+
// thanks) or a verify clause.
|
|
31
|
+
const BLOCK_RESOLVE: readonly RegExp[] = [
|
|
32
|
+
/\bmarked resolved\b/,
|
|
33
|
+
/\bthread resolved\b/,
|
|
34
|
+
/\bthat resolves it\b/,
|
|
35
|
+
/\bthis resolves it\b/,
|
|
36
|
+
/\bclosing this out\b/,
|
|
37
|
+
/\bconfirmed fixed\b/,
|
|
38
|
+
// verify clause + a fix/resolve verb, allowing a short gap ("verified at <sha>, that fixes it").
|
|
39
|
+
/\b(verified|confirmed)\b[^.!?]*\b(fix(es|ed)|resolv)/,
|
|
40
|
+
/\b(thanks,?|fixed,?) (looks )?resolved\b/,
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
// Casual phrasing that might be chatter, not a formal close-out: allow + nudge.
|
|
44
|
+
const WARN: readonly RegExp[] = [
|
|
45
|
+
/\blgtm\b/,
|
|
46
|
+
/\blooks good\b/,
|
|
47
|
+
/\blooks fine\b/,
|
|
48
|
+
/\bseems fine\b/,
|
|
49
|
+
/\bshould be (fine|good)\b/,
|
|
50
|
+
/\bneeds changes\b/,
|
|
51
|
+
/\bstill needs work\b/,
|
|
52
|
+
/\blooks resolved\b/,
|
|
53
|
+
/\bseems resolved\b/,
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
// Negation / future-intent / past-reference markers DEMOTE a positive match to
|
|
57
|
+
// ignore. Blocking "I haven't approved" / "I'll approve" / "approved it earlier"
|
|
58
|
+
// (answering a question) is the worst false-positive class, so it is checked first.
|
|
59
|
+
const DEMOTE_TO_IGNORE: readonly RegExp[] = [
|
|
60
|
+
/\b(haven'?t|have not|did ?n'?t|did not|not yet|never)\b[^.!?]*\b(approv|request|resolv|block)/,
|
|
61
|
+
/\b(can'?t|cannot|won'?t|will not|wouldn'?t)\b[^.!?]*\b(approv|request|resolv|block)/,
|
|
62
|
+
/\bnot (approved|resolved|blocked|requesting)\b/,
|
|
63
|
+
/\b(i'?ll|i will|going to|gonna|about to|planning to)\b[^.!?]*\b(approv|review|request|resolv)/,
|
|
64
|
+
/\b(approved|resolved|requested changes)\b[^.!?]*\b(earlier|already|yesterday|before|last (review|time)|previously)\b/,
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
export function classifyReviewClaim(rawText: string): ReviewClaim {
|
|
68
|
+
const text = normalize(rawText)
|
|
69
|
+
if (text === '') return 'ignore'
|
|
70
|
+
|
|
71
|
+
if (DEMOTE_TO_IGNORE.some((re) => re.test(text))) return 'ignore'
|
|
72
|
+
|
|
73
|
+
// Block-tier wins over warn-tier: an unambiguous "approved" in a casual message
|
|
74
|
+
// is still a formal claim.
|
|
75
|
+
if (BLOCK_APPROVE.some((re) => re.test(text))) return 'block-approve'
|
|
76
|
+
if (BLOCK_REQUEST_CHANGES.some((re) => re.test(text))) return 'block-request-changes'
|
|
77
|
+
if (BLOCK_RESOLVE.some((re) => re.test(text))) return 'block-resolve'
|
|
78
|
+
if (WARN.some((re) => re.test(text))) return 'warn'
|
|
79
|
+
return 'ignore'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Strips markdown/emoji noise so "**Approved!**" and "approved" classify alike,
|
|
83
|
+
// keeping apostrophes + sentence punctuation that the negation regexes rely on.
|
|
84
|
+
function normalize(text: string): string {
|
|
85
|
+
return text
|
|
86
|
+
.toLowerCase()
|
|
87
|
+
.replace(/[*_`~#>]/g, ' ')
|
|
88
|
+
.replace(/[^\p{L}\p{N}\s'.!?]/gu, ' ')
|
|
89
|
+
.replace(/\s+/g, ' ')
|
|
90
|
+
.trim()
|
|
91
|
+
}
|