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
|
@@ -9,7 +9,7 @@ const GRAPHQL_ENDPOINT = `${GITHUB_API_BASE}/graphql`
|
|
|
9
9
|
// carry more, so the resolver paginates until it matches the root comment id
|
|
10
10
|
// or exhausts the pages — stopping early on a 404-equivalent (thread absent)
|
|
11
11
|
// rather than fabricating a node id.
|
|
12
|
-
const THREADS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:1){nodes{databaseId author{login}}}}}}}}`
|
|
12
|
+
const THREADS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:1){nodes{databaseId author{__typename login}}}}}}}}`
|
|
13
13
|
|
|
14
14
|
const RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{id isResolved}}}`
|
|
15
15
|
|
|
@@ -18,6 +18,7 @@ type ReviewThreadNode = {
|
|
|
18
18
|
isResolved: boolean
|
|
19
19
|
rootCommentId: number | null
|
|
20
20
|
rootAuthorLogin: string | null
|
|
21
|
+
rootAuthorIsBot: boolean
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
type ThreadLookup =
|
|
@@ -66,7 +67,7 @@ export function createGithubReviewThreadResolver(deps: {
|
|
|
66
67
|
const thread = lookup.thread
|
|
67
68
|
// The load-bearing guard: only the bot may resolve the bot's own thread.
|
|
68
69
|
// Resolving a human reviewer's thread would erase their open question.
|
|
69
|
-
if (thread
|
|
70
|
+
if (!isSelfAuthor(thread, selfLogin)) {
|
|
70
71
|
return {
|
|
71
72
|
ok: false,
|
|
72
73
|
error: `refusing to resolve thread authored by @${thread.rootAuthorLogin ?? 'unknown'} (not @${selfLogin})`,
|
|
@@ -79,6 +80,29 @@ export function createGithubReviewThreadResolver(deps: {
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
// A GitHub App's own login differs across the two APIs this guard straddles:
|
|
84
|
+
// REST `getSelf` returns `slug[bot]` (selfLogin) but GraphQL's `Bot` author node
|
|
85
|
+
// returns the bare `slug` (rootAuthorLogin). Strict `===` thus refused the App's
|
|
86
|
+
// OWN thread (production: "refusing to resolve thread authored by @typeey (not
|
|
87
|
+
// @typeey[bot])"). The bare-slug match is gated on the GraphQL author actually
|
|
88
|
+
// being a `Bot`: a human `User` can legitimately own the bare slug as a login
|
|
89
|
+
// (e.g. the user `typeey` exists alongside the App `typeey[bot]`), so a User
|
|
90
|
+
// author must still match `selfLogin` exactly — otherwise the suffix-strip would
|
|
91
|
+
// let the bot close a human reviewer's thread, defeating the guard above.
|
|
92
|
+
const BOT_LOGIN_SUFFIX = '[bot]'
|
|
93
|
+
|
|
94
|
+
function isSelfAuthor(thread: ReviewThreadNode, selfLogin: string): boolean {
|
|
95
|
+
if (thread.rootAuthorLogin === null) return false
|
|
96
|
+
if (thread.rootAuthorIsBot) {
|
|
97
|
+
return normalizeBotLogin(thread.rootAuthorLogin) === normalizeBotLogin(selfLogin)
|
|
98
|
+
}
|
|
99
|
+
return thread.rootAuthorLogin === selfLogin
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeBotLogin(login: string): string {
|
|
103
|
+
return login.endsWith(BOT_LOGIN_SUFFIX) ? login.slice(0, -BOT_LOGIN_SUFFIX.length) : login
|
|
104
|
+
}
|
|
105
|
+
|
|
82
106
|
type ResolveTarget = { owner: string; repo: string; prNumber: number; rootCommentId: number }
|
|
83
107
|
|
|
84
108
|
function parseTarget(req: ReviewThreadResolveRequest): ResolveTarget | null {
|
|
@@ -104,6 +128,69 @@ function parseDecimalId(value: string | undefined): number | null {
|
|
|
104
128
|
}
|
|
105
129
|
|
|
106
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> {
|
|
107
194
|
let after: string | null = null
|
|
108
195
|
for (;;) {
|
|
109
196
|
let response: Response
|
|
@@ -124,11 +211,8 @@ async function findThread(fetchImpl: typeof fetch, token: string, target: Resolv
|
|
|
124
211
|
}
|
|
125
212
|
const parsed = await parseThreadsPage(response)
|
|
126
213
|
if (parsed.kind === 'error') return { kind: 'error', result: parsed.result }
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (node.rootCommentId === target.rootCommentId) return { kind: 'found', thread: node }
|
|
130
|
-
}
|
|
131
|
-
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' }
|
|
132
216
|
after = parsed.endCursor
|
|
133
217
|
}
|
|
134
218
|
}
|
|
@@ -175,6 +259,7 @@ async function parseThreadsPage(response: Response): Promise<ThreadsPage> {
|
|
|
175
259
|
isResolved: n.isResolved,
|
|
176
260
|
rootCommentId: root?.databaseId ?? null,
|
|
177
261
|
rootAuthorLogin: root?.author?.login ?? null,
|
|
262
|
+
rootAuthorIsBot: root?.author?.__typename === 'Bot',
|
|
178
263
|
}
|
|
179
264
|
})
|
|
180
265
|
return { kind: 'ok', nodes, hasNextPage: connection.pageInfo.hasNextPage, endCursor: connection.pageInfo.endCursor }
|
|
@@ -231,7 +316,7 @@ type GraphqlThreadsResponse = {
|
|
|
231
316
|
nodes: Array<{
|
|
232
317
|
id: string
|
|
233
318
|
isResolved: boolean
|
|
234
|
-
comments: { nodes: Array<{ databaseId?: number; author?: { login?: string } }> }
|
|
319
|
+
comments: { nodes: Array<{ databaseId?: number; author?: { __typename?: string; login?: string } }> }
|
|
235
320
|
}>
|
|
236
321
|
}
|
|
237
322
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// In-process record of REAL github review actions performed during the current
|
|
2
|
+
// turn, shared across two plugin boundaries: github-cli-auth records a formal
|
|
3
|
+
// review / thread-resolve here after the `gh` command SUCCEEDS, and channel-reply
|
|
4
|
+
// consults it before sending a verdict/close-out reply. If the agent claims a
|
|
5
|
+
// verdict in prose but this ledger shows no matching action this turn, the reply
|
|
6
|
+
// is a false receipt (see channel-reply.ts). State is per-session and reset at
|
|
7
|
+
// turn start, so a claim must be backed by an action in the SAME turn.
|
|
8
|
+
|
|
9
|
+
export type ReviewVerdict = 'APPROVE' | 'REQUEST_CHANGES'
|
|
10
|
+
|
|
11
|
+
type PrKey = string
|
|
12
|
+
type ThreadKey = string
|
|
13
|
+
|
|
14
|
+
const reviewsByPr = new Map<PrKey, Set<ReviewVerdict>>()
|
|
15
|
+
const resolvedThreads = new Set<ThreadKey>()
|
|
16
|
+
|
|
17
|
+
function prKey(sessionId: string, workspace: string, prNumber: number): PrKey {
|
|
18
|
+
return `${sessionId}::${workspace}::${prNumber}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function threadKey(sessionId: string, workspace: string, prNumber: number, rootCommentId: string): ThreadKey {
|
|
22
|
+
return `${sessionId}::${workspace}::${prNumber}::${rootCommentId}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resetReviewTurn(sessionId: string): void {
|
|
26
|
+
for (const key of reviewsByPr.keys()) {
|
|
27
|
+
if (key.startsWith(`${sessionId}::`)) reviewsByPr.delete(key)
|
|
28
|
+
}
|
|
29
|
+
for (const key of resolvedThreads) {
|
|
30
|
+
if (key.startsWith(`${sessionId}::`)) resolvedThreads.delete(key)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function recordReview(args: {
|
|
35
|
+
sessionId: string
|
|
36
|
+
workspace: string
|
|
37
|
+
prNumber: number
|
|
38
|
+
verdict: ReviewVerdict
|
|
39
|
+
}): void {
|
|
40
|
+
const key = prKey(args.sessionId, args.workspace, args.prNumber)
|
|
41
|
+
const set = reviewsByPr.get(key) ?? new Set<ReviewVerdict>()
|
|
42
|
+
set.add(args.verdict)
|
|
43
|
+
reviewsByPr.set(key, set)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function hasReview(args: {
|
|
47
|
+
sessionId: string
|
|
48
|
+
workspace: string
|
|
49
|
+
prNumber: number
|
|
50
|
+
verdict: ReviewVerdict
|
|
51
|
+
}): boolean {
|
|
52
|
+
return reviewsByPr.get(prKey(args.sessionId, args.workspace, args.prNumber))?.has(args.verdict) ?? false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function recordResolvedThread(args: {
|
|
56
|
+
sessionId: string
|
|
57
|
+
workspace: string
|
|
58
|
+
prNumber: number
|
|
59
|
+
rootCommentId: string
|
|
60
|
+
}): void {
|
|
61
|
+
resolvedThreads.add(threadKey(args.sessionId, args.workspace, args.prNumber, args.rootCommentId))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function hasResolvedThread(args: {
|
|
65
|
+
sessionId: string
|
|
66
|
+
workspace: string
|
|
67
|
+
prNumber: number
|
|
68
|
+
rootCommentId: string
|
|
69
|
+
}): boolean {
|
|
70
|
+
return resolvedThreads.has(threadKey(args.sessionId, args.workspace, args.prNumber, args.rootCommentId))
|
|
71
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir,
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import type { ChannelParticipant } from '@/agent/session-origin'
|
|
@@ -16,10 +16,8 @@ const FILE_VERSION = 4
|
|
|
16
16
|
// UUID, which never matches on disk — every restart silently creates a
|
|
17
17
|
// fresh session and the channel loses its transcript memory.
|
|
18
18
|
//
|
|
19
|
-
// `sessionFile` is optional because
|
|
20
|
-
//
|
|
21
|
-
// directory for `*_${sessionId}.jsonl`; if no match is found the file is
|
|
22
|
-
// considered lost and reopen will fall back to a fresh session.
|
|
19
|
+
// `sessionFile` is optional because a session can exist in memory before a
|
|
20
|
+
// transcript path is known; reopen falls back to a fresh session when absent.
|
|
23
21
|
export type ChannelSessionRecord = {
|
|
24
22
|
adapter: AdapterId
|
|
25
23
|
workspace: string
|
|
@@ -36,16 +34,6 @@ type FileV4 = {
|
|
|
36
34
|
sessions: ChannelSessionRecord[]
|
|
37
35
|
}
|
|
38
36
|
|
|
39
|
-
type FileV3 = {
|
|
40
|
-
version: 3
|
|
41
|
-
sessions: ChannelSessionRecord[]
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
type FileV2 = {
|
|
45
|
-
version: 2
|
|
46
|
-
sessions: Array<Omit<ChannelSessionRecord, 'sessionFile'>>
|
|
47
|
-
}
|
|
48
|
-
|
|
49
37
|
export type ChannelSessionsLogger = {
|
|
50
38
|
info: (msg: string) => void
|
|
51
39
|
warn: (msg: string) => void
|
|
@@ -62,10 +50,6 @@ export function channelsSessionsPath(agentDir: string): string {
|
|
|
62
50
|
return join(agentDir, 'channels', 'sessions.json')
|
|
63
51
|
}
|
|
64
52
|
|
|
65
|
-
function sessionsDirOf(agentDir: string): string {
|
|
66
|
-
return join(agentDir, 'sessions')
|
|
67
|
-
}
|
|
68
|
-
|
|
69
53
|
export async function loadChannelSessions(
|
|
70
54
|
agentDir: string,
|
|
71
55
|
logger: ChannelSessionsLogger = consoleLogger,
|
|
@@ -94,21 +78,7 @@ export async function loadChannelSessions(
|
|
|
94
78
|
if (!Array.isArray(file.sessions)) return []
|
|
95
79
|
return file.sessions.filter(isValidRecord)
|
|
96
80
|
}
|
|
97
|
-
|
|
98
|
-
const file = parsed as FileV3
|
|
99
|
-
if (!Array.isArray(file.sessions)) return []
|
|
100
|
-
return migrateV3ToV4(file.sessions.filter(isValidRecord), logger)
|
|
101
|
-
}
|
|
102
|
-
if (version === 2) {
|
|
103
|
-
const file = parsed as FileV2
|
|
104
|
-
if (!Array.isArray(file.sessions)) return []
|
|
105
|
-
const v2Records = file.sessions.filter(isValidV2Record)
|
|
106
|
-
const v3Records = await migrateV2Records(agentDir, v2Records, logger)
|
|
107
|
-
return migrateV3ToV4(v3Records, logger)
|
|
108
|
-
}
|
|
109
|
-
logger.warn(
|
|
110
|
-
`[channels] ${path} version ${String(version)} not supported (expected 2, 3, or ${FILE_VERSION}); ignored`,
|
|
111
|
-
)
|
|
81
|
+
logger.warn(`[channels] ${path} version ${String(version)} not supported (expected ${FILE_VERSION}); ignored`)
|
|
112
82
|
return []
|
|
113
83
|
}
|
|
114
84
|
|
|
@@ -130,59 +100,6 @@ export async function saveChannelSessions(
|
|
|
130
100
|
}
|
|
131
101
|
}
|
|
132
102
|
|
|
133
|
-
// One-shot migration from v2 (sessionId only) to v3 (sessionId + sessionFile).
|
|
134
|
-
// pi-coding-agent writes session files as `${ISO_TIMESTAMP}_${UUID}.jsonl`,
|
|
135
|
-
// so we look for any file ending in `_${sessionId}.jsonl`. If a directory
|
|
136
|
-
// scan fails we leave sessionFile undefined; the next reopen attempt will
|
|
137
|
-
// fall back to a fresh session (the same broken behavior v2 had — but at
|
|
138
|
-
// least the next successful create will populate sessionFile correctly and
|
|
139
|
-
// we'll be migrated forward.)
|
|
140
|
-
async function migrateV2Records(
|
|
141
|
-
agentDir: string,
|
|
142
|
-
v2Records: readonly (Omit<ChannelSessionRecord, 'sessionFile' | 'sessionId'> & { sessionId: string })[],
|
|
143
|
-
logger: ChannelSessionsLogger,
|
|
144
|
-
): Promise<ChannelSessionRecord[]> {
|
|
145
|
-
if (v2Records.length === 0) return []
|
|
146
|
-
const sessionsDir = sessionsDirOf(agentDir)
|
|
147
|
-
let entries: string[]
|
|
148
|
-
try {
|
|
149
|
-
entries = await readdir(sessionsDir)
|
|
150
|
-
} catch {
|
|
151
|
-
logger.warn(`[channels] could not scan ${sessionsDir} for v2→v3 migration; sessionFile left empty`)
|
|
152
|
-
return v2Records.map((r) => ({ ...r }))
|
|
153
|
-
}
|
|
154
|
-
// pi-coding-agent writes files as `${ISO_TIMESTAMP}_${UUID}.jsonl` where
|
|
155
|
-
// the ISO timestamp uses `-` (no `_`) and the UUID may contain `-`. Split
|
|
156
|
-
// on the FIRST underscore so the trailing portion is the full UUID even
|
|
157
|
-
// when the UUID contains hyphens.
|
|
158
|
-
const bySessionIdSuffix = new Map<string, string>()
|
|
159
|
-
for (const entry of entries) {
|
|
160
|
-
if (!entry.endsWith('.jsonl')) continue
|
|
161
|
-
const underscore = entry.indexOf('_')
|
|
162
|
-
if (underscore < 0) continue
|
|
163
|
-
const trailing = entry.slice(underscore + 1, -'.jsonl'.length)
|
|
164
|
-
bySessionIdSuffix.set(trailing, entry)
|
|
165
|
-
}
|
|
166
|
-
return v2Records.map((r) => {
|
|
167
|
-
const matched = bySessionIdSuffix.get(r.sessionId)
|
|
168
|
-
if (matched === undefined) {
|
|
169
|
-
logger.warn(
|
|
170
|
-
`[channels] v2→v3: no session file matching *_${r.sessionId}.jsonl in ${sessionsDir}; ` +
|
|
171
|
-
`sessionFile left empty (next inbound will create a fresh session for ${r.adapter}:${r.chat}:${r.thread ?? ''})`,
|
|
172
|
-
)
|
|
173
|
-
return { ...r }
|
|
174
|
-
}
|
|
175
|
-
return { ...r, sessionFile: matched }
|
|
176
|
-
})
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function migrateV3ToV4(v3Records: ChannelSessionRecord[], logger: ChannelSessionsLogger): ChannelSessionRecord[] {
|
|
180
|
-
logger.info(
|
|
181
|
-
`[channels] v3→v4: ${v3Records.length} record(s) migrated; first post-upgrade inbound will force fresh session`,
|
|
182
|
-
)
|
|
183
|
-
return v3Records.map((r) => ({ ...r, lastInboundAt: 0 }))
|
|
184
|
-
}
|
|
185
|
-
|
|
186
103
|
function dedupe(sessions: readonly ChannelSessionRecord[]): ChannelSessionRecord[] {
|
|
187
104
|
const seen = new Map<string, ChannelSessionRecord>()
|
|
188
105
|
for (const s of sessions) {
|
|
@@ -208,21 +125,6 @@ function isObject(v: unknown): v is Record<string, unknown> {
|
|
|
208
125
|
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
209
126
|
}
|
|
210
127
|
|
|
211
|
-
function isValidV2Record(
|
|
212
|
-
v: unknown,
|
|
213
|
-
): v is Omit<ChannelSessionRecord, 'sessionFile' | 'sessionId'> & { sessionId: string } {
|
|
214
|
-
if (!isObject(v)) return false
|
|
215
|
-
const r = v as Record<string, unknown>
|
|
216
|
-
return (
|
|
217
|
-
typeof r.adapter === 'string' &&
|
|
218
|
-
typeof r.workspace === 'string' &&
|
|
219
|
-
typeof r.chat === 'string' &&
|
|
220
|
-
(r.thread === null || typeof r.thread === 'string') &&
|
|
221
|
-
typeof r.sessionId === 'string' &&
|
|
222
|
-
Array.isArray(r.participants)
|
|
223
|
-
)
|
|
224
|
-
}
|
|
225
|
-
|
|
226
128
|
function isValidRecord(v: unknown): v is ChannelSessionRecord {
|
|
227
129
|
if (!isObject(v)) return false
|
|
228
130
|
const r = v as Record<string, unknown>
|