@djoeyg/pi-review 0.0.0-stage → 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 +33 -2
- package/package.json +38 -4
- package/src/index.ts +175 -0
- package/src/reviewer.md +99 -0
- package/src/reviewer.ts +138 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joey Goldman
|
|
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
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-review
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Gives the agent a background code reviewer it can steer.
|
|
4
|
+
|
|
5
|
+
- `request_review` starts a reviewer and returns a review id (`R1`, `R2`, …) immediately. The reviewer is a headless pi on its own model, with fresh context and read-only tools (`read`, `grep`, `find`, `ls`, `bash`). When it finishes, its verdict and findings arrive in the session as a message, which starts a turn if the agent is idle.
|
|
6
|
+
- `steer_review` messages a review. While it runs, the message redirects it. After it finishes, the message starts another round in the same reviewer context, so the agent can dispute findings or ask for a re-review after fixing them.
|
|
7
|
+
|
|
8
|
+
Reviewers live until the session ends. The footer shows which are running.
|
|
9
|
+
|
|
10
|
+
`/review off` removes both tools from the agent and stops running reviewers; `/review on` restores them, and `/review` toggles. The choice persists in `~/.pi/agent/review.json` as `"enabled"`.
|
|
11
|
+
|
|
12
|
+
The reviewer prompt ([`src/reviewer.md`](src/reviewer.md)) asks for a strict maintainability review: correctness first, then structural simplification, ownership boundaries, and illegal state.
|
|
13
|
+
|
|
14
|
+
## Models
|
|
15
|
+
|
|
16
|
+
The agent picks the reviewer model per request. Offer it a menu in `~/.pi/agent/review.json`: keys are `provider/id`, optionally with a `:thinking` suffix, and values say when to pick each. The first entry is the default.
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"models": {
|
|
21
|
+
"openai/gpt-6.1-sol:high": "Default. Deep structural and correctness review.",
|
|
22
|
+
"claude-bridge/claude-opus-5-5:high": "Second opinion from another model family; large or cross-cutting changes.",
|
|
23
|
+
"openai/gpt-6-luna:medium": "Small, low-risk diffs where speed matters."
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Without a menu, the agent may name any model that has credentials, and defaults to its own. An optional `"promptFile"` (absolute path) replaces the bundled reviewer prompt.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
pi install npm:@djoeyg/pi-review
|
|
34
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djoeyg/pi-review",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "request_review / steer_review: background code review by an independent, steerable reviewer model",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"code-review",
|
|
10
|
+
"subagent"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18.0.0"
|
|
18
|
+
},
|
|
19
|
+
"pi": {
|
|
20
|
+
"extensions": [
|
|
21
|
+
"./src/index.ts"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"author": "Joey Goldman",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/D-Joey-G/pi-extensions.git",
|
|
28
|
+
"directory": "packages/pi-review"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/D-Joey-G/pi-extensions/tree/main/packages/pi-review#readme",
|
|
31
|
+
"bugs": "https://github.com/D-Joey-G/pi-extensions/issues",
|
|
32
|
+
"files": [
|
|
33
|
+
"src",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { type ReviewOutcome, Reviewer } from "./reviewer.ts";
|
|
7
|
+
|
|
8
|
+
const CONFIG_PATH = path.join(getAgentDir(), "review.json");
|
|
9
|
+
const DEFAULT_PROMPT = path.join(path.dirname(fileURLToPath(import.meta.url)), "reviewer.md");
|
|
10
|
+
const TOOL_NAMES = ["request_review", "steer_review"];
|
|
11
|
+
|
|
12
|
+
interface Config {
|
|
13
|
+
/** `provider/id[:thinking]` -> when to pick it. First entry is the default. */
|
|
14
|
+
models: Record<string, string>;
|
|
15
|
+
promptFile?: string;
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readRaw(): Record<string, any> {
|
|
20
|
+
if (!fs.existsSync(CONFIG_PATH)) return {};
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
23
|
+
} catch (e) {
|
|
24
|
+
throw new Error(`Invalid ${CONFIG_PATH}: ${e}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loadConfig(): Config {
|
|
29
|
+
const raw = readRaw();
|
|
30
|
+
return { models: raw.models ?? {}, promptFile: raw.promptFile, enabled: raw.enabled ?? true };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function saveEnabled(enabled: boolean): void {
|
|
34
|
+
fs.writeFileSync(CONFIG_PATH, `${JSON.stringify({ ...readRaw(), enabled }, null, 2)}\n`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function modelDescription(config: Config): string {
|
|
38
|
+
const entries = Object.entries(config.models);
|
|
39
|
+
if (!entries.length) {
|
|
40
|
+
return "Reviewer model as `provider/id`, optionally with `:thinking` (e.g. `openai/gpt-5:high`). Defaults to your own model.";
|
|
41
|
+
}
|
|
42
|
+
const menu = entries.map(([model, note], i) => `- \`${model}\`${i === 0 ? " (default)" : ""}: ${note}`).join("\n");
|
|
43
|
+
return `Reviewer model. Pick the one that best fits the change:\n${menu}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveModel(requested: string | undefined, config: Config, ctx: ExtensionContext): string {
|
|
47
|
+
const configured = Object.keys(config.models);
|
|
48
|
+
const spec = requested ?? configured[0] ?? (ctx.model && `${ctx.model.provider}/${ctx.model.id}`);
|
|
49
|
+
if (!spec) throw new Error("No reviewer model: pass `model`.");
|
|
50
|
+
if (configured.length && !configured.includes(spec)) {
|
|
51
|
+
throw new Error(`Model ${spec} is not offered. Choose one of: ${configured.join(", ")}`);
|
|
52
|
+
}
|
|
53
|
+
const [ref] = spec.split(":");
|
|
54
|
+
if (!ctx.modelRegistry.getAvailable().some((m) => `${m.provider}/${m.id}` === ref)) {
|
|
55
|
+
throw new Error(`Model ${ref} is unknown or has no credentials.`);
|
|
56
|
+
}
|
|
57
|
+
return spec;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function outcomeMessage(reviewer: Reviewer, outcome: ReviewOutcome): string {
|
|
61
|
+
const head = `Review ${reviewer.id} (${reviewer.model})`;
|
|
62
|
+
if (outcome.kind === "failed") return `${head} failed: ${outcome.error}`;
|
|
63
|
+
return (
|
|
64
|
+
`${head} finished:\n\n${outcome.text}\n\n` +
|
|
65
|
+
`Use steer_review with id ${reviewer.id} to dispute findings or to request a re-review after fixing them.`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default function (pi: ExtensionAPI) {
|
|
70
|
+
const config = loadConfig();
|
|
71
|
+
const promptFile = config.promptFile ?? DEFAULT_PROMPT;
|
|
72
|
+
const reviewers = new Map<string, Reviewer>();
|
|
73
|
+
let ui: ExtensionContext["ui"] | undefined;
|
|
74
|
+
|
|
75
|
+
const updateStatus = () => {
|
|
76
|
+
const running = [...reviewers.values()].filter((r) => r.running).map((r) => r.id);
|
|
77
|
+
ui?.setStatus("review", running.length ? `reviewing: ${running.join(", ")}` : undefined);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const onOutcome = (reviewer: Reviewer, outcome: ReviewOutcome) => {
|
|
81
|
+
updateStatus();
|
|
82
|
+
pi.sendMessage(
|
|
83
|
+
{
|
|
84
|
+
customType: "review",
|
|
85
|
+
content: outcomeMessage(reviewer, outcome),
|
|
86
|
+
display: true,
|
|
87
|
+
details: { id: reviewer.id, model: reviewer.model },
|
|
88
|
+
},
|
|
89
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
pi.registerTool({
|
|
94
|
+
name: "request_review",
|
|
95
|
+
label: "Request Review",
|
|
96
|
+
description:
|
|
97
|
+
"Start a code review in the background. The reviewer runs on its own model with fresh context and read-only tools. " +
|
|
98
|
+
"Returns a review id immediately; the review arrives later as a message. Keep working meanwhile, or end your turn " +
|
|
99
|
+
"if there is nothing else to do: the result starts a new turn. Do not poll. " +
|
|
100
|
+
"The reviewer cannot see this conversation, so state the goal, constraints, and scope (base ref, files).",
|
|
101
|
+
promptSnippet: "Start a background code review by an independent reviewer model",
|
|
102
|
+
parameters: Type.Object({
|
|
103
|
+
request: Type.String({ description: "What changed and why, constraints, and what to review (base ref, files)." }),
|
|
104
|
+
model: Type.Optional(Type.String({ description: modelDescription(config) })),
|
|
105
|
+
}),
|
|
106
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
107
|
+
ui = ctx.ui;
|
|
108
|
+
const model = resolveModel(params.model, config, ctx);
|
|
109
|
+
const id = `R${reviewers.size + 1}`;
|
|
110
|
+
const reviewer = new Reviewer({ id, model, cwd: ctx.cwd, promptFile, onOutcome });
|
|
111
|
+
reviewers.set(id, reviewer);
|
|
112
|
+
reviewer.send(params.request);
|
|
113
|
+
updateStatus();
|
|
114
|
+
return {
|
|
115
|
+
content: [{ type: "text", text: `Review ${id} started with ${model}. Its result will arrive as a message.` }],
|
|
116
|
+
details: { id, model },
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
pi.registerTool({
|
|
122
|
+
name: "steer_review",
|
|
123
|
+
label: "Steer Review",
|
|
124
|
+
description:
|
|
125
|
+
"Message a review started with request_review. While it runs, the message redirects it. After it finishes, the " +
|
|
126
|
+
"message starts another round in the same reviewer context: dispute findings, answer its questions, or ask for a " +
|
|
127
|
+
"re-review after fixes. Returns immediately; the reply arrives as a message.",
|
|
128
|
+
promptSnippet: "Redirect a running review, or continue a finished one",
|
|
129
|
+
parameters: Type.Object({
|
|
130
|
+
id: Type.String({ description: "Review id from request_review, e.g. R1." }),
|
|
131
|
+
message: Type.String(),
|
|
132
|
+
}),
|
|
133
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
134
|
+
ui = ctx.ui;
|
|
135
|
+
const reviewer = reviewers.get(params.id);
|
|
136
|
+
if (!reviewer) throw new Error(`No review ${params.id}. Known: ${[...reviewers.keys()].join(", ") || "none"}`);
|
|
137
|
+
const wasRunning = reviewer.running;
|
|
138
|
+
reviewer.send(params.message);
|
|
139
|
+
updateStatus();
|
|
140
|
+
const text = wasRunning ? `Steered review ${params.id}.` : `Started another round of review ${params.id}.`;
|
|
141
|
+
return {
|
|
142
|
+
content: [{ type: "text", text: `${text} The reply will arrive as a message.` }],
|
|
143
|
+
details: { id: params.id },
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const stopAll = () => {
|
|
149
|
+
for (const reviewer of reviewers.values()) reviewer.stop();
|
|
150
|
+
reviewers.clear();
|
|
151
|
+
ui?.setStatus("review", undefined);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
let enabled = config.enabled;
|
|
155
|
+
const applyEnabled = () => {
|
|
156
|
+
const others = pi.getActiveTools().filter((name) => !TOOL_NAMES.includes(name));
|
|
157
|
+
pi.setActiveTools(enabled ? [...others, ...TOOL_NAMES] : others);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
pi.registerCommand("review", {
|
|
161
|
+
description: "Turn the review tools on or off (persists): /review [on|off]",
|
|
162
|
+
handler: async (args, ctx) => {
|
|
163
|
+
const arg = args.trim();
|
|
164
|
+
if (arg && arg !== "on" && arg !== "off") return void ctx.ui.notify("Usage: /review [on|off]", "warning");
|
|
165
|
+
enabled = arg ? arg === "on" : !enabled;
|
|
166
|
+
saveEnabled(enabled);
|
|
167
|
+
applyEnabled();
|
|
168
|
+
if (!enabled) stopAll();
|
|
169
|
+
ctx.ui.notify(`Review tools ${enabled ? "on" : "off"}`, "info");
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
pi.on("session_start", applyEnabled);
|
|
174
|
+
pi.on("session_shutdown", stopAll);
|
|
175
|
+
}
|
package/src/reviewer.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Reviewer
|
|
2
|
+
|
|
3
|
+
Another coding agent asked you to review its work. You cannot see its conversation; rely on the request and the repository. It may message you mid-review to redirect you, or after you finish to discuss findings or ask for a re-review. On a re-review, re-verify every earlier finding against the code: drop it if fixed, or if the agent's rationale for disputing it holds; otherwise keep it and say why.
|
|
4
|
+
|
|
5
|
+
Do not edit files. Bash is for read-only work: `git diff`, `git log`, `git show`, and running tests, linters, or type-checkers.
|
|
6
|
+
|
|
7
|
+
Start from the request. If it names a base ref or files, review those; otherwise run `git status` and `git diff HEAD`. Read the changed code and enough of its surroundings to judge where it belongs.
|
|
8
|
+
|
|
9
|
+
## Standard
|
|
10
|
+
|
|
11
|
+
Perform a deep, strict review of implementation quality. Find correctness defects first. Then rethink how the change could be structured to be meaningfully simpler without changing behavior: better abstractions, fewer concepts, less spaghetti, more legible code. Be thorough and rigorous: measure twice, cut once.
|
|
12
|
+
|
|
13
|
+
Be **ambitious** about structure. Do not stop at local cleanup. Look for the "code judo" move: a reorganization that uses the existing architecture better so that whole branches, helpers, modes, conditionals, or layers disappear, and the code feels inevitable in hindsight. Prefer deleting complexity to rearranging it.
|
|
14
|
+
|
|
15
|
+
The test for every finding: **what does the next maintainer no longer need to know or do?** Use it to judge; state the answer when it isn't obvious.
|
|
16
|
+
|
|
17
|
+
When principles conflict, resolve in order; a lower item never overrides a higher one:
|
|
18
|
+
|
|
19
|
+
1. Contract correctness, including failure and compatibility semantics.
|
|
20
|
+
2. Caller simplicity: fewer things a caller must know or remember.
|
|
21
|
+
3. Fewer concepts: types, layers, parameters, modes.
|
|
22
|
+
4. Fewer lines.
|
|
23
|
+
|
|
24
|
+
So a long cohesive function beats fragments joined by hidden assumptions, and duplicated code beats coupling unrelated concepts.
|
|
25
|
+
|
|
26
|
+
## Lenses
|
|
27
|
+
|
|
28
|
+
Sweep every change through each lens.
|
|
29
|
+
|
|
30
|
+
### Structure and spaghetti
|
|
31
|
+
|
|
32
|
+
- New ad-hoc conditionals, scattered special cases, or one-off branches in unrelated flows are a design problem, not a nit. Push the logic into a dedicated abstraction, helper, state machine, policy, or module.
|
|
33
|
+
- One-off booleans, nullable modes, or flags that complicate existing control flow; repeated conditionals that signal a missing model.
|
|
34
|
+
- Narrow edge-case handling in the middle of an already busy function.
|
|
35
|
+
- "Temporary" branching likely to become permanent debt.
|
|
36
|
+
- A file pushed from under 1000 lines to over 1000 is a presumptive blocker: ask whether it should be decomposed first.
|
|
37
|
+
- Refactors that move complexity around without reducing the concepts a reader must hold.
|
|
38
|
+
- Independent work serialized for no reason; related updates that can leave state half-applied.
|
|
39
|
+
|
|
40
|
+
### Machinery
|
|
41
|
+
|
|
42
|
+
An abstraction earns its place by deleting something: a check, a rule callers must remember, a branch repeated across modules, a pass-through layer. Name what it deletes. A type, base class, protocol, option, wrapper, or helper that deletes nothing is machinery; flag it. Be skeptical of generic "magic" that hides a simple data shape. Prefer direct, boring code.
|
|
43
|
+
|
|
44
|
+
### Ownership and contracts
|
|
45
|
+
|
|
46
|
+
Every responsibility, invariant, and format has one owner. For each one the change touches, name the owner and check the rest of the system defers to it.
|
|
47
|
+
|
|
48
|
+
- Put work where the knowledge already is. Downstream layers stay generic.
|
|
49
|
+
- Carry a value whole through layers; don't unpack it and rebuild it later.
|
|
50
|
+
- Related facts travel together: if callers reach into `a.b.c` for a fact that belongs with `a`, put it on `a`.
|
|
51
|
+
- When two sides validate the same format, the contract belongs in one shared place. Duplicate validators drift.
|
|
52
|
+
- Feature logic leaking into shared paths, implementation details leaking through APIs, bespoke helpers duplicating a canonical one, and logic in the wrong layer are findings.
|
|
53
|
+
- One conceptual change should touch one place. A diff editing one idea in five files is the finding.
|
|
54
|
+
- Errors are contract: expected outcomes are return variants; caller bugs raise uncaught; environment faults raise with identifying context and are caught only where something can act.
|
|
55
|
+
- A required call order becomes one call, or a type the next call requires.
|
|
56
|
+
- Clocks, randomness, env, and capabilities are passed in where callers need control.
|
|
57
|
+
- Consumers upgrade in lock-step: shims, aliases, and re-exports kept for an old shape are findings.
|
|
58
|
+
- A test that patches internals marks a misplaced seam.
|
|
59
|
+
|
|
60
|
+
### Illegal state
|
|
61
|
+
|
|
62
|
+
A type admits a **contradiction** when it can hold a value the domain says cannot exist. Each contradiction forces a **defense** on a consumer: `error ?? "Unknown error"`, `assert x is not None`, a non-null `!`, `raise ValueError("unexpected ...")`, a cast.
|
|
63
|
+
|
|
64
|
+
List every model, schema, signature, and boundary the change adds or modifies, including persisted state. For each, name the concrete illegal value and the defense it forces, with file and line. A fix succeeds only if it deletes a defense; a new union that deletes none is machinery.
|
|
65
|
+
|
|
66
|
+
Parse, don't validate: parse untrusted input once, at the outermost boundary that holds it, into a type that cannot hold the contradiction. Reject impossible input there rather than mapping it to the nearest legal state. An illegal combination becomes a visible variant or an error, never a quiet fallback.
|
|
67
|
+
|
|
68
|
+
Not worth fixing: a value that only packages a branch one caller unpacks immediately; types whose invalid instances stay constructible anyway and closing them removes no checks; contradictions only producible by parse paths that already filter them; independent state machines that would multiply if merged; types a pending redesign deletes anyway.
|
|
69
|
+
|
|
70
|
+
Also watch: defaulted discriminators dropped by serializers; wire and persisted shapes whose existing tags must keep parsing; hand-written types that should derive from generated ones.
|
|
71
|
+
|
|
72
|
+
## Approval bar
|
|
73
|
+
|
|
74
|
+
Do not approve because behavior seems correct. Treat these as presumptive blockers unless clearly justified:
|
|
75
|
+
|
|
76
|
+
- a correctness defect or contract break;
|
|
77
|
+
- a plausible code-judo move that would delete much incidental complexity;
|
|
78
|
+
- a file pushed past 1000 lines;
|
|
79
|
+
- ad-hoc branching that tangles an existing flow, or feature checks scattered across shared code;
|
|
80
|
+
- an abstraction, wrapper, cast, or optional that makes the design more indirect;
|
|
81
|
+
- a duplicated canonical helper or logic in the wrong layer;
|
|
82
|
+
- a type change that admits a contradiction consumers must defend against.
|
|
83
|
+
|
|
84
|
+
## Tone and output
|
|
85
|
+
|
|
86
|
+
Be direct, serious, and demanding. Not rude, but do not soften structural problems into mild suggestions. Prefer a few high-conviction findings to a long list; skip cosmetic nits when larger issues exist. Don't invent issues.
|
|
87
|
+
|
|
88
|
+
Order findings: correctness, structural regressions and missed simplifications, spaghetti growth, boundary/type/illegal-state problems, file size and decomposition, legibility.
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
## Verdict
|
|
92
|
+
approve | changes_requested — one sentence why.
|
|
93
|
+
|
|
94
|
+
## Findings
|
|
95
|
+
- **blocker|major|minor|nit** `path/to/file:line` — the problem; the change; what it deletes.
|
|
96
|
+
|
|
97
|
+
## Summary
|
|
98
|
+
2–3 sentences.
|
|
99
|
+
```
|
package/src/reviewer.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
|
|
4
|
+
export type ReviewOutcome = { kind: "done"; text: string } | { kind: "failed"; error: string };
|
|
5
|
+
|
|
6
|
+
type State = "running" | "idle" | "dead";
|
|
7
|
+
|
|
8
|
+
const TOOLS = "read,grep,find,ls,bash";
|
|
9
|
+
|
|
10
|
+
function piInvocation(args: string[]): { command: string; args: string[] } {
|
|
11
|
+
const script = process.argv[1];
|
|
12
|
+
if (script && !script.startsWith("/$bunfs/root/") && fs.existsSync(script)) {
|
|
13
|
+
return { command: process.execPath, args: [script, ...args] };
|
|
14
|
+
}
|
|
15
|
+
return { command: "pi", args };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function tail(text: string): string {
|
|
19
|
+
return text.trim().split("\n").slice(-5).join("\n");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A long-lived reviewer: a headless pi in RPC mode that keeps its context between rounds.
|
|
24
|
+
* Every settled run reports one outcome through `onOutcome`.
|
|
25
|
+
*/
|
|
26
|
+
export class Reviewer {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly model: string;
|
|
29
|
+
private state: State = "idle";
|
|
30
|
+
private readonly proc: ChildProcess;
|
|
31
|
+
private readonly onOutcome: (reviewer: Reviewer, outcome: ReviewOutcome) => void;
|
|
32
|
+
private stdout = "";
|
|
33
|
+
private stderr = "";
|
|
34
|
+
private lastText = "";
|
|
35
|
+
private lastError = "";
|
|
36
|
+
|
|
37
|
+
constructor(opts: {
|
|
38
|
+
id: string;
|
|
39
|
+
model: string;
|
|
40
|
+
cwd: string;
|
|
41
|
+
promptFile: string;
|
|
42
|
+
onOutcome: (reviewer: Reviewer, outcome: ReviewOutcome) => void;
|
|
43
|
+
}) {
|
|
44
|
+
this.id = opts.id;
|
|
45
|
+
this.model = opts.model;
|
|
46
|
+
this.onOutcome = opts.onOutcome;
|
|
47
|
+
const inv = piInvocation([
|
|
48
|
+
"--mode",
|
|
49
|
+
"rpc",
|
|
50
|
+
"--no-session",
|
|
51
|
+
"--model",
|
|
52
|
+
opts.model,
|
|
53
|
+
"--tools",
|
|
54
|
+
TOOLS,
|
|
55
|
+
"--append-system-prompt",
|
|
56
|
+
opts.promptFile,
|
|
57
|
+
]);
|
|
58
|
+
this.proc = spawn(inv.command, inv.args, { cwd: opts.cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
59
|
+
this.proc.stdout!.on("data", (d) => this.read(d.toString()));
|
|
60
|
+
this.proc.stderr!.on("data", (d) => (this.stderr += d.toString()));
|
|
61
|
+
this.proc.on("error", (e) => this.die(String(e)));
|
|
62
|
+
this.proc.on("exit", (code, signal) => this.die(`reviewer exited (${signal ?? code}). ${tail(this.stderr)}`));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get running(): boolean {
|
|
66
|
+
return this.state === "running";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get alive(): boolean {
|
|
70
|
+
return this.state !== "dead";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Steers a running review, or starts a new round in the same context when idle.
|
|
75
|
+
*/
|
|
76
|
+
send(message: string): void {
|
|
77
|
+
if (this.state === "dead") throw new Error(`Review ${this.id} has ended; request a new review.`);
|
|
78
|
+
this.state = "running";
|
|
79
|
+
this.proc.stdin!.write(`${JSON.stringify({ type: "prompt", message, streamingBehavior: "steer" })}\n`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
stop(): void {
|
|
83
|
+
if (this.state === "dead") return;
|
|
84
|
+
this.state = "dead";
|
|
85
|
+
this.proc.stdin!.end();
|
|
86
|
+
setTimeout(() => this.proc.kill("SIGKILL"), 2000).unref();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private read(chunk: string): void {
|
|
90
|
+
this.stdout += chunk;
|
|
91
|
+
const lines = this.stdout.split("\n");
|
|
92
|
+
this.stdout = lines.pop() ?? "";
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
let event: any;
|
|
95
|
+
try {
|
|
96
|
+
event = JSON.parse(line);
|
|
97
|
+
} catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
this.handle(event);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private handle(event: any): void {
|
|
105
|
+
if (event.type === "response" && !event.success) {
|
|
106
|
+
this.settle({ kind: "failed", error: `${event.command} rejected: ${event.error}` });
|
|
107
|
+
} else if (event.type === "message_end" && event.message?.role === "assistant") {
|
|
108
|
+
const text = (event.message.content as any[])
|
|
109
|
+
.filter((p) => p.type === "text")
|
|
110
|
+
.map((p) => p.text)
|
|
111
|
+
.join("\n")
|
|
112
|
+
.trim();
|
|
113
|
+
if (text) this.lastText = text;
|
|
114
|
+
if (event.message.errorMessage) this.lastError = event.message.errorMessage;
|
|
115
|
+
} else if (event.type === "agent_settled") {
|
|
116
|
+
this.settle(
|
|
117
|
+
this.lastText
|
|
118
|
+
? { kind: "done", text: this.lastText }
|
|
119
|
+
: { kind: "failed", error: this.lastError || "no output" },
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private settle(outcome: ReviewOutcome): void {
|
|
125
|
+
if (this.state !== "running") return;
|
|
126
|
+
this.state = "idle";
|
|
127
|
+
this.lastText = "";
|
|
128
|
+
this.lastError = "";
|
|
129
|
+
this.onOutcome(this, outcome);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private die(error: string): void {
|
|
133
|
+
if (this.state === "dead") return;
|
|
134
|
+
const wasRunning = this.state === "running";
|
|
135
|
+
this.state = "dead";
|
|
136
|
+
if (wasRunning) this.onOutcome(this, { kind: "failed", error });
|
|
137
|
+
}
|
|
138
|
+
}
|