typeclaw 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/session-origin.ts +9 -1
- 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/reviewer/skills/code-review.ts +3 -1
- package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
- package/src/channels/adapters/github/inbound.ts +155 -9
- package/src/channels/adapters/github/review-thread-resolver.ts +93 -8
- 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 +191 -7
- package/src/channels/schema.ts +20 -5
- package/src/cli/channel.ts +2 -1
- package/src/cli/init.ts +2 -1
- package/src/cli/inspect.ts +216 -36
- package/src/cli/logs.ts +15 -0
- package/src/cli/tui.ts +33 -39
- package/src/compose/logs.ts +1 -1
- package/src/config/config.ts +19 -288
- package/src/container/logs.ts +70 -22
- 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/inspect/index.ts +128 -42
- package/src/inspect/item-list.ts +44 -0
- package/src/inspect/item.ts +17 -0
- package/src/inspect/label.ts +1 -1
- package/src/inspect/logs-item.ts +79 -0
- package/src/inspect/loop.ts +74 -3
- package/src/inspect/open-item.ts +100 -0
- package/src/inspect/preview.ts +106 -0
- package/src/inspect/session-list.ts +15 -3
- package/src/inspect/transcript-view.ts +182 -0
- package/src/inspect/tui-item.ts +97 -0
- 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-channel-github/SKILL.md +3 -1
- package/src/skills/typeclaw-config/SKILL.md +9 -11
- package/src/skills/typeclaw-permissions/SKILL.md +1 -1
- package/src/tui/index.ts +72 -32
- 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
|
@@ -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
|
|
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`** —
|
|
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) => {
|
|
@@ -66,7 +66,9 @@ Prioritize in this order:
|
|
|
66
66
|
|
|
67
67
|
### Re-reviews must re-decide, not observe
|
|
68
68
|
|
|
69
|
-
When the payload tells you this is a **re-review** — you (or this agent) previously requested changes on this PR and the author has pushed fixes
|
|
69
|
+
When the payload tells you this is a **re-review** — you (or this agent) previously requested changes on this PR and the author has pushed fixes — your verdict's whole purpose is to **re-decide the blocking state**, so:
|
|
70
|
+
|
|
71
|
+
This includes payloads where the parent says the author **addressed your prior blocking feedback** — "fixed both issues", "addressed your review", "pushed a fix" — even when the inbound was phrased conversationally rather than as an explicit "review again". An author responding to the blocker you raised IS the re-review trigger; the absence of the words "review again" does not downgrade it to a \`comment\`. Re-decide:
|
|
70
72
|
|
|
71
73
|
- Return **approve** if the blockers that drove the prior \`request-changes\` are resolved (leftover nits do not block — \`approve\` with inline nits is correct).
|
|
72
74
|
- Return **request-changes** if any blocker remains or a new one appeared.
|
|
@@ -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)
|
|
@@ -187,6 +290,7 @@ export function classifyGithubInbound(
|
|
|
187
290
|
): InboundMessage | null {
|
|
188
291
|
const repository = readRepository(payload)
|
|
189
292
|
if (repository === null) return null
|
|
293
|
+
const mention = resolveBotMentionLogins(selfLogin, options?.authType ?? 'pat')
|
|
190
294
|
const base = {
|
|
191
295
|
adapter: 'github' as const,
|
|
192
296
|
workspace: `${repository.owner}/${repository.name}`,
|
|
@@ -209,7 +313,7 @@ export function classifyGithubInbound(
|
|
|
209
313
|
comment.body,
|
|
210
314
|
id,
|
|
211
315
|
user,
|
|
212
|
-
|
|
316
|
+
mention,
|
|
213
317
|
comment.created_at,
|
|
214
318
|
{ kind: 'issue-comment', owner: repository.owner, repo: repository.name, commentId: id },
|
|
215
319
|
)
|
|
@@ -228,7 +332,7 @@ export function classifyGithubInbound(
|
|
|
228
332
|
comment.body,
|
|
229
333
|
id,
|
|
230
334
|
readUser(comment.user),
|
|
231
|
-
|
|
335
|
+
mention,
|
|
232
336
|
comment.created_at,
|
|
233
337
|
{ kind: 'pr-review-comment', owner: repository.owner, repo: repository.name, commentId: id },
|
|
234
338
|
)
|
|
@@ -246,7 +350,7 @@ export function classifyGithubInbound(
|
|
|
246
350
|
comment.body,
|
|
247
351
|
id,
|
|
248
352
|
readUser(comment.user),
|
|
249
|
-
|
|
353
|
+
mention,
|
|
250
354
|
comment.created_at,
|
|
251
355
|
null,
|
|
252
356
|
)
|
|
@@ -270,7 +374,7 @@ export function classifyGithubInbound(
|
|
|
270
374
|
text,
|
|
271
375
|
id,
|
|
272
376
|
opener,
|
|
273
|
-
|
|
377
|
+
mention,
|
|
274
378
|
issue.created_at,
|
|
275
379
|
{ kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
|
|
276
380
|
action === 'opened' && !hasBody,
|
|
@@ -325,7 +429,7 @@ export function classifyGithubInbound(
|
|
|
325
429
|
prText,
|
|
326
430
|
id,
|
|
327
431
|
opener,
|
|
328
|
-
|
|
432
|
+
mention,
|
|
329
433
|
pr.created_at,
|
|
330
434
|
{ kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
|
|
331
435
|
isOpenLike && !hasBody,
|
|
@@ -352,7 +456,7 @@ export function classifyGithubInbound(
|
|
|
352
456
|
text,
|
|
353
457
|
id,
|
|
354
458
|
reviewer,
|
|
355
|
-
|
|
459
|
+
mention,
|
|
356
460
|
review.submitted_at,
|
|
357
461
|
null,
|
|
358
462
|
!hasBody,
|
|
@@ -377,7 +481,7 @@ export function classifyGithubInbound(
|
|
|
377
481
|
text,
|
|
378
482
|
id,
|
|
379
483
|
opener,
|
|
380
|
-
|
|
484
|
+
mention,
|
|
381
485
|
discussion.created_at,
|
|
382
486
|
null,
|
|
383
487
|
action === 'created' && !hasBody,
|
|
@@ -417,6 +521,48 @@ function resolveDecoyReviewerLogin(selfLogin: string, authType: 'pat' | 'app'):
|
|
|
417
521
|
return slug !== '' ? slug : null
|
|
418
522
|
}
|
|
419
523
|
|
|
524
|
+
// The @-handles that count as "addressed to us" in inbound body text. Under
|
|
525
|
+
// App auth `selfLogin` is the actor login `slug[bot]`, but GitHub renders a
|
|
526
|
+
// human's mention of the App as `@slug` (the bare slug — the decoy account's
|
|
527
|
+
// login), with no `[bot]` suffix and no way to type one. Matching only against
|
|
528
|
+
// `selfLogin` therefore never sees `@typeey` for a `typeey[bot]` actor, so a
|
|
529
|
+
// direct "@typeey review again" lands with isBotMention=false and falls through
|
|
530
|
+
// the engagement mention gate. Include the decoy slug so the bare-slug mention
|
|
531
|
+
// is recognized. Under PAT auth the bot IS a real user, so there is no decoy
|
|
532
|
+
// and only `selfLogin` applies.
|
|
533
|
+
export type BotMentionLogins = readonly string[]
|
|
534
|
+
|
|
535
|
+
export function resolveBotMentionLogins(selfLogin: string | null, authType: 'pat' | 'app'): BotMentionLogins {
|
|
536
|
+
if (selfLogin === null) return []
|
|
537
|
+
const decoyLogin = resolveDecoyReviewerLogin(selfLogin, authType)
|
|
538
|
+
return decoyLogin !== null ? [selfLogin, decoyLogin] : [selfLogin]
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// GitHub login chars are ASCII letters, digits, and hyphen. A `@login` token is
|
|
542
|
+
// a real mention of `login` only when the char right after it is not one of
|
|
543
|
+
// these — otherwise `@${login}` is a prefix of a longer, different login. This
|
|
544
|
+
// matters for the App decoy slug: `resolveBotMentionLogins('typeclaw[bot]')`
|
|
545
|
+
// yields the bare slug `typeclaw`, and a naive substring check would treat
|
|
546
|
+
// `@typeclaw-bot` (a different user) as a self-mention. The trailing `[` of
|
|
547
|
+
// `@typeclaw[bot]` is not a login char, so the full actor handle still matches.
|
|
548
|
+
const LOGIN_CHAR = /[A-Za-z0-9-]/
|
|
549
|
+
|
|
550
|
+
function textMentionsBot(text: string, mentionLogins: BotMentionLogins): boolean {
|
|
551
|
+
return mentionLogins.some((login) => mentionsLogin(text, login))
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function mentionsLogin(text: string, login: string): boolean {
|
|
555
|
+
const token = `@${login}`
|
|
556
|
+
let from = 0
|
|
557
|
+
for (;;) {
|
|
558
|
+
const at = text.indexOf(token, from)
|
|
559
|
+
if (at === -1) return false
|
|
560
|
+
const next = text[at + token.length]
|
|
561
|
+
if (next === undefined || !LOGIN_CHAR.test(next)) return true
|
|
562
|
+
from = at + 1
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
420
566
|
function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null {
|
|
421
567
|
const { action, payload, pr, number, base, selfLogin, authType, teamIsBotMember } = input
|
|
422
568
|
if (selfLogin === null) return null
|
|
@@ -550,7 +696,7 @@ function buildInbound(
|
|
|
550
696
|
rawText: unknown,
|
|
551
697
|
id: number,
|
|
552
698
|
user: GithubUser | null,
|
|
553
|
-
|
|
699
|
+
mention: BotMentionLogins,
|
|
554
700
|
rawTs: unknown,
|
|
555
701
|
reactionTarget: GithubReactionTarget | null,
|
|
556
702
|
synthesizedAwareness = false,
|
|
@@ -567,7 +713,7 @@ function buildInbound(
|
|
|
567
713
|
// Synthesized awareness lines carry an `@author` prefix describing who acted;
|
|
568
714
|
// that handle is the author, never a third-party mention of the bot, so the
|
|
569
715
|
// body-text mention heuristic must not fire on it.
|
|
570
|
-
const isBotMention = !synthesizedAwareness &&
|
|
716
|
+
const isBotMention = !synthesizedAwareness && textMentionsBot(text, mention)
|
|
571
717
|
return {
|
|
572
718
|
...key,
|
|
573
719
|
text,
|