typeclaw 0.30.1 → 0.31.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.
@@ -1,14 +1,28 @@
1
1
  import type { ReviewVerdict } from '@/channels/github-review-turn-ledger'
2
2
 
3
- // `NONE` covers "never reviewed" and "last decisive review was DISMISSED" both
4
- // mean a fresh verdict is legitimate (not a duplicate).
5
- export type EffectiveVerdict = 'APPROVED' | 'CHANGES_REQUESTED' | 'NONE'
3
+ // Raw latest-decisive state. DISMISSED is kept DISTINCT from NONE on purpose: a
4
+ // genuine dismissal means a fresh same-verdict re-review is legitimate and must
5
+ // NOT be shadowed by the read-after-write-lag cache (which only overrides a bare
6
+ // NONE — "GitHub shows no decisive review, but we just landed one"). Collapsing
7
+ // DISMISSED into NONE would let the lag cache re-strand a dismiss-then-reapprove,
8
+ // the exact failure 35287f99 removed.
9
+ export type EffectiveVerdict = 'APPROVED' | 'CHANGES_REQUESTED' | 'DISMISSED' | 'NONE'
6
10
 
7
11
  export type EffectiveApprovalResolver = (target: {
8
12
  workspace: string
9
13
  prNumber: number
10
14
  }) => Promise<{ ok: true; effective: EffectiveVerdict } | { ok: false }>
11
15
 
16
+ // Resolves the PR's current head commit SHA. Called twice: once in guard() (the
17
+ // pre-submit head, resolved AFTER the in-flight lease so the await cannot widen the
18
+ // reserve-before-await race) and once in release() (the post-submit head, to detect
19
+ // a push that landed during the review). Fails soft (null). A null PRE-submit head
20
+ // skips the cache write entirely — the guard falls open to GitHub rather than ever
21
+ // stranding a genuine verdict on local memory. A null POST-submit head (or one that
22
+ // differs from the pre-submit head) is recorded as the uncertainty sentinel so a
23
+ // push-during-review still blocks a same-verdict duplicate for the lag window.
24
+ export type HeadShaResolver = (target: { workspace: string; prNumber: number }) => Promise<string | null>
25
+
12
26
  export type ApproveBlock = { block: true; reason: string }
13
27
 
14
28
  export type ReviewVerdictGuard = {
@@ -18,7 +32,7 @@ export type ReviewVerdictGuard = {
18
32
  prNumber: number
19
33
  verdict: ReviewVerdict
20
34
  }) => Promise<ApproveBlock | null>
21
- release: (args: { callId: string; succeeded: boolean }) => void
35
+ release: (args: { callId: string; succeeded: boolean }) => Promise<void>
22
36
  }
23
37
 
24
38
  // Back-compat alias: the guard now covers REQUEST_CHANGES too, not just APPROVE.
@@ -55,7 +69,32 @@ function duplicatesStanding(verdict: ReviewVerdict, effective: EffectiveVerdict)
55
69
  // never strand a PR for long.
56
70
  const LEASE_TTL_MS = 5 * 60_000
57
71
 
58
- type Reservation = { key: string; token: number; createdAt: number }
72
+ // How long a just-landed verdict is trusted to explain a GitHub `NONE` as
73
+ // read-after-write lag rather than a genuine absence. GitHub's `/pulls/<n>/reviews`
74
+ // list lags a write by up to ~10s, so a second engagement turn firing in that
75
+ // window reads NONE and would land a duplicate. Observed duplicates were ~10-18s
76
+ // apart; 60s is a comfortable lag margin without making a legitimate re-verdict
77
+ // wait long. This window only shadows a raw NONE on the SAME verdict (+ same or
78
+ // uncertain head) — a DISMISSED/CHANGES_REQUESTED/flipped-verdict all bypass it.
79
+ const RECENT_LANDED_TTL_MS = 60_000
80
+
81
+ type Reservation = {
82
+ key: string
83
+ token: number
84
+ createdAt: number
85
+ headSha: string | null
86
+ verdict: ReviewVerdict
87
+ workspace: string
88
+ prNumber: number
89
+ }
90
+
91
+ // headSha === null is the UNCERTAINTY sentinel: the command succeeded but the head
92
+ // the review actually attached to is unknown (the PR head advanced between the
93
+ // pre-submit capture and the write, or the post-submit re-resolve failed). A null
94
+ // record matches any current head for the window — same verdict + raw NONE only —
95
+ // so a push-during-review cannot let a same-verdict duplicate slip past on the new
96
+ // head. A resolved string keys precise same-head matching for the normal case.
97
+ type LandedVerdict = { verdict: ReviewVerdict; headSha: string | null; landedAt: number }
59
98
 
60
99
  // MODULE-LEVEL singletons, shared by every plugin instance in this process. The
61
100
  // github-cli-auth plugin's `plugin: async (ctx) => ...` factory may run once per
@@ -65,10 +104,11 @@ type Reservation = { key: string; token: number; createdAt: number }
65
104
  // three sessions each landed an APPROVE on the same PR within ten seconds.
66
105
  const inFlightByPr = new Map<string, Reservation>()
67
106
  const reservationByCall = new Map<string, Reservation>()
107
+ const recentLandedByPr = new Map<string, LandedVerdict>()
68
108
  let tokenSeq = 0
69
109
 
70
110
  // Makes a formal `gh ... event=APPROVE|REQUEST_CHANGES` idempotent per PR across
71
- // turns, sessions, and (in-process) concurrent fan-out. Two layers:
111
+ // turns, sessions, and (in-process) concurrent fan-out. Three layers, in order:
72
112
  //
73
113
  // 1. A process-wide in-flight lease keyed by `workspace#prNumber`, held from
74
114
  // tool.before through tool.after. While one verdict is mid-flight, every
