typeclaw 0.27.0 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/package.json +1 -1
  2. package/scripts/generate-schema.ts +4 -6
  3. package/src/agent/index.ts +26 -4
  4. package/src/agent/multimodal/look-at.ts +1 -2
  5. package/src/agent/provider-error.ts +33 -1
  6. package/src/agent/tools/channel-fetch-attachment.ts +1 -2
  7. package/src/agent/tools/channel-react.ts +9 -3
  8. package/src/agent/tools/channel-reply.ts +52 -1
  9. package/src/agent/tools/channel-send.ts +115 -1
  10. package/src/bundled-plugins/github-cli-auth/gh-review-detect.ts +175 -0
  11. package/src/bundled-plugins/github-cli-auth/index.ts +4 -0
  12. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +93 -0
  13. package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
  14. package/src/bundled-plugins/memory/README.md +3 -21
  15. package/src/bundled-plugins/memory/index.ts +1 -149
  16. package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
  17. package/src/channels/adapters/github/inbound.ts +103 -0
  18. package/src/channels/adapters/github/index.ts +10 -0
  19. package/src/channels/adapters/github/review-state.ts +137 -0
  20. package/src/channels/adapters/github/review-thread-resolver.ts +65 -5
  21. package/src/channels/github-false-receipt.ts +87 -0
  22. package/src/channels/github-rereview-guard.ts +76 -0
  23. package/src/channels/github-review-claim.ts +92 -0
  24. package/src/channels/github-review-turn-ledger.ts +71 -0
  25. package/src/channels/persistence.ts +4 -102
  26. package/src/channels/router.ts +181 -7
  27. package/src/channels/schema.ts +20 -5
  28. package/src/channels/types.ts +31 -0
  29. package/src/cli/channel.ts +2 -1
  30. package/src/cli/init.ts +2 -1
  31. package/src/config/config.ts +19 -288
  32. package/src/container/start.ts +0 -2
  33. package/src/cron/index.ts +3 -44
  34. package/src/cron/schema.ts +2 -96
  35. package/src/init/gitignore.ts +1 -2
  36. package/src/inspect/transcript-view.ts +10 -0
  37. package/src/secrets/defaults.ts +1 -18
  38. package/src/secrets/index.ts +0 -2
  39. package/src/secrets/schema.ts +4 -90
  40. package/src/secrets/storage.ts +0 -2
  41. package/src/server/index.ts +11 -5
  42. package/src/shared/protocol.ts +18 -6
  43. package/src/skills/typeclaw-config/SKILL.md +9 -11
  44. package/src/skills/typeclaw-permissions/SKILL.md +1 -1
  45. package/src/tui/format.ts +13 -0
  46. package/src/tui/index.ts +21 -7
  47. package/typeclaw.schema.json +1 -0
  48. package/src/agent/tools/normalize-ref.ts +0 -11
  49. package/src/bundled-plugins/memory/migration.ts +0 -633
  50. package/src/secrets/migrate-kakaotalk.ts +0 -82
  51. package/src/secrets/migrate.ts +0 -96
@@ -32,6 +32,7 @@ import {
32
32
  StickyLedger,
33
33
  type EngagementDecision,
34
34
  } from './engagement'
