@gethmy/agent 1.7.0 → 1.7.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.
Files changed (76) hide show
  1. package/README.md +8 -1
  2. package/dist/cli.js +6376 -205
  3. package/dist/index.js +6206 -341
  4. package/package.json +2 -2
  5. package/dist/board-helpers.d.ts +0 -31
  6. package/dist/board-helpers.js +0 -150
  7. package/dist/budget.d.ts +0 -47
  8. package/dist/budget.js +0 -161
  9. package/dist/cli.d.ts +0 -16
  10. package/dist/completion.d.ts +0 -32
  11. package/dist/completion.js +0 -304
  12. package/dist/config-validation.d.ts +0 -23
  13. package/dist/config-validation.js +0 -77
  14. package/dist/config.d.ts +0 -23
  15. package/dist/config.js +0 -103
  16. package/dist/episode-writer.d.ts +0 -84
  17. package/dist/episode-writer.js +0 -232
  18. package/dist/git-pr.d.ts +0 -38
  19. package/dist/git-pr.js +0 -399
  20. package/dist/http-server.d.ts +0 -79
  21. package/dist/http-server.js +0 -114
  22. package/dist/index.d.ts +0 -5
  23. package/dist/log.d.ts +0 -34
  24. package/dist/log.js +0 -100
  25. package/dist/merge-monitor.d.ts +0 -23
  26. package/dist/merge-monitor.js +0 -169
  27. package/dist/pm.d.ts +0 -14
  28. package/dist/pm.js +0 -63
  29. package/dist/pool.d.ts +0 -70
  30. package/dist/pool.js +0 -258
  31. package/dist/process-group.d.ts +0 -26
  32. package/dist/process-group.js +0 -72
  33. package/dist/progress-tracker.d.ts +0 -79
  34. package/dist/progress-tracker.js +0 -442
  35. package/dist/prompt.d.ts +0 -18
  36. package/dist/prompt.js +0 -117
  37. package/dist/queue.d.ts +0 -39
  38. package/dist/queue.js +0 -100
  39. package/dist/reconcile.d.ts +0 -35
  40. package/dist/reconcile.js +0 -174
  41. package/dist/recovery.d.ts +0 -30
  42. package/dist/recovery.js +0 -141
  43. package/dist/review-completion.d.ts +0 -40
  44. package/dist/review-completion.js +0 -474
  45. package/dist/review-knowledge.d.ts +0 -14
  46. package/dist/review-knowledge.js +0 -89
  47. package/dist/review-prompt.d.ts +0 -12
  48. package/dist/review-prompt.js +0 -103
  49. package/dist/review-worker.d.ts +0 -56
  50. package/dist/review-worker.js +0 -638
  51. package/dist/review-worktree.d.ts +0 -12
  52. package/dist/review-worktree.js +0 -95
  53. package/dist/run-log.d.ts +0 -6
  54. package/dist/run-log.js +0 -19
  55. package/dist/startup-banner.d.ts +0 -29
  56. package/dist/startup-banner.js +0 -143
  57. package/dist/state-store.d.ts +0 -88
  58. package/dist/state-store.js +0 -239
  59. package/dist/stream-parser-selftest.d.ts +0 -9
  60. package/dist/stream-parser-selftest.js +0 -97
  61. package/dist/stream-parser.d.ts +0 -43
  62. package/dist/stream-parser.js +0 -174
  63. package/dist/transitions.d.ts +0 -57
  64. package/dist/transitions.js +0 -131
  65. package/dist/types.d.ts +0 -140
  66. package/dist/types.js +0 -79
  67. package/dist/verification.d.ts +0 -39
  68. package/dist/verification.js +0 -317
  69. package/dist/watcher.d.ts +0 -53
  70. package/dist/watcher.js +0 -153
  71. package/dist/worker.d.ts +0 -53
  72. package/dist/worker.js +0 -464
  73. package/dist/worktree-gc.d.ts +0 -67
  74. package/dist/worktree-gc.js +0 -245
  75. package/dist/worktree.d.ts +0 -18
  76. package/dist/worktree.js +0 -177
