opencode-bug-review-gate 0.1.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/LICENSE +21 -0
- package/README.md +105 -0
- package/package.json +35 -0
- package/src/index.ts +372 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Coral Bricks
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# opencode-bug-review-gate
|
|
2
|
+
|
|
3
|
+
An [opencode](https://opencode.ai) plugin that automatically runs a bug review
|
|
4
|
+
over every turn that edited files, drives fix rounds for blocking findings, and
|
|
5
|
+
resumes turns that were interrupted midstream.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
Two failure modes make coding sessions silently stall or ship unreviewed
|
|
10
|
+
changes:
|
|
11
|
+
|
|
12
|
+
1. **Unreviewed edits** — agents mark turns "done" without anyone checking the
|
|
13
|
+
diff. This gate runs a dedicated review subagent after every turn that
|
|
14
|
+
edited files, parses its verdict, and re-prompts the session to fix every
|
|
15
|
+
BUG-class finding (up to `maxFixRounds` rounds).
|
|
16
|
+
2. **Interrupted turns** — a model stream can terminate prematurely (e.g. a
|
|
17
|
+
spurious EOS from the provider or the model itself) and report a clean
|
|
18
|
+
`stop` with zero output. opencode treats that as a normal end-of-turn, so
|
|
19
|
+
the session just goes idle. This gate detects the signature — a finished
|
|
20
|
+
turn with no error, no text, and no tool calls — and automatically sends a
|
|
21
|
+
continue prompt (up to `maxAutoContinues` times), resuming the task with
|
|
22
|
+
the same agent that was interrupted. It also retries a review that dies
|
|
23
|
+
the same way, and resumes a fix turn that dies the same way.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
Add to `opencode.json` (or `~/.config/opencode/opencode.json`):
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"plugin": ["opencode-bug-review-gate"]
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
With options:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"plugin": [
|
|
40
|
+
["opencode-bug-review-gate", { "maxAutoContinues": 3 }]
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Restart opencode after changing config — plugins are loaded once at startup.
|
|
46
|
+
|
|
47
|
+
## Options
|
|
48
|
+
|
|
49
|
+
| Option | Default | Description |
|
|
50
|
+
| --------------------- | -------------- | ------------------------------------------------------------------------ |
|
|
51
|
+
| `reviewAgent` | `"bug-review"` | Agent that performs the static review. |
|
|
52
|
+
| `maxFixRounds` | `2` | Fix-and-re-review rounds after an `ISSUES` verdict. |
|
|
53
|
+
| `maxNoVerdictRetries` | `1` | Retries when a review ends without a verdict line. |
|
|
54
|
+
| `maxAutoContinues` | `2` | Resume attempts for turns that stop with no model output. |
|
|
55
|
+
| `askResponse` | `"once"` | How gate-driven turns answer permission asks (`"once"` or `"reject"`). |
|
|
56
|
+
| `autoContinue` | `true` | Resume turns that stop with no model output. |
|
|
57
|
+
| `defineAgent` | `true` | Register a default `bug-review` subagent when the config has none. |
|
|
58
|
+
|
|
59
|
+
## The default review agent
|
|
60
|
+
|
|
61
|
+
If your config does not define an agent named `bug-review` (or your
|
|
62
|
+
`reviewAgent` value), the plugin registers one via its `config` hook: a
|
|
63
|
+
subagent that cannot edit files (`edit: deny`, the hard guarantee — read,
|
|
64
|
+
glob, grep, and list default to allow), whose bash permission allows only
|
|
65
|
+
git diff/log/status/show and asks for everything else, and whose prompt
|
|
66
|
+
directs it to find real defects, classify findings (BUG / RISK / VERIFY),
|
|
67
|
+
cite `file:line`, and end with a verdict line:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
PASS — no blocking issues found
|
|
71
|
+
ISSUES — n blocking, m advisory
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Define your own agent with that name to override it entirely.
|
|
75
|
+
|
|
76
|
+
## Security notes
|
|
77
|
+
|
|
78
|
+
- The review agent cannot edit or write files, and the review prompt forbids
|
|
79
|
+
executing the code under review.
|
|
80
|
+
- While the gate is driving a session (review / fix / resume turns), permission
|
|
81
|
+
asks are answered automatically (`askResponse`, default `"once"` = allow one
|
|
82
|
+
execution) so headless runs cannot hang on a dialog. Normal user turns are
|
|
83
|
+
never auto-answered. Set `askResponse` to `"reject"` to make gate turns fail
|
|
84
|
+
fast instead, or restrict the review agent's permissions further.
|
|
85
|
+
- Auto-resume only fires for turns that edited files (the gate only runs for
|
|
86
|
+
those), only when the turn's stream ended with no error and no visible
|
|
87
|
+
output, and is capped by `maxAutoContinues`.
|
|
88
|
+
|
|
89
|
+
## Verdicts
|
|
90
|
+
|
|
91
|
+
- `PASS` — toast, session goes idle.
|
|
92
|
+
- `ISSUES` — the gate prompts the session to fix every BUG-class finding and
|
|
93
|
+
re-reviews, up to `maxFixRounds` times; remaining issues produce a warning
|
|
94
|
+
toast.
|
|
95
|
+
- No verdict after retries — warning toast, no auto-fixing.
|
|
96
|
+
- Degenerate stops (main turn, review, or fix turn) — auto-continue, capped.
|
|
97
|
+
|
|
98
|
+
## Compatibility
|
|
99
|
+
|
|
100
|
+
Targets opencode ≥ 1.18 (plugin API with the `config` hook and
|
|
101
|
+
`client.session.messages`). No runtime dependencies.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-bug-review-gate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "opencode plugin: automated post-change bug review gate, with auto-resume for turns interrupted midstream",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "src/index.ts",
|
|
8
|
+
"author": "Coral Bricks",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Coral-Bricks-AI/opencode-bug-review-gate.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Coral-Bricks-AI/opencode-bug-review-gate/issues"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/Coral-Bricks-AI/opencode-bug-review-gate#readme",
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"keywords": [
|
|
23
|
+
"opencode",
|
|
24
|
+
"opencode-plugin",
|
|
25
|
+
"code-review",
|
|
26
|
+
"agent"
|
|
27
|
+
],
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@opencode-ai/plugin": ">=1.18.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@opencode-ai/plugin": "^1.18.4",
|
|
33
|
+
"typescript": "^5.9.2"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
+
|
|
3
|
+
// How the gate answers permission asks raised by its own review/fix/resume
|
|
4
|
+
// turns. "once" keeps the turn alive (the agent may run the command one time);
|
|
5
|
+
// "reject" would abort the turn entirely, losing the review.
|
|
6
|
+
type AskResponse = "once" | "reject"
|
|
7
|
+
|
|
8
|
+
export type Options = {
|
|
9
|
+
/** Agent that performs the static review. Defaults to "bug-review". */
|
|
10
|
+
reviewAgent?: string
|
|
11
|
+
/** Fix rounds after an ISSUES verdict. Defaults to 2. */
|
|
12
|
+
maxFixRounds?: number
|
|
13
|
+
/** Retries for a review that ends without a verdict line. Defaults to 1. */
|
|
14
|
+
maxNoVerdictRetries?: number
|
|
15
|
+
/** Auto-continues for turns that stop with no model output. Defaults to 2. */
|
|
16
|
+
maxAutoContinues?: number
|
|
17
|
+
/** How gate-driven turns answer permission asks. Defaults to "once". */
|
|
18
|
+
askResponse?: AskResponse
|
|
19
|
+
/** Resume turns that stop with no model output. Defaults to true. */
|
|
20
|
+
autoContinue?: boolean
|
|
21
|
+
/** Define the default review agent when the config does not provide one. Defaults to true. */
|
|
22
|
+
defineAgent?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_REVIEW_AGENT_PROMPT = `You are a meticulous bug reviewer. Your job is to find real defects in
|
|
26
|
+
code that was just written or modified, and to challenge the reasoning
|
|
27
|
+
that produced it. You do NOT fix things — you report findings.
|
|
28
|
+
|
|
29
|
+
Review the changes (ask for or locate the diff if not provided) and
|
|
30
|
+
scrutinize:
|
|
31
|
+
|
|
32
|
+
## Code correctness
|
|
33
|
+
- Edge cases: empty inputs, zero/negative values, nil/null, off-by-one,
|
|
34
|
+
boundary conditions, unicode/encoding issues.
|
|
35
|
+
- Error handling: unhandled exceptions, swallowed errors, missing
|
|
36
|
+
rollbacks/cleanup, resource leaks (files, connections, locks).
|
|
37
|
+
- Concurrency: race conditions, deadlocks, non-thread-safe shared state.
|
|
38
|
+
- Type/logic errors: wrong operators, inverted conditions, unreachable
|
|
39
|
+
branches, incorrect defaults, copy-paste mistakes (wrong variable
|
|
40
|
+
names, wrong constants).
|
|
41
|
+
|
|
42
|
+
## Reasoning quality
|
|
43
|
+
- Assumptions presented as facts: verify against the actual codebase,
|
|
44
|
+
not the claim.
|
|
45
|
+
- Claims that code "works" or "handles X" without evidence — check the
|
|
46
|
+
code paths.
|
|
47
|
+
- Mismatch between stated intent and what the code actually does.
|
|
48
|
+
- Silent scope creep: changes to files/behavior beyond what was asked.
|
|
49
|
+
|
|
50
|
+
## Security & robustness
|
|
51
|
+
- Injection, path traversal, exposed secrets, unsafe deserialization,
|
|
52
|
+
missing auth checks on new endpoints.
|
|
53
|
+
|
|
54
|
+
## Process
|
|
55
|
+
- Run whatever read-only verification you can (grep for callers of
|
|
56
|
+
changed functions, check test coverage of modified paths, inspect
|
|
57
|
+
dependent code).
|
|
58
|
+
- For each finding, cite file:line and explain the concrete failure
|
|
59
|
+
scenario (input → wrong behavior), not just a style complaint.
|
|
60
|
+
- Classify each finding: BUG (will misbehave), RISK (could misbehave
|
|
61
|
+
under conditions), VERIFY (claim you could not confirm).
|
|
62
|
+
- End with a verdict: "PASS — no blocking issues found" or "ISSUES —
|
|
63
|
+
n blocking, m advisory" plus a one-line summary.
|
|
64
|
+
|
|
65
|
+
Be skeptical. A quiet, empty report is only acceptable if you actively
|
|
66
|
+
looked and can say what you checked.`
|
|
67
|
+
|
|
68
|
+
const DEFAULT_REVIEW_AGENT_DESCRIPTION =
|
|
69
|
+
"Reviews generated code and the reasoning behind it for bugs, edge cases, and incorrect assumptions. Use after any non-trivial code change, or whenever the main agent should verify its own work before finishing."
|
|
70
|
+
|
|
71
|
+
const EDIT_TOOLS = new Set(["edit", "write", "patch", "apply"])
|
|
72
|
+
|
|
73
|
+
type GateState = { edited: boolean; busy: boolean }
|
|
74
|
+
|
|
75
|
+
type ReviewOutcome = "pass" | "no-verdict" | "issues-remain" | "no-fix-edits" | "fix-interrupted"
|
|
76
|
+
|
|
77
|
+
function textOf(parts: unknown): string {
|
|
78
|
+
if (!Array.isArray(parts)) return ""
|
|
79
|
+
const out: string[] = []
|
|
80
|
+
for (const part of parts) {
|
|
81
|
+
if (part && typeof part === "object") {
|
|
82
|
+
const p = part as { type?: string; text?: unknown }
|
|
83
|
+
if (p.type === "text" && typeof p.text === "string") out.push(p.text)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out.join("\n")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function lastVerdict(text: string): "PASS" | "ISSUES" | undefined {
|
|
90
|
+
let verdict: "PASS" | "ISSUES" | undefined
|
|
91
|
+
const re = /(?:^|\n)[ \t]*(PASS|ISSUES)\b/g
|
|
92
|
+
let m: RegExpExecArray | null
|
|
93
|
+
while ((m = re.exec(text))) verdict = m[1] as "PASS" | "ISSUES"
|
|
94
|
+
return verdict
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const BugReviewGate: Plugin = async ({ client, serverUrl }, rawOptions = {}) => {
|
|
98
|
+
const options = rawOptions as Options
|
|
99
|
+
const reviewAgent = options.reviewAgent ?? "bug-review"
|
|
100
|
+
const maxFixRounds = options.maxFixRounds ?? 2
|
|
101
|
+
const maxNoVerdictRetries = options.maxNoVerdictRetries ?? 1
|
|
102
|
+
const maxAutoContinues = options.maxAutoContinues ?? 2
|
|
103
|
+
const askResponse: AskResponse = options.askResponse ?? "once"
|
|
104
|
+
const autoContinue = options.autoContinue !== false
|
|
105
|
+
const defineAgent = options.defineAgent !== false
|
|
106
|
+
const marker = `[[gate:${reviewAgent}]]`
|
|
107
|
+
|
|
108
|
+
const state = new Map<string, GateState>()
|
|
109
|
+
const answered = new Set<string>()
|
|
110
|
+
|
|
111
|
+
const get = (sessionID: string): GateState => {
|
|
112
|
+
let s = state.get(sessionID)
|
|
113
|
+
if (!s) {
|
|
114
|
+
s = { edited: false, busy: false }
|
|
115
|
+
state.set(sessionID, s)
|
|
116
|
+
}
|
|
117
|
+
return s
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const log = async (
|
|
121
|
+
level: "debug" | "info" | "warn" | "error",
|
|
122
|
+
message: string,
|
|
123
|
+
extra?: Record<string, unknown>,
|
|
124
|
+
) => {
|
|
125
|
+
try {
|
|
126
|
+
await client.app.log({ body: { service: "bug-review-gate", level, message, extra } })
|
|
127
|
+
} catch {
|
|
128
|
+
// logging must never break the gate
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const toast = async (message: string, variant: "success" | "warning" | "error") => {
|
|
133
|
+
try {
|
|
134
|
+
await client.tui.showToast({ body: { message, variant } })
|
|
135
|
+
} catch {
|
|
136
|
+
// no TUI attached (headless / serve mode)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const ask = async (sessionID: string, body: Record<string, unknown>): Promise<string> => {
|
|
141
|
+
const res = await client.session.prompt({ path: { id: sessionID }, body: body as never })
|
|
142
|
+
const data = ((res as { data?: unknown }).data ?? res) as { parts?: unknown }
|
|
143
|
+
return textOf(data.parts)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Inspect the turn that just went idle. "interrupted" is true when its last
|
|
147
|
+
// assistant message has no error yet produced no visible output — the stream
|
|
148
|
+
// stopped cleanly without completing (a degenerate stop, e.g. a premature
|
|
149
|
+
// EOS). Errored or user-aborted turns are never "interrupted", so they are
|
|
150
|
+
// left stopped. "agent" is the agent that ran the turn, so a resume reuses
|
|
151
|
+
// it instead of falling back to the default agent.
|
|
152
|
+
const lastTurnState = async (sessionID: string): Promise<{ interrupted: boolean; agent?: string }> => {
|
|
153
|
+
try {
|
|
154
|
+
const res = await client.session.messages({ path: { id: sessionID } })
|
|
155
|
+
const list = ((res as { data?: unknown }).data ?? res) as Array<{
|
|
156
|
+
info?: { role?: string; agent?: string; error?: unknown }
|
|
157
|
+
parts?: unknown
|
|
158
|
+
}>
|
|
159
|
+
let agent: string | undefined
|
|
160
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
161
|
+
const info = list[i]?.info
|
|
162
|
+
if (info?.role === "user") {
|
|
163
|
+
if (info.agent && info.agent !== reviewAgent) agent = info.agent
|
|
164
|
+
break
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
168
|
+
const info = list[i]?.info
|
|
169
|
+
if (info?.role !== "assistant") continue
|
|
170
|
+
if (info.error) return { interrupted: false }
|
|
171
|
+
return { interrupted: textOf(list[i]?.parts).trim().length === 0, agent }
|
|
172
|
+
}
|
|
173
|
+
return { interrupted: false }
|
|
174
|
+
} catch (err) {
|
|
175
|
+
await log("warn", `failed to inspect last turn: ${err instanceof Error ? err.message : String(err)}`)
|
|
176
|
+
return { interrupted: false }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const runReviewCycle = async (sessionID: string, s: GateState): Promise<ReviewOutcome> => {
|
|
181
|
+
for (let round = 0; round <= maxFixRounds; round++) {
|
|
182
|
+
let verdict: "PASS" | "ISSUES" | undefined = undefined
|
|
183
|
+
for (let retry = 0; ; retry++) {
|
|
184
|
+
const review = await ask(sessionID, {
|
|
185
|
+
agent: reviewAgent,
|
|
186
|
+
parts: [
|
|
187
|
+
{
|
|
188
|
+
type: "text",
|
|
189
|
+
text: `${marker} Automated post-change review. Files were edited in this session earlier. Locate those changes (read the session transcript; run git diff / git status if this is a git repo; read the edited files) and review them per your protocol. End with your verdict line: "PASS — no blocking issues found" or "ISSUES — n blocking, m advisory". Note: keep this a static review — do NOT execute the code under review (no python/node/test runs); rely on git commands, reading files, and grep.`,
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
})
|
|
193
|
+
verdict = lastVerdict(review)
|
|
194
|
+
if (verdict !== undefined || retry >= maxNoVerdictRetries) break
|
|
195
|
+
await log("warn", "review ended without a verdict line; retrying review", { sessionID })
|
|
196
|
+
}
|
|
197
|
+
await log("info", `review round ${round + 1} verdict: ${verdict ?? "NO_VERDICT"}`, { sessionID })
|
|
198
|
+
if (verdict === "PASS") return "pass"
|
|
199
|
+
if (verdict === undefined) return "no-verdict"
|
|
200
|
+
if (round === maxFixRounds) break
|
|
201
|
+
s.edited = false
|
|
202
|
+
await ask(sessionID, {
|
|
203
|
+
parts: [
|
|
204
|
+
{
|
|
205
|
+
type: "text",
|
|
206
|
+
text: `${marker} The automated review above found blocking issues. Fix every BUG-class finding now: edit the code, then run lint/tests where available. Do not launch another bug-review yourself — the gate will re-review your fixes automatically.`,
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
})
|
|
210
|
+
if (!s.edited) {
|
|
211
|
+
// Distinguish a fix turn that died midstream (no output, no edits)
|
|
212
|
+
// from one that deliberately made no edits.
|
|
213
|
+
const turn = await lastTurnState(sessionID)
|
|
214
|
+
if (turn.interrupted) {
|
|
215
|
+
await log("warn", "fix turn ended with no model output; treating as interrupted", { sessionID })
|
|
216
|
+
return "fix-interrupted"
|
|
217
|
+
}
|
|
218
|
+
await log("info", "fix turn made no file edits; stopping gate", { sessionID })
|
|
219
|
+
return "no-fix-edits"
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return "issues-remain"
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const runGate = async (sessionID: string) => {
|
|
226
|
+
const s = get(sessionID)
|
|
227
|
+
s.busy = true
|
|
228
|
+
s.edited = false
|
|
229
|
+
try {
|
|
230
|
+
await log("info", "gate started", { sessionID })
|
|
231
|
+
for (let resume = 0; ; resume++) {
|
|
232
|
+
const { interrupted, agent } = autoContinue ? await lastTurnState(sessionID) : { interrupted: false, agent: undefined }
|
|
233
|
+
const outcome = await runReviewCycle(sessionID, s)
|
|
234
|
+
if (outcome === "issues-remain") {
|
|
235
|
+
await log("warn", `issues remain after ${maxFixRounds} fix rounds`, { sessionID })
|
|
236
|
+
await toast(`bug-review gate: ISSUES remain after ${maxFixRounds} fix rounds — needs attention`, "warning")
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
if (outcome === "no-fix-edits") return
|
|
240
|
+
const mustResume = autoContinue && (outcome === "fix-interrupted" || interrupted)
|
|
241
|
+
if (!mustResume || resume >= maxAutoContinues) {
|
|
242
|
+
if (outcome === "pass") {
|
|
243
|
+
await toast("bug-review gate: PASS", "success")
|
|
244
|
+
} else if (outcome === "no-verdict") {
|
|
245
|
+
await log("warn", "review ended without a verdict line; not auto-fixing", { sessionID })
|
|
246
|
+
await toast("bug-review gate: no verdict — see transcript", "warning")
|
|
247
|
+
} else {
|
|
248
|
+
await toast("bug-review gate: fix turn interrupted — needs attention", "warning")
|
|
249
|
+
}
|
|
250
|
+
if (mustResume) {
|
|
251
|
+
await log("warn", `turn ended with no model output; auto-continue cap (${maxAutoContinues}) reached`, { sessionID })
|
|
252
|
+
}
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
await log("info", `turn ended with no model output; auto-continuing (${resume + 1}/${maxAutoContinues})`, {
|
|
256
|
+
sessionID,
|
|
257
|
+
})
|
|
258
|
+
await toast("bug-review gate: resuming interrupted turn", "warning")
|
|
259
|
+
s.edited = false
|
|
260
|
+
const body: Record<string, unknown> = {
|
|
261
|
+
parts: [
|
|
262
|
+
{
|
|
263
|
+
type: "text",
|
|
264
|
+
text: `${marker} The previous turn ended abnormally — the model stream stopped before producing any output. Continue the task from where it stopped (re-read the transcript above to see what was in progress). Do not launch a bug-review yourself — the gate reviews changes automatically.`,
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
}
|
|
268
|
+
// A fix-interrupted resume continues the fix turn, which ran under
|
|
269
|
+
// the default agent — not the main turn's agent.
|
|
270
|
+
if (agent && outcome !== "fix-interrupted") body.agent = agent
|
|
271
|
+
await ask(sessionID, body)
|
|
272
|
+
}
|
|
273
|
+
} catch (err) {
|
|
274
|
+
await log("error", `gate failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
275
|
+
} finally {
|
|
276
|
+
s.busy = false
|
|
277
|
+
s.edited = false
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const respondPermission = async (sessionID: string, permissionID: string) => {
|
|
282
|
+
try {
|
|
283
|
+
const res = await fetch(`${serverUrl}session/${sessionID}/permissions/${permissionID}`, {
|
|
284
|
+
method: "POST",
|
|
285
|
+
headers: { "content-type": "application/json" },
|
|
286
|
+
body: JSON.stringify({ response: askResponse }),
|
|
287
|
+
})
|
|
288
|
+
await log("info", `auto-answered permission ${permissionID} with ${askResponse} (status ${res.status})`, {
|
|
289
|
+
sessionID,
|
|
290
|
+
})
|
|
291
|
+
} catch (err) {
|
|
292
|
+
await log("warn", `failed to answer permission ${permissionID}: ${err instanceof Error ? err.message : String(err)}`)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
config: async (input) => {
|
|
298
|
+
if (!defineAgent) return
|
|
299
|
+
if (input.agent?.[reviewAgent]) return
|
|
300
|
+
input.agent ??= {}
|
|
301
|
+
input.agent[reviewAgent] = {
|
|
302
|
+
description: DEFAULT_REVIEW_AGENT_DESCRIPTION,
|
|
303
|
+
mode: "subagent",
|
|
304
|
+
prompt: DEFAULT_REVIEW_AGENT_PROMPT,
|
|
305
|
+
permission: {
|
|
306
|
+
edit: "deny",
|
|
307
|
+
bash: {
|
|
308
|
+
"*": "ask",
|
|
309
|
+
"git diff*": "allow",
|
|
310
|
+
"git log*": "allow",
|
|
311
|
+
"git status*": "allow",
|
|
312
|
+
"git show*": "allow",
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
"permission.ask": async (input, output) => {
|
|
319
|
+
// While the gate is driving a session (review/fix/resume turns), answer
|
|
320
|
+
// permission asks automatically so headless runs can't hang on a
|
|
321
|
+
// dialog. The configured response keeps the turn alive. Normal user
|
|
322
|
+
// turns are unaffected.
|
|
323
|
+
const s = state.get(input.sessionID)
|
|
324
|
+
if (s?.busy) {
|
|
325
|
+
output.status = askResponse === "once" ? "allow" : "deny"
|
|
326
|
+
await log("info", `permission.ask hook answered ask with ${output.status}`, { sessionID: input.sessionID, id: input.id })
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
"tool.execute.after": async ({ tool, sessionID }) => {
|
|
331
|
+
if (EDIT_TOOLS.has(tool)) get(sessionID).edited = true
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
event: async ({ event }) => {
|
|
335
|
+
if (event.type === "session.deleted") {
|
|
336
|
+
const info = (event.properties as { info?: { id?: string } }).info
|
|
337
|
+
if (info?.id) state.delete(info.id)
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
if ((event.type as string) === "permission.asked" || event.type === "permission.updated") {
|
|
341
|
+
// Safety net for permission asks raised by gate-driven turns
|
|
342
|
+
// (review/fix/resume): watch the ask broadcast and answer it via the
|
|
343
|
+
// server API (the same channel a TUI client uses), so headless
|
|
344
|
+
// runs can't hang on a dialog. Normal user turns are unaffected
|
|
345
|
+
// (their sessions aren't gate-busy).
|
|
346
|
+
const p = event.properties as { id?: string; sessionID?: string }
|
|
347
|
+
const s = p.sessionID ? state.get(p.sessionID) : undefined
|
|
348
|
+
if (p.id && s?.busy && !answered.has(p.id)) {
|
|
349
|
+
answered.add(p.id)
|
|
350
|
+
await respondPermission(p.sessionID as string, p.id)
|
|
351
|
+
}
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
if (event.type !== "session.idle") return
|
|
355
|
+
const sessionID = (event.properties as { sessionID?: string }).sessionID
|
|
356
|
+
if (!sessionID) return
|
|
357
|
+
const s = get(sessionID)
|
|
358
|
+
if (!s.edited || s.busy) return
|
|
359
|
+
let session: { id?: string; parentID?: string }
|
|
360
|
+
try {
|
|
361
|
+
const res = await client.session.get({ path: { id: sessionID } })
|
|
362
|
+
session = ((res as { data?: unknown }).data ?? res) as { id?: string; parentID?: string }
|
|
363
|
+
} catch {
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
if (!session?.id || session.parentID) return
|
|
367
|
+
void runGate(sessionID)
|
|
368
|
+
},
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export default BugReviewGate
|