35
+ import { resetReviewTurn } from './github-review-turn-ledger'
35
36
  import {
36
37
  MEMBERSHIP_COLD_FETCH_TIMEOUT_MS,
37
38
  type MembershipCount,
@@ -74,6 +75,9 @@ import type {
74
75
  ReactionRequest,
75
76
  ReactionResult,
76
77
  ResolvedChannelNames,
78
+ ReviewStateRequest,
79
+ ReviewStateResolver,
80
+ ReviewStateResult,
77
81
  ReviewThreadResolveRequest,
78
82
  ReviewThreadResolveResult,
79
83
  ReviewThreadResolver,
@@ -177,6 +181,44 @@ export const MAX_POLICY_DENIED_CHANNEL_SENDS_PER_TURN = 3
177
181
  // including reasoning). Deliberately NOT lowered in `providers.ts`, where
178
182
  // `maxTokens` is the model's true capability that compaction math reads.
179
183
  export const CHANNEL_MAX_OUTPUT_TOKENS = 4096
184
+ // Ceiling on automatic re-prompts for a turn that ended with NO user-facing
185
+ // reply AND no attempted send — the pure "the model burned its budget thinking
186
+ // and produced nothing" failure. The canonical trigger is Fireworks'
187
+ // kimi-k2p6-turbo spiraling into a long reasoning loop on an ambiguous request
188
+ // until it hits CHANNEL_MAX_OUTPUT_TOKENS (`stopReason: 'length'`); the same
189
+ // path also catches a provider/router `aborted` leaf that left no recoverable
190
+ // prose. Each retry injects EMPTY_TURN_RETRY_NUDGE as a reminder-only turn (no
191
+ // new inbound) so `drain()` re-runs `session.prompt()` against the same branch.
192
+ // Bounded because a genuinely stuck model would otherwise re-loop forever; on
193
+ // exhaustion the user gets EMPTY_TURN_FALLBACK_TEXT instead of dead air. Reset
194
+ // at turn start alongside `turnSeq`. Deliberately NOT applied to turns that
195
+ // ATTEMPTED a send this turn (skip-locked or policy-denied) — those already
196
+ // thrashed the send path, so a re-prompt would just re-thrash; they skip
197
+ // straight to the fallback. See validateChannelTurn's candidate===null branch.
198
+ export const MAX_EMPTY_TURN_RETRIES = 2
199
+ // Reminder-only nudge injected before an empty-turn retry. Uses the repo's
200
+ // SYSTEM MESSAGE framing (see composeTurnPrompt) so persona-rich models do not
201
+ // reply to the notice itself. Neutral by design: it asks for a direct reply
202
+ // without prescribing length or tone, matching the chosen "just retry" posture.
203
+ export const EMPTY_TURN_RETRY_NUDGE = [
204
+ '---',
205
+ '**[SYSTEM MESSAGE — not from a human]**',
206
+ '',
207
+ 'Your previous turn ended without sending any reply to the channel. This is',
208
+ 'an automated signal from the channel router, not a message from anyone in',
209
+ 'the chat. **Do not acknowledge or reply to this notice itself.**',
210
+ '',
211
+ 'Respond to the last user message now with a direct answer via your channel',
212
+ 'reply tool. If you genuinely have nothing to say, reply with `NO_REPLY`.',
213
+ '',
214
+ '---',
215
+ ].join('\n')
216
+ // Posted to the channel (via the `source:'system'` one-shot bypass) when an
217
+ // empty turn cannot be recovered AND retries are exhausted (or are skipped
218
+ // because the turn thrashed the send path). Replaces the historical silent
219
+ // drop so the human is never left staring at dead air after a degenerate turn.
220
+ export const EMPTY_TURN_FALLBACK_TEXT =
221
+ "⚠️ I got stuck putting together a reply and couldn't finish. Could you rephrase or try again?"
180
222
  // Rolling window for outbound send-rate telemetry. 5s matches Discord's
181
223
  // rate-limit shape (5 msg / 5 s / channel) and comfortably covers Slack's
182
224
  // 1 msg/s sustained. The window is observational; exceeding the burst
@@ -480,6 +522,14 @@ type LiveSession = {
480
522
  // livelock the byte-identical loop-guard misses. Reset at turn start and
481
523
  // cleared per-target on a successful delivery to that target.
482
524
  policyDeniedToolSendsThisTurn: Map<string, number>
525
+ // Count of automatic empty-turn re-prompts already spent on the CURRENT
526
+ // logical turn, bounded by `MAX_EMPTY_TURN_RETRIES`. A "logical turn" spans
527
+ // the original user batch plus any router-injected retry nudges, so this is
528
+ // reset only when a real user/reminder batch starts a fresh turn — NOT on the
529
+ // reminder-only iterations the retry itself queues. `validateChannelTurn`
530
+ // increments it before injecting EMPTY_TURN_RETRY_NUDGE and reads it to decide
531
+ // retry-vs-fallback. See the candidate===null branch.
532
+ emptyTurnRetries: number
483
533
  // Stamped by `markTurnSkipped` (called from the `skip_response` tool)
484
534
  // with the current `turnSeq`. Read at the top of `validateChannelTurn`:
485
535
  // if it matches the just-completed turn, recovery is skipped entirely
@@ -634,6 +684,12 @@ export type ChannelRouter = {
634
684
  registerReviewThreadResolver: (adapter: ChannelKey['adapter'], resolver: ReviewThreadResolver) => void
635
685
  unregisterReviewThreadResolver: (adapter: ChannelKey['adapter'], resolver: ReviewThreadResolver) => void
636
686
  resolveReviewThread: (req: ReviewThreadResolveRequest) => Promise<ReviewThreadResolveResult>
687
+ // Re-review stranding guard support: answers whether the bot still holds a
688
+ // blocking CHANGES_REQUESTED on a PR. Opt-in per adapter like the thread
689
+ // resolver; `getReviewState` answers `unsupported` when none is registered.
690
+ registerReviewStateResolver: (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver) => void
691
+ unregisterReviewStateResolver: (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver) => void
692
+ getReviewState: (req: ReviewStateRequest) => Promise<ReviewStateResult>
637
693
  lookupInboundAttachment: (args: ChannelKey & { id: number }) => InboundAttachment | null
638
694
  listInboundAttachmentIds: (args: ChannelKey) => readonly number[]
639
695
  // Execute a command by name against an existing live session, bypassing
@@ -904,6 +960,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
904
960
  const historyCallbacks = new Map<ChannelKey['adapter'], Set<HistoryCallback>>()
905
961
  const fetchAttachmentCallbacks = new Map<ChannelKey['adapter'], Set<FetchAttachmentCallback>>()
906
962
  const reviewThreadResolvers = new Map<ChannelKey['adapter'], ReviewThreadResolver>()
963
+ const reviewStateResolvers = new Map<ChannelKey['adapter'], ReviewStateResolver>()
907
964
  const stickyLedger = new StickyLedger()
908
965
  // The /help handler reads the live registry to enumerate commands, so it
909
966
  // forward-references `commands`. Safe at runtime — the handler only runs on
@@ -1351,6 +1408,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1351
1408
  successfulSendsAtTurnStart: 0,
1352
1409
  inFlightToolSends: new Map(),
1353
1410
  policyDeniedToolSendsThisTurn: new Map(),
1411
+ emptyTurnRetries: 0,
1354
1412
  skippedTurn: null,
1355
1413
  skipLockedSendTurn: null,
1356
1414
  pendingQuoteCandidate: null,
@@ -1366,6 +1424,30 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1366
1424
  }
1367
1425
  live.unsubProviderErrors = subscribeProviderErrors(created.session, (err) => {
1368
1426
  logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
1427
+ // A provider soft-error (rate/usage limit, billing, malformed response)
1428
+ // ends the turn with no assistant text, so the human otherwise sees
1429
+ // silence. Surface the REDACTED `safeMessage` (never the raw provider
1430
+ // text, which can carry response bodies / URLs / tokens) via a 'system'
1431
+ // send — the same one-shot bypass path validateChannelTurn uses, so it
1432
+ // lands regardless of per-turn send caps and skips the duplicate guard.
1433
+ void send(
1434
+ {
1435
+ adapter: live.key.adapter,
1436
+ workspace: live.key.workspace,
1437
+ chat: live.key.chat,
1438
+ thread: live.key.thread,
1439
+ text: `⚠️ ${err.safeMessage}`,
1440
+ },
1441
+ { source: 'system' },
1442
+ )
1443
+ .then((result) => {
1444
+ if (!result.ok) {
1445
+ logger.warn(`[channels] ${live.keyId}: provider-error notice send failed: ${result.error}`)
1446
+ }
1447
+ })
1448
+ .catch((sendErr) => {
1449
+ logger.warn(`[channels] ${live.keyId}: provider-error notice send threw: ${describe(sendErr)}`)
1450
+ })
1369
1451
  })
1370
1452
  live.unsubTodoOutcome = created.session.subscribe((event: unknown) => {
1371
1453
  const usage = extractTurnUsage(event)
@@ -1795,6 +1877,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1795
1877
  live.consecutiveSends.clear()
1796
1878
  live.lastSentText.clear()
1797
1879
  live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
1880
+ // A real user batch starts a fresh logical turn → restore the full
1881
+ // empty-turn retry budget. Reset here (batch.length > 0) and NOT in
1882
+ // the per-prompt block below, so the reminder-only iterations the
1883
+ // retry itself queues do not refill the budget and loop forever.
1884
+ live.emptyTurnRetries = 0
1798
1885
  } else if (live.lastTurnAuthorId !== null) {
1799
1886
  live.currentTurnEngageReactions = []
1800
1887
  // Reminder-only turn (batch.length === 0, reminders.length > 0):
@@ -1844,6 +1931,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1844
1931
  live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
1845
1932
  live.skipLockedSendTurn = null
1846
1933
  live.policyDeniedToolSendsThisTurn.clear()
1934
+ resetReviewTurn(live.sessionId)
1847
1935
  const isRealUserTurn = batch.length > 0
1848
1936
  await fireSessionTurnStart(live, text)
1849
1937
  try {
@@ -2572,6 +2660,26 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2572
2660
  )
2573
2661
  }
2574
2662
 
2663
+ const registerReviewStateResolver = (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver): void => {
2664
+ reviewStateResolvers.set(adapter, resolver)
2665
+ }
2666
+
2667
+ const unregisterReviewStateResolver = (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver): void => {
2668
+ if (reviewStateResolvers.get(adapter) === resolver) {
2669
+ reviewStateResolvers.delete(adapter)
2670
+ }
2671
+ }
2672
+
2673
+ const getReviewState = async (req: ReviewStateRequest): Promise<ReviewStateResult> => {
2674
+ const resolver = reviewStateResolvers.get(req.adapter)
2675
+ if (resolver === undefined) {
2676
+ return { ok: false, error: `adapter "${req.adapter}" does not support review-state lookup`, code: 'unsupported' }
2677
+ }
2678
+ return await resolver(req).catch(
2679
+ (err): ReviewStateResult => ({ ok: false, error: describe(err), code: 'transient' }),
2680
+ )
2681
+ }
2682
+
2575
2683
  const lookupInboundAttachment = (args: ChannelKey & { id: number }): InboundAttachment | null => {
2576
2684
  const live = liveSessions.get(channelKeyId(args))
2577
2685
  if (live === undefined) return null
@@ -2842,15 +2950,64 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2842
2950
  }
2843
2951
  if (live.successfulChannelSends > successfulSendsBeforePrompt) return
2844
2952
 
2953
+ const postEmptyTurnFallback = async (cause: string): Promise<void> => {
2954
+ logger.warn(`[channels] ${live.keyId} empty_turn_fallback cause=${cause}`)
2955
+ const result = await send(
2956
+ {
2957
+ adapter: live.key.adapter,
2958
+ workspace: live.key.workspace,
2959
+ chat: live.key.chat,
2960
+ thread: live.key.thread,
2961
+ text: EMPTY_TURN_FALLBACK_TEXT,
2962
+ },
2963
+ { source: 'system' },
2964
+ )
2965
+ if (!result.ok) {
2966
+ logger.warn(`[channels] ${live.keyId}: empty-turn fallback send failed: ${result.error}`)
2967
+ }
2968
+ }
2969
+
2845
2970
  const candidate = recoverableAssistantText(live.session)
2846
2971
  if (candidate === null) {
2847
- // Observability: previously a silent bail-out. The most common cause is a
2848
- // turn that ends mid-loop with NO assistant message at all (leaf is a
2849
- // session header / model_change / similar non-message entry, or a session
2850
- // that just started). Logged at debug-level info so operators can grep for
2851
- // unexpected silent turns; not warn-level because legitimate empty-state
2852
- // sessions hit this on every TUI-only check before the first user prompt.
2853
- logger.info(`[channels] ${live.keyId}: no recoverable assistant text in branch`)
2972
+ // No recoverable assistant prose: the turn ended with no usable reply.
2973
+ // Two distinct shapes, handled differently (Option B):
2974
+ //
2975
+ // 1. The model THRASHED the send path this turn it tried to send but
2976
+ // every attempt was denied (skip-locked, or policy-denied/duplicate/
2977
+ // cap, tracked on skipLockedSendTurn / policyDeniedToolSendsThisTurn).
2978
+ // Re-prompting would just re-thrash, so skip retry and post the
2979
+ // user-facing fallback once.
2980
+ //
2981
+ // 2. The PURE reasoning-loop — no send was ever attempted; the model
2982
+ // burned its budget thinking and produced nothing (the canonical
2983
+ // kimi `stopReason: 'length'` / `aborted` degeneration). Re-prompt up
2984
+ // to MAX_EMPTY_TURN_RETRIES with a neutral nudge; on exhaustion, fall
2985
+ // back. The nudge is injected as a reminder-only turn so drain()'s
2986
+ // while-loop re-runs session.prompt() against the same branch.
2987
+ //
2988
+ // The legitimate empty-state case (a TUI-only check before any user
2989
+ // prompt, no inbound this turn) is excluded: no batch means no real turn
2990
+ // to retry or apologize for — keep the historical silent bail there.
2991
+ const attemptedSendThisTurn =
2992
+ live.skipLockedSendTurn === live.turnSeq || live.policyDeniedToolSendsThisTurn.size > 0
2993
+
2994
+ // Only a TRUNCATED assistant leaf (length/error/aborted) from a real
2995
+ // conversational turn is a degeneration worth retrying. A cold/empty turn
2996
+ // (no inbound author, or no assistant message at all) keeps the historical
2997
+ // silent bail — re-prompting it would manufacture replies to nothing.
2998
+ if (live.currentTurnAuthorId === null || !assistantLeafTruncated(live.session)) {
2999
+ logger.info(`[channels] ${live.keyId}: no recoverable assistant text in branch`)
3000
+ return
3001
+ }
3002
+ if (!attemptedSendThisTurn && live.emptyTurnRetries < MAX_EMPTY_TURN_RETRIES) {
3003
+ live.emptyTurnRetries++
3004
+ logger.warn(
3005
+ `[channels] ${live.keyId} empty_turn_retry attempt=${live.emptyTurnRetries}/${MAX_EMPTY_TURN_RETRIES}`,
3006
+ )
3007
+ live.pendingSystemReminders.push(EMPTY_TURN_RETRY_NUDGE)
3008
+ return
3009
+ }
3010
+ await postEmptyTurnFallback(attemptedSendThisTurn ? 'send_thrash' : 'retries_exhausted')
2854
3011
  return
2855
3012
  }
2856
3013
 
@@ -3396,6 +3553,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3396
3553
  registerReviewThreadResolver,
3397
3554
  unregisterReviewThreadResolver,
3398
3555
  resolveReviewThread,
3556
+ registerReviewStateResolver,
3557
+ unregisterReviewStateResolver,
3558
+ getReviewState,
3399
3559
  lookupInboundAttachment,
3400
3560
  listInboundAttachmentIds,
3401
3561
  executeCommand,
@@ -4107,6 +4267,20 @@ function recoverableAssistantText(
4107
4267
  return null
4108
4268
  }
4109
4269
 
4270
+ // True only when the leaf is an assistant message that was CUT OFF mid-output:
4271
+ // `length` (hit the token cap — the canonical kimi reasoning-loop), `error`, or
4272
+ // `aborted`. This is the precise signature of "the model was producing but got
4273
+ // truncated", as distinct from a turn that produced no assistant message at all
4274
+ // (leaf undefined / a non-assistant entry), which is a benign empty/cold turn —
4275
+ // NOT something to re-prompt. The empty-turn retry guard keys off this so it
4276
+ // fires for real degenerations and stays silent for cold sessions.
4277
+ function assistantLeafTruncated(session: AgentSession): boolean {
4278
+ const leaf = session.sessionManager.getLeafEntry()
4279
+ if (!leaf || leaf.type !== 'message' || leaf.message.role !== 'assistant') return false
4280
+ const stop = leaf.message.stopReason
4281
+ return stop === 'length' || stop === 'error' || stop === 'aborted'
4282
+ }
4283
+
4110
4284
  function visibleAssistantText(message: AssistantMessage): string {
4111
4285
  return message.content
4112
4286
  .filter((block) => block.type === 'text')
@@ -107,11 +107,9 @@ const quotedReplySchema = z
107
107
  .default({ enabled: true, queueDelayMs: DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS })
108
108
 
109
109
  // Deliberately non-strict: a stale on-disk file may still carry the
110
- // legacy `allow` field (`migrateLegacyConfigShape` lifts it into
111
- // `roles.member.match[]` on load, but a between-reload window can
112
- // briefly contain both). Zod silently drops unknown keys here, which is
113
- // exactly what we want — a hard `.strict()` reject would brick recovery
114
- // for any user mid-migration.
110
+ // legacy `allow` field. Zod silently drops unknown keys here, which is
111
+ // exactly what we want — the field is ignored, not translated, and a hard
112
+ // `.strict()` reject would brick recovery for any user with an old config.
115
113
  const adapterSchema = z.object({
116
114
  engagement: engagementSchema,
117
115
  history: historySchema,
@@ -128,6 +126,7 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
128
126
  'pull_request.ready_for_review',
129
127
  'pull_request.review_requested',
130
128
  'pull_request.review_request_removed',
129
+ 'pull_request.synchronize',
131
130
  'discussion.created',
132
131
  'pull_request_review.submitted',
133
132
  ] as const
@@ -158,6 +157,21 @@ const GITHUB_EVENT_ALLOWLIST_V2 = [
158
157
  'discussion.created',
159
158
  'pull_request_review.submitted',
160
159
  ] as const
160
+ // - v3: added ready_for_review, shipped 0.12.0+ (the default just before
161
+ // synchronize was added). Snapshotted here so configs seeded with the
162
+ // pre-synchronize default unfreeze and re-track the new default.
163
+ const GITHUB_EVENT_ALLOWLIST_V3 = [
164
+ 'issue_comment.created',
165
+ 'pull_request_review_comment.created',
166
+ 'discussion_comment.created',
167
+ 'issues.opened',
168
+ 'pull_request.opened',
169
+ 'pull_request.ready_for_review',
170
+ 'pull_request.review_requested',
171
+ 'pull_request.review_request_removed',
172
+ 'discussion.created',
173
+ 'pull_request_review.submitted',
174
+ ] as const
161
175
 
162
176
  // Every event-allowlist that `channel add` / `init` has ever seeded verbatim
163
177
  // into typeclaw.json, oldest first, current default last. The legacy-shape
@@ -169,6 +183,7 @@ const GITHUB_EVENT_ALLOWLIST_V2 = [
169
183
  export const SEEDED_GITHUB_EVENT_ALLOWLISTS: readonly (readonly string[])[] = [
170
184
  GITHUB_EVENT_ALLOWLIST_V1,
171
185
  GITHUB_EVENT_ALLOWLIST_V2,
186
+ GITHUB_EVENT_ALLOWLIST_V3,
172
187
  DEFAULT_GITHUB_EVENT_ALLOWLIST,
173
188
  ]
174
189
 
@@ -382,6 +382,37 @@ export type ReviewThreadResolveResult =
382
382
  // support review threads never register one; the router answers `unsupported`.
383
383
  export type ReviewThreadResolver = (req: ReviewThreadResolveRequest) => Promise<ReviewThreadResolveResult>
384
384
 
385
+ // A query for "does the bot still owe this PR a verdict?" — i.e. is the bot's
386
+ // latest formal review on the PR a sticky CHANGES_REQUESTED that no later
387
+ // APPROVE/dismissal has cleared. Used by the re-review stranding guard to stop
388
+ // the bot from resolving a thread / posting a close-out ack while it still
389
+ // holds a blocking review (the PR #644 failure: thread resolved + chat ack, but
390
+ // reviewDecision stuck at CHANGES_REQUESTED because neither carries review
391
+ // state). `workspace` is the repo slug `owner/name`; `chat` is `pr:<N>`.
392
+ export type ReviewStateRequest = {
393
+ adapter: AdapterId
394
+ workspace: string
395
+ chat: string
396
+ }
397
+
398
+ // `selfBlocking` is the answer the guard acts on: true means the bot's latest
399
+ // effective formal review is its own CHANGES_REQUESTED (COMMENTED reviews are
400
+ // ignored — they never clear the sticky block, GitHub's own rule). `approve`
401
+ // mirrors `channels.github.review.approve` so the guard's denial text can tell
402
+ // the model whether to land a fresh APPROVE or to DISMISS its prior review.
403
+ //
404
+ // On `ok: false` the caller MUST fail closed: an unverifiable review state is
405
+ // treated like a live block, so the bot never claims close-out when the runtime
406
+ // could not confirm the platform-side verdict.
407
+ export type ReviewStateResult =
408
+ | { ok: true; selfBlocking: boolean; approve: boolean }
409
+ | { ok: false; error: string; code?: 'unsupported' | 'not-found' | 'permission-denied' | 'transient' }
410
+
411
+ // Registered per-adapter on the ChannelRouter, last-write-wins like the
412
+ // review-thread resolver. Adapters that never register one make `getReviewState`
413
+ // answer `unsupported`.
414
+ export type ReviewStateResolver = (req: ReviewStateRequest) => Promise<ReviewStateResult>
415
+
385
416
  export function channelKeyId(key: { adapter: string; workspace: string; chat: string; thread: string | null }): string {
386
417
  return `${key.adapter}:${key.workspace}:${key.chat}:${key.thread ?? ''}`
387
418
  }
@@ -629,8 +629,9 @@ async function promptGithubCredentials(cwd: string): Promise<{
629
629
  message: 'GitHub authentication type',
630
630
  options: [
631
631
  { value: 'pat', label: 'Fine-grained personal access token' },
632
- { value: 'app', label: 'GitHub App installation token' },
632
+ { value: 'app', label: 'GitHub App installation token (recommended)' },
633
633
  ],
634
+ initialValue: 'app',
634
635
  })
635
636
  if (isCancel(authType)) {
636
637
  cancel('Aborted.')
package/src/cli/init.ts CHANGED
@@ -1182,8 +1182,9 @@ async function runGithubFlow(cwd: string): Promise<StepResult<CollectedInputs['c
1182
1182
  message: 'GitHub authentication type',
1183
1183
  options: [
1184
1184
  { value: 'pat', label: 'Fine-grained personal access token' },
1185
- { value: 'app', label: 'GitHub App installation token' },
1185
+ { value: 'app', label: 'GitHub App installation token (recommended)' },
1186
1186
  ],
1187
+ initialValue: 'app',
1187
1188
  })
1188
1189
  if (isCancel(authType)) return back()
1189
1190
  const auth = authType === 'pat' ? await promptGithubPatAuth() : await promptGithubAppAuth()