@fullstackhouse/cerber 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/dist/core/artifact.js +67 -0
- package/dist/core/revise.js +166 -0
- package/dist/runner/chat.js +215 -0
- package/dist/runner/claude.js +3 -0
- package/dist/runner/prompt.js +132 -0
- package/dist/runner/review.js +19 -1
- package/dist/server/daemon.js +2 -0
- package/dist/server/index.js +62 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-Dmz5lQnG.css → index-BGy7f14m.css} +1 -1
- package/web/dist/assets/index-CmpTqzKD.js +236 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CsWW0EBv.js +0 -236
package/README.md
CHANGED
|
@@ -199,6 +199,42 @@ get its opinion of the new code, hit **Re-review at the new head** in the
|
|
|
199
199
|
cockpit (or `cerber review <pr> --force`) — comments you wrote or rewrote are
|
|
200
200
|
carried into the fresh review.
|
|
201
201
|
|
|
202
|
+
### Arguing with the review before you send it
|
|
203
|
+
|
|
204
|
+
A draft you disagree with used to leave two options: rewrite it by hand, or
|
|
205
|
+
re-review and hope. Neither lets you say *what was wrong*. So every review has
|
|
206
|
+
a conversation attached — **Talk to the reviewer**, at the bottom of the
|
|
207
|
+
walkthrough.
|
|
208
|
+
|
|
209
|
+
Ask it why it said something, and it answers. Tell it the summary is restating
|
|
210
|
+
the author's claims rather than what it verified, and it rewrites the summary.
|
|
211
|
+
The reviewer revises the draft as it answers: there is no accept step, because
|
|
212
|
+
you already asked for the change. What it changed shows up in the transcript
|
|
213
|
+
(*"✎ rewrote the summary"*, *"✎ dropped the comment on line 250"*).
|
|
214
|
+
|
|
215
|
+
- **It is the same reviewer.** A turn resumes the Claude session the review ran
|
|
216
|
+
in, so it still holds everything it read. Asking "did you actually check
|
|
217
|
+
that?" gets an answer from the run that did or didn't — not from a fresh
|
|
218
|
+
agent re-deriving an opinion from the diff.
|
|
219
|
+
- **It reads the code to answer you.** The checkout cache is small, so by the
|
|
220
|
+
time you argue with a review its source is usually evicted; a turn re-clones
|
|
221
|
+
it. If it can't, the turn still runs and the reviewer is told it is working
|
|
222
|
+
blind rather than left to describe files from memory.
|
|
223
|
+
- **It can say no.** The prompt tells it not to fold under push-back. If you
|
|
224
|
+
are wrong about something it checked, it says so and changes nothing.
|
|
225
|
+
- **Your words are yours.** Comments you wrote or edited are off limits: it
|
|
226
|
+
will tell you one of them needs changing rather than rewriting it. Say so
|
|
227
|
+
explicitly and it will.
|
|
228
|
+
- **Point at things.** *discuss* on any comment, chapter, or the summary drops
|
|
229
|
+
a reference into your next message.
|
|
230
|
+
- **Reset** puts the review back the way the AI first wrote it and keeps the
|
|
231
|
+
conversation — you lose the edits, not the reasoning.
|
|
232
|
+
|
|
233
|
+
Nothing in the conversation reaches GitHub. Send still builds its payload from
|
|
234
|
+
the summary, comments and verdict alone, and is still one deliberate click. A
|
|
235
|
+
review that has already been sent can't be argued with: that artifact is the
|
|
236
|
+
record of what GitHub has.
|
|
237
|
+
|
|
202
238
|
## Status / roadmap
|
|
203
239
|
|
|
204
240
|
Early, but all six planned phases have shipped.
|
package/dist/core/artifact.js
CHANGED
|
@@ -66,6 +66,11 @@ export const RunInfoSchema = z.object({
|
|
|
66
66
|
withSource: z.boolean().default(false),
|
|
67
67
|
/** The user vouched for this PR, so the reviewer could also run commands. */
|
|
68
68
|
trusted: z.boolean().default(false),
|
|
69
|
+
/**
|
|
70
|
+
* The Claude session this review ran in. Chat turns resume it, so the
|
|
71
|
+
* reviewer answering a question still holds everything it read.
|
|
72
|
+
*/
|
|
73
|
+
sessionId: z.string().nullable().default(null),
|
|
69
74
|
});
|
|
70
75
|
export const SentInfoSchema = z.object({
|
|
71
76
|
at: z.string(),
|
|
@@ -94,6 +99,59 @@ export const CalibrationSchema = z.object({
|
|
|
94
99
|
aiCommentsEdited: z.number().int(),
|
|
95
100
|
userCommentsAdded: z.number().int(),
|
|
96
101
|
});
|
|
102
|
+
/**
|
|
103
|
+
* One edit a chat turn made to the review it is about.
|
|
104
|
+
*
|
|
105
|
+
* The agent revises the draft directly — there is no accept step — so these are
|
|
106
|
+
* a record of what already happened, rendered in the transcript, not a proposal
|
|
107
|
+
* awaiting a click. The user's escape hatch is the conversation itself, plus
|
|
108
|
+
* the one pre-chat snapshot.
|
|
109
|
+
*/
|
|
110
|
+
export const RevisionSchema = z.discriminatedUnion("kind", [
|
|
111
|
+
z.object({ kind: z.literal("summary"), body: z.string() }),
|
|
112
|
+
z.object({ kind: z.literal("verdict"), verdict: VerdictSchema }),
|
|
113
|
+
z.object({ kind: z.literal("chapter"), chapterId: z.string(), title: z.string().optional(), explanation: z.string().optional() }),
|
|
114
|
+
z.object({ kind: z.literal("comment-edit"), commentId: z.string(), body: z.string() }),
|
|
115
|
+
z.object({ kind: z.literal("comment-drop"), commentId: z.string() }),
|
|
116
|
+
z.object({
|
|
117
|
+
kind: z.literal("comment-add"),
|
|
118
|
+
path: z.string(),
|
|
119
|
+
line: z.number().int().positive().nullable(),
|
|
120
|
+
body: z.string(),
|
|
121
|
+
chapterId: z.string().nullable().default(null),
|
|
122
|
+
}),
|
|
123
|
+
]);
|
|
124
|
+
/** A revision the run declined to make, and why — shown in the transcript. */
|
|
125
|
+
export const RefusalSchema = z.object({
|
|
126
|
+
revision: RevisionSchema,
|
|
127
|
+
reason: z.string(),
|
|
128
|
+
});
|
|
129
|
+
export const ChatTurnSchema = z.object({
|
|
130
|
+
id: z.string(),
|
|
131
|
+
role: z.enum(["user", "assistant"]),
|
|
132
|
+
at: z.string(),
|
|
133
|
+
body: z.string(),
|
|
134
|
+
/** What the user pointed at with "discuss this" — user turns only. */
|
|
135
|
+
refs: z
|
|
136
|
+
.array(z.object({
|
|
137
|
+
target: z.enum(["summary", "verdict", "chapter", "comment"]),
|
|
138
|
+
id: z.string().nullable().default(null),
|
|
139
|
+
}))
|
|
140
|
+
.default([]),
|
|
141
|
+
/** Edits this turn made to the review. Assistant turns only. */
|
|
142
|
+
revisions: z.array(RevisionSchema).default([]),
|
|
143
|
+
/** Edits this turn asked for but the artifact would not accept. */
|
|
144
|
+
refused: z.array(RefusalSchema).default([]),
|
|
145
|
+
costUsd: z.number().nullable().default(null),
|
|
146
|
+
});
|
|
147
|
+
/** The parts of a review a chat turn may rewrite — the reset target. */
|
|
148
|
+
export const ReviewSnapshotSchema = z.object({
|
|
149
|
+
at: z.string(),
|
|
150
|
+
summary: z.string(),
|
|
151
|
+
chapters: z.array(ChapterSchema),
|
|
152
|
+
comments: z.array(CommentSchema),
|
|
153
|
+
verdict: VerdictSchema.nullable(),
|
|
154
|
+
});
|
|
97
155
|
export const ArtifactSchema = z.object({
|
|
98
156
|
schemaVersion: z.literal(SCHEMA_VERSION),
|
|
99
157
|
/** "owner/repo#123" */
|
|
@@ -115,6 +173,10 @@ export const ArtifactSchema = z.object({
|
|
|
115
173
|
/** Last time this review was pulled forward onto a newer head commit. */
|
|
116
174
|
refresh: RefreshInfoSchema.nullable().default(null),
|
|
117
175
|
calibration: CalibrationSchema.nullable().default(null),
|
|
176
|
+
/** The conversation about this review. Never sent to GitHub. */
|
|
177
|
+
chat: z.array(ChatTurnSchema).default([]),
|
|
178
|
+
/** The review as it stood before the first chat turn — "reset" restores this. */
|
|
179
|
+
preChat: ReviewSnapshotSchema.nullable().default(null),
|
|
118
180
|
});
|
|
119
181
|
/** What the AI must return. Subset of the artifact; cerber owns the rest. */
|
|
120
182
|
export const AiReviewSchema = z.object({
|
|
@@ -133,6 +195,11 @@ export const AiReviewSchema = z.object({
|
|
|
133
195
|
})),
|
|
134
196
|
verdict: VerdictSchema,
|
|
135
197
|
});
|
|
198
|
+
/** What a chat turn must return: something to say, and what it changed. */
|
|
199
|
+
export const AiChatTurnSchema = z.object({
|
|
200
|
+
reply: z.string(),
|
|
201
|
+
revisions: z.array(RevisionSchema).default([]),
|
|
202
|
+
});
|
|
136
203
|
export function artifactId(pr) {
|
|
137
204
|
return `${pr.owner}/${pr.repo}#${pr.number}`;
|
|
138
205
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
/** The review as it stands, for restoring later. */
|
|
3
|
+
export function snapshotReview(artifact, at) {
|
|
4
|
+
return {
|
|
5
|
+
at,
|
|
6
|
+
summary: artifact.summary,
|
|
7
|
+
chapters: artifact.chapters,
|
|
8
|
+
comments: artifact.comments,
|
|
9
|
+
verdict: artifact.verdict,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** Put a review back the way the snapshot found it. Chat history is untouched. */
|
|
13
|
+
export function restoreReview(artifact, snapshot) {
|
|
14
|
+
return {
|
|
15
|
+
...artifact,
|
|
16
|
+
summary: snapshot.summary,
|
|
17
|
+
chapters: snapshot.chapters,
|
|
18
|
+
comments: snapshot.comments,
|
|
19
|
+
verdict: snapshot.verdict,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Fold a finished chat turn back onto whatever the artifact says now.
|
|
24
|
+
*
|
|
25
|
+
* A turn takes minutes, and the cockpit stays live throughout: the user can
|
|
26
|
+
* edit, drop or add a comment while waiting for the answer. Writing the turn's
|
|
27
|
+
* result wholesale would silently undo that, which is the one thing a run must
|
|
28
|
+
* never do to the user's own work.
|
|
29
|
+
*
|
|
30
|
+
* `before` is the artifact the turn started from, `after` is what it produced,
|
|
31
|
+
* `current` is what is on disk now. Anything the user changed between `before`
|
|
32
|
+
* and `current` wins; everything else comes from the turn.
|
|
33
|
+
*/
|
|
34
|
+
export function mergeConcurrentEdits(before, after, current) {
|
|
35
|
+
const wasThere = new Map(before.comments.map((c) => [c.id, c]));
|
|
36
|
+
const fromTurn = new Map(after.comments.map((c) => [c.id, c]));
|
|
37
|
+
const userTouched = (id, now) => {
|
|
38
|
+
const then = wasThere.get(id);
|
|
39
|
+
// Absent from `before` means the user added it while the turn ran.
|
|
40
|
+
if (!then)
|
|
41
|
+
return true;
|
|
42
|
+
return then.body !== now.body || then.status !== now.status;
|
|
43
|
+
};
|
|
44
|
+
const merged = current.comments.map((now) => userTouched(now.id, now) ? now : fromTurn.get(now.id) ?? now);
|
|
45
|
+
// Comments the turn itself added are not in `current` yet. Only those: a
|
|
46
|
+
// comment present in both `before` and `after` but missing from `current` is
|
|
47
|
+
// one the user deleted while the turn ran, and pushing it back would undo
|
|
48
|
+
// the deletion — the very thing this function exists to prevent. (Delete
|
|
49
|
+
// removes the row outright; drop only marks it, so drops survive above.)
|
|
50
|
+
const seen = new Set(merged.map((c) => c.id));
|
|
51
|
+
for (const c of after.comments) {
|
|
52
|
+
if (!wasThere.has(c.id) && !seen.has(c.id))
|
|
53
|
+
merged.push(c);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
...after,
|
|
57
|
+
comments: merged,
|
|
58
|
+
// The turn never sets a queue status, so a "mark reviewed" clicked while it
|
|
59
|
+
// ran is the user's and stands. The verdict it can change, so the turn wins
|
|
60
|
+
// there only when it actually revised one.
|
|
61
|
+
status: current.status,
|
|
62
|
+
verdict: after.verdict === before.verdict ? current.verdict : after.verdict,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function applyRevisions(artifact, revisions, opts = {}) {
|
|
66
|
+
const newId = opts.newId ?? randomUUID;
|
|
67
|
+
const applied = [];
|
|
68
|
+
const refused = [];
|
|
69
|
+
let next = artifact;
|
|
70
|
+
const refuse = (revision, reason) => refused.push({ revision, reason });
|
|
71
|
+
/** A comment the user authored or rewrote is theirs, not the run's to redo. */
|
|
72
|
+
const isUsers = (id) => {
|
|
73
|
+
const c = next.comments.find((x) => x.id === id);
|
|
74
|
+
return c ? c.origin === "user" || c.editedByUser : false;
|
|
75
|
+
};
|
|
76
|
+
for (const revision of revisions) {
|
|
77
|
+
switch (revision.kind) {
|
|
78
|
+
case "summary": {
|
|
79
|
+
next = { ...next, summary: revision.body };
|
|
80
|
+
applied.push(revision);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case "verdict": {
|
|
84
|
+
next = { ...next, verdict: revision.verdict };
|
|
85
|
+
applied.push(revision);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case "chapter": {
|
|
89
|
+
const chapter = next.chapters.find((ch) => ch.id === revision.chapterId);
|
|
90
|
+
if (!chapter) {
|
|
91
|
+
refuse(revision, `no chapter "${revision.chapterId}" in this review`);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
next = {
|
|
95
|
+
...next,
|
|
96
|
+
chapters: next.chapters.map((ch) => ch.id === revision.chapterId
|
|
97
|
+
? {
|
|
98
|
+
...ch,
|
|
99
|
+
title: revision.title ?? ch.title,
|
|
100
|
+
explanation: revision.explanation ?? ch.explanation,
|
|
101
|
+
}
|
|
102
|
+
: ch),
|
|
103
|
+
};
|
|
104
|
+
applied.push(revision);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case "comment-edit": {
|
|
108
|
+
if (!next.comments.some((c) => c.id === revision.commentId)) {
|
|
109
|
+
refuse(revision, `no comment ${revision.commentId} in this review`);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
if (isUsers(revision.commentId) && !opts.allowUserComments) {
|
|
113
|
+
refuse(revision, "that comment is the user's own writing — ask before rewriting it");
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
next = {
|
|
117
|
+
...next,
|
|
118
|
+
comments: next.comments.map((c) => c.id === revision.commentId ? { ...c, body: revision.body } : c),
|
|
119
|
+
};
|
|
120
|
+
applied.push(revision);
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case "comment-drop": {
|
|
124
|
+
if (!next.comments.some((c) => c.id === revision.commentId)) {
|
|
125
|
+
refuse(revision, `no comment ${revision.commentId} in this review`);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
if (isUsers(revision.commentId) && !opts.allowUserComments) {
|
|
129
|
+
refuse(revision, "that comment is the user's own writing — ask before dropping it");
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
// Dropped, not deleted: `status` is what Send already reads, and the
|
|
133
|
+
// cockpit shows a dropped comment struck through rather than vanished.
|
|
134
|
+
next = {
|
|
135
|
+
...next,
|
|
136
|
+
comments: next.comments.map((c) => c.id === revision.commentId ? { ...c, status: "dropped" } : c),
|
|
137
|
+
};
|
|
138
|
+
applied.push(revision);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case "comment-add": {
|
|
142
|
+
next = {
|
|
143
|
+
...next,
|
|
144
|
+
comments: [
|
|
145
|
+
...next.comments,
|
|
146
|
+
{
|
|
147
|
+
id: newId(),
|
|
148
|
+
path: revision.path,
|
|
149
|
+
line: revision.line,
|
|
150
|
+
body: revision.body,
|
|
151
|
+
chapterId: revision.chapterId,
|
|
152
|
+
origin: "ai",
|
|
153
|
+
status: "draft",
|
|
154
|
+
editedByUser: false,
|
|
155
|
+
originalLine: null,
|
|
156
|
+
drifted: false,
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
};
|
|
160
|
+
applied.push(revision);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { artifact: next, applied, refused };
|
|
166
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { AiChatTurnSchema, } from "../core/artifact.js";
|
|
4
|
+
import { createRunDir, prepareCheckout, removeRunDir, sourceDir } from "../core/checkout.js";
|
|
5
|
+
import { applyRevisions, snapshotReview } from "../core/revise.js";
|
|
6
|
+
import { extractJson, runClaude, unauthenticatedEnv } from "./claude.js";
|
|
7
|
+
import { beginReview, endReview } from "./inflight.js";
|
|
8
|
+
import { buildChatPrompt } from "./prompt.js";
|
|
9
|
+
/**
|
|
10
|
+
* One turn of a conversation about a drafted review.
|
|
11
|
+
*
|
|
12
|
+
* The turn resumes the review's own Claude session where it can, so the model
|
|
13
|
+
* answering "did you actually check that?" is the one that did or didn't. The
|
|
14
|
+
* agent revises the draft directly — no accept step — because the user asked
|
|
15
|
+
* for the change and asking again would be friction wearing safety's coat.
|
|
16
|
+
*
|
|
17
|
+
* Everything a review run is denied, a chat turn is denied identically: it has
|
|
18
|
+
* no GitHub credentials, treats the checkout as untrusted content, and never
|
|
19
|
+
* gets Edit or Write. It is one step from the only path that writes to GitHub,
|
|
20
|
+
* so the isolation is the same isolation.
|
|
21
|
+
*/
|
|
22
|
+
/** All an untrusted turn needs from a checkout — everything else stays off. */
|
|
23
|
+
const READ_TOOLS = ["Read", "Grep", "Glob"];
|
|
24
|
+
const OFF_TOOLS = ["Bash", "Edit", "Write", "NotebookEdit", "Task", "WebFetch", "WebSearch"];
|
|
25
|
+
const TRUSTED_TOOLS = [...READ_TOOLS, "Bash", "WebFetch", "WebSearch"];
|
|
26
|
+
const TRUSTED_OFF_TOOLS = ["Edit", "Write", "NotebookEdit"];
|
|
27
|
+
/**
|
|
28
|
+
* Put the reviewed source back under the chat turn, or say plainly that we
|
|
29
|
+
* could not.
|
|
30
|
+
*
|
|
31
|
+
* The checkout cache is small and evicts by least-recent use, so by the time
|
|
32
|
+
* someone argues with a review its source is often already gone. That matters
|
|
33
|
+
* more here than anywhere else: the turn resumes a session whose transcript
|
|
34
|
+
* still describes files it read, and Claude sessions are keyed by working
|
|
35
|
+
* directory. Resuming into a deleted one leaves a model that believes it can
|
|
36
|
+
* open the file and will otherwise describe it from memory.
|
|
37
|
+
*
|
|
38
|
+
* `prepareCheckout` is idempotent and restores the same deterministic path, so
|
|
39
|
+
* re-cloning is enough to make resume whole again. It also touches the
|
|
40
|
+
* directory, which is what keeps a checkout alive through a long conversation:
|
|
41
|
+
* eviction is by mtime, so every turn renews it.
|
|
42
|
+
*/
|
|
43
|
+
export async function ensureChatSource(artifact, opts = {}) {
|
|
44
|
+
const log = opts.log ?? (() => { });
|
|
45
|
+
const prepare = opts.prepare ?? prepareCheckout;
|
|
46
|
+
// A review that never had a checkout has nothing to restore, and re-cloning
|
|
47
|
+
// now would give the conversation context the review itself never had.
|
|
48
|
+
if (!artifact.run?.withSource)
|
|
49
|
+
return { dir: null, sha: null, recloned: false };
|
|
50
|
+
const ref = {
|
|
51
|
+
owner: artifact.pr.owner,
|
|
52
|
+
repo: artifact.pr.repo,
|
|
53
|
+
number: artifact.pr.number,
|
|
54
|
+
};
|
|
55
|
+
const existed = await isCheckedOut(sourceDir(ref));
|
|
56
|
+
try {
|
|
57
|
+
// The same trust the review ran under: a checkout prepared as trusted keeps
|
|
58
|
+
// the PR's own CLAUDE.md in place, and re-preparing with the wrong flag
|
|
59
|
+
// would either un-quarantine it or quarantine what the review could read.
|
|
60
|
+
const checkout = await prepare(ref, { log, trusted: artifact.run.trusted });
|
|
61
|
+
if (!existed)
|
|
62
|
+
log("The checkout had been evicted — re-cloned it so this turn can read the code.");
|
|
63
|
+
return { dir: checkout.dir, sha: checkout.sha, recloned: !existed };
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
log(`Could not restore the source (${err instanceof Error ? err.message : err}) — ` +
|
|
67
|
+
`this turn works from the review, the diff and the conversation alone.`);
|
|
68
|
+
return { dir: null, sha: null, recloned: false };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function isCheckedOut(dir) {
|
|
72
|
+
return fs
|
|
73
|
+
.stat(dir)
|
|
74
|
+
.then((s) => s.isDirectory())
|
|
75
|
+
.catch(() => false);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Append the user's message and the reviewer's answer, applying whatever the
|
|
79
|
+
* answer revised. Claims the artifact's inflight slot for the duration: the
|
|
80
|
+
* daemon's timer and the cockpit's re-review button write the same file.
|
|
81
|
+
*/
|
|
82
|
+
export async function runChatTurn(artifact, message, opts = {}) {
|
|
83
|
+
beginReview(artifact.id);
|
|
84
|
+
try {
|
|
85
|
+
return await turn(artifact, message, opts);
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
endReview(artifact.id);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function turn(artifact, message, opts) {
|
|
92
|
+
const now = opts.now ?? (() => new Date().toISOString());
|
|
93
|
+
const newId = opts.newId ?? randomUUID;
|
|
94
|
+
const run = opts.run ?? runClaude;
|
|
95
|
+
const log = opts.onProgress ?? (() => { });
|
|
96
|
+
// Restore the checkout unless the caller already resolved one (or asked for
|
|
97
|
+
// none). Doing it here, rather than making the cockpit remember, is what
|
|
98
|
+
// stops "the cache evicted it" from silently degrading every later turn.
|
|
99
|
+
const resolved = opts.source === undefined
|
|
100
|
+
? await ensureChatSource(artifact, { log })
|
|
101
|
+
: { dir: opts.source, sha: opts.sourceSha ?? null, recloned: false };
|
|
102
|
+
const source = resolved.dir;
|
|
103
|
+
const trusted = (opts.trusted ?? artifact.run?.trusted ?? false) && source !== null;
|
|
104
|
+
// A re-cloned checkout follows the PR, not the review: if the author pushed
|
|
105
|
+
// since, the files under the model are newer than the draft it is defending.
|
|
106
|
+
const moved = source !== null && resolved.sha !== null && artifact.pr.headSha !== "" && resolved.sha !== artifact.pr.headSha
|
|
107
|
+
? `Note: the review was written against ${artifact.pr.headSha.slice(0, 7)}, but the checkout is now at ${resolved.sha.slice(0, 7)} — the author has pushed since. Code you read may not be the code the review is about; say so when it matters.`
|
|
108
|
+
: undefined;
|
|
109
|
+
if (moved)
|
|
110
|
+
log(moved);
|
|
111
|
+
// The session is only worth resuming into the directory it was created in.
|
|
112
|
+
// Without the checkout the transcript survives but Read and Grep do not, and
|
|
113
|
+
// a model that thinks it can still open the file will describe code from
|
|
114
|
+
// memory — worse than one that knows it is working blind.
|
|
115
|
+
const resumeSessionId = source && artifact.run?.sessionId ? artifact.run.sessionId : undefined;
|
|
116
|
+
if (artifact.run?.sessionId && !source) {
|
|
117
|
+
log("The checkout is gone — this turn works from the review, the diff and the conversation.");
|
|
118
|
+
}
|
|
119
|
+
const { prompt } = buildChatPrompt(artifact, message, {
|
|
120
|
+
refs: opts.refs,
|
|
121
|
+
source: source !== null,
|
|
122
|
+
trusted,
|
|
123
|
+
resumed: Boolean(resumeSessionId),
|
|
124
|
+
sourceNote: moved,
|
|
125
|
+
});
|
|
126
|
+
const empty = await createRunDir();
|
|
127
|
+
const claudeOpts = {
|
|
128
|
+
model: opts.model,
|
|
129
|
+
cwd: source ?? empty,
|
|
130
|
+
env: unauthenticatedEnv(process.env, empty),
|
|
131
|
+
allowedTools: source ? (trusted ? TRUSTED_TOOLS : READ_TOOLS) : undefined,
|
|
132
|
+
disallowedTools: source
|
|
133
|
+
? trusted
|
|
134
|
+
? TRUSTED_OFF_TOOLS
|
|
135
|
+
: OFF_TOOLS
|
|
136
|
+
: [...OFF_TOOLS, ...READ_TOOLS],
|
|
137
|
+
isolateWorkspace: !(source && trusted),
|
|
138
|
+
resumeSessionId,
|
|
139
|
+
};
|
|
140
|
+
let reply;
|
|
141
|
+
let costUsd;
|
|
142
|
+
let sessionId;
|
|
143
|
+
try {
|
|
144
|
+
const first = await run(prompt, claudeOpts);
|
|
145
|
+
try {
|
|
146
|
+
reply = AiChatTurnSchema.parse(extractJson(first.text));
|
|
147
|
+
costUsd = first.costUsd;
|
|
148
|
+
sessionId = first.sessionId;
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
152
|
+
log("Model output failed validation — retrying once…");
|
|
153
|
+
// Resume the turn we just had rather than the review: the retry is about
|
|
154
|
+
// the JSON shape, and re-sending the whole prompt would say it all twice.
|
|
155
|
+
const second = await run(retryPrompt(first.text, detail), {
|
|
156
|
+
...claudeOpts,
|
|
157
|
+
resumeSessionId: first.sessionId ?? resumeSessionId,
|
|
158
|
+
});
|
|
159
|
+
reply = AiChatTurnSchema.parse(extractJson(second.text));
|
|
160
|
+
const total = (first.costUsd ?? 0) + (second.costUsd ?? 0);
|
|
161
|
+
costUsd = total > 0 ? total : null;
|
|
162
|
+
sessionId = second.sessionId ?? first.sessionId;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
await removeRunDir(empty);
|
|
167
|
+
}
|
|
168
|
+
// The first turn snapshots the review, so "reset" always has somewhere to go
|
|
169
|
+
// back to. Later turns leave that snapshot alone: it is the pre-chat draft,
|
|
170
|
+
// not the previous turn's.
|
|
171
|
+
const at = now();
|
|
172
|
+
const preChat = artifact.preChat ?? snapshotReview(artifact, at);
|
|
173
|
+
const userTurn = {
|
|
174
|
+
id: newId(),
|
|
175
|
+
role: "user",
|
|
176
|
+
at,
|
|
177
|
+
body: message,
|
|
178
|
+
refs: opts.refs ?? [],
|
|
179
|
+
revisions: [],
|
|
180
|
+
refused: [],
|
|
181
|
+
costUsd: null,
|
|
182
|
+
};
|
|
183
|
+
const revised = applyRevisions(artifact, reply.revisions, {
|
|
184
|
+
newId,
|
|
185
|
+
allowUserComments: opts.allowUserComments,
|
|
186
|
+
});
|
|
187
|
+
const assistantTurn = {
|
|
188
|
+
id: newId(),
|
|
189
|
+
role: "assistant",
|
|
190
|
+
at: now(),
|
|
191
|
+
body: reply.reply,
|
|
192
|
+
refs: [],
|
|
193
|
+
revisions: revised.applied,
|
|
194
|
+
refused: revised.refused,
|
|
195
|
+
costUsd,
|
|
196
|
+
};
|
|
197
|
+
return {
|
|
198
|
+
artifact: {
|
|
199
|
+
...revised.artifact,
|
|
200
|
+
updatedAt: assistantTurn.at,
|
|
201
|
+
preChat,
|
|
202
|
+
chat: [...artifact.chat, userTurn, assistantTurn],
|
|
203
|
+
run: artifact.run ? { ...artifact.run, sessionId: sessionId ?? artifact.run.sessionId } : null,
|
|
204
|
+
},
|
|
205
|
+
applied: revised.applied,
|
|
206
|
+
refused: revised.refused,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function retryPrompt(badOutput, error) {
|
|
210
|
+
return `Your previous response could not be parsed as the required JSON.
|
|
211
|
+
Error: ${error}
|
|
212
|
+
Previous response (truncated): ${badOutput.slice(0, 2_000)}
|
|
213
|
+
|
|
214
|
+
Respond again with ONLY the valid JSON object described earlier: an object with "reply" and "revisions".`;
|
|
215
|
+
}
|
package/dist/runner/claude.js
CHANGED
|
@@ -40,6 +40,8 @@ export const DEFAULT_TIMEOUT_MS = 30 * 60_000;
|
|
|
40
40
|
const SIGKILL_GRACE_MS = 5_000;
|
|
41
41
|
export function buildClaudeArgs(opts) {
|
|
42
42
|
const args = ["-p", "--output-format", "json"];
|
|
43
|
+
if (opts.resumeSessionId)
|
|
44
|
+
args.push("--resume", opts.resumeSessionId);
|
|
43
45
|
if (opts.model)
|
|
44
46
|
args.push("--model", opts.model);
|
|
45
47
|
if (opts.allowedTools?.length)
|
|
@@ -107,6 +109,7 @@ export function runClaude(prompt, opts = {}) {
|
|
|
107
109
|
text: wrapper.result ?? "",
|
|
108
110
|
costUsd: typeof wrapper.total_cost_usd === "number" ? wrapper.total_cost_usd : null,
|
|
109
111
|
model: wrapper.modelUsage ? Object.keys(wrapper.modelUsage)[0] ?? null : null,
|
|
112
|
+
sessionId: typeof wrapper.session_id === "string" ? wrapper.session_id : null,
|
|
110
113
|
});
|
|
111
114
|
}
|
|
112
115
|
catch {
|
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
|
|