@fullstackhouse/cerber 0.9.1 → 0.10.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.
- package/README.md +47 -0
- package/dist/core/artifact.js +92 -0
- package/dist/core/revise.js +166 -0
- package/dist/core/state.js +15 -9
- package/dist/runner/chat.js +220 -0
- package/dist/runner/claude.js +75 -14
- package/dist/runner/progress.js +88 -0
- package/dist/runner/prompt.js +132 -0
- package/dist/runner/review.js +24 -1
- package/dist/server/daemon.js +3 -0
- package/dist/server/index.js +121 -1
- package/dist/server/progress.js +72 -0
- package/package.json +1 -1
- package/web/dist/assets/index-C25hWJar.js +236 -0
- package/web/dist/assets/{index-Dmz5lQnG.css → index-si1RfFZU.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CsWW0EBv.js +0 -236
package/dist/runner/claude.js
CHANGED
|
@@ -39,7 +39,12 @@ export const DEFAULT_TIMEOUT_MS = 30 * 60_000;
|
|
|
39
39
|
/** How long a killed run gets to exit on its own before SIGKILL. */
|
|
40
40
|
const SIGKILL_GRACE_MS = 5_000;
|
|
41
41
|
export function buildClaudeArgs(opts) {
|
|
42
|
-
|
|
42
|
+
// stream-json, not json: the same final result arrives in the closing event,
|
|
43
|
+
// and everything before it is the run saying what it is doing. `--verbose` is
|
|
44
|
+
// not optional — the CLI refuses the combination without it.
|
|
45
|
+
const args = ["-p", "--output-format", "stream-json", "--verbose"];
|
|
46
|
+
if (opts.resumeSessionId)
|
|
47
|
+
args.push("--resume", opts.resumeSessionId);
|
|
43
48
|
if (opts.model)
|
|
44
49
|
args.push("--model", opts.model);
|
|
45
50
|
if (opts.allowedTools?.length)
|
|
@@ -51,9 +56,39 @@ export function buildClaudeArgs(opts) {
|
|
|
51
56
|
}
|
|
52
57
|
return args;
|
|
53
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Split a stream-json stdout chunk into whole events, keeping whatever trailing
|
|
61
|
+
* fragment has not been terminated yet. A tool result can be megabytes, so a
|
|
62
|
+
* single event routinely arrives across several chunks.
|
|
63
|
+
*/
|
|
64
|
+
export function readEvents(buffer) {
|
|
65
|
+
const lines = buffer.split("\n");
|
|
66
|
+
// The last piece has no newline after it — it may be half an event.
|
|
67
|
+
const rest = lines.pop() ?? "";
|
|
68
|
+
const events = [];
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
if (!line.trim())
|
|
71
|
+
continue;
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(line);
|
|
74
|
+
if (parsed && typeof parsed === "object" && typeof parsed.type === "string") {
|
|
75
|
+
events.push(parsed);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// A line we can't parse is a line we don't need — the run's own result
|
|
80
|
+
// comes from the closing event, and dying here would throw away a
|
|
81
|
+
// finished review over a stray byte.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { events, rest };
|
|
85
|
+
}
|
|
54
86
|
/**
|
|
55
87
|
* Run `claude -p` headless with the prompt on stdin, return the result text.
|
|
56
88
|
* Rides the user's existing Claude Code login — no API key handling here.
|
|
89
|
+
*
|
|
90
|
+
* Reads the run as a stream so {@link ClaudeOptions.onEvent} can report what it
|
|
91
|
+
* is doing while it does it; the result itself is the closing event.
|
|
57
92
|
*/
|
|
58
93
|
export function runClaude(prompt, opts = {}) {
|
|
59
94
|
const args = buildClaudeArgs(opts);
|
|
@@ -63,7 +98,10 @@ export function runClaude(prompt, opts = {}) {
|
|
|
63
98
|
cwd: opts.cwd,
|
|
64
99
|
env: opts.env,
|
|
65
100
|
});
|
|
66
|
-
let
|
|
101
|
+
let unread = "";
|
|
102
|
+
let result = null;
|
|
103
|
+
/** Kept only to explain a failure — the events carry everything we use. */
|
|
104
|
+
let tail = "";
|
|
67
105
|
let stderr = "";
|
|
68
106
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
69
107
|
let settled = false;
|
|
@@ -87,7 +125,26 @@ export function runClaude(prompt, opts = {}) {
|
|
|
87
125
|
child.stdin.destroy();
|
|
88
126
|
settle(() => reject(new Error(`claude did not finish within ${Math.round(timeoutMs / 60_000)} minutes — run killed.`)));
|
|
89
127
|
}, timeoutMs);
|
|
90
|
-
|
|
128
|
+
const ingest = (chunk) => {
|
|
129
|
+
const read = readEvents(unread + chunk);
|
|
130
|
+
unread = read.rest;
|
|
131
|
+
for (const event of read.events) {
|
|
132
|
+
if (event.type === "result")
|
|
133
|
+
result = event;
|
|
134
|
+
// A throwing listener must not take the run down with it: the answer is
|
|
135
|
+
// the point, the narration is not.
|
|
136
|
+
try {
|
|
137
|
+
opts.onEvent?.(event);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// ignore
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
child.stdout.on("data", (d) => {
|
|
145
|
+
tail = (tail + d).slice(-2_000);
|
|
146
|
+
ingest(String(d));
|
|
147
|
+
});
|
|
91
148
|
child.stderr.on("data", (d) => (stderr += d));
|
|
92
149
|
child.on("error", (err) => {
|
|
93
150
|
settle(() => reject(new Error(`Failed to start claude CLI: ${err.message}. Is Claude Code installed and on PATH?`)));
|
|
@@ -98,20 +155,24 @@ export function runClaude(prompt, opts = {}) {
|
|
|
98
155
|
clearTimeout(timer);
|
|
99
156
|
settled = true;
|
|
100
157
|
if (code !== 0) {
|
|
101
|
-
reject(new Error(`claude exited with code ${code}: ${stderr.trim() ||
|
|
158
|
+
reject(new Error(`claude exited with code ${code}: ${stderr.trim() || tail.slice(-500)}`));
|
|
102
159
|
return;
|
|
103
160
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
catch {
|
|
113
|
-
reject(new Error(`Could not parse claude output as JSON wrapper: ${stdout.slice(0, 500)}`));
|
|
161
|
+
// The last line need not be newline-terminated, and it is the one that
|
|
162
|
+
// matters: dropping it would throw away a finished review for the want
|
|
163
|
+
// of a trailing byte.
|
|
164
|
+
ingest("\n");
|
|
165
|
+
const wrapper = result;
|
|
166
|
+
if (!wrapper) {
|
|
167
|
+
reject(new Error(`claude produced no result event: ${tail.slice(-500)}`));
|
|
168
|
+
return;
|
|
114
169
|
}
|
|
170
|
+
resolve({
|
|
171
|
+
text: typeof wrapper.result === "string" ? wrapper.result : "",
|
|
172
|
+
costUsd: typeof wrapper.total_cost_usd === "number" ? wrapper.total_cost_usd : null,
|
|
173
|
+
model: wrapper.modelUsage ? Object.keys(wrapper.modelUsage)[0] ?? null : null,
|
|
174
|
+
sessionId: typeof wrapper.session_id === "string" ? wrapper.session_id : null,
|
|
175
|
+
});
|
|
115
176
|
});
|
|
116
177
|
child.stdin.write(prompt);
|
|
117
178
|
child.stdin.end();
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a run's own events into a line a human can read while they wait.
|
|
3
|
+
*
|
|
4
|
+
* A review or a chat turn takes minutes, and until now the only honest thing
|
|
5
|
+
* cerber could say about that time was "thinking…". The run already narrates
|
|
6
|
+
* itself — what it is about to read, what it concluded, what it was refused —
|
|
7
|
+
* so this says that instead, in the same plain words the review itself uses:
|
|
8
|
+
* what happened, not the wire format it happened in.
|
|
9
|
+
*
|
|
10
|
+
* Ignoring is the default. An event we don't recognise, and anything that would
|
|
11
|
+
* leak the machinery (the JSON answer, tool results, session bookkeeping),
|
|
12
|
+
* returns null and never reaches the user.
|
|
13
|
+
*
|
|
14
|
+
* Pure: no I/O, no clock.
|
|
15
|
+
*/
|
|
16
|
+
/** How much of any one line survives. Long enough to be useful in a sidebar. */
|
|
17
|
+
const MAX = 180;
|
|
18
|
+
export function describeEvent(event) {
|
|
19
|
+
if (event.type === "assistant") {
|
|
20
|
+
return (event.message?.content ?? []).flatMap((block) => {
|
|
21
|
+
if (block.type === "thinking")
|
|
22
|
+
return ["thinking…"];
|
|
23
|
+
if (block.type === "text") {
|
|
24
|
+
const text = (block.text ?? "").trim();
|
|
25
|
+
// The turn's answer is a JSON object; showing it raw would put the
|
|
26
|
+
// machinery on screen a second before the rendered version lands.
|
|
27
|
+
if (!text || looksLikeJson(text))
|
|
28
|
+
return [];
|
|
29
|
+
return [clip(collapse(text))];
|
|
30
|
+
}
|
|
31
|
+
if (block.type === "tool_use")
|
|
32
|
+
return [describeTool(block.name ?? "", block.input ?? {})];
|
|
33
|
+
return [];
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
// Cerber denies most tools on purpose. When the run reaches for one anyway,
|
|
37
|
+
// saying so beats a silent gap in the story.
|
|
38
|
+
if (event.type === "system" && event.subtype === "permission_denied") {
|
|
39
|
+
const tool = typeof event.tool_name === "string" ? event.tool_name : "a tool";
|
|
40
|
+
return [`not allowed to use ${tool}`];
|
|
41
|
+
}
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
function describeTool(name, input) {
|
|
45
|
+
const str = (key) => (typeof input[key] === "string" ? input[key] : "");
|
|
46
|
+
switch (name) {
|
|
47
|
+
case "Read": {
|
|
48
|
+
const file = short(str("file_path"));
|
|
49
|
+
return file ? `reading ${file}` : "reading a file";
|
|
50
|
+
}
|
|
51
|
+
case "Grep": {
|
|
52
|
+
const where = short(str("path"));
|
|
53
|
+
return clip(`searching for ${str("pattern")}${where ? ` in ${where}` : ""}`);
|
|
54
|
+
}
|
|
55
|
+
case "Glob":
|
|
56
|
+
return clip(`looking for files matching ${str("pattern")}`);
|
|
57
|
+
case "Bash": {
|
|
58
|
+
// The description is the model's own words for what it is about to run;
|
|
59
|
+
// the command is what it will actually do. The command is the honest one.
|
|
60
|
+
const command = collapse(str("command"));
|
|
61
|
+
return command ? clip(`running ${command}`) : "running a command";
|
|
62
|
+
}
|
|
63
|
+
case "WebSearch":
|
|
64
|
+
return clip(`searching the web for ${str("query")}`);
|
|
65
|
+
case "WebFetch":
|
|
66
|
+
return clip(`fetching ${str("url")}`);
|
|
67
|
+
case "TodoWrite":
|
|
68
|
+
return "planning the rest of the turn";
|
|
69
|
+
default:
|
|
70
|
+
return name ? `using ${name}` : "using a tool";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Paths in a checkout are absolute and long; the repo-relative tail is the useful part. */
|
|
74
|
+
function short(file) {
|
|
75
|
+
if (!file)
|
|
76
|
+
return "";
|
|
77
|
+
const parts = file.split("/").filter(Boolean);
|
|
78
|
+
return parts.length > 3 ? parts.slice(-3).join("/") : file;
|
|
79
|
+
}
|
|
80
|
+
function looksLikeJson(text) {
|
|
81
|
+
return text.startsWith("{") || text.startsWith("```");
|
|
82
|
+
}
|
|
83
|
+
function collapse(text) {
|
|
84
|
+
return text.replace(/\s+/g, " ").trim();
|
|
85
|
+
}
|
|
86
|
+
function clip(text) {
|
|
87
|
+
return text.length > MAX ? `${text.slice(0, MAX - 1)}…` : text;
|
|
88
|
+
}
|
package/dist/runner/prompt.js
CHANGED
|
@@ -93,6 +93,138 @@ ${diffBody}
|
|
|
93
93
|
`;
|
|
94
94
|
return { prompt, truncated };
|
|
95
95
|
}
|
|
96
|
+
/** Human names for what the user pointed at, so the model can find it. */
|
|
97
|
+
function describeRefs(artifact, refs) {
|
|
98
|
+
if (refs.length === 0)
|
|
99
|
+
return "";
|
|
100
|
+
const lines = refs.map((ref) => {
|
|
101
|
+
if (ref.target === "summary")
|
|
102
|
+
return "- the summary";
|
|
103
|
+
if (ref.target === "verdict")
|
|
104
|
+
return "- the verdict";
|
|
105
|
+
if (ref.target === "chapter") {
|
|
106
|
+
const ch = artifact.chapters.find((c) => c.id === ref.id);
|
|
107
|
+
return `- the chapter "${ch?.title ?? ref.id}" (chapterId: ${ref.id})`;
|
|
108
|
+
}
|
|
109
|
+
const c = artifact.comments.find((x) => x.id === ref.id);
|
|
110
|
+
return c
|
|
111
|
+
? `- the comment on ${c.path}:${c.line ?? "file"} (commentId: ${c.id})`
|
|
112
|
+
: `- comment ${ref.id}`;
|
|
113
|
+
});
|
|
114
|
+
return `\n## What the user is pointing at\n\n${lines.join("\n")}\n`;
|
|
115
|
+
}
|
|
116
|
+
/** The review as it stands right now, with the ids a revision has to name. */
|
|
117
|
+
function renderReview(artifact) {
|
|
118
|
+
const chapters = artifact.chapters
|
|
119
|
+
.map((ch) => `- ${ch.id} — ${ch.title}\n ${ch.explanation}`)
|
|
120
|
+
.join("\n");
|
|
121
|
+
const comments = artifact.comments
|
|
122
|
+
.map((c) => {
|
|
123
|
+
const mine = c.origin === "user" || c.editedByUser ? " [THE USER'S OWN WRITING]" : "";
|
|
124
|
+
const dropped = c.status === "dropped" ? " [dropped]" : "";
|
|
125
|
+
return `- ${c.id} — ${c.path}:${c.line ?? "file-level"}${mine}${dropped}\n ${c.body}`;
|
|
126
|
+
})
|
|
127
|
+
.join("\n");
|
|
128
|
+
const verdict = artifact.verdict
|
|
129
|
+
? `${artifact.verdict.recommendation} (confidence ${artifact.verdict.confidence})\n${artifact.verdict.reasoning}`
|
|
130
|
+
: "(none)";
|
|
131
|
+
return `## The review as it stands
|
|
132
|
+
|
|
133
|
+
### Summary
|
|
134
|
+
|
|
135
|
+
${artifact.summary || "(empty)"}
|
|
136
|
+
|
|
137
|
+
### Chapters
|
|
138
|
+
|
|
139
|
+
${chapters || "(none)"}
|
|
140
|
+
|
|
141
|
+
### Comments
|
|
142
|
+
|
|
143
|
+
${comments || "(none)"}
|
|
144
|
+
|
|
145
|
+
### Verdict
|
|
146
|
+
|
|
147
|
+
${verdict}
|
|
148
|
+
`;
|
|
149
|
+
}
|
|
150
|
+
function renderTranscript(chat) {
|
|
151
|
+
if (chat.length === 0)
|
|
152
|
+
return "";
|
|
153
|
+
const turns = chat
|
|
154
|
+
.map((t) => `${t.role === "user" ? "User" : "You"}: ${t.body}`)
|
|
155
|
+
.join("\n\n");
|
|
156
|
+
return `\n## The conversation so far\n\n${turns}\n`;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The prompt for one turn of a conversation about a drafted review.
|
|
160
|
+
*
|
|
161
|
+
* The point of this over a one-shot "redo it" is that the turn resumes the
|
|
162
|
+
* review's own session, so the model can answer *why* it said something rather
|
|
163
|
+
* than re-deriving an opinion. When resume is unavailable the transcript and
|
|
164
|
+
* the review come along anyway, and the model is told it is arriving cold —
|
|
165
|
+
* confidently citing a file it cannot open is the failure mode to avoid.
|
|
166
|
+
*/
|
|
167
|
+
export function buildChatPrompt(artifact, message, opts = {}) {
|
|
168
|
+
const needsDiff = !opts.resumed;
|
|
169
|
+
const truncated = needsDiff && artifact.diff.length > MAX_DIFF_CHARS;
|
|
170
|
+
const diffSection = needsDiff
|
|
171
|
+
? `\n## Diff\n\n\`\`\`diff\n${truncated ? artifact.diff.slice(0, MAX_DIFF_CHARS) + "\n[... diff truncated ...]" : artifact.diff}\n\`\`\`\n`
|
|
172
|
+
: "";
|
|
173
|
+
const note = opts.sourceNote ? `\n${opts.sourceNote}\n` : "";
|
|
174
|
+
const sourceSection = opts.source
|
|
175
|
+
? (opts.trusted ? TRUSTED_SECTION : SOURCE_SECTION) + note + "\n"
|
|
176
|
+
: `## No source checkout
|
|
177
|
+
|
|
178
|
+
The repository is NOT available to you. Every tool is off. Answer from the diff, the review, and this conversation — and when the honest answer is "I would have to look at the file to be sure", say that rather than describing code you cannot open.
|
|
179
|
+
`;
|
|
180
|
+
const cold = opts.resumed
|
|
181
|
+
? ""
|
|
182
|
+
: `\nYou are picking this review up cold: it was written in an earlier session you no longer hold. Everything you know about it is below. Do not claim to remember reading something — if the review asserts a thing you cannot now confirm, say so.\n`;
|
|
183
|
+
const prompt = `You are the code reviewer who wrote the review below, talking to the user who is about to send it.
|
|
184
|
+
|
|
185
|
+
They are not asking you to re-review the PR. They are questioning the draft you produced. Answer them honestly — defend what you actually checked, concede what you did not, and revise the review where they are right.
|
|
186
|
+
|
|
187
|
+
Respond with ONLY a JSON object (no markdown fences, no prose before or after) matching exactly this shape:
|
|
188
|
+
|
|
189
|
+
{
|
|
190
|
+
"reply": "markdown — what you say back to the user. Plain, direct, 1-3 short paragraphs.",
|
|
191
|
+
"revisions": [
|
|
192
|
+
{ "kind": "summary", "body": "the rewritten summary" },
|
|
193
|
+
{ "kind": "verdict", "verdict": { "recommendation": "approve" | "comment" | "request_changes", "confidence": 0-100, "reasoning": "..." } },
|
|
194
|
+
{ "kind": "chapter", "chapterId": "...", "title": "optional new title", "explanation": "optional new explanation" },
|
|
195
|
+
{ "kind": "comment-edit", "commentId": "...", "body": "the rewritten comment" },
|
|
196
|
+
{ "kind": "comment-drop", "commentId": "..." },
|
|
197
|
+
{ "kind": "comment-add", "path": "path/to/file.ts", "line": 42, "body": "...", "chapterId": "..." }
|
|
198
|
+
]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
Rules:
|
|
202
|
+
- Revise when the user is right, and only then. "revisions" is usually empty or has one entry; an empty array with a reply that explains your reasoning is a perfectly good turn.
|
|
203
|
+
- Do NOT revise merely to seem agreeable. If you checked something and the user is wrong about it, say so and change nothing. A reviewer that folds under every push-back is worthless.
|
|
204
|
+
- Never revise something the user did not raise. Fix what they pointed at, not everything you would now write differently.
|
|
205
|
+
- Comments marked [THE USER'S OWN WRITING] are theirs. Do not edit or drop them. If one of them is what needs changing, say so in your reply and let them do it.
|
|
206
|
+
- Every "commentId" and "chapterId" must be one listed below. Never invent one.
|
|
207
|
+
- "line" is a line in the NEW version of the file and must appear in the diff. Use null for a file-level comment.
|
|
208
|
+
- Your reply is prose to a person, not a changelog. The cockpit already shows what you changed — do not list your own edits back to them.
|
|
209
|
+
|
|
210
|
+
Writing style:
|
|
211
|
+
- Plain words, short sentences, no jargon. Someone who has NOT read the diff should follow you.
|
|
212
|
+
- Lead with the point. State the claim first, support it after.
|
|
213
|
+
- Do NOT imitate the PR description's writing style, and do not restate the author's claims as your own findings. If the description asserts something, it is a claim to check, not a fact to repeat.
|
|
214
|
+
${cold}
|
|
215
|
+
${sourceSection}## Pull request
|
|
216
|
+
|
|
217
|
+
Repo: ${artifact.pr.owner}/${artifact.pr.repo}
|
|
218
|
+
PR #${artifact.pr.number}: ${artifact.pr.title}
|
|
219
|
+
Author: ${artifact.pr.author}
|
|
220
|
+
|
|
221
|
+
${renderReview(artifact)}${describeRefs(artifact, opts.refs ?? [])}${renderTranscript(artifact.chat)}${diffSection}
|
|
222
|
+
## The user's message
|
|
223
|
+
|
|
224
|
+
${message}
|
|
225
|
+
`;
|
|
226
|
+
return { prompt, truncated };
|
|
227
|
+
}
|
|
96
228
|
export function buildRetryPrompt(originalPrompt, badOutput, error) {
|
|
97
229
|
return `${originalPrompt}
|
|
98
230
|
|
package/dist/runner/review.js
CHANGED
|
@@ -7,6 +7,7 @@ import { loadArtifact, saveArtifact } from "../core/state.js";
|
|
|
7
7
|
import { loadConfig } from "../core/config.js";
|
|
8
8
|
import { decideTrust, membershipQueries, parseTrustRules } from "../core/trust.js";
|
|
9
9
|
import { extractJson, runClaude, unauthenticatedEnv } from "./claude.js";
|
|
10
|
+
import { describeEvent } from "./progress.js";
|
|
10
11
|
import { beginReview, endReview, isReviewRunning } from "./inflight.js";
|
|
11
12
|
import { buildReviewPrompt, buildRetryPrompt } from "./prompt.js";
|
|
12
13
|
/** All an untrusted reviewer needs from a checkout — everything else stays off. */
|
|
@@ -121,10 +122,18 @@ async function runReview(ref, opts) {
|
|
|
121
122
|
error: null,
|
|
122
123
|
withSource: source !== null,
|
|
123
124
|
trusted: trusted && source !== null,
|
|
125
|
+
sessionId: null,
|
|
124
126
|
},
|
|
125
127
|
sent: null,
|
|
126
128
|
refresh: null,
|
|
127
129
|
calibration: null,
|
|
130
|
+
// The conversation is the user's writing, so a re-review keeps it — the
|
|
131
|
+
// chat prompt replays the transcript, which is what makes it survive the
|
|
132
|
+
// session change. The snapshot does NOT carry over: it describes the draft
|
|
133
|
+
// this run just replaced, and resetting to it would restore a dead review.
|
|
134
|
+
chat: existing?.chat ?? [],
|
|
135
|
+
preChat: null,
|
|
136
|
+
pendingChat: null,
|
|
128
137
|
};
|
|
129
138
|
await saveArtifact(artifact);
|
|
130
139
|
const { prompt, truncated } = buildReviewPrompt(pr, diff, { source: source !== null, trusted });
|
|
@@ -170,6 +179,9 @@ async function runReview(ref, opts) {
|
|
|
170
179
|
error: null,
|
|
171
180
|
withSource: source !== null,
|
|
172
181
|
trusted: trusted && source !== null,
|
|
182
|
+
// Only worth keeping when there is a checkout to resume into: a session
|
|
183
|
+
// whose working directory was empty has nothing a chat turn could read.
|
|
184
|
+
sessionId: source ? review.sessionId : null,
|
|
173
185
|
},
|
|
174
186
|
};
|
|
175
187
|
// A re-review regenerates the AI's comments, but the human's own comments
|
|
@@ -221,6 +233,9 @@ async function runAiReview(prompt, opts, source, trusted) {
|
|
|
221
233
|
const empty = await createRunDir();
|
|
222
234
|
const claudeOpts = {
|
|
223
235
|
model: opts.model,
|
|
236
|
+
// Say what the review is doing while it does it, rather than going quiet
|
|
237
|
+
// for minutes. `cerber review` and the daemon log print these.
|
|
238
|
+
onEvent: (event) => opts.onProgress && describeEvent(event).forEach(opts.onProgress),
|
|
224
239
|
cwd: source ?? empty,
|
|
225
240
|
env: unauthenticatedEnv(process.env, empty),
|
|
226
241
|
allowedTools: source ? (trusted ? TRUSTED_TOOLS : READ_TOOLS) : undefined,
|
|
@@ -236,17 +251,25 @@ async function runAiReview(prompt, opts, source, trusted) {
|
|
|
236
251
|
try {
|
|
237
252
|
const first = await runClaude(prompt, claudeOpts);
|
|
238
253
|
try {
|
|
239
|
-
return {
|
|
254
|
+
return {
|
|
255
|
+
ai: AiReviewSchema.parse(extractJson(first.text)),
|
|
256
|
+
costUsd: first.costUsd,
|
|
257
|
+
model: first.model,
|
|
258
|
+
sessionId: first.sessionId,
|
|
259
|
+
};
|
|
240
260
|
}
|
|
241
261
|
catch (err) {
|
|
242
262
|
const message = err instanceof Error ? err.message : String(err);
|
|
243
263
|
opts.onProgress?.("Model output failed validation — retrying once…");
|
|
264
|
+
// A fresh run, not a resume: the retry prompt already carries the whole
|
|
265
|
+
// original prompt, so resuming would send the diff twice.
|
|
244
266
|
const second = await runClaude(buildRetryPrompt(prompt, first.text, message), claudeOpts);
|
|
245
267
|
const cost = (first.costUsd ?? 0) + (second.costUsd ?? 0);
|
|
246
268
|
return {
|
|
247
269
|
ai: AiReviewSchema.parse(extractJson(second.text)),
|
|
248
270
|
costUsd: cost > 0 ? cost : null,
|
|
249
271
|
model: second.model,
|
|
272
|
+
sessionId: second.sessionId ?? first.sessionId,
|
|
250
273
|
};
|
|
251
274
|
}
|
|
252
275
|
}
|
package/dist/server/daemon.js
CHANGED
package/dist/server/index.js
CHANGED
|
@@ -17,6 +17,10 @@ import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
|
17
17
|
import { listArtifacts, loadArtifactByKey, reconcileRunning, updateArtifactByKey, } from "../core/state.js";
|
|
18
18
|
import { isReviewRunning } from "../runner/inflight.js";
|
|
19
19
|
import { reviewPr } from "../runner/review.js";
|
|
20
|
+
import { runChatTurn } from "../runner/chat.js";
|
|
21
|
+
import { mergeConcurrentEdits, restoreReview } from "../core/revise.js";
|
|
22
|
+
import { progressWriter } from "./progress.js";
|
|
23
|
+
import { ChatTurnSchema } from "../core/artifact.js";
|
|
20
24
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
25
|
export async function buildApp(opts) {
|
|
22
26
|
const app = new Hono();
|
|
@@ -294,6 +298,7 @@ export async function buildApp(opts) {
|
|
|
294
298
|
error: null,
|
|
295
299
|
withSource: withSource !== false,
|
|
296
300
|
trusted: false,
|
|
301
|
+
sessionId: null,
|
|
297
302
|
},
|
|
298
303
|
}));
|
|
299
304
|
// Minutes-long: run it detached and let the cockpit poll the artifact.
|
|
@@ -315,6 +320,121 @@ export async function buildApp(opts) {
|
|
|
315
320
|
});
|
|
316
321
|
return c.json(running, 202);
|
|
317
322
|
});
|
|
323
|
+
// ---- Chat: argue with the review before sending it ----
|
|
324
|
+
// Local only. The transcript never reaches GitHub — Send still builds its
|
|
325
|
+
// payload from the summary, comments and verdict alone.
|
|
326
|
+
const ChatRequestSchema = z.object({
|
|
327
|
+
message: z.string().min(1),
|
|
328
|
+
refs: ChatTurnSchema.shape.refs.optional(),
|
|
329
|
+
/** The user said, in so many words, that the run may touch their comments. */
|
|
330
|
+
allowUserComments: z.boolean().optional(),
|
|
331
|
+
});
|
|
332
|
+
const turnInFlight = (a) => a.pendingChat != null && a.pendingChat.error == null;
|
|
333
|
+
app.post("/api/reviews/:key/chat", async (c) => {
|
|
334
|
+
const key = c.req.param("key");
|
|
335
|
+
const artifact = await loadArtifactByKey(key);
|
|
336
|
+
if (!artifact)
|
|
337
|
+
return c.json({ error: "not found" }, 404);
|
|
338
|
+
// A sent review is a record of what was posted — arguing with it now would
|
|
339
|
+
// rewrite history that GitHub already has.
|
|
340
|
+
if (artifact.sent)
|
|
341
|
+
return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
|
|
342
|
+
if (isReviewRunning(artifact.id) || turnInFlight(artifact)) {
|
|
343
|
+
return c.json({ error: "a run for this PR is already in flight" }, 409);
|
|
344
|
+
}
|
|
345
|
+
let request;
|
|
346
|
+
try {
|
|
347
|
+
request = ChatRequestSchema.parse(await c.req.json());
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
|
|
351
|
+
}
|
|
352
|
+
// Record the question before responding, so the cockpit's next poll — and a
|
|
353
|
+
// reload mid-turn — finds a turn being answered rather than a message that
|
|
354
|
+
// went nowhere.
|
|
355
|
+
const pending = await updateArtifactByKey(key, (a) => ({
|
|
356
|
+
...a,
|
|
357
|
+
pendingChat: {
|
|
358
|
+
message: request.message,
|
|
359
|
+
refs: request.refs ?? [],
|
|
360
|
+
startedAt: new Date().toISOString(),
|
|
361
|
+
progress: [],
|
|
362
|
+
error: null,
|
|
363
|
+
},
|
|
364
|
+
}));
|
|
365
|
+
// The turn narrates itself as it reads the code; those lines go onto the
|
|
366
|
+
// artifact so the cockpit's poll can show them instead of a spinner.
|
|
367
|
+
const progress = progressWriter(key);
|
|
368
|
+
// Minutes-long, like a re-review: run it detached and let the cockpit poll.
|
|
369
|
+
// Holding the request open is fine on localhost and fails behind a reverse
|
|
370
|
+
// proxy's read timeout, which tells the user it broke while the turn
|
|
371
|
+
// quietly succeeds — a worse failure than an honest error.
|
|
372
|
+
void runChatTurn(artifact, request.message, {
|
|
373
|
+
refs: request.refs,
|
|
374
|
+
allowUserComments: request.allowUserComments,
|
|
375
|
+
onProgress: (m) => {
|
|
376
|
+
console.log(`[chat ${artifact.id}] ${m}`);
|
|
377
|
+
progress.push(m);
|
|
378
|
+
},
|
|
379
|
+
})
|
|
380
|
+
.then(async (result) => {
|
|
381
|
+
// Let the narration finish writing first: an append that read the
|
|
382
|
+
// artifact before this point would otherwise land on top of the answer.
|
|
383
|
+
await progress.stop();
|
|
384
|
+
// The cockpit stays live throughout, so fold the result onto whatever
|
|
385
|
+
// is on disk now rather than overwriting it — an edit the user made
|
|
386
|
+
// while waiting must not vanish when the answer lands.
|
|
387
|
+
await updateArtifactByKey(key, (current) => ({
|
|
388
|
+
...mergeConcurrentEdits(artifact, result.artifact, current),
|
|
389
|
+
pendingChat: null,
|
|
390
|
+
}));
|
|
391
|
+
})
|
|
392
|
+
.catch(async (err) => {
|
|
393
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
394
|
+
console.error(`[chat ${artifact.id}] failed: ${message}`);
|
|
395
|
+
await progress.stop();
|
|
396
|
+
// Nobody is holding a response to hand this to any more, so the failure
|
|
397
|
+
// lives on the artifact against the question that caused it — under
|
|
398
|
+
// whatever the turn had managed to do before it died.
|
|
399
|
+
await updateArtifactByKey(key, (a) => ({
|
|
400
|
+
...a,
|
|
401
|
+
pendingChat: a.pendingChat ? { ...a.pendingChat, error: message } : null,
|
|
402
|
+
})).catch(() => { });
|
|
403
|
+
});
|
|
404
|
+
return c.json(pending, 202);
|
|
405
|
+
});
|
|
406
|
+
// Clear a turn that failed. Only that: a turn still being answered would come
|
|
407
|
+
// back on the next write and the cockpit would have lied about dropping it.
|
|
408
|
+
app.delete("/api/reviews/:key/chat/pending", async (c) => {
|
|
409
|
+
const key = c.req.param("key");
|
|
410
|
+
const artifact = await loadArtifactByKey(key);
|
|
411
|
+
if (!artifact)
|
|
412
|
+
return c.json({ error: "not found" }, 404);
|
|
413
|
+
if (turnInFlight(artifact)) {
|
|
414
|
+
return c.json({ error: "that turn is still being answered" }, 409);
|
|
415
|
+
}
|
|
416
|
+
const updated = await updateArtifactByKey(key, (a) => ({ ...a, pendingChat: null }));
|
|
417
|
+
return c.json(updated);
|
|
418
|
+
});
|
|
419
|
+
// Put the review back the way it was before the conversation started. The
|
|
420
|
+
// conversation itself stays — you lose the edits, not the reasoning.
|
|
421
|
+
app.post("/api/reviews/:key/chat/reset", async (c) => {
|
|
422
|
+
const key = c.req.param("key");
|
|
423
|
+
const artifact = await loadArtifactByKey(key);
|
|
424
|
+
if (!artifact)
|
|
425
|
+
return c.json({ error: "not found" }, 404);
|
|
426
|
+
if (artifact.sent)
|
|
427
|
+
return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
|
|
428
|
+
// A turn in flight will fold its own result on top of whatever is on disk
|
|
429
|
+
// when it lands, so resetting now would be undone a minute later.
|
|
430
|
+
if (turnInFlight(artifact)) {
|
|
431
|
+
return c.json({ error: "a turn is still being answered — wait for it to land" }, 409);
|
|
432
|
+
}
|
|
433
|
+
if (!artifact.preChat)
|
|
434
|
+
return c.json({ error: "this review has not been chatted about" }, 409);
|
|
435
|
+
const updated = await updateArtifactByKey(key, (a) => a.preChat ? restoreReview(a, a.preChat) : a);
|
|
436
|
+
return c.json(updated);
|
|
437
|
+
});
|
|
318
438
|
// ---- Export (local file download) ----
|
|
319
439
|
app.get("/api/reviews/:key/export", async (c) => {
|
|
320
440
|
const artifact = await loadArtifactByKey(c.req.param("key"));
|
|
@@ -381,7 +501,7 @@ export async function buildApp(opts) {
|
|
|
381
501
|
export async function startServer(opts) {
|
|
382
502
|
const cleared = await reconcileRunning();
|
|
383
503
|
if (cleared > 0) {
|
|
384
|
-
console.log(`Cleared ${cleared}
|
|
504
|
+
console.log(`Cleared ${cleared} AI run(s) left in flight by a previous process — start them again from the cockpit.`);
|
|
385
505
|
}
|
|
386
506
|
const app = await buildApp(opts);
|
|
387
507
|
serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { updateArtifactByKey } from "../core/state.js";
|
|
2
|
+
/**
|
|
3
|
+
* Putting a running turn's narration where the cockpit can see it.
|
|
4
|
+
*
|
|
5
|
+
* The turn is detached, so there is no connection to stream down: the artifact
|
|
6
|
+
* is the channel, exactly as it is for the pending question itself. That keeps
|
|
7
|
+
* the story on a reload and behind a proxy, and costs no new transport.
|
|
8
|
+
*
|
|
9
|
+
* Two things it must not do. It must not rewrite the whole artifact — diff and
|
|
10
|
+
* all — once per line, so lines are coalesced into one write every couple of
|
|
11
|
+
* seconds, which is as often as the cockpit polls anyway. And it must not still
|
|
12
|
+
* be writing when the turn lands: an append that read the artifact before the
|
|
13
|
+
* answer was folded in would write back the "still thinking" version on top of
|
|
14
|
+
* it. `stop()` is awaited before the result is saved, and refuses every append
|
|
15
|
+
* after that.
|
|
16
|
+
*/
|
|
17
|
+
/** Enough to see what it has been up to, not so much that the panel scrolls away. */
|
|
18
|
+
const KEEP = 30;
|
|
19
|
+
export function progressWriter(key, opts = {}) {
|
|
20
|
+
const everyMs = opts.everyMs ?? 2_000;
|
|
21
|
+
const keep = opts.keep ?? KEEP;
|
|
22
|
+
const update = opts.update ?? updateArtifactByKey;
|
|
23
|
+
let queued = [];
|
|
24
|
+
let last = null;
|
|
25
|
+
let timer = null;
|
|
26
|
+
let stopped = false;
|
|
27
|
+
// Writes chain onto each other so two flushes can never interleave their
|
|
28
|
+
// read-modify-write, and so stop() has something to await.
|
|
29
|
+
let writes = Promise.resolve();
|
|
30
|
+
const flush = () => {
|
|
31
|
+
timer = null;
|
|
32
|
+
const lines = queued;
|
|
33
|
+
queued = [];
|
|
34
|
+
if (!lines.length)
|
|
35
|
+
return;
|
|
36
|
+
writes = writes.then(() => update(key, (a) =>
|
|
37
|
+
// No pending turn means it already landed (or was dismissed) — there is
|
|
38
|
+
// nothing left for this narration to describe.
|
|
39
|
+
a.pendingChat
|
|
40
|
+
? {
|
|
41
|
+
...a,
|
|
42
|
+
pendingChat: {
|
|
43
|
+
...a.pendingChat,
|
|
44
|
+
progress: [...a.pendingChat.progress, ...lines].slice(-keep),
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
: a).catch(() => {
|
|
48
|
+
// A line that never reaches disk is a line the user doesn't see. The
|
|
49
|
+
// turn itself is unaffected, so it is not worth failing over.
|
|
50
|
+
}));
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
push(line) {
|
|
54
|
+
if (stopped || !line || line === last)
|
|
55
|
+
return;
|
|
56
|
+
last = line;
|
|
57
|
+
queued.push(line);
|
|
58
|
+
if (!timer)
|
|
59
|
+
timer = setTimeout(flush, everyMs);
|
|
60
|
+
},
|
|
61
|
+
async stop() {
|
|
62
|
+
stopped = true;
|
|
63
|
+
if (timer)
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
// Flush what's left rather than dropping it: on the failure path the
|
|
66
|
+
// pending turn survives, and the last thing it did before dying is
|
|
67
|
+
// exactly what the user wants to see.
|
|
68
|
+
flush();
|
|
69
|
+
await writes;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullstackhouse/cerber",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "AI code-review cockpit: Claude reviews your PRs into local artifacts; nothing reaches GitHub until you say so.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": "github:fullstackhouse/cerber",
|