typeclaw 0.23.0 → 0.25.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/README.md +1 -1
- package/package.json +1 -1
- package/src/agent/index.ts +133 -27
- package/src/agent/llm-replay-sanitizer.ts +120 -0
- package/src/agent/loop-guard.ts +34 -0
- package/src/agent/multimodal/look-at.ts +1 -1
- package/src/agent/plugin-tools.ts +122 -8
- package/src/agent/restart/index.ts +15 -3
- package/src/agent/restart-handoff/index.ts +110 -12
- package/src/agent/session-origin.ts +30 -0
- package/src/agent/subagent-completion-reminder.ts +26 -1
- package/src/agent/subagents.ts +75 -3
- package/src/agent/system-prompt.ts +5 -1
- package/src/agent/todo/continuation-policy.ts +242 -0
- package/src/agent/todo/continuation-state.ts +87 -0
- package/src/agent/todo/continuation-wiring.ts +113 -0
- package/src/agent/todo/continuation.ts +71 -0
- package/src/agent/todo/scope.ts +77 -0
- package/src/agent/todo/store.ts +98 -0
- package/src/agent/tool-not-found-nudge.ts +126 -0
- package/src/agent/tools/channel-reply.ts +51 -0
- package/src/agent/tools/curl-impersonate.ts +2 -2
- package/src/agent/tools/restart.ts +11 -4
- package/src/agent/tools/spawn-subagent.ts +19 -2
- package/src/agent/tools/subagent-access.ts +40 -5
- package/src/agent/tools/subagent-cancel.ts +3 -1
- package/src/agent/tools/subagent-output.ts +6 -2
- package/src/agent/tools/todo/index.ts +119 -0
- package/src/agent/tools/webfetch/fetch.ts +18 -18
- package/src/agent/tools/webfetch/index.ts +1 -1
- package/src/agent/tools/webfetch/tool.ts +13 -13
- package/src/agent/tools/webfetch/types.ts +1 -1
- package/src/agent/tools/websearch.ts +6 -6
- package/src/bundled-plugins/backup/index.ts +40 -37
- package/src/bundled-plugins/backup/runner.ts +23 -2
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +15 -7
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +38 -1
- package/src/bundled-plugins/memory/README.md +11 -11
- package/src/bundled-plugins/memory/dreaming.ts +5 -0
- package/src/bundled-plugins/memory/search-tool.ts +98 -1
- package/src/bundled-plugins/operator/operator.ts +5 -1
- package/src/bundled-plugins/reviewer/reviewer.ts +32 -9
- package/src/bundled-plugins/reviewer/skills/code-review.ts +1 -1
- package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
- package/src/bundled-plugins/scout/scout.ts +7 -7
- package/src/bundled-plugins/security/policies/private-surface-read.ts +2 -2
- package/src/bundled-plugins/security/policies/ssrf.ts +3 -3
- package/src/bundled-plugins/tool-result-cap/README.md +1 -1
- package/src/channels/adapters/discord-bot-reference.ts +78 -0
- package/src/channels/adapters/discord-bot.ts +25 -3
- package/src/channels/adapters/github/inbound.ts +172 -10
- package/src/channels/adapters/github/index.ts +10 -0
- package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
- package/src/channels/adapters/github/webhook-register.ts +32 -27
- package/src/channels/adapters/kakaotalk-classify.ts +67 -6
- package/src/channels/adapters/slack-bot-classify.ts +9 -1
- package/src/channels/adapters/slack-bot-reference.ts +129 -0
- package/src/channels/adapters/slack-bot.ts +67 -8
- package/src/channels/manager.ts +8 -2
- package/src/channels/router.ts +506 -45
- package/src/channels/schema.ts +21 -4
- package/src/channels/subagent-completion-bridge.ts +18 -18
- package/src/channels/types.ts +69 -1
- package/src/cli/inspect-controller.ts +132 -33
- package/src/cli/inspect.ts +2 -1
- package/src/commands/index.ts +9 -0
- package/src/container/start.ts +7 -1
- package/src/git/mutex.ts +22 -0
- package/src/git/reconcile-ignored.ts +214 -0
- package/src/hostd/daemon.ts +26 -1
- package/src/hostd/portbroker-manager.ts +7 -0
- package/src/init/dockerfile.ts +1 -1
- package/src/init/gitignore.ts +28 -16
- package/src/inspect/index.ts +53 -4
- package/src/inspect/loop.ts +16 -12
- package/src/plugin/define.ts +2 -2
- package/src/plugin/index.ts +2 -2
- package/src/portbroker/hostd-client.ts +36 -13
- package/src/run/index.ts +74 -5
- package/src/sandbox/build.ts +20 -0
- package/src/sandbox/index.ts +10 -0
- package/src/sandbox/policy.ts +22 -0
- package/src/sandbox/session-tmp.ts +43 -0
- package/src/sandbox/writable-zones.ts +178 -0
- package/src/server/command-runner.ts +1 -1
- package/src/server/index.ts +126 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +71 -17
- package/src/skills/typeclaw-memory/SKILL.md +3 -1
- package/src/tui/format.ts +11 -11
- package/typeclaw.schema.json +10 -0
package/src/server/index.ts
CHANGED
|
@@ -11,12 +11,22 @@ import {
|
|
|
11
11
|
import { runPluginDoctorChecks, runPluginDoctorFix } from '@/agent/doctor'
|
|
12
12
|
import type { LiveSessionRegistry } from '@/agent/live-sessions'
|
|
13
13
|
import type { LiveSubagentRegistry } from '@/agent/live-subagents'
|
|
14
|
+
import { forgetSharedLoopGuardTool } from '@/agent/plugin-tools'
|
|
14
15
|
import { detectProviderError } from '@/agent/provider-error'
|
|
15
16
|
import { requestContainerRestart } from '@/agent/restart'
|
|
16
17
|
import { consumeRestartHandoff, type RestartHandoff } from '@/agent/restart-handoff'
|
|
17
18
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
18
19
|
import { parseSubagentCompletedPayload, renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
|
|
19
20
|
import type { CreateSessionForSubagent } from '@/agent/subagents'
|
|
21
|
+
import { TODO_CONTINUATION_SOURCE } from '@/agent/todo/continuation'
|
|
22
|
+
import {
|
|
23
|
+
armRestartKickForOrigin,
|
|
24
|
+
extractTurnUsage,
|
|
25
|
+
recordTurnOutcome,
|
|
26
|
+
recordTurnStart,
|
|
27
|
+
runIdleContinuation,
|
|
28
|
+
} from '@/agent/todo/continuation-wiring'
|
|
29
|
+
import { SUBAGENT_OUTPUT_TOOL_NAME } from '@/agent/tools/subagent-output'
|
|
20
30
|
import type { ChannelRouter } from '@/channels/router'
|
|
21
31
|
import { aggregateCronList, type CronListEntry, loadCron } from '@/cron'
|
|
22
32
|
import type { McpManager } from '@/mcp'
|
|
@@ -155,6 +165,7 @@ type QueuedPrompt = {
|
|
|
155
165
|
text: string
|
|
156
166
|
delivery: PromptDelivery
|
|
157
167
|
ts: number
|
|
168
|
+
source?: string
|
|
158
169
|
}
|
|
159
170
|
|
|
160
171
|
type SessionState = {
|
|
@@ -172,6 +183,7 @@ type SessionState = {
|
|
|
172
183
|
// generation that ran session.start. A plugin reload mid-connection does
|
|
173
184
|
// not re-target this session's lifecycle hooks.
|
|
174
185
|
runtimeSnapshot: PluginRuntimeState | null
|
|
186
|
+
unsubTurnOutcome: Unsubscribe | null
|
|
175
187
|
dispose: () => Promise<void>
|
|
176
188
|
}
|
|
177
189
|
|
|
@@ -257,7 +269,7 @@ export function createServer({
|
|
|
257
269
|
handoffPending = false
|
|
258
270
|
return null
|
|
259
271
|
}
|
|
260
|
-
handoffInFlight = consumeRestartHandoff(agentDir).catch(() => null)
|
|
272
|
+
handoffInFlight = consumeRestartHandoff(agentDir, { accept: (h) => h.origin.kind === 'tui' }).catch(() => null)
|
|
261
273
|
const result = await handoffInFlight
|
|
262
274
|
handoffPending = false
|
|
263
275
|
handoffInFlight = null
|
|
@@ -497,6 +509,7 @@ export function createServer({
|
|
|
497
509
|
unsubClaim: null,
|
|
498
510
|
activeClaimCode: null,
|
|
499
511
|
runtimeSnapshot: runtimeSnapshot ?? null,
|
|
512
|
+
unsubTurnOutcome: null,
|
|
500
513
|
dispose,
|
|
501
514
|
}
|
|
502
515
|
sessionStates.set(ws, state)
|
|
@@ -505,12 +518,16 @@ export function createServer({
|
|
|
505
518
|
await runtimeSnapshot.hooks.runSessionStart({ sessionId: sessionFileId, agentDir })
|
|
506
519
|
}
|
|
507
520
|
|
|
521
|
+
if (agentDir !== undefined) {
|
|
522
|
+
state.unsubTurnOutcome = subscribeTurnOutcome(session, agentDir, origin, sessionFileId, logger)
|
|
523
|
+
}
|
|
524
|
+
|
|
508
525
|
liveSessionRegistry?.register({ sessionId: sessionFileId, session })
|
|
509
526
|
forwardSessionEvents(ws, session, logger, sessionFileId)
|
|
510
527
|
|
|
511
528
|
if (stream) {
|
|
512
529
|
state.unsubPrompts = stream.subscribe({ target: { kind: 'session', sessionId: sessionFileId } }, (msg) =>
|
|
513
|
-
enqueuePrompt(ws, state, msg, agentDir, logger),
|
|
530
|
+
enqueuePrompt(ws, state, msg, agentDir, logger, stream),
|
|
514
531
|
)
|
|
515
532
|
|
|
516
533
|
state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
|
|
@@ -543,6 +560,17 @@ export function createServer({
|
|
|
543
560
|
// wired (state.unsubPrompts above) so the kick is enqueued, not
|
|
544
561
|
// dropped on the floor.
|
|
545
562
|
if (resumed !== null && stream) {
|
|
563
|
+
// Arm the one-shot restart-kick suppressor BEFORE publishing the
|
|
564
|
+
// kick: the kick owns the first post-restart turn ("I'm back"),
|
|
565
|
+
// so the first idle after it must not also fire a todo
|
|
566
|
+
// continuation. The flag is consumed by that first idle. Best-
|
|
567
|
+
// effort: a failure here only risks one redundant nudge, which
|
|
568
|
+
// the episode budget still bounds.
|
|
569
|
+
if (agentDir !== undefined) {
|
|
570
|
+
await armRestartKickForOrigin(agentDir, origin).catch((err) =>
|
|
571
|
+
logger.error(`[server] ${sessionFileId}: arm restart-kick suppression failed: ${describeErr(err)}`),
|
|
572
|
+
)
|
|
573
|
+
}
|
|
546
574
|
stream.publish({
|
|
547
575
|
target: { kind: 'session', sessionId: sessionFileId },
|
|
548
576
|
payload: { kind: 'prompt', text: ' ', delivery: 'queue' },
|
|
@@ -798,6 +826,7 @@ export function createServer({
|
|
|
798
826
|
}
|
|
799
827
|
} finally {
|
|
800
828
|
if (state) {
|
|
829
|
+
state.unsubTurnOutcome?.()
|
|
801
830
|
state.session.dispose()
|
|
802
831
|
await state.dispose()
|
|
803
832
|
liveSessionRegistry?.unregister(state.sessionFileId)
|
|
@@ -867,6 +896,31 @@ function forwardSessionEvents(ws: Ws, session: AgentSession, logger: ServerLogge
|
|
|
867
896
|
})
|
|
868
897
|
}
|
|
869
898
|
|
|
899
|
+
// Record each completed turn's stopReason for the todo-continuation guard.
|
|
900
|
+
// Ordering-independent by design: this writes the outcome from `message_end`,
|
|
901
|
+
// and the idle path only reads the stored outcome — it never assumes the
|
|
902
|
+
// event arrived before idle fired. An unrecognized stopReason classifies as
|
|
903
|
+
// 'unknown', which the idle path treats as not-safe-to-continue (fail closed).
|
|
904
|
+
function subscribeTurnOutcome(
|
|
905
|
+
session: AgentSession,
|
|
906
|
+
agentDir: string,
|
|
907
|
+
origin: SessionOrigin,
|
|
908
|
+
sessionFileId: string,
|
|
909
|
+
logger: ServerLogger,
|
|
910
|
+
): Unsubscribe {
|
|
911
|
+
return session.subscribe((event) => {
|
|
912
|
+
const usage = extractTurnUsage(event)
|
|
913
|
+
if (usage === null) return
|
|
914
|
+
void recordTurnOutcome({
|
|
915
|
+
agentDir,
|
|
916
|
+
origin,
|
|
917
|
+
turnId: sessionFileId,
|
|
918
|
+
stopReason: usage.stopReason,
|
|
919
|
+
...(usage.tokens !== undefined ? { tokens: usage.tokens } : {}),
|
|
920
|
+
}).catch((err) => logger.error(`[server] ${sessionFileId}: todo outcome capture failed: ${describeErr(err)}`))
|
|
921
|
+
})
|
|
922
|
+
}
|
|
923
|
+
|
|
870
924
|
function forwardAssistantError(ws: Ws, message: unknown, logger: ServerLogger, sessionFileId: string): void {
|
|
871
925
|
const detected = detectProviderError(message)
|
|
872
926
|
if (detected === null) return
|
|
@@ -879,6 +933,12 @@ function routeSubagentCompletionReminder(state: SessionState, msg: StreamMessage
|
|
|
879
933
|
if (parsed === null) return
|
|
880
934
|
if (parsed.parentSessionId !== state.sessionFileId) return
|
|
881
935
|
|
|
936
|
+
// The reminder asks the agent to fetch this result now; clear the
|
|
937
|
+
// subagent_output window first so an earlier premature-polling streak can't
|
|
938
|
+
// hard-block that fetch. Reset before publish so the wakeup can't race stale
|
|
939
|
+
// guard state.
|
|
940
|
+
forgetSharedLoopGuardTool(state.sessionFileId, SUBAGENT_OUTPUT_TOOL_NAME)
|
|
941
|
+
|
|
882
942
|
const idle = state.drainQueue.length === 0 && !state.draining
|
|
883
943
|
const delivery = idle ? 'interrupt' : 'queue'
|
|
884
944
|
const text = renderSubagentCompletionReminder(parsed)
|
|
@@ -895,6 +955,7 @@ function enqueuePrompt(
|
|
|
895
955
|
msg: StreamMessage,
|
|
896
956
|
agentDir: string | undefined,
|
|
897
957
|
logger: ServerLogger,
|
|
958
|
+
stream: Stream | undefined,
|
|
898
959
|
): void {
|
|
899
960
|
const payload = msg.payload as { kind?: string; text?: string; delivery?: PromptDelivery }
|
|
900
961
|
if (payload?.kind !== 'prompt' || typeof payload.text !== 'string') return
|
|
@@ -904,14 +965,16 @@ function enqueuePrompt(
|
|
|
904
965
|
send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
|
|
905
966
|
})
|
|
906
967
|
}
|
|
968
|
+
const source = (msg.meta as { source?: unknown } | undefined)?.source
|
|
907
969
|
state.drainQueue.push({
|
|
908
970
|
streamMessageId: msg.id,
|
|
909
971
|
text: payload.text,
|
|
910
972
|
delivery,
|
|
911
973
|
ts: msg.ts,
|
|
974
|
+
...(typeof source === 'string' ? { source } : {}),
|
|
912
975
|
})
|
|
913
976
|
pushQueueState(ws, state)
|
|
914
|
-
void drain(ws, state, agentDir, logger)
|
|
977
|
+
void drain(ws, state, agentDir, logger, stream)
|
|
915
978
|
}
|
|
916
979
|
|
|
917
980
|
// `session.idle` semantically means "the agent finished a prompt and is now
|
|
@@ -948,7 +1011,13 @@ function makeTurnHookCallers(
|
|
|
948
1011
|
}
|
|
949
1012
|
}
|
|
950
1013
|
|
|
951
|
-
async function drain(
|
|
1014
|
+
async function drain(
|
|
1015
|
+
ws: Ws,
|
|
1016
|
+
state: SessionState,
|
|
1017
|
+
agentDir: string | undefined,
|
|
1018
|
+
logger: ServerLogger,
|
|
1019
|
+
stream: Stream | undefined,
|
|
1020
|
+
): Promise<void> {
|
|
952
1021
|
if (state.draining) return
|
|
953
1022
|
state.draining = true
|
|
954
1023
|
const fireIdle = makeIdleHookCaller(state)
|
|
@@ -960,6 +1029,14 @@ async function drain(ws: Ws, state: SessionState, agentDir: string | undefined,
|
|
|
960
1029
|
pushQueueState(ws, state)
|
|
961
1030
|
send(ws, { type: 'prompt_started', messageId: item.streamMessageId, text: item.text })
|
|
962
1031
|
|
|
1032
|
+
if (agentDir !== undefined) {
|
|
1033
|
+
await recordTurnStart({
|
|
1034
|
+
agentDir,
|
|
1035
|
+
origin: state.origin,
|
|
1036
|
+
isRealUserTurn: item.source !== TODO_CONTINUATION_SOURCE,
|
|
1037
|
+
}).catch((err) => logger.error(`[server] ${state.sessionFileId}: todo turn-start failed: ${describeErr(err)}`))
|
|
1038
|
+
}
|
|
1039
|
+
|
|
963
1040
|
await fireTurnStart(item.text)
|
|
964
1041
|
try {
|
|
965
1042
|
await state.session.prompt(`${renderTurnTimeAnchor()}\n\n${item.text}`)
|
|
@@ -971,12 +1048,57 @@ async function drain(ws: Ws, state: SessionState, agentDir: string | undefined,
|
|
|
971
1048
|
}
|
|
972
1049
|
await fireTurnEnd()
|
|
973
1050
|
await fireIdle()
|
|
1051
|
+
|
|
1052
|
+
// Idle-continuation runs INSIDE the loop and enqueues directly onto
|
|
1053
|
+
// drainQueue (not via stream.publish). Publishing would re-enter drain()
|
|
1054
|
+
// through the session subscriber while `state.draining` is still true, so
|
|
1055
|
+
// the nested call would no-op and the continuation would stall until some
|
|
1056
|
+
// unrelated event woke the loop again. Enqueuing here lets the same `while`
|
|
1057
|
+
// consume it on the next iteration. Only fires when the queue is otherwise
|
|
1058
|
+
// empty so a real user turn is never preempted by a continuation.
|
|
1059
|
+
if (state.drainQueue.length === 0) {
|
|
1060
|
+
await maybeContinueTodos(state, agentDir, logger)
|
|
1061
|
+
}
|
|
974
1062
|
}
|
|
975
1063
|
} finally {
|
|
976
1064
|
state.draining = false
|
|
977
1065
|
}
|
|
978
1066
|
}
|
|
979
1067
|
|
|
1068
|
+
// If incomplete todos remain and all guards pass, push a single continuation
|
|
1069
|
+
// prompt directly onto this session's drainQueue, tagged TODO_CONTINUATION_SOURCE
|
|
1070
|
+
// so the next drain iteration treats it as an injected (non-user) turn that does
|
|
1071
|
+
// not reset the episode budget. The enclosing drain loop consumes it; this never
|
|
1072
|
+
// calls drain() itself.
|
|
1073
|
+
async function maybeContinueTodos(
|
|
1074
|
+
state: SessionState,
|
|
1075
|
+
agentDir: string | undefined,
|
|
1076
|
+
logger: ServerLogger,
|
|
1077
|
+
): Promise<void> {
|
|
1078
|
+
if (agentDir === undefined) return
|
|
1079
|
+
try {
|
|
1080
|
+
await runIdleContinuation({
|
|
1081
|
+
agentDir,
|
|
1082
|
+
origin: state.origin,
|
|
1083
|
+
deliver: (text) => {
|
|
1084
|
+
state.drainQueue.push({
|
|
1085
|
+
streamMessageId: `todo-continuation-${crypto.randomUUID()}` as StreamMessageId,
|
|
1086
|
+
text,
|
|
1087
|
+
delivery: 'queue',
|
|
1088
|
+
ts: Date.now(),
|
|
1089
|
+
source: TODO_CONTINUATION_SOURCE,
|
|
1090
|
+
})
|
|
1091
|
+
},
|
|
1092
|
+
})
|
|
1093
|
+
} catch (err) {
|
|
1094
|
+
logger.error(`[server] ${state.sessionFileId}: todo continuation failed: ${describeErr(err)}`)
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function describeErr(err: unknown): string {
|
|
1099
|
+
return err instanceof Error ? err.message : String(err)
|
|
1100
|
+
}
|
|
1101
|
+
|
|
980
1102
|
function pushQueueState(ws: Ws, state: SessionState): void {
|
|
981
1103
|
const pending: QueueStateItem[] = state.drainQueue.map((q) => ({
|
|
982
1104
|
id: q.streamMessageId,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: typeclaw-channel-github
|
|
3
|
-
description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `github`, AND before composing replies to GitHub-originated inbounds, AND before opening new issues or PRs with `gh`, AND ALWAYS when you are asked to review a PR — whether the inbound says "requested your review on PR #N" / "requested a review from team @… on PR #N", or a human asks for a review in plain language in an issue/PR body or comment ("@bot review this", "can you take a look at #123"). On a review request you delegate the analysis to the `reviewer` subagent, which produces line-anchored findings, then you post them as an inline review via `gh api`. GitHub renders **real markdown** — `**bold**`, `## headings`, `| tables |`, fenced code blocks, and `inline code` all render natively. Use rich markdown freely. GitHub cannot send file attachments via API — do not call `channel_send` with attachments on github chats. GitHub has no typing indicator. PR review threads use `thread` keyed on the root comment id; reply to a thread to stay in it, or omit `thread` to post a top-level issue/PR comment. When a review comment **you authored** gets addressed — the author pushed a fix or replied that resolves it — verify the fix at the PR's head SHA and then resolve the thread with the
|
|
3
|
+
description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `github`, AND before composing replies to GitHub-originated inbounds, AND before opening new issues or PRs with `gh`, AND ALWAYS when you are asked to review a PR — whether the inbound says "requested your review on PR #N" / "requested a review from team @… on PR #N", or a human asks for a review in plain language in an issue/PR body or comment ("@bot review this", "can you take a look at #123"). On a review request you delegate the analysis to the `reviewer` subagent, which produces line-anchored findings, then you post them as an inline review via `gh api`. GitHub renders **real markdown** — `**bold**`, `## headings`, `| tables |`, fenced code blocks, and `inline code` all render natively. Use rich markdown freely. GitHub cannot send file attachments via API — do not call `channel_send` with attachments on github chats. GitHub has no typing indicator. PR review threads use `thread` keyed on the root comment id; reply to a thread to stay in it, or omit `thread` to post a top-level issue/PR comment. When a review comment **you authored** gets addressed — the author pushed a fix or replied that resolves it — verify the fix at the PR's head SHA and then resolve the thread by acknowledging with `channel_reply({ …, resolve_review_thread: true })`, which resolves the thread before posting the reply (see "Resolving review threads you authored" below); resolving is the close-out that tells the author the concern is settled. To open new issues or PRs use the `gh` CLI — `GH_TOKEN` is pre-set by the adapter. Read this skill before composing anything on GitHub.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
GitHub renders normal Markdown in issues, PRs, discussions, and review comments. Use headings, lists, tables, fenced code blocks, links, and inline code when they improve clarity.
|
|
@@ -8,15 +8,33 @@ GitHub renders normal Markdown in issues, PRs, discussions, and review comments.
|
|
|
8
8
|
- Do not send attachments on GitHub chats; the adapter rejects them.
|
|
9
9
|
- There is no typing indicator.
|
|
10
10
|
- For PR review threads, keep `thread` set to reply in-place. Omit `thread` for a top-level PR/issue comment.
|
|
11
|
-
- When a review comment **you authored** has been addressed, resolve its thread — see "Resolving review threads you authored" below. The base principle is **whoever opened the thread closes it**: you resolve only the threads you started, never a human's.
|
|
11
|
+
- When a review comment **you authored** has been addressed, resolve its thread by replying with `channel_reply({ …, resolve_review_thread: true })` — see "Resolving review threads you authored" below. The base principle is **whoever opened the thread closes it**: you resolve only the threads you started, never a human's (the runtime enforces this).
|
|
12
12
|
|
|
13
13
|
## Mid-turn status replies need `continue: true`
|
|
14
14
|
|
|
15
15
|
A successful `channel_reply` ends your turn by default — the runtime stops the model right after the reply lands. That is correct for a final answer, but it will **silently truncate** a turn that still has work to do. If you post a status line like "Reviewing now, I'll be back with findings" and then expect to keep working (fetch the diff, spawn the reviewer, post the review) in the **same** turn, you must call `channel_reply({ text: "…", continue: true })`. Without `continue: true`, the turn ends at that status reply and the review never runs. Reserve `continue: true` for genuine multi-step turns; the final reply that wraps up the turn omits it.
|
|
16
16
|
|
|
17
|
+
## Inbound triage — do this first, every time
|
|
18
|
+
|
|
19
|
+
Before you pick an action, classify the inbound. Skipping this step is how a PR ends up with a "looks good" comment but no approval: the model pattern-matches on the prose ("they fixed it → resolve the thread") and never asks whether it owes the PR a formal review. Answer these in order; the **first** that matches decides your path. Do not skip ahead.
|
|
20
|
+
|
|
21
|
+
1. **Is this a PR, and do I have an unresolved blocking obligation on it?** On any `pr:N` inbound, before anything else, check whether you owe this PR a verdict you have not yet landed. Check **both** signals below — checking only formal review state misses the very failure this gate exists to catch, because a prior block may never have become formal state:
|
|
22
|
+
- **Formal review state.** Run the step-1 re-review query in the PR review flow (`gh api --paginate --slurp /repos/owner/repo/pulls/<N>/reviews --jq '…'` filtered to `{CHANGES_REQUESTED, APPROVED}`). If your latest **blocking decision** is `CHANGES_REQUESTED`, you have a live sticky block.
|
|
23
|
+
- **Flat-comment blockers you authored.** A prior "request changes" may have been posted as a plain PR/issue comment instead of a formal review — in which case **no `CHANGES_REQUESTED` row exists** and the query above returns empty even though you blocked the PR in prose. So also scan your own recent comments (`gh api /repos/owner/repo/issues/<N>/comments --jq '[.[] | select(.user.login == "<your-login>")]'`) for one that requested changes / raised blockers and has not since been superseded by a formal review or a clear retraction. For routing, a blocking comment you wrote is as binding as a formal `CHANGES_REQUESTED`.
|
|
24
|
+
|
|
25
|
+
If **either** signal shows an unresolved blocker you raised, this inbound is a **re-review** — go to the **PR review flow** regardless of how it is phrased. An author commenting "fixed both issues" / "addressed your feedback" / "pushed a fix" is a re-review trigger, **not** a thread-resolve trigger. A re-review is closed by re-deciding the verdict and landing a **formal** review via `POST /pulls/<N>/reviews`: `APPROVE` clears a sticky `CHANGES_REQUESTED`; a comment or a flat reply clears neither a formal block nor a flat-comment blocker — it just strands the verdict again, which is the original bug.
|
|
26
|
+
|
|
27
|
+
2. **Am I being asked to review (first-time)?** Explicit `review_requested` inbound, or a human asking in plain language ("review this", "take a look at #N"). → **PR review flow** (see "When you are being asked to review").
|
|
28
|
+
|
|
29
|
+
3. **Is this a reply inside an inline review thread I authored** (`pr:N` with `thread` set, on a thread whose root comment is mine)? → verify the fix at head SHA and **resolve the thread** (see "Resolving review threads you authored"). `resolve_review_thread` only works when `thread` is set on the origin; if there is **no** `thread`, this branch does not apply — do not attempt it, fall through to the table below.
|
|
30
|
+
|
|
31
|
+
4. **None of the above** → use the routing table below.
|
|
32
|
+
|
|
33
|
+
> The decisive question is **#1**. A blocking verdict you owe a PR is never discharged by a `channel_reply` or an `issue_comment` — neither carries review state, and neither clears a sticky `CHANGES_REQUESTED`. This applies to an **unresolved blocking obligation** (a live `CHANGES_REQUESTED`, or an unretracted blocker you raised in a flat comment), not to a stale `APPROVED` or a past non-blocking comment — those impose no closeout duty. When you do owe a block, the close-out is always a formal review via `POST /pulls/<N>/reviews`.
|
|
34
|
+
|
|
17
35
|
## What to do, by inbound type
|
|
18
36
|
|
|
19
|
-
Every GitHub inbound lands on a `chat` keyed by its subject: `issue:N`, `pr:N`, or `discussion:N`.
|
|
37
|
+
Every GitHub inbound lands on a `chat` keyed by its subject: `issue:N`, `pr:N`, or `discussion:N`. **Run the triage above first.** Only if no triage branch matched do you pick an action from this table. The default action for anything addressed to you is a normal `channel_reply` in that thread; the **PR review flow** is the exception that requires delegation.
|
|
20
38
|
|
|
21
39
|
| Inbound | Looks like | What to do |
|
|
22
40
|
| -------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -43,31 +61,44 @@ A `review_request_removed` inbound ("removed your review request on PR #N") is t
|
|
|
43
61
|
|
|
44
62
|
The `reviewer` subagent is the analyst; you are the integration layer between its output and GitHub's review API. It loads the `code-review` skill on demand and returns line-anchored findings inside a `<review>` block. Your job is mechanics: spawn, wait, translate, post.
|
|
45
63
|
|
|
64
|
+
**The reviewer's `<review>` block is the only source of the verdict and the findings.** You do not review the PR yourself. Between spawning the reviewer and reading its result you do **no analysis of this PR** — do not run `gh pr diff`, do not read the changed files to form an opinion, do not draft a verdict. The reviewer runs on the `deep` model precisely so this judgment is not yours to make on the parent model. If you analyze the diff and post your own assessment while the reviewer is still running, you will post one verdict now and the reviewer's (often different) verdict when it completes — **two contradictory reviews on the same PR**, the exact failure this flow exists to prevent. Wait for the reviewer; post what it returns; nothing before that.
|
|
65
|
+
|
|
66
|
+
**HARD RULE — a review with actionable findings is a formal review, never a flat comment.** If the reviewer returns **one or more** actionable findings (`blocker`/`concern`/`nit`), the ONLY acceptable way to deliver them is a formal review via `POST /pulls/<N>/reviews` (step 4) — `REQUEST_CHANGES`, `COMMENT`, or `APPROVE` per the verdict, with the line-anchored findings in `comments[]`. You may **never** flatten those findings into a `channel_reply` or a top-level issue comment, **even when** the `gh api` call fails. A 422 means an anchor is wrong (almost always a `line` not in the diff): re-anchor it or move that one finding into the top-level review `body`, then resubmit the formal review — do **not** abandon the formal review and post prose instead. A flat "## Review … two blockers" comment is a bug, not a fallback: it strands the findings without line anchors and is the exact failure this rule exists to prevent. The flat/issue-comment path is reserved for the **zero-actionable-findings** branch only (see below). If you genuinely cannot land a formal review after fixing anchors, say so plainly and post nothing that claims a review happened — silence beats a false receipt.
|
|
67
|
+
|
|
46
68
|
1. **Confirm the target, and check whether you already reviewed it.** Capture the PR number, the repo, and the head SHA — you may need the SHA to read files at the revision the reviewer analyzed.
|
|
47
69
|
|
|
48
70
|
```sh
|
|
49
71
|
gh pr view <N> --repo owner/repo --json title,body,baseRefName,headRefOid,files
|
|
50
72
|
```
|
|
51
73
|
|
|
52
|
-
Then check for
|
|
74
|
+
Then check for an **unresolved blocking obligation of yours** — this is what makes the current request a _re-review_ (the author pushed fixes after you previously blocked the PR). As in triage #1, a block can live in **two** places, and you must check both:
|
|
53
75
|
|
|
54
76
|
```sh
|
|
77
|
+
# (a) formal review state
|
|
55
78
|
gh api --paginate --slurp /repos/owner/repo/pulls/<N>/reviews --jq 'add | [.[] | select(.user.login == "<your-login>" and (.state == "CHANGES_REQUESTED" or .state == "APPROVED"))] | last | .state'
|
|
79
|
+
# (b) flat-comment blocker you authored (when (a) is empty)
|
|
80
|
+
gh api --paginate /repos/owner/repo/issues/<N>/comments --jq '[.[] | select(.user.login == "<your-login>")]'
|
|
56
81
|
```
|
|
57
82
|
|
|
58
|
-
If
|
|
83
|
+
If (a) prints `CHANGES_REQUESTED`, **or** (a) is empty but (b) surfaces a comment of yours that requested changes / raised blockers and has not since been superseded by a formal review or a clear retraction, treat the current request as a **re-review** and carry that fact — including which form the prior block took — into the spawn in step 2. Only when **neither** signal shows an unresolved block do you handle the request normally. (`<your-login>` is your GitHub App login, typically `name[bot]`.)
|
|
59
84
|
|
|
60
|
-
Two things make
|
|
85
|
+
Two things make the formal-review query load-bearing — both are bugs if you simplify it:
|
|
61
86
|
- **Filter to _decision_ states, not the latest review row.** GitHub's sticky block is cleared only by a later `APPROVED` (or a dismissal) from the same reviewer — a later `COMMENTED` review does **not** clear it. So a history of `CHANGES_REQUESTED` → `COMMENTED` is _still blocked_, even though the latest row is `COMMENTED`. Selecting `last` over the raw review list would misread that as "not a re-review". Filtering to `{CHANGES_REQUESTED, APPROVED}` first, then taking `last`, asks the right question: "what is my latest _blocking decision_, ignoring non-deciding comments?" (Dismissed reviews surface as `state: "DISMISSED"`, so they're correctly excluded from the decision set too.)
|
|
62
87
|
- **`--paginate --slurp` is mandatory.** GitHub returns reviews 30 per page; a bot on a long-lived PR can have its blocking `CHANGES_REQUESTED` past the first page. Without paginating, that review is invisible and a genuine re-review silently falls back to the plain-comment path. `--slurp` collects every page into one array of arrays; the `add` concatenates them before filtering.
|
|
63
88
|
|
|
64
89
|
2. **Spawn the `reviewer` subagent with the PR target.** Use `run_in_background: true` so you stay responsive while the deep model works. Pass the PR URL (or `owner/repo#N`) plus any context the requester gave you (focus areas, specific files, etc.). The reviewer fetches the diff itself (`gh pr diff`, `gh api /repos/.../pulls/<n>`), loads the `code-review` skill, and returns a `<review>` block whose code findings carry `location="path:line"`.
|
|
65
90
|
|
|
66
|
-
**If step 1 found a
|
|
91
|
+
**If step 1 found an unresolved blocking obligation — a formal `CHANGES_REQUESTED` _or_ an unretracted flat-comment blocker — say so in the spawn payload** — e.g. _"This is a re-review: you previously blocked this PR (the prior blockers were …; the block was a formal `CHANGES_REQUESTED` / a flat PR comment). Verify they are resolved and return `approve` or `request-changes` — a re-review must re-decide the blocking state, not return `comment`."_ The reviewer's `code-review` skill enforces the same rule, but telling it the prior blockers (and which form they took) is what lets it apply that rule; a fresh reviewer session has no memory of your earlier block. The flat-comment case especially must be passed through — the reviewer cannot recover it from review state, so omitting it would silently drop the re-review context the moment the flow starts.
|
|
92
|
+
|
|
93
|
+
Do **not** post an "on it" acknowledgement comment before spawning the reviewer — the runtime already adds an :eyes: reaction to the PR the moment it engages, so a "looking into this" comment is redundant noise. Just spawn the reviewer with `run_in_background: true`; the formal review is your reply. If you want to acknowledge explicitly, use `channel_react({ emoji: "eyes" })`, which reacts without posting a comment.
|
|
94
|
+
|
|
95
|
+
After spawning, **end your turn** — the background reviewer wakes you with a completion `<system-reminder>` (step 3). "Stay responsive" means you remain free to handle _other_ chats meanwhile; it does **not** license you to keep working _this_ PR. Do not poll `subagent_output` in a busy-wait, and do not fill the wait by reviewing the diff yourself (see the exclusivity rule at the top of this flow). The next thing you do on this PR is read the reviewer's `<review>` block when the reminder arrives.
|
|
67
96
|
|
|
68
|
-
|
|
97
|
+
3. **On the completion `<system-reminder>`, first check you have not already posted — then** call `subagent_output({ task_id })` to read the reviewer's final assistant message.
|
|
69
98
|
|
|
70
|
-
|
|
99
|
+
**One verdict per PR per request — guard this before you read or post anything.** The completion reminder is not a license to post; it is a wake-up. The very first thing you do on this turn is ask: have I **already posted a review or verdict on this PR during this engagement**? If yes, stop here — do not fetch the reviewer output, do not translate, do not post. Call `skip_response({ reason: "review already posted for this PR" })` and end the turn. Posting the reminder's result on top of a verdict you already shipped is how a PR ends up with two reviews — and if the two disagree (because the earlier one was your own premature take), it contradicts you in public. Only when no review has gone out yet do you proceed to read and post below — which is the normal path, since you waited for the reviewer instead of reviewing it yourself.
|
|
100
|
+
|
|
101
|
+
With that confirmed, read the reviewer's final assistant message. The structured payload looks like:
|
|
71
102
|
|
|
72
103
|
```xml
|
|
73
104
|
<review>
|
|
@@ -97,10 +128,14 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
|
|
|
97
128
|
|
|
98
129
|
**Operator approval policy.** If the inbound carries a note that PR approval is disabled (`channels.github.review.approve: false` — the adapter appends "Operator policy: PR approval is disabled for this agent" to the message), you must **not** submit an `APPROVE`. Map an `approve` verdict to `COMMENT` instead: post the same `<summary>` and all inline `comments[]` as a `COMMENT` review, just without the formal approval. `request-changes` and `comment` verdicts are unaffected (they never approve). Absent that note, approval is enabled and the table above applies unchanged.
|
|
99
130
|
|
|
100
|
-
**Re-review.** If step 1 established this is a re-review (
|
|
131
|
+
**Re-review.** If step 1 established this is a re-review (an unresolved blocking obligation of yours — a formal `CHANGES_REQUESTED` **or** an unretracted flat-comment blocker), the result MUST clear or re-assert that block — never a top-level PR comment. The clearing mechanics depend on which form the prior block took:
|
|
132
|
+
- **Prior block was a formal `CHANGES_REQUESTED`.** It is sticky: **only** a fresh `APPROVE` from you, or a dismissal of your prior review, clears it. A plain issue comment does **not** clear it, and — critically — **neither does a `COMMENT` review.**
|
|
133
|
+
- **Prior block was a flat comment** (no formal `CHANGES_REQUESTED` exists). There is no sticky GitHub state to clear, but the obligation is still yours to discharge as a **formal** review so the verdict finally lands as review state: submit `APPROVE` (resolved, approval enabled) or `REQUEST_CHANGES` (not resolved). Do not discharge a flat-comment block with another flat comment — that re-strands the verdict, the original bug.
|
|
134
|
+
|
|
135
|
+
So even if the reviewer returns zero actionable findings, do **not** take the `comment` → top-level-comment branch below for a re-review. The reviewer's skill is instructed not to return `comment` on a re-review; if it does anyway despite a reachable diff, prefer `approve` when the prior blockers are visibly resolved in the diff, otherwise `request-changes` — and say which in your reasoning. Resolve the re-review by verdict:
|
|
101
136
|
- **`request-changes`** — submit a fresh `REQUEST_CHANGES` review (re-asserts the block with the new findings). Straightforward.
|
|
102
137
|
- **`approve`, approval enabled** — submit `APPROVE`. This clears the block.
|
|
103
|
-
- **`approve`, approval disabled (`channels.github.review.approve: false`)** — you cannot `APPROVE
|
|
138
|
+
- **`approve`, approval disabled (`channels.github.review.approve: false`)** — you cannot `APPROVE`. How you close out depends on the prior block's form. **If the prior block was a flat comment** (no formal `CHANGES_REQUESTED`), there is no sticky state to clear: submit a `COMMENT` review carrying the `<summary>` so the verdict lands as review state, and you are done — nothing to dismiss. **If the prior block was a formal `CHANGES_REQUESTED`**, a `COMMENT` review will **not** clear the sticky block, so the PR would stay blocked by your stale review; clear it explicitly by **dismissing your own prior `CHANGES_REQUESTED` review**. Grab that review's `id` by re-running the step-1 formal-review query with the trailing filter changed from `| .state` to `| {state, id}` (same `select`), take the entry whose `state` is `CHANGES_REQUESTED`, then:
|
|
104
139
|
|
|
105
140
|
```sh
|
|
106
141
|
gh api -X PUT /repos/owner/repo/pulls/<N>/reviews/<review_id>/dismissals -f message="Blockers resolved; dismissing my prior changes request per operator approval-disabled policy." -f event=DISMISS
|
|
@@ -110,7 +145,7 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
|
|
|
110
145
|
|
|
111
146
|
Then submit the review. **Write the JSON payload to a file with the `write` tool, then run a single bare `gh api --input <file>`** — two steps:
|
|
112
147
|
|
|
113
|
-
First write `/tmp/review
|
|
148
|
+
First write `/tmp/review-<N>.json` (via the `write` tool, not bash) — `/tmp` is per-session scratch, and the `<N>` keeps concurrent reviews in one session from colliding:
|
|
114
149
|
|
|
115
150
|
```json
|
|
116
151
|
{
|
|
@@ -131,7 +166,7 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
|
|
|
131
166
|
Then post it:
|
|
132
167
|
|
|
133
168
|
```sh
|
|
134
|
-
gh api -X POST /repos/owner/repo/pulls/<N>/reviews --input /tmp/review
|
|
169
|
+
gh api -X POST /repos/owner/repo/pulls/<N>/reviews --input /tmp/review-<N>.json
|
|
135
170
|
```
|
|
136
171
|
|
|
137
172
|
**A repo-targeting `gh` command must be a single bare `gh` invocation — no pipes, `;`, `&&`, heredocs, or command substitution.** The `github-cli-auth` plugin injects the GitHub App token into the command's environment, so any sibling/upstream stage in a pipeline would inherit a live token; the runtime blocks those shapes. That is why the old `cat <<'JSON' | gh api --input -` heredoc-pipe no longer works: write the JSON to a file and feed it with `--input <file>` instead. Do **not** use `-f body=...` or `-F 'comments[][body]=...'`: those go through shell argument parsing, so backticks trigger command substitution. The file passes the JSON through untouched — backticks, newlines, and `${...}` all survive verbatim. The same file-then-`--input` pattern applies to any `gh api` POST whose body contains backticks, embedded newlines, or shell metacharacters.
|
|
@@ -155,7 +190,7 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
|
|
|
155
190
|
A finding is "actionable" if its severity is `blocker`, `concern`, or `nit`. The inline-review post in step 4 applies whenever the actionable count is **at least one**. When the reviewer returns **exactly zero** actionable findings (only `praise`, or none), there is nothing to anchor inline — handle by verdict:
|
|
156
191
|
|
|
157
192
|
- `approve` → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array). **If the operator approval policy above disabled approval, submit a `COMMENT` review instead — same `<summary>` as the review body, `event: "COMMENT"`, no `comments[]` array. Keep it a formal review, not a top-level issue comment, so the review metadata and flow are preserved.** (Re-review caveat: a `COMMENT` review does **not** clear a sticky `CHANGES_REQUESTED` block. If this is a re-review under approval-disabled policy, follow the step-4 re-review branch — dismiss your prior review — instead of relying on this `COMMENT`.)
|
|
158
|
-
- `comment` → post the summary as a top-level PR comment via `gh api -X POST /repos/.../issues/<N>/comments` instead of submitting an empty review. **Exception — re-reviews:** if this is a re-review (
|
|
193
|
+
- `comment` → post the summary as a top-level PR comment via `gh api -X POST /repos/.../issues/<N>/comments` instead of submitting an empty review. **Exception — re-reviews:** if this is a re-review (you have an unresolved blocking obligation — a formal `CHANGES_REQUESTED` **or** an unretracted flat-comment blocker), a top-level comment discharges neither. Do not use this branch; resolve it via the step-4 re-review branch (`APPROVE` if resolved and approval is enabled, the dismissal endpoint if a formal block is resolved but approval is disabled, `REQUEST_CHANGES` if not resolved).
|
|
159
194
|
- `request-changes` → submit `REQUEST_CHANGES` with the `<summary>` as the review body and no `comments[]` array. This combination is rare (the reviewer's contract says `request-changes` requires at least one blocker or load-bearing concern); if it happens, faithfully encode the verdict and trust the reviewer's reasoning is in the summary.
|
|
160
195
|
|
|
161
196
|
The bundled `agent-browser` is **not** for PR reviews — `gh api` is faster and more reliable. Only use the browser when the API genuinely can't reach what you need.
|
|
@@ -174,11 +209,30 @@ Do not resolve on a bare "done" claim. A reply that says "fixed" is a prompt to
|
|
|
174
209
|
2. Read the lines your comment anchored to, at that SHA: `gh api /repos/owner/repo/contents/<path>?ref=<headRefOid>` (or `gh pr diff <N>` to see what the new push changed). Confirm the change actually addresses the concern your comment raised — not a different line, not a partial fix.
|
|
175
210
|
3. Only when the code at head genuinely resolves the finding do you resolve the thread. If the fix is partial or misses the point, **reply in the thread** explaining what's still open and leave it unresolved.
|
|
176
211
|
|
|
177
|
-
If the author merely **replied** without pushing (e.g. "this is intentional because …") and their reasoning settles it, that is also "addressed"
|
|
212
|
+
If the author merely **replied** without pushing (e.g. "this is intentional because …") and their reasoning settles it, that is also "addressed". If their reasoning does **not** settle it, keep the thread open and answer instead.
|
|
213
|
+
|
|
214
|
+
> **The verify and the resolve are one action, not two.** Once you've verified the fix, your acknowledgement reply **is** the close-out — carry `resolve_review_thread: true` on it. The common failure is posting a bare "Verified at \<sha\> — thanks, that addresses it" with the flag omitted: that reads as closed but leaves the thread **open**, because a successful reply ends your turn and the resolve can't happen in a later one. The flag is technically optional (nothing rejects a reply without it), but on an acknowledgement it is the only thing that actually closes the thread — so treat it as part of the acknowledgement, not an afterthought.
|
|
215
|
+
|
|
216
|
+
### How to resolve — `channel_reply({ resolve_review_thread: true })`
|
|
217
|
+
|
|
218
|
+
Once you have verified the fix, **acknowledge and resolve in one call**: pass `resolve_review_thread: true` to your `channel_reply`. The runtime resolves the thread you're replying in **before** it posts your acknowledgement, then posts the reply:
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
channel_reply({ text: "Verified — the fix addresses the concern. Thanks!", resolve_review_thread: true })
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
This is the correct path and it removes a footgun. A bare `channel_reply` ends your turn the moment it lands, so a resolve attempted _after_ the acknowledgement would never run — the thread would stay open even though you "handled" it. The flag resolves first, so a normal final reply still closes the thread. You do **not** need `continue: true` for this: resolution happens inside the same call, before the turn ends.
|
|
225
|
+
|
|
226
|
+
Two guarantees make the flag safe to use as your default:
|
|
227
|
+
|
|
228
|
+
- **Author check is enforced in code.** The runtime only resolves a thread whose root comment **you** authored; a request to resolve a human reviewer's thread is refused, and the reply is **not** posted. You cannot accidentally close someone else's open question.
|
|
229
|
+
- **A failed resolve blocks the reply.** If the resolve fails (permission denied, wrong author, the fix doesn't verify on the API side), `channel_reply` is denied and posts nothing — so you never end up with a cheerful "looks resolved" comment sitting next to a still-open thread. Read the denial, fix the cause, and retry.
|
|
230
|
+
|
|
231
|
+
The flag is valid only on a github session replying inside a thread (`thread` set on the origin). It is ignored — and denied — elsewhere. If the thread is already resolved or already gone, the reply still posts (nothing left to close).
|
|
178
232
|
|
|
179
|
-
###
|
|
233
|
+
### Fallback — the raw `resolveReviewThread` GraphQL mutation
|
|
180
234
|
|
|
181
|
-
There is no REST endpoint for this. Resolution is a GraphQL mutation that takes the thread's **node id** (`PRRT_…`), not the comment's numeric id. Two steps: find the thread id, then resolve it.
|
|
235
|
+
Prefer the flag above. Reach for the raw mutation only when you need to resolve a thread you are **not** currently replying in, or to debug. There is no REST endpoint for this. Resolution is a GraphQL mutation that takes the thread's **node id** (`PRRT_…`), not the comment's numeric id. Two steps: find the thread id, then resolve it.
|
|
182
236
|
|
|
183
237
|
1. **Find the node id of the thread you authored.** Query the PR's review threads and pick the one whose root comment is yours and matches the `thread` you're replying in:
|
|
184
238
|
|
|
@@ -25,12 +25,14 @@ Citations in shard bodies use the canonical form `streams/yyyy-MM-dd#<fragment-i
|
|
|
25
25
|
|
|
26
26
|
When index-mode injection hides bodies, or when you need recent fragments the dreaming subagent hasn't consolidated yet, use `memory_search({query, asRegex?, full?, maxResults?})`. It searches BOTH topic shards under `memory/topics/` and undreamed stream events under `memory/streams/`. Substring (case-insensitive) by default; `asRegex: true` for regex.
|
|
27
27
|
|
|
28
|
+
Plain queries are **phrase-first with a word fallback**: the whole query is tried as one substring, and only if that finds nothing is the query split on whitespace and the distinct words OR-matched (ranked by how many words each hit contains). So a descriptive multi-word query like `quarterly regional revenue summary` still returns results even when no entry contains that exact phrase. You don't need to pre-split queries into single keywords — but a focused phrase still wins when an entry contains it verbatim. Regex queries never fall back (whitespace stays part of the pattern).
|
|
29
|
+
|
|
28
30
|
Results are discriminated by `source`:
|
|
29
31
|
|
|
30
32
|
- `source: "topic"` — fields `shardPath`, `slug`, `heading`, `excerpt`, `fullBody?`
|
|
31
33
|
- `source: "stream"` — fields `streamPath`, `date`, `eventId?` (citation-format `streams/yyyy-MM-dd#<id>` for fragments; absent for legacy prose), `topic`, `excerpt`, `fullBody?`
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
Ordering depends on mode. Exact-phrase (and regex) results list all topic matches first (alphabetical by slug), then stream matches (newest day first), and `maxResults` truncates streams before topics. Word-fallback results are instead ranked by matched-word count — that same topic-first/stream-newest order is only the tiebreak within a score band, so a higher-scoring stream can precede a lower-scoring topic, and `maxResults` drops the lowest-scored tail regardless of source. `full: true` returns the entire shard or fragment body.
|
|
34
36
|
|
|
35
37
|
## Per-shard truncation
|
|
36
38
|
|
package/src/tui/format.ts
CHANGED
|
@@ -63,10 +63,10 @@ function humanizeArgs(name: string, args: unknown): string | null {
|
|
|
63
63
|
return humanizeFindArgs(args)
|
|
64
64
|
case 'ls':
|
|
65
65
|
return humanizeLsArgs(args)
|
|
66
|
-
case '
|
|
67
|
-
return
|
|
68
|
-
case '
|
|
69
|
-
return
|
|
66
|
+
case 'web_search':
|
|
67
|
+
return humanizeWebSearchArgs(args)
|
|
68
|
+
case 'web_fetch':
|
|
69
|
+
return humanizeWebFetchArgs(args)
|
|
70
70
|
default:
|
|
71
71
|
return null
|
|
72
72
|
}
|
|
@@ -123,14 +123,14 @@ function humanizeLsArgs(args: ArgRecord): string | null {
|
|
|
123
123
|
return asString(args.path) ?? '.'
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
function
|
|
126
|
+
function humanizeWebSearchArgs(args: ArgRecord): string | null {
|
|
127
127
|
const query = asString(args.query)
|
|
128
128
|
if (query === null) return null
|
|
129
129
|
const source = asString(args.source)
|
|
130
130
|
return source && source !== 'web' ? `"${query}" (${source})` : `"${query}"`
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
function
|
|
133
|
+
function humanizeWebFetchArgs(args: ArgRecord): string | null {
|
|
134
134
|
return asString(args.url)
|
|
135
135
|
}
|
|
136
136
|
|
|
@@ -153,8 +153,8 @@ function enrichResult(name: string, result: ArgRecord): string | null {
|
|
|
153
153
|
return enrichBashResult(result)
|
|
154
154
|
case 'read':
|
|
155
155
|
return enrichReadResult(result)
|
|
156
|
-
case '
|
|
157
|
-
return
|
|
156
|
+
case 'web_search':
|
|
157
|
+
return enrichWebSearchResult(result)
|
|
158
158
|
default:
|
|
159
159
|
return null
|
|
160
160
|
}
|
|
@@ -187,7 +187,7 @@ function enrichReadResult(result: ArgRecord): string | null {
|
|
|
187
187
|
return mime ? `[image: ${mime}]` : '[image]'
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
function
|
|
190
|
+
function enrichWebSearchResult(result: ArgRecord): string | null {
|
|
191
191
|
const details = isObject(result.details) ? result.details : null
|
|
192
192
|
if (details === null) return null
|
|
193
193
|
const results = Array.isArray(details.results) ? details.results : null
|
|
@@ -198,13 +198,13 @@ function enrichWebsearchResult(result: ArgRecord): string | null {
|
|
|
198
198
|
const source = asString(details.source) ?? ''
|
|
199
199
|
const header = query ? `${results.length} result${results.length === 1 ? '' : 's'} for "${query}" (${source})` : null
|
|
200
200
|
const lines = results
|
|
201
|
-
.map((entry, i) =>
|
|
201
|
+
.map((entry, i) => formatWebSearchEntry(entry, i + 1))
|
|
202
202
|
.filter((line): line is string => line !== null)
|
|
203
203
|
if (lines.length === 0) return extractContentText(result)
|
|
204
204
|
return header === null ? lines.join('\n') : `${header}\n${lines.join('\n')}`
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
function
|
|
207
|
+
function formatWebSearchEntry(entry: unknown, index: number): string | null {
|
|
208
208
|
if (!isObject(entry)) return null
|
|
209
209
|
const title = asString(entry.title)
|
|
210
210
|
const url = asString(entry.url)
|
package/typeclaw.schema.json
CHANGED
|
@@ -548,10 +548,20 @@
|
|
|
548
548
|
},
|
|
549
549
|
"review": {
|
|
550
550
|
"default": {
|
|
551
|
+
"on": "review_requested",
|
|
551
552
|
"approve": true
|
|
552
553
|
},
|
|
553
554
|
"type": "object",
|
|
554
555
|
"properties": {
|
|
556
|
+
"on": {
|
|
557
|
+
"default": "review_requested",
|
|
558
|
+
"type": "string",
|
|
559
|
+
"enum": [
|
|
560
|
+
"review_requested",
|
|
561
|
+
"opened",
|
|
562
|
+
"off"
|
|
563
|
+
]
|
|
564
|
+
},
|
|
555
565
|
"approve": {
|
|
556
566
|
"default": true,
|
|
557
567
|
"type": "boolean"
|