@@ -77,12 +117,25 @@ let tokenSeq = 0
77
117
  // closure-local Set could not provide: separate plugin instances meant
78
118
  // separate Sets, so concurrent sessions never saw each other.
79
119
  //
80
- // 2. The authoritative GitHub effective-state read, consulted AFTER the lease
81
- // is acquired. It catches the cross-restart case (lease lost) and tracks
82
- // supersession: a later CHANGES_REQUESTED/DISMISSED demotes an earlier
83
- // APPROVED, so a genuine re-verdict is allowed. Reads fail OPEN — a
84
- // transient error must never strand a genuine first verdict; the lease
85
- // still covers the concurrent case while the command runs.
120
+ // 2. The authoritative GitHub effective-state read, consulted AFTER the lease.
121
+ // It is the SOLE source of truth for a standing verdict and for supersession:
122
+ // a later CHANGES_REQUESTED/DISMISSED demotes an earlier APPROVED, so a
123
+ // genuine re-verdict is allowed (the 35287f99 invariantnever block a
124
+ // re-verdict on stale LOCAL memory). A standing same verdict blocks; DISMISSED
125
+ // and the opposite decisive verdict pass. Reads fail OPEN.
126
+ //
127
+ // 3. A read-after-write-lag shield, consulted ONLY when layer 2 returns a raw
128
+ // NONE. The lease (layer 1) covers two OVERLAPPING in-flight commands, but a
129
+ // second engagement turn ~10s later starts after the first's lease released,
130
+ // and GitHub's reviews list still lags the write (reports NONE). A short-lived
131
+ // `recentLandedByPr` record — same verdict + (same OR uncertain head), written
132
+ // on a succeeded release, RECENT_LANDED_TTL_MS — disambiguates "NONE because
133
+ // lag" from "NONE because genuinely absent": only the former blocks. The head
134
+ // is re-resolved at release time; if the PR head advanced during the submit the
135
+ // record stores a null head (uncertainty), which matches the current head so a
136
+ // push-during-review cannot leak a duplicate. Because it fires after a raw
137
+ // NONE, a real DISMISSED/CHANGES_REQUESTED already allowed the re-verdict at
138
+ // layer 2, so this cannot re-strand a supersession.
86
139
  //
87
140
  // The lease is released only in release() (tool.after) or on a terminal block,
88
141
  // never after the remote read — releasing early reopens the TOCTOU the lease
@@ -90,6 +143,7 @@ let tokenSeq = 0
90
143
  // tool.after for a superseded reservation cannot drop a newer session's lease.
