@gethmy/agent 1.0.0 → 1.0.2
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 +7 -6
- package/dist/board-helpers.d.ts +31 -0
- package/dist/board-helpers.js +150 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +2 -11761
- package/dist/completion.d.ts +14 -0
- package/dist/completion.js +142 -0
- package/dist/config.d.ts +23 -0
- package/dist/config.js +91 -0
- package/dist/git-pr.d.ts +25 -0
- package/dist/git-pr.js +305 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +169 -11730
- package/dist/log.d.ts +10 -0
- package/dist/log.js +35 -0
- package/dist/merge-monitor.d.ts +23 -0
- package/dist/merge-monitor.js +167 -0
- package/dist/pm.d.ts +14 -0
- package/dist/pm.js +63 -0
- package/dist/pool.d.ts +40 -0
- package/dist/pool.js +157 -0
- package/dist/progress-tracker.d.ts +64 -0
- package/dist/progress-tracker.js +361 -0
- package/dist/prompt.d.ts +5 -0
- package/dist/prompt.js +40 -0
- package/dist/queue.d.ts +37 -0
- package/dist/queue.js +96 -0
- package/dist/reconcile.d.ts +21 -0
- package/dist/reconcile.js +114 -0
- package/dist/review-completion.d.ts +31 -0
- package/dist/review-completion.js +253 -0
- package/dist/review-knowledge.d.ts +14 -0
- package/dist/review-knowledge.js +89 -0
- package/dist/review-prompt.d.ts +12 -0
- package/dist/review-prompt.js +103 -0
- package/dist/review-worker.d.ts +46 -0
- package/dist/review-worker.js +437 -0
- package/dist/review-worktree.d.ts +12 -0
- package/dist/review-worktree.js +83 -0
- package/dist/stream-parser.d.ts +31 -0
- package/dist/stream-parser.js +95 -0
- package/dist/types.d.ts +76 -0
- package/dist/types.js +56 -0
- package/dist/verification.d.ts +16 -0
- package/dist/verification.js +251 -0
- package/dist/watcher.d.ts +27 -0
- package/dist/watcher.js +74 -0
- package/dist/worker.d.ts +43 -0
- package/dist/worker.js +327 -0
- package/dist/worktree.d.ts +13 -0
- package/dist/worktree.js +115 -0
- package/package.json +8 -7
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { addLabelByName, moveCardToColumn } from "./board-helpers.js";
|
|
3
|
+
import { log } from "./log.js";
|
|
4
|
+
import { ProgressTracker } from "./progress-tracker.js";
|
|
5
|
+
import { parseReviewOutput, runReviewCompletion } from "./review-completion.js";
|
|
6
|
+
import { buildReviewSystemPrompt, buildReviewUserPrompt, } from "./review-prompt.js";
|
|
7
|
+
import { checkoutExistingBranch, extractBranchFromDescription, } from "./review-worktree.js";
|
|
8
|
+
import { StreamParser } from "./stream-parser.js";
|
|
9
|
+
import { AGENT_NAME, agentIdentifier, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR, } from "./types.js";
|
|
10
|
+
import { waitForDevServer } from "./verification.js";
|
|
11
|
+
import { cleanupWorktree } from "./worktree.js";
|
|
12
|
+
const TAG = "review-worker";
|
|
13
|
+
const CANCEL_SIGINT_TIMEOUT = 30_000;
|
|
14
|
+
const CANCEL_SIGTERM_TIMEOUT = 10_000;
|
|
15
|
+
export class ReviewWorker {
|
|
16
|
+
config;
|
|
17
|
+
client;
|
|
18
|
+
onDone;
|
|
19
|
+
id;
|
|
20
|
+
state = "idle";
|
|
21
|
+
cardId = null;
|
|
22
|
+
branchName = null;
|
|
23
|
+
worktreePath = null;
|
|
24
|
+
startedAt = null;
|
|
25
|
+
process = null;
|
|
26
|
+
devServerProcess = null;
|
|
27
|
+
timeoutTimer = null;
|
|
28
|
+
progressTracker = null;
|
|
29
|
+
aborted = false;
|
|
30
|
+
constructor(id, config, client, _userEmail, onDone) {
|
|
31
|
+
this.config = config;
|
|
32
|
+
this.client = client;
|
|
33
|
+
this.onDone = onDone;
|
|
34
|
+
this.id = id;
|
|
35
|
+
}
|
|
36
|
+
get tag() {
|
|
37
|
+
return `${TAG}:${this.id}`;
|
|
38
|
+
}
|
|
39
|
+
get isIdle() {
|
|
40
|
+
return this.state === "idle";
|
|
41
|
+
}
|
|
42
|
+
get reviewPort() {
|
|
43
|
+
return this.config.review.devServerPort + (this.id - this.config.poolSize);
|
|
44
|
+
}
|
|
45
|
+
get isActive() {
|
|
46
|
+
return (this.state === "preparing" ||
|
|
47
|
+
this.state === "running" ||
|
|
48
|
+
this.state === "verifying" ||
|
|
49
|
+
this.state === "completing");
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Start reviewing a card. Runs the full lifecycle:
|
|
53
|
+
* PREPARING → REVIEWING → COMPLETING → IDLE
|
|
54
|
+
*/
|
|
55
|
+
async run(card, column, labels, subtasks) {
|
|
56
|
+
this.aborted = false;
|
|
57
|
+
this.cardId = card.id;
|
|
58
|
+
this.startedAt = Date.now();
|
|
59
|
+
try {
|
|
60
|
+
// --- PREPARING ---
|
|
61
|
+
this.state = "preparing";
|
|
62
|
+
log.info(this.tag, `Preparing review for #${card.short_id} "${card.title}"`);
|
|
63
|
+
// Extract branch name from card description
|
|
64
|
+
this.branchName = extractBranchFromDescription(card.description);
|
|
65
|
+
const localMode = !this.branchName;
|
|
66
|
+
if (localMode) {
|
|
67
|
+
// LOCAL FALLBACK: no branch → review local working tree
|
|
68
|
+
log.info(this.tag, `No branch found for #${card.short_id}, attempting local review`);
|
|
69
|
+
// Use repo root as working directory
|
|
70
|
+
this.worktreePath = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
71
|
+
cwd: this.config.worktree.basePath,
|
|
72
|
+
encoding: "utf-8",
|
|
73
|
+
timeout: 5_000,
|
|
74
|
+
}).trim();
|
|
75
|
+
// Check if dev server is already running on the configured port
|
|
76
|
+
const serverRunning = await this.checkDevServer(this.reviewPort);
|
|
77
|
+
if (!serverRunning) {
|
|
78
|
+
log.info(this.tag, `No dev server on port ${this.reviewPort} — adding "${NEED_REVIEW_LABEL}" label and moving to "${this.config.review.failColumn}"`);
|
|
79
|
+
await Promise.all([
|
|
80
|
+
addLabelByName(this.client, card, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR),
|
|
81
|
+
moveCardToColumn(this.client, card, this.config.review.failColumn),
|
|
82
|
+
]);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (this.branchName) {
|
|
87
|
+
log.info(this.tag, `Review branch: ${this.branchName}`);
|
|
88
|
+
}
|
|
89
|
+
// Start agent session and make it visible on the board
|
|
90
|
+
await this.client.startAgentSession(card.id, {
|
|
91
|
+
agentIdentifier: agentIdentifier(this.id),
|
|
92
|
+
agentName: `${AGENT_NAME} (Review)`,
|
|
93
|
+
status: "working",
|
|
94
|
+
currentTask: localMode
|
|
95
|
+
? "Reviewing local changes"
|
|
96
|
+
: "Setting up review worktree",
|
|
97
|
+
progressPercent: 5,
|
|
98
|
+
});
|
|
99
|
+
// Fire label addition concurrently with sync worktree checkout
|
|
100
|
+
const labelPromise = addLabelByName(this.client, card, "agent", "#8b5cf6");
|
|
101
|
+
if (!localMode) {
|
|
102
|
+
// Checkout existing branch into worktree (sync)
|
|
103
|
+
this.worktreePath = checkoutExistingBranch(this.config.worktree.basePath, this.branchName);
|
|
104
|
+
}
|
|
105
|
+
await labelPromise;
|
|
106
|
+
if (this.aborted)
|
|
107
|
+
return;
|
|
108
|
+
// --- REVIEWING ---
|
|
109
|
+
this.state = "running";
|
|
110
|
+
if (!this.worktreePath) {
|
|
111
|
+
throw new Error("worktreePath not set before review phase");
|
|
112
|
+
}
|
|
113
|
+
// Start dev server (only in branch mode — local mode uses existing server)
|
|
114
|
+
const port = this.reviewPort;
|
|
115
|
+
const cwd = this.worktreePath;
|
|
116
|
+
if (!localMode) {
|
|
117
|
+
log.info(this.tag, `Starting dev server on port ${port}...`);
|
|
118
|
+
this.devServerProcess = spawn("bun", ["run", "dev", "--", "--port", String(port)], { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
119
|
+
await waitForDevServer(this.devServerProcess, 30_000);
|
|
120
|
+
log.info(this.tag, `Dev server ready on port ${port}`);
|
|
121
|
+
}
|
|
122
|
+
if (this.aborted)
|
|
123
|
+
return;
|
|
124
|
+
// Get diff
|
|
125
|
+
let diff = "";
|
|
126
|
+
try {
|
|
127
|
+
if (localMode) {
|
|
128
|
+
// Local mode: show uncommitted changes or last commit's diff
|
|
129
|
+
const uncommitted = execFileSync("git", ["diff"], {
|
|
130
|
+
cwd,
|
|
131
|
+
encoding: "utf-8",
|
|
132
|
+
timeout: 30_000,
|
|
133
|
+
});
|
|
134
|
+
const staged = execFileSync("git", ["diff", "--cached"], {
|
|
135
|
+
cwd,
|
|
136
|
+
encoding: "utf-8",
|
|
137
|
+
timeout: 30_000,
|
|
138
|
+
});
|
|
139
|
+
diff = [staged, uncommitted].filter(Boolean).join("\n");
|
|
140
|
+
if (!diff) {
|
|
141
|
+
// No uncommitted changes — get last commit's diff
|
|
142
|
+
diff = execFileSync("git", ["diff", "HEAD~1..HEAD"], {
|
|
143
|
+
cwd,
|
|
144
|
+
encoding: "utf-8",
|
|
145
|
+
timeout: 30_000,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
diff = execFileSync("git", ["diff", `origin/${this.config.worktree.baseBranch}..HEAD`], { cwd, encoding: "utf-8", timeout: 30_000 });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
diff = "(unable to retrieve diff)";
|
|
155
|
+
}
|
|
156
|
+
const previewUrl = `http://localhost:${port}`;
|
|
157
|
+
const enriched = {
|
|
158
|
+
card,
|
|
159
|
+
column,
|
|
160
|
+
labels,
|
|
161
|
+
subtasks,
|
|
162
|
+
mode: "review",
|
|
163
|
+
};
|
|
164
|
+
const systemPrompt = buildReviewSystemPrompt();
|
|
165
|
+
const userPrompt = buildReviewUserPrompt(enriched, this.branchName, cwd, previewUrl, diff, this.config.worktree.baseBranch);
|
|
166
|
+
await this.client.updateAgentProgress(card.id, {
|
|
167
|
+
agentIdentifier: agentIdentifier(this.id),
|
|
168
|
+
agentName: `${AGENT_NAME} (Review)`,
|
|
169
|
+
status: "working",
|
|
170
|
+
currentTask: "Running Claude review",
|
|
171
|
+
progressPercent: 20,
|
|
172
|
+
});
|
|
173
|
+
// Start timeout watchdog
|
|
174
|
+
this.timeoutTimer = setTimeout(() => {
|
|
175
|
+
log.warn(this.tag, `Review timeout reached (${this.config.review.maxTimeout}ms), cancelling`);
|
|
176
|
+
this.cancel();
|
|
177
|
+
}, this.config.review.maxTimeout);
|
|
178
|
+
// Set up progress tracking
|
|
179
|
+
this.progressTracker = new ProgressTracker(this.client, card.id, this.id, subtasks);
|
|
180
|
+
// Spawn Claude CLI for review with streaming
|
|
181
|
+
const stdout = await this.spawnClaude(userPrompt, systemPrompt, this.progressTracker);
|
|
182
|
+
this.progressTracker.stop();
|
|
183
|
+
this.progressTracker = null;
|
|
184
|
+
if (this.aborted)
|
|
185
|
+
return;
|
|
186
|
+
// --- COMPLETING ---
|
|
187
|
+
this.state = "completing";
|
|
188
|
+
log.info(this.tag, `Claude review finished for #${card.short_id}`);
|
|
189
|
+
// Kill dev server (only if we started one)
|
|
190
|
+
if (!localMode) {
|
|
191
|
+
this.killDevServer();
|
|
192
|
+
}
|
|
193
|
+
// Parse findings
|
|
194
|
+
const result = parseReviewOutput(stdout);
|
|
195
|
+
log.info(this.tag, `Review verdict: ${result.verdict} (${result.findings.length} finding(s))`);
|
|
196
|
+
await this.client.updateAgentProgress(card.id, {
|
|
197
|
+
agentIdentifier: agentIdentifier(this.id),
|
|
198
|
+
agentName: `${AGENT_NAME} (Review)`,
|
|
199
|
+
status: "working",
|
|
200
|
+
currentTask: `Processing ${result.verdict} verdict`,
|
|
201
|
+
progressPercent: 80,
|
|
202
|
+
});
|
|
203
|
+
// Run review completion pipeline
|
|
204
|
+
await runReviewCompletion(this.client, card, result, this.config, cwd, this.branchName);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
this.state = "error";
|
|
208
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
209
|
+
log.error(this.tag, `Error reviewing #${card.short_id}: ${msg}`);
|
|
210
|
+
// End session as paused on error
|
|
211
|
+
try {
|
|
212
|
+
await this.client.endAgentSession(card.id, { status: "paused" });
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// best-effort
|
|
216
|
+
}
|
|
217
|
+
// Move card out of Review to break the re-enqueue loop
|
|
218
|
+
try {
|
|
219
|
+
await moveCardToColumn(this.client, card, this.config.review.failColumn);
|
|
220
|
+
log.info(this.tag, `Moved #${card.short_id} to "${this.config.review.failColumn}" after error`);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
log.warn(this.tag, "Failed to move card to fail column after error");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
this.cleanup();
|
|
228
|
+
this.state = "idle";
|
|
229
|
+
this.onDone(this);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Pause the current review by suspending the Claude process (SIGSTOP).
|
|
234
|
+
*/
|
|
235
|
+
async pause() {
|
|
236
|
+
if (!this.isActive || !this.process || this.process.killed)
|
|
237
|
+
return;
|
|
238
|
+
log.info(this.tag, `Pausing review on ${this.cardId}`);
|
|
239
|
+
this.process.kill("SIGSTOP");
|
|
240
|
+
if (this.timeoutTimer) {
|
|
241
|
+
clearTimeout(this.timeoutTimer);
|
|
242
|
+
this.timeoutTimer = null;
|
|
243
|
+
}
|
|
244
|
+
// Update agent session so the UI reflects the paused state
|
|
245
|
+
if (this.cardId) {
|
|
246
|
+
try {
|
|
247
|
+
await this.client.updateAgentProgress(this.cardId, {
|
|
248
|
+
agentIdentifier: agentIdentifier(this.id),
|
|
249
|
+
agentName: AGENT_NAME,
|
|
250
|
+
status: "paused",
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
log.warn(this.tag, "Failed to update agent session to paused");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Resume the Claude process after a pause (SIGCONT).
|
|
260
|
+
*/
|
|
261
|
+
async resume() {
|
|
262
|
+
if (!this.isActive || !this.process || this.process.killed)
|
|
263
|
+
return;
|
|
264
|
+
log.info(this.tag, `Resuming review on ${this.cardId}`);
|
|
265
|
+
this.process.kill("SIGCONT");
|
|
266
|
+
this.timeoutTimer = setTimeout(() => {
|
|
267
|
+
log.warn(this.tag, `Timeout reached (${this.config.review.maxTimeout}ms), cancelling`);
|
|
268
|
+
this.cancel();
|
|
269
|
+
}, this.config.review.maxTimeout);
|
|
270
|
+
// Update agent session so the UI reflects the resumed state
|
|
271
|
+
if (this.cardId) {
|
|
272
|
+
try {
|
|
273
|
+
await this.client.updateAgentProgress(this.cardId, {
|
|
274
|
+
agentIdentifier: agentIdentifier(this.id),
|
|
275
|
+
agentName: AGENT_NAME,
|
|
276
|
+
status: "working",
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
log.warn(this.tag, "Failed to update agent session to working");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Cancel the current review. Sends escalating signals to both processes.
|
|
286
|
+
*/
|
|
287
|
+
async cancel() {
|
|
288
|
+
if (!this.isActive)
|
|
289
|
+
return;
|
|
290
|
+
this.aborted = true;
|
|
291
|
+
this.state = "cancelling";
|
|
292
|
+
log.info(this.tag, `Cancelling review on ${this.cardId}`);
|
|
293
|
+
// Stop progress tracking
|
|
294
|
+
if (this.progressTracker) {
|
|
295
|
+
this.progressTracker.stop();
|
|
296
|
+
this.progressTracker = null;
|
|
297
|
+
}
|
|
298
|
+
// Kill dev server first
|
|
299
|
+
this.killDevServer();
|
|
300
|
+
// Then kill Claude process with escalating signals
|
|
301
|
+
if (this.process && !this.process.killed) {
|
|
302
|
+
// Resume first in case the process is suspended
|
|
303
|
+
this.process.kill("SIGCONT");
|
|
304
|
+
this.process.kill("SIGINT");
|
|
305
|
+
log.debug(this.tag, "Sent SIGINT to Claude");
|
|
306
|
+
const sigintDead = await this.waitForExit(CANCEL_SIGINT_TIMEOUT);
|
|
307
|
+
if (!sigintDead) {
|
|
308
|
+
this.process.kill("SIGTERM");
|
|
309
|
+
log.debug(this.tag, "Sent SIGTERM to Claude");
|
|
310
|
+
const sigtermDead = await this.waitForExit(CANCEL_SIGTERM_TIMEOUT);
|
|
311
|
+
if (!sigtermDead) {
|
|
312
|
+
this.process.kill("SIGKILL");
|
|
313
|
+
log.warn(this.tag, "Sent SIGKILL to Claude");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// End agent session as paused
|
|
318
|
+
if (this.cardId) {
|
|
319
|
+
try {
|
|
320
|
+
await this.client.endAgentSession(this.cardId, { status: "paused" });
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// best-effort
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
async checkDevServer(port) {
|
|
328
|
+
try {
|
|
329
|
+
const resp = await fetch(`http://localhost:${port}`, {
|
|
330
|
+
signal: AbortSignal.timeout(3_000),
|
|
331
|
+
});
|
|
332
|
+
return resp.ok;
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
spawnClaude(prompt, systemPrompt, tracker) {
|
|
339
|
+
return new Promise((resolve, reject) => {
|
|
340
|
+
const args = [
|
|
341
|
+
"--output-format",
|
|
342
|
+
"stream-json",
|
|
343
|
+
"--verbose",
|
|
344
|
+
"--model",
|
|
345
|
+
this.config.claude.model,
|
|
346
|
+
"--max-turns",
|
|
347
|
+
String(this.config.claude.maxTurns),
|
|
348
|
+
"--allowedTools",
|
|
349
|
+
"Bash(readonly),Read,Glob,Grep,Agent,mcp__harmony__*",
|
|
350
|
+
...(systemPrompt ? ["--append-system-prompt", systemPrompt] : []),
|
|
351
|
+
...this.config.claude.additionalArgs,
|
|
352
|
+
"--",
|
|
353
|
+
prompt,
|
|
354
|
+
];
|
|
355
|
+
log.info(this.tag, `Spawning review: claude ${args.slice(0, 5).join(" ")} ...`);
|
|
356
|
+
this.process = spawn("claude", args, {
|
|
357
|
+
cwd: this.worktreePath,
|
|
358
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
359
|
+
});
|
|
360
|
+
// Stream parser for progress tracking + text reconstruction for verdict
|
|
361
|
+
const parser = new StreamParser();
|
|
362
|
+
tracker.attach(parser);
|
|
363
|
+
const textChunks = [];
|
|
364
|
+
parser.on("text", (content) => {
|
|
365
|
+
textChunks.push(content);
|
|
366
|
+
});
|
|
367
|
+
// Attach parser to stdout (single consumer)
|
|
368
|
+
if (this.process.stdout) {
|
|
369
|
+
parser.attach(this.process.stdout);
|
|
370
|
+
}
|
|
371
|
+
let stderr = "";
|
|
372
|
+
this.process.stderr?.on("data", (data) => {
|
|
373
|
+
stderr += data.toString();
|
|
374
|
+
});
|
|
375
|
+
this.process.on("error", (err) => {
|
|
376
|
+
reject(new Error(`Failed to spawn claude: ${err.message}`));
|
|
377
|
+
});
|
|
378
|
+
this.process.on("close", (code) => {
|
|
379
|
+
this.process = null;
|
|
380
|
+
// Reconstruct text from stream parser text events
|
|
381
|
+
const stdout = textChunks.join("");
|
|
382
|
+
if (this.aborted) {
|
|
383
|
+
resolve(stdout);
|
|
384
|
+
}
|
|
385
|
+
else if (code === 0) {
|
|
386
|
+
resolve(stdout);
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
reject(new Error(`claude exited with code ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ""}`));
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
waitForExit(timeout) {
|
|
395
|
+
return new Promise((resolve) => {
|
|
396
|
+
if (!this.process || this.process.killed) {
|
|
397
|
+
resolve(true);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const timer = setTimeout(() => {
|
|
401
|
+
resolve(false);
|
|
402
|
+
}, timeout);
|
|
403
|
+
this.process.once("close", () => {
|
|
404
|
+
clearTimeout(timer);
|
|
405
|
+
resolve(true);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
killDevServer() {
|
|
410
|
+
if (this.devServerProcess && !this.devServerProcess.killed) {
|
|
411
|
+
this.devServerProcess.kill("SIGTERM");
|
|
412
|
+
this.devServerProcess = null;
|
|
413
|
+
log.debug(this.tag, "Killed dev server");
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
cleanup() {
|
|
417
|
+
if (this.timeoutTimer) {
|
|
418
|
+
clearTimeout(this.timeoutTimer);
|
|
419
|
+
this.timeoutTimer = null;
|
|
420
|
+
}
|
|
421
|
+
this.killDevServer();
|
|
422
|
+
// Clean up worktree on error (only if we created one — skip in local mode)
|
|
423
|
+
if (this.worktreePath && this.state === "error" && this.branchName) {
|
|
424
|
+
try {
|
|
425
|
+
cleanupWorktree(this.worktreePath, this.branchName);
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
log.warn(this.tag, "Failed to cleanup review worktree");
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
this.process = null;
|
|
432
|
+
this.cardId = null;
|
|
433
|
+
this.branchName = null;
|
|
434
|
+
this.worktreePath = null;
|
|
435
|
+
this.startedAt = null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkout an existing remote branch into a worktree for review.
|
|
3
|
+
* Unlike createWorktree() which creates a new branch, this checks out
|
|
4
|
+
* an existing branch that was pushed by the implementation worker.
|
|
5
|
+
*/
|
|
6
|
+
export declare function checkoutExistingBranch(basePath: string, branchName: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Extract branch name from card description's completion summary.
|
|
9
|
+
* Looks for: Branch: `agent/...`
|
|
10
|
+
* Validates that the extracted name contains only safe characters.
|
|
11
|
+
*/
|
|
12
|
+
export declare function extractBranchFromDescription(description: string | null | undefined): string | null;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { log } from "./log.js";
|
|
5
|
+
import { installCommand } from "./pm.js";
|
|
6
|
+
import { cleanupWorktree } from "./worktree.js";
|
|
7
|
+
const TAG = "review-worktree";
|
|
8
|
+
/**
|
|
9
|
+
* Checkout an existing remote branch into a worktree for review.
|
|
10
|
+
* Unlike createWorktree() which creates a new branch, this checks out
|
|
11
|
+
* an existing branch that was pushed by the implementation worker.
|
|
12
|
+
*/
|
|
13
|
+
export function checkoutExistingBranch(basePath, branchName) {
|
|
14
|
+
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
15
|
+
encoding: "utf-8",
|
|
16
|
+
}).trim();
|
|
17
|
+
const worktreeDir = resolve(repoRoot, basePath, `review-${branchName}`);
|
|
18
|
+
if (existsSync(worktreeDir)) {
|
|
19
|
+
log.warn(TAG, `Review worktree already exists at ${worktreeDir}, cleaning up`);
|
|
20
|
+
cleanupWorktree(worktreeDir);
|
|
21
|
+
}
|
|
22
|
+
// Fetch latest to ensure we have the remote branch
|
|
23
|
+
try {
|
|
24
|
+
execFileSync("git", ["fetch", "origin", branchName], {
|
|
25
|
+
cwd: repoRoot,
|
|
26
|
+
stdio: "pipe",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new Error(`Failed to fetch remote branch: ${branchName}`);
|
|
31
|
+
}
|
|
32
|
+
// Delete stale local branch if it exists
|
|
33
|
+
try {
|
|
34
|
+
execFileSync("git", ["branch", "-D", branchName], {
|
|
35
|
+
cwd: repoRoot,
|
|
36
|
+
stdio: "pipe",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Branch doesn't exist locally — that's fine
|
|
41
|
+
}
|
|
42
|
+
// Create worktree from the remote branch
|
|
43
|
+
log.info(TAG, `Creating review worktree: ${worktreeDir} (branch: ${branchName})`);
|
|
44
|
+
execFileSync("git", [
|
|
45
|
+
"worktree",
|
|
46
|
+
"add",
|
|
47
|
+
"--track",
|
|
48
|
+
"-b",
|
|
49
|
+
branchName,
|
|
50
|
+
worktreeDir,
|
|
51
|
+
`origin/${branchName}`,
|
|
52
|
+
], { cwd: repoRoot, stdio: "pipe" });
|
|
53
|
+
// Install dependencies
|
|
54
|
+
log.info(TAG, "Installing dependencies in review worktree...");
|
|
55
|
+
try {
|
|
56
|
+
execSync(installCommand(), {
|
|
57
|
+
cwd: worktreeDir,
|
|
58
|
+
stdio: "pipe",
|
|
59
|
+
timeout: 60_000,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
log.warn(TAG, "Install failed (may be fine if deps are hoisted)");
|
|
64
|
+
}
|
|
65
|
+
return worktreeDir;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Extract branch name from card description's completion summary.
|
|
69
|
+
* Looks for: Branch: `agent/...`
|
|
70
|
+
* Validates that the extracted name contains only safe characters.
|
|
71
|
+
*/
|
|
72
|
+
export function extractBranchFromDescription(description) {
|
|
73
|
+
if (!description)
|
|
74
|
+
return null;
|
|
75
|
+
const match = description.match(/Branch:\s*`([^`]+)`/);
|
|
76
|
+
const branch = match?.[1] ?? null;
|
|
77
|
+
// Validate branch name contains only safe git ref characters
|
|
78
|
+
if (branch && !/^[a-zA-Z0-9/_.-]+$/.test(branch)) {
|
|
79
|
+
log.warn(TAG, `Extracted branch name contains unsafe characters: ${branch}`);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return branch;
|
|
83
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import type { Readable } from "node:stream";
|
|
3
|
+
export interface CostUpdate {
|
|
4
|
+
totalCostUsd: number;
|
|
5
|
+
totalInputTokens: number;
|
|
6
|
+
totalOutputTokens: number;
|
|
7
|
+
durationMs: number;
|
|
8
|
+
durationApiMs: number;
|
|
9
|
+
numTurns: number;
|
|
10
|
+
}
|
|
11
|
+
export interface StreamParserEvents {
|
|
12
|
+
tool_start: [name: string, input: unknown];
|
|
13
|
+
tool_end: [name: string, toolUseId: string, content: string | undefined];
|
|
14
|
+
text: [content: string];
|
|
15
|
+
result: [stopReason: string];
|
|
16
|
+
cost_update: [cost: CostUpdate];
|
|
17
|
+
parse_error: [msg: string];
|
|
18
|
+
}
|
|
19
|
+
export declare class StreamParser extends EventEmitter<StreamParserEvents> {
|
|
20
|
+
private buffer;
|
|
21
|
+
private attached;
|
|
22
|
+
/**
|
|
23
|
+
* Attach a readable stream (Claude CLI stdout) to the parser.
|
|
24
|
+
* Parses NDJSON lines and emits typed events.
|
|
25
|
+
* Each instance must only be attached once.
|
|
26
|
+
*/
|
|
27
|
+
attach(stream: Readable): void;
|
|
28
|
+
private flush;
|
|
29
|
+
private parseLine;
|
|
30
|
+
private handleMessage;
|
|
31
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { log } from "./log.js";
|
|
3
|
+
const TAG = "stream-parser";
|
|
4
|
+
export class StreamParser extends EventEmitter {
|
|
5
|
+
buffer = "";
|
|
6
|
+
attached = false;
|
|
7
|
+
/**
|
|
8
|
+
* Attach a readable stream (Claude CLI stdout) to the parser.
|
|
9
|
+
* Parses NDJSON lines and emits typed events.
|
|
10
|
+
* Each instance must only be attached once.
|
|
11
|
+
*/
|
|
12
|
+
attach(stream) {
|
|
13
|
+
if (this.attached) {
|
|
14
|
+
throw new Error("StreamParser already attached to a stream");
|
|
15
|
+
}
|
|
16
|
+
this.attached = true;
|
|
17
|
+
stream.on("data", (chunk) => {
|
|
18
|
+
this.buffer += chunk.toString();
|
|
19
|
+
this.flush();
|
|
20
|
+
});
|
|
21
|
+
stream.on("end", () => {
|
|
22
|
+
// Process any remaining buffer
|
|
23
|
+
if (this.buffer.trim()) {
|
|
24
|
+
this.flush();
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
flush() {
|
|
29
|
+
const lines = this.buffer.split("\n");
|
|
30
|
+
// Keep incomplete last line in buffer
|
|
31
|
+
this.buffer = lines.pop() ?? "";
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
const trimmed = line.trim();
|
|
34
|
+
if (!trimmed)
|
|
35
|
+
continue;
|
|
36
|
+
this.parseLine(trimmed);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
parseLine(line) {
|
|
40
|
+
let msg;
|
|
41
|
+
try {
|
|
42
|
+
msg = JSON.parse(line);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Not valid JSON — skip silently (could be stray output)
|
|
46
|
+
log.debug(TAG, `Non-JSON line: ${line.slice(0, 100)}`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
this.handleMessage(msg);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
54
|
+
log.warn(TAG, `Error handling stream event: ${errMsg}`);
|
|
55
|
+
this.emit("parse_error", errMsg);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
handleMessage(msg) {
|
|
59
|
+
switch (msg.type) {
|
|
60
|
+
case "assistant": {
|
|
61
|
+
if (msg.subtype === "tool_use" && msg.tool_name) {
|
|
62
|
+
this.emit("tool_start", msg.tool_name, msg.input);
|
|
63
|
+
}
|
|
64
|
+
else if (msg.subtype === "text" && msg.content) {
|
|
65
|
+
this.emit("text", msg.content);
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case "tool_result": {
|
|
70
|
+
if (msg.tool_use_id && msg.tool_name) {
|
|
71
|
+
this.emit("tool_end", msg.tool_name, msg.tool_use_id, msg.content);
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case "result": {
|
|
76
|
+
this.emit("result", msg.stop_reason ?? "unknown");
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case "cost_update": {
|
|
80
|
+
if (msg.total_cost_usd != null) {
|
|
81
|
+
this.emit("cost_update", {
|
|
82
|
+
totalCostUsd: msg.total_cost_usd,
|
|
83
|
+
totalInputTokens: msg.total_input_tokens ?? 0,
|
|
84
|
+
totalOutputTokens: msg.total_output_tokens ?? 0,
|
|
85
|
+
durationMs: msg.duration_ms ?? 0,
|
|
86
|
+
durationApiMs: msg.duration_api_ms ?? 0,
|
|
87
|
+
numTurns: msg.num_turns ?? 0,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
// Ignore system, ping, etc.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|