@@ -1,638 +0,0 @@
1
- import { execFileSync } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
- import { addLabelByName, moveCardToColumn } from "./board-helpers.js";
4
- import { buildTokenPayload } from "./completion.js";
5
- import { log } from "./log.js";
6
- import { signalGroup, spawnInGroup, terminateGroup } from "./process-group.js";
7
- import { ProgressTracker } from "./progress-tracker.js";
8
- import { parseReviewOutput, runReviewCompletion } from "./review-completion.js";
9
- import { buildReviewSystemPrompt, buildReviewUserPrompt, } from "./review-prompt.js";
10
- import { checkoutExistingBranch, extractBranchFromDescription, } from "./review-worktree.js";
11
- import { openRunLog } from "./run-log.js";
12
- import { newRunId } from "./state-store.js";
13
- import { StreamParser } from "./stream-parser.js";
14
- import { runTransition, TransitionError } from "./transitions.js";
15
- import { AGENT_NAME, agentIdentifier, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR, } from "./types.js";
16
- import { DevServerReadinessError, probeDevServer, waitForDevServer, } from "./verification.js";
17
- import { cleanupWorktree } from "./worktree.js";
18
- const TAG = "review-worker";
19
- const CANCEL_SIGINT_TIMEOUT = 30_000;
20
- const CANCEL_SIGTERM_TIMEOUT = 10_000;
21
- export class ReviewWorker {
22
- config;
23
- client;
24
- onDone;
25
- stateStore;
26
- workspaceId;
27
- id;
28
- state = "idle";
29
- cardId = null;
30
- branchName = null;
31
- worktreePath = null;
32
- startedAt = null;
33
- process = null;
34
- devServerProcess = null;
35
- timeoutTimer = null;
36
- heartbeatTimer = null;
37
- progressTracker = null;
38
- lastSessionStats = null;
39
- aborted = false;
40
- runId = null;
41
- lastRunLogPath = null;
42
- sessionId = null;
43
- constructor(id, config, client, _userEmail, onDone, stateStore, workspaceId, _projectId) {
44
- this.config = config;
45
- this.client = client;
46
- this.onDone = onDone;
47
- this.stateStore = stateStore;
48
- this.workspaceId = workspaceId;
49
- this.id = id;
50
- }
51
- startHeartbeat() {
52
- this.stopHeartbeat();
53
- const interval = this.config.timing.heartbeatMs;
54
- this.heartbeatTimer = setInterval(() => {
55
- if (this.runId) {
56
- this.stateStore.heartbeat(this.runId).catch(() => {
57
- /* retry next tick */
58
- });
59
- }
60
- }, interval);
61
- // Don't block event loop shutdown for a pending heartbeat.
62
- this.heartbeatTimer.unref();
63
- }
64
- stopHeartbeat() {
65
- if (this.heartbeatTimer) {
66
- clearInterval(this.heartbeatTimer);
67
- this.heartbeatTimer = null;
68
- }
69
- }
70
- async recordPhase(phase) {
71
- if (!this.runId)
72
- return;
73
- try {
74
- await this.stateStore.updateRun(this.runId, {
75
- phase,
76
- lastHeartbeatAt: Date.now(),
77
- worktreePath: this.worktreePath,
78
- branchName: this.branchName,
79
- });
80
- }
81
- catch (err) {
82
- log.warn(this.tag, `state store updateRun failed: ${err instanceof Error ? err.message : err}`);
83
- }
84
- }
85
- get tag() {
86
- return `${TAG}:${this.id}`;
87
- }
88
- get isIdle() {
89
- return this.state === "idle";
90
- }
91
- get reviewPort() {
92
- return this.config.review.devServerPort + (this.id - this.config.poolSize);
93
- }
94
- get isActive() {
95
- return (this.state === "preparing" ||
96
- this.state === "running" ||
97
- this.state === "verifying" ||
98
- this.state === "completing");
99
- }
100
- /**
101
- * Start reviewing a card. Runs the full lifecycle:
102
- * PREPARING → REVIEWING → COMPLETING → IDLE
103
- */
104
- async run(card, column, labels, subtasks) {
105
- this.aborted = false;
106
- this.cardId = card.id;
107
- this.startedAt = Date.now();
108
- this.runId = newRunId();
109
- try {
110
- // --- PREPARING ---
111
- this.state = "preparing";
112
- log.info(this.tag, `Preparing review for #${card.short_id} "${card.title}"`);
113
- this.startHeartbeat();
114
- await this.stateStore.insertRun({
115
- runId: this.runId,
116
- cardId: card.id,
117
- cardShortId: card.short_id,
118
- pipeline: "review",
119
- workerId: this.id,
120
- sessionId: null,
121
- worktreePath: null,
122
- branchName: null,
123
- daemonPid: process.pid,
124
- phase: "preparing",
125
- startedAt: this.startedAt,
126
- lastHeartbeatAt: this.startedAt,
127
- endedAt: null,
128
- status: "active",
129
- costCents: 0,
130
- });
131
- // Extract branch name from card description
132
- this.branchName = extractBranchFromDescription(card.description);
133
- const localMode = !this.branchName;
134
- let localDiff = null;
135
- if (localMode) {
136
- // LOCAL FALLBACK: no branch → review local working tree
137
- log.info(this.tag, `No branch found for #${card.short_id}, attempting local review`);
138
- // Use repo root as working directory
139
- this.worktreePath = execFileSync("git", ["rev-parse", "--show-toplevel"], {
140
- encoding: "utf-8",
141
- timeout: 5_000,
142
- }).trim();
143
- // Try to find reviewable changes
144
- const resolved = this.resolveLocalChanges(this.worktreePath, card.short_id);
145
- if (!resolved) {
146
- // No changes found — mark for human review and leave in current column
147
- log.info(this.tag, `No local changes found for #${card.short_id} — marking for human review (staying in Review)`);
148
- await addLabelByName(this.client, card, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR);
149
- return;
150
- }
151
- log.info(this.tag, `Found local changes via ${resolved.source} for #${card.short_id}`);
152
- localDiff = resolved.diff;
153
- }
154
- if (this.branchName) {
155
- log.info(this.tag, `Review branch: ${this.branchName}`);
156
- }
157
- // Start agent session and make it visible on the board
158
- const { session: reviewSession } = await this.client.startAgentSession(card.id, {
159
- agentIdentifier: agentIdentifier(this.id),
160
- agentName: `${AGENT_NAME} (Review)`,
161
- status: "working",
162
- currentTask: localMode
163
- ? "Reviewing local changes"
164
- : "Setting up review worktree",
165
- progressPercent: 5,
166
- });
167
- this.sessionId =
168
- reviewSession &&
169
- typeof reviewSession === "object" &&
170
- "id" in reviewSession
171
- ? (reviewSession.id ?? null)
172
- : null;
173
- // Fire label addition concurrently with sync worktree checkout
174
- const labelPromise = addLabelByName(this.client, card, "agent", "#8b5cf6");
175
- if (!localMode) {
176
- // Checkout existing branch into worktree (sync)
177
- this.worktreePath = checkoutExistingBranch(this.config.worktree.basePath, this.branchName);
178
- }
179
- await labelPromise;
180
- if (this.aborted)
181
- return;
182
- // --- REVIEWING ---
183
- this.state = "running";
184
- await this.recordPhase("running");
185
- if (!this.worktreePath) {
186
- throw new Error("worktreePath not set before review phase");
187
- }
188
- // Start dev server (only in branch mode — local mode uses existing server)
189
- const port = this.reviewPort;
190
- const cwd = this.worktreePath;
191
- if (!localMode) {
192
- log.info(this.tag, `Starting dev server on port ${port}...`);
193
- this.devServerProcess = spawnInGroup("bun", ["run", "dev", "--", "--port", String(port)], { cwd, stdio: ["ignore", "pipe", "pipe"] });
194
- // Surface the dev-server warmup as `waiting` so the AgentTopBar
195
- // doesn't sit on a stale `working` task while the server takes
196
- // 10–20 seconds to boot.
197
- await this.client.updateAgentProgress(card.id, {
198
- agentIdentifier: agentIdentifier(this.id),
199
- agentName: `${AGENT_NAME} (Review)`,
200
- status: "waiting",
201
- currentTask: `Starting dev server on port ${port}…`,
202
- progressPercent: 10,
203
- });
204
- // Invariant I2: review only proceeds with a proven-live dev server.
205
- // waitForDevServer rejects on timeout / exit; probeDevServer verifies
206
- // the port actually answers HTTP.
207
- await waitForDevServer(this.devServerProcess, 30_000);
208
- await probeDevServer(port);
209
- log.info(this.tag, `Dev server ready on port ${port}`);
210
- // Restore active status now that the server is live.
211
- await this.client.updateAgentProgress(card.id, {
212
- agentIdentifier: agentIdentifier(this.id),
213
- agentName: `${AGENT_NAME} (Review)`,
214
- status: "working",
215
- currentTask: "Reviewing changes",
216
- progressPercent: 15,
217
- });
218
- }
219
- if (this.aborted)
220
- return;
221
- // Get diff
222
- let diff = "";
223
- try {
224
- if (localMode) {
225
- // Use pre-computed diff from resolveLocalChanges
226
- diff = localDiff ?? "";
227
- }
228
- else {
229
- diff = execFileSync("git", ["diff", `origin/${this.config.worktree.baseBranch}..HEAD`], { cwd, encoding: "utf-8", timeout: 30_000 });
230
- }
231
- }
232
- catch {
233
- diff = "(unable to retrieve diff)";
234
- }
235
- const previewUrl = `http://localhost:${port}`;
236
- const enriched = {
237
- card,
238
- column,
239
- labels,
240
- subtasks,
241
- mode: "review",
242
- };
243
- const systemPrompt = buildReviewSystemPrompt();
244
- const userPrompt = buildReviewUserPrompt(enriched, this.branchName, cwd, previewUrl, diff, this.config.worktree.baseBranch);
245
- // AGP P2: persist a session-linked snapshot of the review system
246
- // prompt so outcome feedback can credit / penalise the framing.
247
- // Best-effort — never block review on logging.
248
- try {
249
- const sessionResp = await this.client.getAgentSession(card.id);
250
- const reviewSession = sessionResp.session;
251
- const reviewSessionId = reviewSession?.id ?? null;
252
- const contentHash = createHash("sha256")
253
- .update(systemPrompt)
254
- .digest("hex");
255
- await this.client.recordPromptHistory({
256
- cardId: card.id,
257
- generatedPrompt: systemPrompt,
258
- variant: "execute",
259
- contextIncluded: { source: "review-knowledge", mode: "review" },
260
- sessionId: reviewSessionId,
261
- contentHash,
262
- templateVersion: 1,
263
- confidence: 0.5,
264
- });
265
- }
266
- catch (err) {
267
- log.warn(this.tag, `prompt_history persistence skipped: ${err instanceof Error ? err.message : String(err)}`);
268
- }
269
- await this.client.updateAgentProgress(card.id, {
270
- agentIdentifier: agentIdentifier(this.id),
271
- agentName: `${AGENT_NAME} (Review)`,
272
- status: "working",
273
- currentTask: "Running Claude review",
274
- progressPercent: 20,
275
- });
276
- // Start timeout watchdog
277
- this.timeoutTimer = setTimeout(() => {
278
- log.warn(this.tag, `Review timeout reached (${this.config.review.maxTimeout}ms), cancelling`);
279
- this.cancel();
280
- }, this.config.review.maxTimeout);
281
- // Set up progress tracking
282
- this.progressTracker = new ProgressTracker(this.client, card.id, this.id, subtasks);
283
- // Spawn Claude CLI for review with streaming
284
- const stdout = await this.spawnClaude(userPrompt, systemPrompt, this.progressTracker, card.short_id);
285
- // Capture session stats before stopping tracker
286
- this.lastSessionStats = this.progressTracker?.stats ?? null;
287
- this.progressTracker?.stop();
288
- this.progressTracker = null;
289
- const sessionStats = this.lastSessionStats;
290
- if (this.aborted)
291
- return;
292
- // --- COMPLETING ---
293
- this.state = "completing";
294
- await this.recordPhase("completing");
295
- log.info(this.tag, `Claude review finished for #${card.short_id}`);
296
- // Kill dev server (only if we started one)
297
- if (!localMode) {
298
- this.killDevServer();
299
- }
300
- // Parse findings
301
- const result = parseReviewOutput(stdout);
302
- log.info(this.tag, `Review verdict: ${result.verdict} (${result.findings.length} finding(s))`);
303
- await this.client.updateAgentProgress(card.id, {
304
- agentIdentifier: agentIdentifier(this.id),
305
- agentName: `${AGENT_NAME} (Review)`,
306
- status: "working",
307
- currentTask: `Processing ${result.verdict} verdict`,
308
- progressPercent: 80,
309
- });
310
- // Run review completion pipeline
311
- await runReviewCompletion(this.client, card, result, this.config, cwd, this.branchName, sessionStats, this.lastRunLogPath, this.workspaceId, this.sessionId, this.stateStore);
312
- }
313
- catch (err) {
314
- this.state = "error";
315
- const msg = err instanceof Error ? err.message : String(err);
316
- log.error(this.tag, `Error reviewing #${card.short_id}: ${msg}`);
317
- // End session as paused on error. Retried via runTransition so a
318
- // transient API failure no longer orphans the session.
319
- try {
320
- const stats = this.lastSessionStats ?? this.progressTracker?.stats;
321
- await runTransition(this.client, card, {
322
- endSession: {
323
- status: "paused",
324
- ...buildTokenPayload(stats),
325
- },
326
- });
327
- }
328
- catch (tErr) {
329
- log.error(this.tag, `endAgentSession unrecoverable on #${card.short_id}: ${tErr instanceof TransitionError ? tErr.detail : tErr}`);
330
- }
331
- // Dev server readiness failures are infrastructure errors, not
332
- // implementation errors — the code under review may be perfectly
333
- // fine. Invariant I2: never approve without a live server, but
334
- // also never bounce the card back to "To Do" for rework. Keep
335
- // it in Review with a "Need Review" label so a human steps in.
336
- if (err instanceof DevServerReadinessError) {
337
- try {
338
- await addLabelByName(this.client, card, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR);
339
- log.info(this.tag, `#${card.short_id} kept in Review — dev server unavailable, human review needed`);
340
- }
341
- catch {
342
- log.warn(this.tag, "Failed to add Need Review label after dev-server failure");
343
- }
344
- }
345
- else {
346
- // Move card out of Review to break the re-enqueue loop for other errors
347
- try {
348
- await moveCardToColumn(this.client, card, this.config.review.failColumn);
349
- log.info(this.tag, `Moved #${card.short_id} to "${this.config.review.failColumn}" after error`);
350
- }
351
- catch {
352
- log.warn(this.tag, "Failed to move card to fail column after error");
353
- }
354
- }
355
- if (this.runId) {
356
- try {
357
- await this.stateStore.endRun(this.runId, this.state === "error" ? "paused" : "completed");
358
- }
359
- catch {
360
- // best-effort
361
- }
362
- }
363
- }
364
- finally {
365
- if (this.runId) {
366
- try {
367
- const run = this.stateStore.getRun(this.runId);
368
- if (run && run.endedAt === null) {
369
- // Cancelled runs are paused, not completed — they never
370
- // produced a verdict so treating them as success would be
371
- // a lie to the budget/attempt bookkeeping.
372
- const status = this.state === "error" || this.aborted ? "paused" : "completed";
373
- await this.stateStore.endRun(this.runId, status);
374
- }
375
- }
376
- catch {
377
- // best-effort
378
- }
379
- }
380
- this.cleanup();
381
- this.state = "idle";
382
- this.onDone(this);
383
- }
384
- }
385
- /**
386
- * Pause the current review by suspending the Claude process (SIGSTOP).
387
- */
388
- async pause() {
389
- if (!this.isActive || !this.process || this.process.killed)
390
- return;
391
- log.info(this.tag, `Pausing review on ${this.cardId}`);
392
- signalGroup(this.process, "SIGSTOP");
393
- if (this.timeoutTimer) {
394
- clearTimeout(this.timeoutTimer);
395
- this.timeoutTimer = null;
396
- }
397
- // Update agent session so the UI reflects the paused state
398
- if (this.cardId) {
399
- try {
400
- await this.client.updateAgentProgress(this.cardId, {
401
- agentIdentifier: agentIdentifier(this.id),
402
- agentName: AGENT_NAME,
403
- status: "paused",
404
- });
405
- }
406
- catch {
407
- log.warn(this.tag, "Failed to update agent session to paused");
408
- }
409
- }
410
- }
411
- /**
412
- * Resume the Claude process after a pause (SIGCONT).
413
- */
414
- async resume() {
415
- if (!this.isActive || !this.process || this.process.killed)
416
- return;
417
- log.info(this.tag, `Resuming review on ${this.cardId}`);
418
- signalGroup(this.process, "SIGCONT");
419
- this.timeoutTimer = setTimeout(() => {
420
- log.warn(this.tag, `Timeout reached (${this.config.review.maxTimeout}ms), cancelling`);
421
- this.cancel();
422
- }, this.config.review.maxTimeout);
423
- // Update agent session so the UI reflects the resumed state
424
- if (this.cardId) {
425
- try {
426
- await this.client.updateAgentProgress(this.cardId, {
427
- agentIdentifier: agentIdentifier(this.id),
428
- agentName: AGENT_NAME,
429
- status: "working",
430
- });
431
- }
432
- catch {
433
- log.warn(this.tag, "Failed to update agent session to working");
434
- }
435
- }
436
- }
437
- /**
438
- * Cancel the current review. Sends escalating signals to both processes.
439
- */
440
- async cancel() {
441
- if (!this.isActive)
442
- return;
443
- this.aborted = true;
444
- this.state = "cancelling";
445
- log.info(this.tag, `Cancelling review on ${this.cardId}`);
446
- // Capture tokens/cost snapshot before stopping the tracker so a
447
- // cancelled run still persists its usage to the session.
448
- const snapshotStats = this.lastSessionStats ?? this.progressTracker?.stats;
449
- // Stop progress tracking
450
- if (this.progressTracker) {
451
- this.progressTracker?.stop();
452
- this.progressTracker = null;
453
- }
454
- // Kill dev server first
455
- this.killDevServer();
456
- // Then kill Claude process group with escalating signals
457
- if (this.process && !this.process.killed) {
458
- await terminateGroup(this.process, {
459
- sigintTimeoutMs: CANCEL_SIGINT_TIMEOUT,
460
- sigtermTimeoutMs: CANCEL_SIGTERM_TIMEOUT,
461
- });
462
- }
463
- // End agent session as paused
464
- if (this.cardId) {
465
- try {
466
- await this.client.endAgentSession(this.cardId, {
467
- status: "paused",
468
- ...buildTokenPayload(snapshotStats),
469
- });
470
- }
471
- catch {
472
- // best-effort
473
- }
474
- }
475
- }
476
- spawnClaude(prompt, systemPrompt, tracker, shortId) {
477
- return new Promise((resolve, reject) => {
478
- const args = [
479
- "--output-format",
480
- "stream-json",
481
- "--verbose",
482
- "--model",
483
- this.config.claude.reviewModel,
484
- "--max-turns",
485
- String(this.config.claude.maxTurns),
486
- "--allowedTools",
487
- "Bash(readonly),Read,Glob,Grep,Agent,mcp__harmony__*",
488
- ...(systemPrompt ? ["--append-system-prompt", systemPrompt] : []),
489
- ...this.config.claude.additionalArgs,
490
- "--",
491
- prompt,
492
- ];
493
- log.info(this.tag, `Spawning review: claude ${args.slice(0, 5).join(" ")} ...`);
494
- const runLog = openRunLog(this.tag, this.runId, shortId);
495
- this.lastRunLogPath = runLog?.path ?? null;
496
- if (runLog) {
497
- log.info(this.tag, `Run log: ${runLog.path}`);
498
- runLog.stream.write(`# run=${this.runId} card=#${shortId} pipeline=review started=${new Date().toISOString()}\n` +
499
- `# args: ${args.slice(0, -2).join(" ")} -- <prompt:${prompt.length} chars>\n\n`);
500
- }
501
- this.process = spawnInGroup("claude", args, {
502
- cwd: this.worktreePath,
503
- stdio: ["ignore", "pipe", "pipe"],
504
- });
505
- // Stream parser for progress tracking + text reconstruction for verdict
506
- const parser = new StreamParser();
507
- tracker.attach(parser);
508
- const textChunks = [];
509
- parser.on("text", (content) => {
510
- textChunks.push(content);
511
- });
512
- parser.on("parse_error", (msg) => {
513
- log.debug(this.tag, `Stream parse error (non-fatal): ${msg}`);
514
- runLog?.stream.write(`\n[parse_error] ${msg}\n`);
515
- });
516
- // Attach parser to stdout (single consumer)
517
- if (this.process.stdout) {
518
- parser.attach(this.process.stdout);
519
- if (runLog) {
520
- this.process.stdout.on("data", (chunk) => {
521
- runLog.stream.write(chunk);
522
- });
523
- }
524
- }
525
- let stderr = "";
526
- this.process.stderr?.on("data", (data) => {
527
- stderr += data.toString();
528
- runLog?.stream.write(`[stderr] ${data.toString()}`);
529
- });
530
- this.process.on("error", (err) => {
531
- reject(new Error(`Failed to spawn claude: ${err.message}`));
532
- });
533
- this.process.on("close", (code) => {
534
- this.process = null;
535
- // Reconstruct text from stream parser text events
536
- const stdout = textChunks.join("");
537
- const stats = tracker.stats;
538
- if (runLog) {
539
- runLog.stream.write(`\n# exit code=${code} aborted=${this.aborted} ` +
540
- `toolCalls=${stats?.toolCalls ?? 0} filesEdited=${stats?.filesEdited ?? 0} ` +
541
- `cost=$${stats?.cost?.totalCostUsd.toFixed(4) ?? "0"} ` +
542
- `textChars=${stdout.length} ended=${new Date().toISOString()}\n`);
543
- runLog.stream.end();
544
- }
545
- if (this.aborted) {
546
- resolve(stdout);
547
- }
548
- else if (code === 0) {
549
- resolve(stdout);
550
- }
551
- else {
552
- reject(new Error(`claude exited with code ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ""}`));
553
- }
554
- });
555
- });
556
- }
557
- killDevServer() {
558
- if (this.devServerProcess && !this.devServerProcess.killed) {
559
- signalGroup(this.devServerProcess, "SIGTERM");
560
- this.devServerProcess = null;
561
- log.debug(this.tag, "Killed dev server group");
562
- }
563
- }
564
- resolveLocalChanges(repoRoot, shortId) {
565
- // 1. Check uncommitted changes (staged + unstaged combined)
566
- try {
567
- const localChanges = execFileSync("git", ["diff", "HEAD"], {
568
- cwd: repoRoot,
569
- encoding: "utf-8",
570
- timeout: 5_000,
571
- });
572
- if (localChanges) {
573
- return { diff: localChanges, source: "uncommitted changes" };
574
- }
575
- }
576
- catch {
577
- log.warn(this.tag, "Failed to check uncommitted changes");
578
- }
579
- // 2. Search recent commits for card reference
580
- try {
581
- const matchingCommits = execFileSync("git", ["log", "--format=%H", "-20", `--grep=#${shortId}`], { cwd: repoRoot, encoding: "utf-8", timeout: 10_000 }).trim();
582
- if (matchingCommits) {
583
- const hashes = matchingCommits
584
- .split("\n")
585
- .filter((h) => /^[0-9a-f]{4,40}$/i.test(h));
586
- if (hashes.length === 0)
587
- return null;
588
- log.info(this.tag, `Found ${hashes.length} commit(s) referencing #${shortId}`);
589
- // Generate a combined diff from all matching commits
590
- const diffs = [];
591
- for (const hash of hashes) {
592
- try {
593
- const commitDiff = execFileSync("git", ["diff", `${hash}~1..${hash}`], { cwd: repoRoot, encoding: "utf-8", timeout: 30_000 });
594
- if (commitDiff)
595
- diffs.push(commitDiff);
596
- }
597
- catch {
598
- log.warn(this.tag, `Failed to diff commit ${hash}`);
599
- }
600
- }
601
- if (diffs.length > 0) {
602
- return {
603
- diff: diffs.join("\n"),
604
- source: `${diffs.length} commit(s) matching #${shortId}`,
605
- };
606
- }
607
- }
608
- }
609
- catch {
610
- log.warn(this.tag, "Failed to search recent commits");
611
- }
612
- return null;
613
- }
614
- cleanup() {
615
- if (this.timeoutTimer) {
616
- clearTimeout(this.timeoutTimer);
617
- this.timeoutTimer = null;
618
- }
619
- this.stopHeartbeat();
620
- this.killDevServer();
621
- // Clean up worktree on error (only if we created one — skip in local mode)
622
- if (this.worktreePath && this.state === "error" && this.branchName) {
623
- try {
624
- cleanupWorktree(this.worktreePath, this.branchName);
625
- }
626
- catch {
627
- log.warn(this.tag, "Failed to cleanup review worktree");
628
- }
629
- }
630
- this.process = null;
631
- this.cardId = null;
632
- this.branchName = null;
633
- this.worktreePath = null;
634
- this.startedAt = null;
635
- this.runId = null;
636
- this.lastSessionStats = null;
637
- }
638
- }
@@ -1,12 +0,0 @@
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;