91
144
  export function createApproveIdempotencyGuard(deps: {
92
145
  resolveEffectiveApproval: EffectiveApprovalResolver
146
+ resolveHeadSha?: HeadShaResolver
93
147
  now?: () => number
94
148
  }): ReviewVerdictGuard {
95
149
  const now = deps.now ?? Date.now
@@ -107,10 +161,27 @@ export function createApproveIdempotencyGuard(deps: {
107
161
  if (held !== undefined && now() - held.createdAt < LEASE_TTL_MS) {
108
162
  return { block: true, reason: CONCURRENT_REASON }
109
163
  }
110
- const reservation: Reservation = { key, token: ++tokenSeq, createdAt: now() }
164
+ const reservation: Reservation = {
165
+ key,
166
+ token: ++tokenSeq,
167
+ createdAt: now(),
168
+ headSha: null,
169
+ verdict: args.verdict,
170
+ workspace: args.workspace,
171
+ prNumber: args.prNumber,
172
+ }
111
173
  inFlightByPr.set(key, reservation)
112
174
  reservationByCall.set(args.callId, reservation)
113
175
 
176
+ // Resolve the head SHA only AFTER the lease is held, so this await cannot
177
+ // widen the reserve-before-await race the lease closes above.
178
+ const headSha = (await deps.resolveHeadSha?.({ workspace: args.workspace, prNumber: args.prNumber })) ?? null
179
+ reservation.headSha = headSha
180
+
181
+ // Layer 2: GitHub is the authoritative, sole source of truth for a standing
182
+ // verdict. A standing same verdict is a real duplicate; DISMISSED and the
183
+ // opposite decisive verdict are genuine supersessions that must pass here
184
+ // (the 35287f99 invariant). A read error fails OPEN.
114
185
  const remote = await deps.resolveEffectiveApproval({ workspace: args.workspace, prNumber: args.prNumber })
115
186
  if (remote.ok && duplicatesStanding(args.verdict, remote.effective)) {
116
187
  // Standing verdict upstream already matches. Block, and release the lease
@@ -121,17 +192,62 @@ export function createApproveIdempotencyGuard(deps: {
121
192
  return { block: true, reason: duplicateReason(args.verdict) }
122
193
  }
123
194
 
195
+ // Layer 3: only a raw NONE from a successful read is ambiguous — it can mean
196
+ // "no review" or "our just-landed review not yet indexed". A recent same
197
+ // verdict on the same head resolves it to lag and blocks the duplicate. Any
198
+ // non-NONE state already decided above, so this never overrides a supersession.
199
+ if (remote.ok && remote.effective === 'NONE' && recentlyLandedSame(key, args.verdict, headSha, now)) {
200
+ releaseReservation(args.callId, reservation)
201
+ return { block: true, reason: duplicateReason(args.verdict) }
202
+ }
203
+
124
204
  return null
125
205
  },
126
206
 
127
- release(args): void {
207
+ async release(args): Promise<void> {
128
208
  const reservation = reservationByCall.get(args.callId)
129
209
  if (reservation === undefined) return
130
- releaseReservation(args.callId, reservation)
210
+ try {
211
+ // The pre-submit head can go stale: if the PR head advanced between the
212
+ // guard() capture and the review landing, GitHub attaches the review to the
213
+ // NEWER head while reservation.headSha holds the older one. Re-resolve the
214
+ // head AFTER a successful submit and store what we can prove: the resolved
215
+ // head only when pre==post, else the null uncertainty sentinel (matches any
216
+ // current head for the lag window) so a push-during-review cannot let a
217
+ // same-verdict duplicate slip past on the new head. The lease stays held
218
+ // across this await (finally below), so the window is not reopened.
219
+ if (args.succeeded && reservation.headSha !== null) {
220
+ const postHeadSha =
221
+ (await deps.resolveHeadSha?.({ workspace: reservation.workspace, prNumber: reservation.prNumber })) ?? null
222
+ const landedHeadSha = postHeadSha !== null && postHeadSha === reservation.headSha ? postHeadSha : null
223
+ recentLandedByPr.set(reservation.key, {
224
+ verdict: reservation.verdict,
225
+ headSha: landedHeadSha,
226
+ landedAt: now(),
227
+ })
228
+ }
229
+ } finally {
230
+ releaseReservation(args.callId, reservation)
231
+ }
131
232
  },
132
233
  }
133
234
  }
134
235
 
236
+ // True only when a recently-landed record proves the GitHub NONE is read lag: same
237
+ // verdict, within the window, AND the heads agree. Head agreement holds when the
238
+ // stored head equals the current head, OR the stored head is the null uncertainty
239
+ // sentinel (the landed commit could not be pinned, so it conservatively matches the
240
+ // current head for the window). A flipped verdict or an expired/absent record
241
+ // returns false so the genuine re-verdict passes; a different KNOWN head also
242
+ // returns false so a real new push is never blocked.
243
+ function recentlyLandedSame(key: string, verdict: ReviewVerdict, headSha: string | null, now: () => number): boolean {
244
+ const landed = recentLandedByPr.get(key)
245
+ if (landed === undefined) return false
246
+ if (now() - landed.landedAt >= RECENT_LANDED_TTL_MS) return false
247
+ if (verdict !== landed.verdict) return false
248
+ return landed.headSha === null || landed.headSha === headSha
249
+ }
250
+
135
251
  // Drop the lease only if THIS reservation still owns the key. A stale tool.after
136
252
  // for a reservation that was already superseded (e.g. reclaimed after TTL by a
137
253
  // newer session) must not yank the live session's lease.
@@ -151,5 +267,6 @@ function prKey(workspace: string, prNumber: number): string {
151
267
  export function __resetReviewVerdictGuardForTest(): void {
152
268
  inFlightByPr.clear()
153
269
  reservationByCall.clear()
270
+ recentLandedByPr.clear()
154
271
  tokenSeq = 0
155
272
  }
@@ -1,6 +1,6 @@
1
1
  import { GITHUB_API_BASE, githubJsonHeaders } from '@/channels/adapters/github/auth-pat'
2
2
 
3
- import type { EffectiveApprovalResolver, EffectiveVerdict } from './approve-idempotency'
3
+ import type { EffectiveApprovalResolver, EffectiveVerdict, HeadShaResolver } from './approve-idempotency'
4
4
 
5
5
  // Resolves THIS bot's standing decisive review on a PR, used by the review
6
6
  // verdict guard to stop a second formal verdict after a restart (the in-process
@@ -30,9 +30,40 @@ export function createGithubEffectiveApprovalResolver(deps: {
30
30
  }
31
31
  }
32
32
 
33
+ // Reads the PR's current head commit SHA from `GET /pulls/<n>` (`head.sha`), the
34
+ // strongly-consistent single-object endpoint — NOT the eventually-consistent
35
+ // reviews list the duplicate bug rode in on. Returns null on any failure so the
36
+ // landed-verdict cache degrades to verdict-only matching rather than stranding.
37
+ export function createGithubHeadShaResolver(deps: {
38
+ resolveToken: (workspace: string) => Promise<string | null>
39
+ fetchImpl?: typeof fetch
40
+ }): HeadShaResolver {
41
+ const fetchImpl = deps.fetchImpl ?? fetch
42
+ return async ({ workspace, prNumber }) => {
43
+ const [owner, repo] = workspace.split('/')
44
+ if (owner === undefined || owner === '' || repo === undefined || repo === '') return null
45
+ const token = await deps.resolveToken(workspace).catch(() => null)
46
+ if (token === null || token === '') return null
47
+ try {
48
+ const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/pulls/${prNumber}`
49
+ const response = await fetchImpl(url, { headers: githubJsonHeaders(token) })
50
+ if (!response.ok) return null
51
+ const raw = (await response.json().catch(() => null)) as { head?: { sha?: unknown } } | null
52
+ const sha = raw?.head?.sha
53
+ return typeof sha === 'string' && sha !== '' ? sha : null
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+ }
59
+
60
+ // DISMISSED is surfaced distinctly (not collapsed to NONE) so the verdict guard's
61
+ // lag shield can tell a genuine dismissal — which legitimately allows a same-verdict
62
+ // re-review — apart from a bare NONE that may just be an unindexed just-landed write.
33
63
  function toEffective(state: string | undefined): EffectiveVerdict {
34
64
  if (state === 'APPROVED') return 'APPROVED'
35
65
  if (state === 'CHANGES_REQUESTED') return 'CHANGES_REQUESTED'
66
+ if (state === 'DISMISSED') return 'DISMISSED'
36
67
  return 'NONE'
37
68
  }
38
69
 
@@ -2,7 +2,7 @@ import { TYPECLAW_INTERNAL_BASH_ENV } from '@/agent/plugin-tools'
2
2
  import { definePlugin } from '@/plugin'
3
3
 
4
4
  import { createApproveIdempotencyGuard } from './approve-idempotency'
5
- import { createGithubEffectiveApprovalResolver } from './effective-approval'
5
+ import { createGithubEffectiveApprovalResolver, createGithubHeadShaResolver } from './effective-approval'
6
6
  import { analyzeGhCommand } from './gh-command'
7
7
  import { checkGraphqlAuthNudge } from './graphql-auth-nudge'
8
8
  import { commitReviewIfSucceeded, noteReviewCommand } from './review-recorder'
@@ -11,13 +11,13 @@ import { classifyGhToken } from './token-class'
11
11
  export default definePlugin({
12
12
  plugin: async (ctx) => {
13
13
  const resolveTokenForRepo = ctx.github.resolveTokenForRepo
14
+ const resolveToken = async (workspace: string) => {
15
+ const result = await resolveTokenForRepo(workspace)
16
+ return result.kind === 'token' ? result.token : null
17
+ }
14
18
  const verdictGuard = createApproveIdempotencyGuard({
15
- resolveEffectiveApproval: createGithubEffectiveApprovalResolver({
16
- resolveToken: async (workspace) => {
17
- const result = await resolveTokenForRepo(workspace)
18
- return result.kind === 'token' ? result.token : null
19
- },
20
- }),
19
+ resolveEffectiveApproval: createGithubEffectiveApprovalResolver({ resolveToken }),
20
+ resolveHeadSha: createGithubHeadShaResolver({ resolveToken }),
21
21
  })
22
22
  return {
23
23
  hooks: {
@@ -70,7 +70,7 @@ export default definePlugin({
70
70
  callId: event.callId,
71
71
  result: event.result,
72
72
  })
73
- verdictGuard.release({ callId: event.callId, succeeded: committed })
73
+ await verdictGuard.release({ callId: event.callId, succeeded: committed })
74
74
  },
75
75
  },
76
76
  }
@@ -75,12 +75,14 @@ Write to \`public/\` instead of \`workspace/\` when your resolved role lacks \`f
75
75
  )
76
76
  }
77
77
 
78
- const [realParent, realWorkspace, realPublic] = await Promise.all([
79
- realpath(parent),
80
- realpath(workspaceDir),
81
- realpath(publicDir),
82
- ])
83
- if (realParent !== realWorkspace && realParent !== realPublic) {
78
+ // Resolve ONLY the canonical dir `parent` lexically matched above. `public/`
79
+ // is optional (created only for guest-readable output), so an unconditional
80
+ // `realpath('<agent>/public')` throws ENOENT on agents that never made it,
81
+ // which would reject every valid write to `workspace/`. The symlink-escape
82
+ // defense is unchanged — the parent actually written to is still canonicalized.
83
+ const canonicalDir = parent === workspaceDir ? workspaceDir : publicDir
84
+ const [realParent, realCanonical] = await Promise.all([realpath(parent), realpath(canonicalDir)])
85
+ if (realParent !== realCanonical) {
84
86
  throw new Error(`Report parent directory resolves outside the allowed report directories: ${parent}.`)
85
87
  }
86
88
 
@@ -53,12 +53,13 @@ export const REVIEWER_SKILLS: readonly LoadableSkill[] = [
53
53
  // src/agent/subagents.ts `timeoutMs`.
54
54
  export const REVIEWER_SPAWN_TIMEOUT_MS = 600_000
55
55
 
56
- // TODO(#452): Restrict the reviewer's `bash` to git and a curated set of
57
- // read-only `gh` subcommands once per-subagent bash allowlist support lands.
58
- // Today the read-only contract is enforced only by this system prompt, the
59
- // same way `explorer` enforces its own read-only bash usage. The reviewer
60
- // inherits TypeClaw's global bash guards (`secret-exfil-bash`, `git-exfil`)
61
- // but has no positive allowlist. See https://github.com/typeclaw/typeclaw/issues/452.
56
+ // The reviewer's read-only contract is enforced in depth: this system prompt
57
+ // states it, the global bash guards (`secret-exfil-bash`, `git-exfil`) catch
58
+ // exfil, AND `bashPolicy: { kind: 'readonly-reviewer' }` (set on the subagent
59
+ // below) hard-blocks any mutating `bash` command at the wrap site regardless of
60
+ // the spawning role git commit/push/add, gh pr merge/review/comment, writes
61
+ // outside /tmp, package installs, and shell constructs that defeat static
62
+ // analysis. See `src/agent/reviewer-bash-policy.ts` (issue #452).
62
63
  export const REVIEWER_SYSTEM_PROMPT = `You are a review specialist running inside TypeClaw. Your job: produce a careful, structured review of a target the caller hands you — a code change, a written plan, a design document, a docs update, a draft argument, or anything else that benefits from another pair of eyes — and return findings the caller can act on.
63
64
 
64
65
  You exist to do what \`explorer\` and \`scout\` cannot: deep, model-heavy analysis. Your model has been chosen for quality, not speed — spend tokens on thinking. Read carefully. Cross-check. Form a real opinion.
@@ -70,6 +71,8 @@ You are STRICTLY PROHIBITED from:
70
71
  - Pushing, merging, rebasing, or otherwise mutating remote state
71
72
  - Using bash for: mkdir, touch, rm, cp, mv, git add, git commit, git push, git rebase, git reset, npm install, pip install, or any write operation
72
73
 
74
+ The boundary that matters is **no side effects on the reviewed artifact, remote state, or the persistent workspace** — not "no byte may touch local disk". A loaded domain skill may carve out one narrow, explicit exception: writing into a fresh throwaway scratch directory under \`/tmp\` purely to *acquire* a read target (e.g. cloning a PR head you cannot otherwise read at line accuracy). That scratch cache is never the reviewed artifact; inside it you still only read, and everything in the prohibition list above still applies everywhere else. Absent such an instruction from your loaded skill, treat the list as absolute.
75
+
73
76
  Your role is EXCLUSIVELY to analyze and report. The parent agent decides what to do with your findings. Delegating part of that analysis is fine; performing side effects through a delegate is NOT — anything you cannot do directly, a subagent you spawn cannot do for you.
74
77
 
75
78
  ## Delegating to keep your context lean
@@ -89,7 +92,7 @@ The runtime exposes these tools to you by these EXACT names — call them by nam
89
92
  - \`grep\` — search file contents by text or regex
90
93
  - \`find\` — locate files by name pattern
91
94
  - \`ls\` — list a directory's immediate contents
92
- - \`bash\` — read-only commands ONLY. Read-only \`git\` (\`git log\`, \`git diff\`, \`git show\`, \`git blame\`, \`git status\`, \`git grep\`, \`git rev-parse\`, \`git ls-files\`, \`git cat-file\`) and one-shot pipelines that do not mutate state (\`cat\`, \`head\`, \`tail\`, \`wc\`, \`sort\`, \`uniq\`, \`jq\`). For platform-specific reads (a PR diff, a vendor API), use the canonical read-only invocation of the platform's CLI and consult your loaded skill for which subcommands are appropriate.
95
+ - \`bash\` — read-only commands ONLY. Read-only \`git\` (\`git log\`, \`git diff\`, \`git show\`, \`git blame\`, \`git status\`, \`git grep\`, \`git rev-parse\`, \`git ls-files\`, \`git cat-file\`) and one-shot pipelines that do not mutate state (\`cat\`, \`head\`, \`tail\`, \`wc\`, \`sort\`, \`uniq\`, \`jq\`). For platform-specific reads (a PR diff, a vendor API), use the canonical read-only invocation of the platform's CLI and consult your loaded skill for which subcommands are appropriate. The ONE write a loaded skill may direct you to make is cloning a target into a fresh \`/tmp\` scratch directory purely to read it (\`git clone\`/\`fetch\`/detached \`checkout\` into \`/tmp/review-*\`); that scratch cache is never the reviewed artifact, and everything else above stays read-only.
93
96
  - \`web_search\` — search the public web (e.g. for OWASP guidance, RFCs, library changelogs, framework docs, prior art)
94
97
  - \`web_fetch\` — fetch a single URL (e.g. to read a linked spec, vendor doc, or article cited in the target)
95
98
  - \`load_skill\` — load a curated review skill by name. See the section below.
@@ -192,6 +195,10 @@ If none of the listed skills fit the target, load \`general\`. Keep the skill-se
192
195
  // user has not configured `models.deep` in typeclaw.json, `resolveProfile`
193
196
  // falls back to `default` with a one-time warning — safe degradation.
194
197
  profile: 'deep',
198
+ // Hard-fence the reviewer's bash to read-only commands at the wrap site,
199
+ // independent of the spawning role. The prompt + global guards are the other
200
+ // two layers; this is the one that survives a trusted/owner caller.
201
+ bashPolicy: { kind: 'readonly-reviewer' },
195
202
  tools: [readTool, grepTool, findTool, lsTool, bashTool, webSearchTool, webFetchTool],
196
203
  customTools: [loadSkillTool],
197
204
  payloadSchema: reviewerPayloadSchema,
@@ -13,12 +13,39 @@ You have been asked to review code. Apply this guidance on top of the reviewer's
13
13
 
14
14
  - **PR URL or number** — fetch the diff and the description:
15
15
  - \`gh pr diff <n>\` for the unified diff
16
- - \`gh pr view <n>\` for title, body, labels, linked issues, checks
16
+ - \`gh pr view <n> --json title,body,baseRefName,headRefOid,files\` for title, body, linked issues, the head SHA, and the changed-file list
17
17
  - \`gh api /repos/<owner>/<repo>/pulls/<n>\` for the structured payload when you need machine-readable fields
18
18
  - **Commit SHA** — \`git show <sha>\` and \`git show <sha> --stat\` for the scope.
19
19
  - **File path / module path** — \`read\` the file directly; \`ls\` the parent directory to understand its neighbors; \`grep\` for callers of any function the file exports.
20
20
  - **Branch name** — \`git log <branch> ^main --oneline\` to enumerate commits, then \`git diff main...<branch>\` for the cumulative change.
21
21
 
22
+ ### Your cwd is NOT the PR's repo — read at the head SHA
23
+
24
+ You run in the agent folder (\`/agent\`), **not** a checkout of the PR's target repository. A bare \`read /agent/src/...\` for a file that lives in the PR's repo will fail with \`ENOENT\` — the file is not on this disk. **When \`read\` returns \`ENOENT\` for a path you expected to exist, stop retrying local reads immediately**: that is the signal you are outside the target checkout, not a transient miss. Switch to one of the two acquisition modes below. Do not burn turns re-issuing \`read\` against \`/agent\` paths that will never resolve.
25
+
26
+ Whichever mode you use, **every line number you cite must come from the PR's head SHA** (\`headRefOid\` from \`gh pr view\`), not the default branch — inline comments anchor to that exact revision.
27
+
28
+ **Mode 1 — remote-read (default, for a handful of files).** When you need only a few adjacent files, fetch each **once** at the head SHA. Prefer \`gh api\` over \`raw.githubusercontent.com\`: \`gh api\` carries the adapter's GitHub auth, so it works on private repos too.
29
+
30
+ A repo-targeting \`gh\` command MUST be a **single bare \`gh\` invocation** — no pipes, \`&&\`, \`;\`, or redirects. The runtime injects the GitHub App token into the command's environment, so any sibling stage in a pipeline would inherit a live token; the guard blocks those shapes (the same rule the GitHub channel skill enforces for review posting). So do NOT pipe \`gh api ... | base64 -d | nl -ba\` — that exact shape is rejected before it runs. Instead fetch the **already-decoded** file with the raw media type in one bare call:
31
+
32
+ \`\`\`sh
33
+ gh api "/repos/<owner>/<repo>/contents/<path>?ref=<headSha>" -H "Accept: application/vnd.github.raw"
34
+ \`\`\`
35
+
36
+ That returns the file's raw bytes (no base64, no second stage). For the line numbers your \`location="path:line"\` anchors need, read them off the unified diff you already fetched (\`gh pr diff\` prints the new-side line numbers in its hunk headers, \`@@ -a,b +c,d @@\`), or escalate to Mode 2 where a real \`read\`/\`grep\` gives native line numbers. Fetch each file once and keep its output — do not re-fetch the same file to re-derive a line you already saw.
37
+
38
+ **Mode 2 — scratch checkout (escalate when navigation gets broad).** When the review needs repo-wide \`grep\`, symbol tracing across several directories, many adjacent files, or repeated access to the same files, the remote-read dance is slower and more error-prone than a real checkout. In that case clone the PR head into a **fresh throwaway directory under \`/tmp\`** and read it natively:
39
+
40
+ \`\`\`sh
41
+ git clone --depth 1 "https://github.com/<owner>/<repo>.git" /tmp/review-<n>-src && \
42
+ git -C /tmp/review-<n>-src fetch --depth 1 origin <headSha> && git -C /tmp/review-<n>-src checkout <headSha>
43
+ \`\`\`
44
+
45
+ Then \`read\`, \`grep\`, \`find\`, and read-only \`git\` (\`git -C /tmp/review-<n>-src log|diff|show|blame|grep|ls-files|cat-file\`) all work against \`/tmp/review-<n>-src\` with correct line numbers and zero per-file round-trips.
46
+
47
+ This \`/tmp\` scratch checkout is the **one** write the read-only contract permits — and only because it is a private acquisition cache, never the reviewed artifact. Inside it you may only **read**. You still may NOT: edit any file, install dependencies, run builds or tests, commit/stage/push/rebase/reset, or write anywhere outside this \`/tmp\` scratch dir. Do not \`rm\` it when done — leave cleanup to the session lifecycle (\`rm\` stays forbidden). When in doubt about how many files you'll touch, start with Mode 1 and escalate to Mode 2 only once the file count or grep breadth justifies the clone.
48
+
22
49
  ## How to build context
23
50
 
24
51
  A finding without context is noise. Before forming findings:
@@ -72,6 +99,8 @@ This includes payloads where the parent says the author **addressed your prior b
72
99
 
73
100
  - Return **approve** if the blockers that drove the prior \`request-changes\` are resolved (leftover nits do not block — \`approve\` with inline nits is correct).
74
101
  - Return **request-changes** if any blocker remains or a new one appeared.
102
+
103
+ **Account for resolved threads in the \`<summary>\`, not as \`praise\` findings.** A re-review tempts you to emit one \`praise\` finding per prior concern the author fixed — "Thread 123 is addressed", "Thread 456 is addressed". Do **not**. \`praise\` is reserved for *non-obvious good work*, and a routine "you fixed what I asked" is neither non-obvious nor a finding the parent should post inline (it strips \`praise\` from inline comments anyway, so these become dead weight). Instead, state the resolution accounting in one sentence in your \`<summary>\` — e.g. "Both prior blockers (the unfenced table scan and the backtick-wrap span) are resolved at head \`<sha>\`; one new concern below." Reserve actual \`<finding>\` entries for what still needs action: a prior blocker that is **only partially** fixed (\`blocker\`/\`concern\`, anchored to the line that's still wrong), a **regression the fix introduced** (\`blocker\`/\`concern\`), or a genuinely non-obvious fix worth a rare \`praise\`. A clean re-review where everything was addressed is an \`approve\` whose \`<summary>\` says so and whose \`<findings>\` is empty — not a wall of \`praise\` receipts.
75
104
  - **Do NOT return \`comment\` on a re-review.** \`comment\` is for ambiguous partial reviews with no accept/reject signal; a re-review is the opposite — it is precisely an accept/reject decision. A \`comment\` verdict here leaves the PR's \`REQUEST_CHANGES\` state stuck (a plain comment does not clear it on GitHub), which is the exact failure a re-review exists to resolve. The only escape hatch is the same one that always applies: if you genuinely cannot reach the diff or the prior context, return one \`blocker\` finding stating what you need and a \`comment\` verdict — but a reachable, reviewable re-review must end in \`approve\` or \`request-changes\`.
76
105
 
77
106
  ## Line-anchor every finding
@@ -183,6 +183,18 @@ export const MAX_POLICY_DENIED_CHANNEL_SENDS_PER_TURN = 3
183
183
  // including reasoning). Deliberately NOT lowered in `providers.ts`, where
184
184
  // `maxTokens` is the model's true capability that compaction math reads.
185
185
  export const CHANNEL_MAX_OUTPUT_TOKENS = 4096
186
+ // Raised output-token budget threaded into the ONE re-prompt that follows a
187
+ // `stopReason:'length'` empty turn. The default 4096 backstop bounds kimi's
188
+ // degenerate repetition loop, but it is the same ceiling a *legitimate*
189
+ // reasoning-heavy turn hits when it spends the whole pool thinking and emits no
190
+ // prose — re-prompting under the identical cap reproduces the truncation. A
191
+ // `length` truncation that the byte-identical loop guard did NOT catch is
192
+ // evidence of genuine reasoning starved for room, not a repetition loop, so the
193
+ // retry grants 4x headroom for thinking + a reply. Bounded (not 32000) so a
194
+ // turn that IS looping still can't burn the full pi-ai default. Consumed
195
+ // one-shot via `LiveSession.nextPromptMaxTokens`, then reset at the next real
196
+ // user turn so the raised budget never leaks past the turn that needed it.
197
+ export const CHANNEL_EMPTY_TURN_RETRY_MAX_OUTPUT_TOKENS = 16384
186
198
  // Ceiling on automatic re-prompts for a turn that ended with NO user-facing
187
199
  // reply AND no attempted send — the pure "the model burned its budget thinking
188
200
  // and produced nothing" failure. The canonical trigger is Fireworks'
@@ -200,18 +212,24 @@ export const CHANNEL_MAX_OUTPUT_TOKENS = 4096
200
212
  export const MAX_EMPTY_TURN_RETRIES = 2
201
213
  // Reminder-only nudge injected before an empty-turn retry. Uses the repo's
202
214
  // SYSTEM MESSAGE framing (see composeTurnPrompt) so persona-rich models do not
203
- // reply to the notice itself. Neutral by design: it asks for a direct reply
204
- // without prescribing length or tone, matching the chosen "just retry" posture.
215
+ // reply to the notice itself. Names the actual failure (the prior turn ran out
216
+ // of its output budget mid-reasoning and produced no reply) and asks the model
217
+ // to keep its thinking short and answer directly — the empty turn was budget
218
+ // exhaustion, not a forgotten tool call, so a "reply directly" nudge alone
219
+ // would re-loop. The matching retry re-prompt also runs with a raised budget
220
+ // (CHANNEL_EMPTY_TURN_RETRY_MAX_OUTPUT_TOKENS) so the room actually exists.
205
221
  export const EMPTY_TURN_RETRY_NUDGE = [
206
222
  '---',
207
223
  '**[SYSTEM MESSAGE — not from a human]**',
208
224
  '',
209
- 'Your previous turn ended without sending any reply to the channel. This is',
225
+ 'Your previous turn ran out of its output budget before sending a reply — it',
226
+ 'spent the whole turn thinking and produced nothing for the channel. This is',
210
227
  'an automated signal from the channel router, not a message from anyone in',
211
228
  'the chat. **Do not acknowledge or reply to this notice itself.**',
212
229
  '',
213
- 'Respond to the last user message now with a direct answer via your channel',
214
- 'reply tool. If you genuinely have nothing to say, reply with `NO_REPLY`.',
230
+ 'Answer the last user message now: keep any reasoning brief and send a direct',
231
+ 'reply via your channel reply tool. If you genuinely have nothing to say,',
232
+ 'reply with `NO_REPLY`.',
215
233
  '',
216
234
  '---',
217
235
  ].join('\n')
@@ -532,6 +550,13 @@ type LiveSession = {
532
550
  // increments it before injecting EMPTY_TURN_RETRY_NUDGE and reads it to decide
533
551
  // retry-vs-fallback. See the candidate===null branch.
534
552
  emptyTurnRetries: number
553
+ // One-shot output-token budget for the NEXT `session.prompt()` only.
554
+ // `installChannelOutputCap` reads and clears it per stream call, so it
555
+ // overrides the default backstop for exactly one re-prompt. Set by the
556
+ // empty-turn length-retry branch to CHANNEL_EMPTY_TURN_RETRY_MAX_OUTPUT_TOKENS
557
+ // and reset to undefined at each fresh user turn so the raised budget cannot
558
+ // leak past the turn that needed it.
559
+ nextPromptMaxTokens: number | undefined
535
560
  // Stamped by `markTurnSkipped` (called from the `skip_response` tool)
536
561
  // with the current `turnSeq`. Read at the top of `validateChannelTurn`:
537
562
  // if it matches the just-completed turn, recovery is skipped entirely
@@ -1417,6 +1442,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1417
1442
  inFlightToolSends: new Map(),
1418
1443
  policyDeniedToolSendsThisTurn: new Map(),
1419
1444
  emptyTurnRetries: 0,
1445
+ nextPromptMaxTokens: undefined,
1420
1446
  skippedTurn: null,
1421
1447
  skipLockedSendTurn: null,
1422
1448
  pendingQuoteCandidate: null,
@@ -1704,14 +1730,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1704
1730
  // Override pi-ai's hidden `Math.min(model.maxTokens, 32000)` output cap for
1705
1731
  // channel sessions by threading an explicit `maxTokens` into every stream
1706
1732
  // call. See CHANNEL_MAX_OUTPUT_TOKENS for why. Composes the existing streamFn
1707
- // (pi's default `streamSimple` unless a proxy was installed) and only fills
1708
- // `maxTokens` when the caller left it unset, so an explicit per-call value
1709
- // still wins.
1733
+ // (pi's default `streamSimple` unless a proxy was installed). Precedence:
1734
+ // an explicit per-call `maxTokens` always wins; otherwise a one-shot
1735
+ // `live.nextPromptMaxTokens` (set by the empty-turn length-retry) is consumed
1736
+ // and cleared so the raised budget applies to exactly one stream call;
1737
+ // otherwise the default backstop.
1710
1738
  const installChannelOutputCap = (live: LiveSession): void => {
1711
1739
  const { agent } = live.session
1712
1740
  const inner = agent.streamFn
1713
- agent.streamFn = (model, context, options) =>
1714
- inner(model, context, { ...options, maxTokens: options?.maxTokens ?? CHANNEL_MAX_OUTPUT_TOKENS })
1741
+ agent.streamFn = (model, context, options) => {
1742
+ let maxTokens = options?.maxTokens
1743
+ if (maxTokens === undefined && live.nextPromptMaxTokens !== undefined) {
1744
+ maxTokens = live.nextPromptMaxTokens
1745
+ live.nextPromptMaxTokens = undefined
1746
+ }
1747
+ return inner(model, context, { ...options, maxTokens: maxTokens ?? CHANNEL_MAX_OUTPUT_TOKENS })
1748
+ }
1715
1749
  }
1716
1750
 
1717
1751
  const startTypingHeartbeat = (live: LiveSession): void => {
@@ -1904,10 +1938,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1904
1938
  live.lastSentText.clear()
1905
1939
  live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
1906
1940
  // A real user batch starts a fresh logical turn → restore the full
1907
- // empty-turn retry budget. Reset here (batch.length > 0) and NOT in
1908
- // the per-prompt block below, so the reminder-only iterations the
1909
- // retry itself queues do not refill the budget and loop forever.
1941
+ // empty-turn retry budget and drop any raised output-token budget left
1942
+ // over from a prior turn's length-retry. Reset here (batch.length > 0)
1943
+ // and NOT in the per-prompt block below, so the reminder-only
1944
+ // iterations the retry itself queues do not refill the budget and loop
1945
+ // forever (and the raised cap stays scoped to the turn that set it).
1910
1946
  live.emptyTurnRetries = 0
1947
+ live.nextPromptMaxTokens = undefined
1911
1948
  } else if (live.lastTurnAuthorId !== null) {
1912
1949
  live.currentTurnEngageReactions = []
1913
1950
  // Reminder-only turn (batch.length === 0, reminders.length > 0):
@@ -3037,8 +3074,18 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3037
3074
  }
3038
3075
  if (!attemptedSendThisTurn && live.emptyTurnRetries < MAX_EMPTY_TURN_RETRIES) {
3039
3076
  live.emptyTurnRetries++
3077
+ // Raise the re-prompt's budget ONLY for a `length` truncation: that is
3078
+ // the budget-exhaustion case (reasoning ate the whole pool before any
3079
+ // prose), so the retry needs room to finish thinking AND reply. `error`
3080
+ // and `aborted` are not budget exhaustion — an upstream failure or the
3081
+ // terminal-reply abort — so they retry under the default backstop.
3082
+ // Consumed one-shot by installChannelOutputCap on the next prompt().
3083
+ if (assistantLeafStopReason(live.session) === 'length') {
3084
+ live.nextPromptMaxTokens = CHANNEL_EMPTY_TURN_RETRY_MAX_OUTPUT_TOKENS
3085
+ }
3040
3086
  logger.warn(
3041
- `[channels] ${live.keyId} empty_turn_retry attempt=${live.emptyTurnRetries}/${MAX_EMPTY_TURN_RETRIES}`,
3087
+ `[channels] ${live.keyId} empty_turn_retry attempt=${live.emptyTurnRetries}/${MAX_EMPTY_TURN_RETRIES} ` +
3088
+ `max_tokens=${live.nextPromptMaxTokens ?? CHANNEL_MAX_OUTPUT_TOKENS}`,
3042
3089
  )
3043
3090
  live.pendingSystemReminders.push(EMPTY_TURN_RETRY_NUDGE)
3044
3091
  return
@@ -4355,18 +4402,25 @@ function recoverableAssistantText(
4355
4402
  return null
4356
4403
  }
4357
4404
 
4358
- // True only when the leaf is an assistant message that was CUT OFF mid-output:
4359
- // `length` (hit the token cap the canonical kimi reasoning-loop), `error`, or
4360
- // `aborted`. This is the precise signature of "the model was producing but got
4361
- // truncated", as distinct from a turn that produced no assistant message at all
4362
- // (leaf undefined / a non-assistant entry), which is a benign empty/cold turn —
4363
- // NOT something to re-prompt. The empty-turn retry guard keys off this so it
4364
- // fires for real degenerations and stays silent for cold sessions.
4365
- function assistantLeafTruncated(session: AgentSession): boolean {
4405
+ // The truncation stop reason when the leaf is an assistant message that was CUT
4406
+ // OFF mid-output — `length` (hit the token cap, the canonical kimi reasoning-
4407
+ // loop), `error`, or `aborted` — else undefined. This is the precise signature
4408
+ // of "the model was producing but got truncated", as distinct from a turn that
4409
+ // produced no assistant message at all (leaf undefined / a non-assistant
4410
+ // entry), which is a benign empty/cold turn. Callers that only need the boolean
4411
+ // use `assistantLeafTruncated`; the retry guard reads the reason itself because
4412
+ // the raised reasoning budget is justified ONLY for `length` (budget
4413
+ // exhaustion), not for `error`/`aborted`.
4414
+ function assistantLeafStopReason(session: AgentSession): 'length' | 'error' | 'aborted' | undefined {
4366
4415
  const leaf = session.sessionManager.getLeafEntry()
4367
- if (!leaf || leaf.type !== 'message' || leaf.message.role !== 'assistant') return false
4416
+ if (!leaf || leaf.type !== 'message' || leaf.message.role !== 'assistant') return undefined
4368
4417
  const stop = leaf.message.stopReason
4369
- return stop === 'length' || stop === 'error' || stop === 'aborted'
4418
+ if (stop === 'length' || stop === 'error' || stop === 'aborted') return stop
4419
+ return undefined
4420
+ }
4421
+
4422
+ function assistantLeafTruncated(session: AgentSession): boolean {
4423
+ return assistantLeafStopReason(session) !== undefined
4370
4424
  }
4371
4425
 
4372
4426
  function visibleAssistantText(message: AssistantMessage): string {
package/src/run/index.ts CHANGED
@@ -375,6 +375,7 @@ export async function startAgent({
375
375
  ...(entry.pluginSubagent.toolResultBudget !== undefined
376
376
  ? { toolResultBudget: entry.pluginSubagent.toolResultBudget }
377
377
  : {}),
378
+ ...(entry.pluginSubagent.bashPolicy !== undefined ? { bashPolicy: entry.pluginSubagent.bashPolicy } : {}),
378
379
  ...runtimeVersionOpt,
379
380
  })
380
381
  liveSessionRegistry.register({ sessionId, session: created.session })