typeclaw 0.27.0 → 0.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/provider-error.ts +33 -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 +52 -1
- package/src/agent/tools/channel-send.ts +115 -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/index.ts +10 -0
- package/src/channels/adapters/github/review-state.ts +137 -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-rereview-guard.ts +76 -0
- package/src/channels/github-review-claim.ts +92 -0
- package/src/channels/github-review-turn-ledger.ts +71 -0
- package/src/channels/persistence.ts +4 -102
- package/src/channels/router.ts +181 -7
- package/src/channels/schema.ts +20 -5
- package/src/channels/types.ts +31 -0
- 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/inspect/transcript-view.ts +10 -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 +11 -5
- package/src/shared/protocol.ts +18 -6
- package/src/skills/typeclaw-config/SKILL.md +9 -11
- package/src/skills/typeclaw-permissions/SKILL.md +1 -1
- package/src/tui/format.ts +13 -0
- package/src/tui/index.ts +21 -7
- 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
|
@@ -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,76 @@
|
|
|
1
|
+
import { classifyReviewClaim } from './github-review-claim'
|
|
2
|
+
import type { ReviewStateResult } from './types'
|
|
3
|
+
|
|
4
|
+
// The re-review stranding guard. A bot that resolves a review thread (or posts a
|
|
5
|
+
// close-out ack) while it still holds its own sticky CHANGES_REQUESTED leaves the
|
|
6
|
+
// PR blocked forever — the resolve/ack carries no review state, so GitHub's
|
|
7
|
+
// reviewDecision never clears (PR #644). This guard blocks that close-out and
|
|
8
|
+
// tells the model to land a formal APPROVE / dismissal first.
|
|
9
|
+
//
|
|
10
|
+
// It is the same enforcement seam as the false-receipt guard and the
|
|
11
|
+
// resolve-thread author check: BLOCK and instruct, never act on the model's
|
|
12
|
+
// behalf — the runtime cannot prove a semantic approval from "one thread closed".
|
|
13
|
+
|
|
14
|
+
export type RereviewGuardInput = {
|
|
15
|
+
adapter: string
|
|
16
|
+
chat: string
|
|
17
|
+
thread: string | null
|
|
18
|
+
text: string | undefined
|
|
19
|
+
wantsResolve: boolean
|
|
20
|
+
getReviewState: (req: { adapter: 'github'; workspace: string; chat: string }) => Promise<ReviewStateResult>
|
|
21
|
+
workspace: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type RereviewGuardDecision = { block: false } | { block: true; reason: string }
|
|
25
|
+
|
|
26
|
+
const ALLOW: RereviewGuardDecision = { block: false }
|
|
27
|
+
|
|
28
|
+
export async function evaluateRereviewGuard(input: RereviewGuardInput): Promise<RereviewGuardDecision> {
|
|
29
|
+
if (input.adapter !== 'github') return ALLOW
|
|
30
|
+
if (!/^pr:\d+$/.test(input.chat)) return ALLOW
|
|
31
|
+
// No `thread === null` exemption: a top-level PR comment carries no thread but
|
|
32
|
+
// a close-out ack in it ("Verified — that closes it") strands the block just
|
|
33
|
+
// as a thread reply would. Only the resolve ACTION needs a thread; the
|
|
34
|
+
// text-claim path fires regardless (caught by isCloseoutAttempt below).
|
|
35
|
+
if (!isCloseoutAttempt(input.wantsResolve, input.thread, input.text)) return ALLOW
|
|
36
|
+
|
|
37
|
+
const state = await input.getReviewState({ adapter: 'github', workspace: input.workspace, chat: input.chat })
|
|
38
|
+
|
|
39
|
+
// Fail closed: an unverifiable review state is treated as a live block, so the
|
|
40
|
+
// bot never strands a re-review on a transient API failure.
|
|
41
|
+
if (!state.ok) return { block: true, reason: unverifiableReason(state.error) }
|
|
42
|
+
if (!state.selfBlocking) return ALLOW
|
|
43
|
+
|
|
44
|
+
return { block: true, reason: state.approve ? STICKY_BLOCK_APPROVE_ENABLED : STICKY_BLOCK_APPROVE_DISABLED }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Trigger when the model asks to resolve a thread (only meaningful with a
|
|
48
|
+
// thread), OR when its reply reads as a close-out/verdict claim — the latter
|
|
49
|
+
// strands the block whether or not it sits in a thread, so it fires for any PR
|
|
50
|
+
// chat. Plain discussion replies (ignore/warn) do not fire.
|
|
51
|
+
function isCloseoutAttempt(wantsResolve: boolean, thread: string | null, text: string | undefined): boolean {
|
|
52
|
+
if (wantsResolve && thread !== null) return true
|
|
53
|
+
const claim = classifyReviewClaim(text ?? '')
|
|
54
|
+
return claim === 'block-resolve' || claim === 'block-approve'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function unverifiableReason(error: string): string {
|
|
58
|
+
return (
|
|
59
|
+
'Could not verify whether your prior CHANGES_REQUESTED on this PR is still live ' +
|
|
60
|
+
`(${error}). Refusing to close out the thread while the block state is unknown — ` +
|
|
61
|
+
'retry once the GitHub API is reachable, or land a formal review verdict first.'
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const STICKY_BLOCK_APPROVE_ENABLED =
|
|
66
|
+
'You still hold a CHANGES_REQUESTED on this PR. Resolving the thread (or posting a close-out ack) ' +
|
|
67
|
+
'does NOT clear it — only a fresh formal review does. Submit `APPROVE` via ' +
|
|
68
|
+
'`gh api -X POST /repos/<owner>/<repo>/pulls/<N>/reviews` (event: APPROVE) if the blockers are fixed, ' +
|
|
69
|
+
'or `REQUEST_CHANGES` if not, THEN resolve the thread / reply.'
|
|
70
|
+
|
|
71
|
+
const STICKY_BLOCK_APPROVE_DISABLED =
|
|
72
|
+
'You still hold a CHANGES_REQUESTED on this PR and resolving the thread does NOT clear it. ' +
|
|
73
|
+
'Approval is disabled for this agent (channels.github.review.approve: false), so you cannot APPROVE — ' +
|
|
74
|
+
'dismiss your prior review via ' +
|
|
75
|
+
'`gh api -X PUT /repos/<owner>/<repo>/pulls/<N>/reviews/<review_id>/dismissals -f message="..." -f event=DISMISS` ' +
|
|
76
|
+
'if the blockers are fixed (or submit REQUEST_CHANGES if not), THEN resolve the thread / reply.'
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
// Bare "resolved" is intentionally NOT here — it collides with the warn-tier
|
|
28
|
+
// "looks resolved?"; resolve claims must carry a definite marker (marked/that/
|
|
29
|
+
// this/thanks) or a verify clause. "that/this closes it" is the canonical PR
|
|
30
|
+
// #644 incident phrasing and must classify as a close-out claim.
|
|
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
|
+
/\b(that|this) closes it\b/,
|
|
37
|
+
/\bclosing this out\b/,
|
|
38
|
+
/\bconfirmed fixed\b/,
|
|
39
|
+
// verify clause + a fix/resolve verb, allowing a short gap ("verified at <sha>, that fixes it").
|
|
40
|
+
/\b(verified|confirmed)\b[^.!?]*\b(fix(es|ed)|resolv)/,
|
|
41
|
+
/\b(thanks,?|fixed,?) (looks )?resolved\b/,
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
// Casual phrasing that might be chatter, not a formal close-out: allow + nudge.
|
|
45
|
+
const WARN: readonly RegExp[] = [
|
|
46
|
+
/\blgtm\b/,
|
|
47
|
+
/\blooks good\b/,
|
|
48
|
+
/\blooks fine\b/,
|
|
49
|
+
/\bseems fine\b/,
|
|
50
|
+
/\bshould be (fine|good)\b/,
|
|
51
|
+
/\bneeds changes\b/,
|
|
52
|
+
/\bstill needs work\b/,
|
|
53
|
+
/\blooks resolved\b/,
|
|
54
|
+
/\bseems resolved\b/,
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
// Negation / future-intent / past-reference markers DEMOTE a positive match to
|
|
58
|
+
// ignore. Blocking "I haven't approved" / "I'll approve" / "approved it earlier"
|
|
59
|
+
// (answering a question) is the worst false-positive class, so it is checked first.
|
|
60
|
+
const DEMOTE_TO_IGNORE: readonly RegExp[] = [
|
|
61
|
+
/\b(haven'?t|have not|did ?n'?t|did not|not yet|never)\b[^.!?]*\b(approv|request|resolv|block)/,
|
|
62
|
+
/\b(can'?t|cannot|won'?t|will not|wouldn'?t)\b[^.!?]*\b(approv|request|resolv|block)/,
|
|
63
|
+
/\bnot (approved|resolved|blocked|requesting)\b/,
|
|
64
|
+
/\b(i'?ll|i will|going to|gonna|about to|planning to)\b[^.!?]*\b(approv|review|request|resolv)/,
|
|
65
|
+
/\b(approved|resolved|requested changes)\b[^.!?]*\b(earlier|already|yesterday|before|last (review|time)|previously)\b/,
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
export function classifyReviewClaim(rawText: string): ReviewClaim {
|
|
69
|
+
const text = normalize(rawText)
|
|
70
|
+
if (text === '') return 'ignore'
|
|
71
|
+
|
|
72
|
+
if (DEMOTE_TO_IGNORE.some((re) => re.test(text))) return 'ignore'
|
|
73
|
+
|
|
74
|
+
// Block-tier wins over warn-tier: an unambiguous "approved" in a casual message
|
|
75
|
+
// is still a formal claim.
|
|
76
|
+
if (BLOCK_APPROVE.some((re) => re.test(text))) return 'block-approve'
|
|
77
|
+
if (BLOCK_REQUEST_CHANGES.some((re) => re.test(text))) return 'block-request-changes'
|
|
78
|
+
if (BLOCK_RESOLVE.some((re) => re.test(text))) return 'block-resolve'
|
|
79
|
+
if (WARN.some((re) => re.test(text))) return 'warn'
|
|
80
|
+
return 'ignore'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Strips markdown/emoji noise so "**Approved!**" and "approved" classify alike,
|
|
84
|
+
// keeping apostrophes + sentence punctuation that the negation regexes rely on.
|
|
85
|
+
function normalize(text: string): string {
|
|
86
|
+
return text
|
|
87
|
+
.toLowerCase()
|
|
88
|
+
.replace(/[*_`~#>]/g, ' ')
|
|
89
|
+
.replace(/[^\p{L}\p{N}\s'.!?]/gu, ' ')
|
|
90
|
+
.replace(/\s+/g, ' ')
|
|
91
|
+
.trim()
|
|
92
|
+
}
|
|
@@ -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>
|