acdev 1.0.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/src/agent.js ADDED
@@ -0,0 +1,669 @@
1
+ import { query } from '@anthropic-ai/claude-agent-sdk';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { ensureNoAiAttributionSettings, getIssueTitle } from './git.js';
5
+ import { extractUsageFromResult } from './usage.js';
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ /** Phrases that must not appear in PR title/body (case-insensitive). */
10
+ const AI_ATTRIBUTION_RES = [
11
+ /co-authored-by:\s*[^\n]*/gi,
12
+ /claude-generated-by:\s*[^\n]*/gi,
13
+ /🤖\s*generated with\s*\[?claude code\]?[^\n]*/gi,
14
+ /generated with\s*\[?claude code\]?[^\n]*/gi,
15
+ /generated by\s+claude[^\n]*/gi,
16
+ /claude\s*code/gi,
17
+ /\banthropic\b/gi,
18
+ /\bclaude\b/gi,
19
+ /\bai\s*agents?\b/gi,
20
+ /\bai[- ]generated\b/gi,
21
+ /\bgenerated by (an? )?(ai|llm|assistant)\b/gi,
22
+ /\bllm\b/gi,
23
+ ];
24
+
25
+ /**
26
+ * True when a single line is Claude/Anthropic/AI commit or PR attribution.
27
+ * @param {string} line
28
+ */
29
+ export function isAiAttributionLine(line) {
30
+ const trimmed = String(line ?? '').trim();
31
+ if (!trimmed) return false;
32
+ if (/^co-authored-by:\s*/i.test(trimmed)) {
33
+ return /(claude|anthropic|noreply@anthropic\.com)/i.test(trimmed);
34
+ }
35
+ if (/^claude-generated-by:/i.test(trimmed)) return true;
36
+ if (/generated with\s*\[?claude code\]?/i.test(trimmed)) return true;
37
+ if (/generated by\s+claude/i.test(trimmed)) return true;
38
+ return (
39
+ /(claude|anthropic|ai agent|llm)/i.test(trimmed) &&
40
+ /(generated|created|authored|written|powered|via|using|by|assistant|automated|this (pr|change|fix|patch))/i.test(
41
+ trimmed
42
+ )
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Strip Claude / Anthropic / AI-agent attribution from PR text.
48
+ * Keeps useful content (including `Closes #<n>`) while removing marketing lines.
49
+ * @param {string} text
50
+ * @returns {string}
51
+ */
52
+ export function stripAiAttribution(text) {
53
+ if (!text || typeof text !== 'string') return text ?? '';
54
+
55
+ let result = text
56
+ .split('\n')
57
+ .filter((line) => {
58
+ if (/closes\s+#\d+/i.test(line)) return true;
59
+ return !isAiAttributionLine(line);
60
+ })
61
+ .join('\n');
62
+
63
+ for (const re of AI_ATTRIBUTION_RES) {
64
+ result = result.replace(re, '');
65
+ }
66
+
67
+ return result
68
+ .replace(/[ \t]{2,}/g, ' ')
69
+ .replace(/[ \t]+\n/g, '\n')
70
+ .replace(/\n{3,}/g, '\n\n')
71
+ .trim();
72
+ }
73
+
74
+ /**
75
+ * @param {string} issueUrl
76
+ * @param {import('./config.js').loadConfig extends (...args: any) => infer R ? R : never} config
77
+ * @param {{
78
+ * branchName?: string,
79
+ * issueNumber?: number,
80
+ * ticketSource?: 'github' | 'jira',
81
+ * jiraKey?: string,
82
+ * jiraIssue?: {
83
+ * key: string,
84
+ * summary: string,
85
+ * description: string,
86
+ * issueType?: string,
87
+ * labels?: string[],
88
+ * comments?: Array<{ author: string, body: string }>,
89
+ * },
90
+ * jiraPrLinkPhrase?: string,
91
+ * }} [context]
92
+ */
93
+ export function buildPrompt(issueUrl, config, context = {}) {
94
+ const issueNumber = context.issueNumber;
95
+ const branchName = context.branchName;
96
+ const isJira = context.ticketSource === 'jira' || Boolean(context.jiraKey);
97
+ const jiraKey = context.jiraKey || context.jiraIssue?.key;
98
+ const linkPhrase = context.jiraPrLinkPhrase || config.jiraPrLinkPhrase || 'Relates to';
99
+
100
+ /** @type {string[]} */
101
+ const parts = [];
102
+
103
+ if (isJira) {
104
+ parts.push(
105
+ `You are working on Jira issue: ${jiraKey || issueUrl}`,
106
+ issueUrl && issueUrl !== jiraKey ? `Browse URL: ${issueUrl}` : '',
107
+ '',
108
+ 'Issue details are provided below. Use this context — do not invent a GitHub issue number,',
109
+ 'and do not invent a fake `Closes #N` link. Prefer the provided content over fetching Jira yourself',
110
+ '(you may not have Jira credentials in this environment).',
111
+ ''
112
+ );
113
+
114
+ const issue = context.jiraIssue;
115
+ if (issue) {
116
+ parts.push(
117
+ `Key: ${issue.key}`,
118
+ `Summary: ${issue.summary || '(none)'}`,
119
+ `Type: ${issue.issueType || '(unknown)'}`,
120
+ `Labels: ${(issue.labels || []).join(', ') || '(none)'}`,
121
+ '',
122
+ 'Description:',
123
+ issue.description || '(no description)',
124
+ ''
125
+ );
126
+ const comments = issue.comments || [];
127
+ if (comments.length > 0) {
128
+ parts.push('Comments:');
129
+ for (const c of comments.slice(-20)) {
130
+ parts.push(`- ${c.author}: ${c.body || '(empty)'}`);
131
+ }
132
+ parts.push('');
133
+ }
134
+ } else {
135
+ parts.push(
136
+ '1. The issue body was not pre-fetched. If you have network access, you may try to retrieve it;',
137
+ ' otherwise work from the key/summary available in the job title and explore the codebase.',
138
+ ''
139
+ );
140
+ }
141
+
142
+ parts.push(
143
+ '1. Determine whether this is a bug fix or a feature request from the issue type and description, and reason accordingly.',
144
+ '',
145
+ '2. Explore the codebase by reading relevant files before writing any code. Do not guess implementation details from the issue text alone.',
146
+ '',
147
+ '3. Implement the required change.'
148
+ );
149
+ } else {
150
+ parts.push(
151
+ `You are working on GitHub issue: ${issueUrl}`,
152
+ '',
153
+ '1. Fetch the issue details using this command via the Bash tool:',
154
+ ` gh issue view ${issueUrl} --json title,body,comments,labels`,
155
+ ' Do not guess issue content from the URL alone.',
156
+ '',
157
+ '2. Determine whether this is a bug fix or a feature request, and reason accordingly.',
158
+ '',
159
+ '3. Explore the codebase by reading relevant files before writing any code. Do not guess implementation details from the issue text alone.',
160
+ '',
161
+ '4. Implement the required change.'
162
+ );
163
+ }
164
+
165
+ // Drop empty strings from optional browse URL line
166
+ const cleaned = parts.filter((p) => p !== '');
167
+
168
+ if (config.testCommand) {
169
+ cleaned.push(
170
+ '',
171
+ `${isJira ? '4' : '5'}. Run the test command: ${config.testCommand}`,
172
+ ' Fix any failures introduced by your change. Ignore pre-existing failures unrelated to your change.'
173
+ );
174
+ }
175
+
176
+ const stepCommit = isJira
177
+ ? config.testCommand
178
+ ? '5'
179
+ : '4'
180
+ : config.testCommand
181
+ ? '6'
182
+ : '5';
183
+ const stepJson = isJira
184
+ ? config.testCommand
185
+ ? '6'
186
+ : '5'
187
+ : config.testCommand
188
+ ? '7'
189
+ : '6';
190
+
191
+ const refHint = isJira
192
+ ? jiraKey
193
+ ? ` (e.g. include ${jiraKey})`
194
+ : ''
195
+ : issueNumber != null
196
+ ? ` (e.g. include #${issueNumber})`
197
+ : '';
198
+
199
+ const prBodyHint = isJira
200
+ ? `... (include ${linkPhrase} ${jiraKey || 'KEY-123'})`
201
+ : '... (include Closes #<issue-number>)';
202
+
203
+ const prBodyRule = isJira
204
+ ? `- Include \`${linkPhrase} ${jiraKey || '<KEY>'}\` in prBody when appropriate. Do NOT invent a GitHub \`Closes #N\`.`
205
+ : '- Include `Closes #<issue-number>` in prBody when appropriate.';
206
+
207
+ cleaned.push(
208
+ '',
209
+ `${stepCommit}. Commit your changes locally with a message that references the issue` +
210
+ refHint +
211
+ '. Do NOT push, create a PR, rename the branch, or create a new branch.' +
212
+ (branchName
213
+ ? ` The branch \`${branchName}\` already exists — stay on it and commit there.`
214
+ : ' Work on the current branch only.'),
215
+ '',
216
+ ' Commit authorship rules (mandatory):',
217
+ ' - Use the repository\'s existing git `user.name` / `user.email`. Do NOT set `--author`, do NOT override `GIT_AUTHOR_*` / `GIT_COMMITTER_*`, and do NOT set the author to Claude or any AI identity.',
218
+ ' - Do NOT add `Co-Authored-By: Claude`, `Co-Authored-By: Claude Code`, `Generated by Claude`, `Generated with Claude Code`, `Claude-Generated-By`, Anthropic, or any other AI attribution trailer/line in the commit message.',
219
+ ' - Ignore any built-in examples that show Claude co-author trailers; omit those trailers entirely.',
220
+ ' - Do not commit `.claude/settings.local.json` if present.',
221
+ '',
222
+ `${stepJson}. End your final response with a fenced JSON block containing prTitle and prBody:`,
223
+ '```json',
224
+ `{"prTitle": "...", "prBody": "${prBodyHint}"}`,
225
+ '```',
226
+ '',
227
+ 'Rules for prTitle and prBody:',
228
+ '- Do NOT mention Claude Code, Claude, Anthropic, AI agents, LLMs, or similar tooling/attribution.',
229
+ '- Do NOT include `Co-Authored-By`, `Generated with Claude Code`, or similar attribution lines.',
230
+ '- Write as a normal human engineer describing the change.',
231
+ prBodyRule
232
+ );
233
+
234
+ return cleaned.join('\n');
235
+ }
236
+
237
+ /**
238
+ * Prompt for applying human review feedback on an existing worktree/branch.
239
+ * @param {{
240
+ * issueUrl: string,
241
+ * generalComment?: string,
242
+ * lineComments?: Array<{ path: string, line: number, side: 'LEFT' | 'RIGHT', body: string }>,
243
+ * config: object,
244
+ * branchName?: string,
245
+ * issueNumber?: number,
246
+ * ticketSource?: 'github' | 'jira',
247
+ * jiraKey?: string,
248
+ * jiraPrLinkPhrase?: string,
249
+ * }} params
250
+ */
251
+ export function buildReviewFeedbackPrompt({
252
+ issueUrl,
253
+ generalComment = '',
254
+ lineComments = [],
255
+ config,
256
+ branchName,
257
+ issueNumber,
258
+ ticketSource,
259
+ jiraKey,
260
+ jiraPrLinkPhrase,
261
+ }) {
262
+ const isJira = ticketSource === 'jira' || Boolean(jiraKey);
263
+ const linkPhrase = jiraPrLinkPhrase || config.jiraPrLinkPhrase || 'Relates to';
264
+ const parts = [
265
+ isJira
266
+ ? `You are continuing work on Jira issue: ${jiraKey || issueUrl}`
267
+ : `You are continuing work on GitHub issue: ${issueUrl}`,
268
+ '',
269
+ 'A human reviewed your current local changes (diff vs the base branch) and left feedback.',
270
+ 'Apply their requested changes on the existing branch. Do NOT start over from scratch.',
271
+ '',
272
+ 'Review feedback:',
273
+ ];
274
+
275
+ const general = String(generalComment || '').trim();
276
+ if (general) {
277
+ parts.push('', 'General comment (whole change):', general);
278
+ }
279
+
280
+ const lines = Array.isArray(lineComments) ? lineComments : [];
281
+ if (lines.length > 0) {
282
+ parts.push('', 'Line comments:');
283
+ for (const c of lines) {
284
+ const side = c.side === 'LEFT' ? 'LEFT (old/removed)' : 'RIGHT (new/added or context)';
285
+ parts.push(`- ${c.path}:${c.line} [${side}]: ${c.body}`);
286
+ }
287
+ }
288
+
289
+ parts.push(
290
+ '',
291
+ 'Instructions:',
292
+ '1. Read the relevant files around the cited paths/lines and understand the current diff.',
293
+ '2. Apply the requested changes. Prefer minimal, focused edits that address the feedback.',
294
+ );
295
+
296
+ if (config.testCommand) {
297
+ parts.push(
298
+ `3. Run the test command: ${config.testCommand}`,
299
+ ' Fix any failures introduced by your change. Ignore pre-existing failures unrelated to your change.'
300
+ );
301
+ }
302
+
303
+ const stepCommit = config.testCommand ? '4' : '3';
304
+ const stepJson = config.testCommand ? '5' : '4';
305
+
306
+ const refHint = isJira
307
+ ? jiraKey
308
+ ? ` (e.g. include ${jiraKey})`
309
+ : ''
310
+ : issueNumber != null
311
+ ? ` (e.g. include #${issueNumber})`
312
+ : '';
313
+
314
+ const prBodyHint = isJira
315
+ ? `... (include ${linkPhrase} ${jiraKey || 'KEY-123'})`
316
+ : '... (include Closes #<issue-number>)';
317
+
318
+ const prBodyRule = isJira
319
+ ? `- Include \`${linkPhrase} ${jiraKey || '<KEY>'}\` in prBody when appropriate. Do NOT invent a GitHub \`Closes #N\`.`
320
+ : '- Include `Closes #<issue-number>` in prBody when appropriate.';
321
+
322
+ parts.push(
323
+ `${stepCommit}. Commit your changes locally with a message that references the issue` +
324
+ refHint +
325
+ '. Do NOT push, create a PR, rename the branch, or create a new branch.' +
326
+ (branchName
327
+ ? ` The branch \`${branchName}\` already exists — stay on it and commit there.`
328
+ : ' Work on the current branch only.'),
329
+ '',
330
+ ' Commit authorship rules (mandatory):',
331
+ ' - Use the repository\'s existing git `user.name` / `user.email`. Do NOT set `--author`, do NOT override `GIT_AUTHOR_*` / `GIT_COMMITTER_*`, and do NOT set the author to Claude or any AI identity.',
332
+ ' - Do NOT add `Co-Authored-By: Claude`, `Co-Authored-By: Claude Code`, `Generated by Claude`, `Generated with Claude Code`, `Claude-Generated-By`, Anthropic, or any other AI attribution trailer/line in the commit message.',
333
+ ' - Ignore any built-in examples that show Claude co-author trailers; omit those trailers entirely.',
334
+ ' - Do not commit `.claude/settings.local.json` if present.',
335
+ '',
336
+ `${stepJson}. End your final response with a fenced JSON block containing updated prTitle and prBody:`,
337
+ '```json',
338
+ `{"prTitle": "...", "prBody": "${prBodyHint}"}`,
339
+ '```',
340
+ '',
341
+ 'Rules for prTitle and prBody:',
342
+ '- Do NOT mention Claude Code, Claude, Anthropic, AI agents, LLMs, or similar tooling/attribution.',
343
+ '- Do NOT include `Co-Authored-By`, `Generated with Claude Code`, or similar attribution lines.',
344
+ '- Write as a normal human engineer describing the change.',
345
+ prBodyRule,
346
+ '- Refresh title/body to reflect the updated change after applying review feedback.'
347
+ );
348
+
349
+ return parts.join('\n');
350
+ }
351
+
352
+ /**
353
+ * @param {string} text
354
+ * @returns {{ prTitle?: string, prBody?: string } | null}
355
+ */
356
+ export function extractPrMetadata(text) {
357
+ if (!text) return null;
358
+ const match = text.match(/```json\s*([\s\S]*?)\s*```/i);
359
+ if (!match) return null;
360
+ try {
361
+ const parsed = JSON.parse(match[1]);
362
+ if (parsed.prTitle && parsed.prBody) {
363
+ return {
364
+ prTitle: stripAiAttribution(String(parsed.prTitle)),
365
+ prBody: stripAiAttribution(String(parsed.prBody)),
366
+ };
367
+ }
368
+ } catch {
369
+ // fall through
370
+ }
371
+ return null;
372
+ }
373
+
374
+ /**
375
+ * @param {string} issueUrl
376
+ * @param {string} worktreePath
377
+ */
378
+ async function fetchIssueTitle(issueUrl, worktreePath) {
379
+ try {
380
+ return await getIssueTitle(worktreePath, issueUrl);
381
+ } catch {
382
+ const { stdout } = await execFileAsync(
383
+ 'gh',
384
+ ['issue', 'view', issueUrl, '--json', 'title'],
385
+ { cwd: worktreePath }
386
+ );
387
+ return JSON.parse(stdout).title;
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Tools that must stay off for headless one-shot runs.
393
+ * Async Task / TaskOutput teardown in Claude Agent SDK streaming mode can emit a
394
+ * spurious `error_during_execution` ("only prompt commands are supported in
395
+ * streaming mode") after a successful result — see anthropics/claude-agent-sdk-typescript#130.
396
+ */
397
+ export const DISALLOWED_AGENT_TOOLS = ['Task', 'TaskOutput', 'AskUserQuestion'];
398
+
399
+ /**
400
+ * Prefer the first successful SDK result. A known SDK bug can emit a follow-up
401
+ * `error_during_execution` right after success when async subagents finish.
402
+ * @param {unknown} current
403
+ * @param {unknown} incoming
404
+ */
405
+ export function mergeAgentResult(current, incoming) {
406
+ if (!incoming || typeof incoming !== 'object' || incoming.type !== 'result') {
407
+ return current ?? null;
408
+ }
409
+ if (current?.type === 'result' && current.subtype === 'success') {
410
+ return current;
411
+ }
412
+ return incoming;
413
+ }
414
+
415
+ /**
416
+ * @param {{
417
+ * prompt: string,
418
+ * worktreePath: string,
419
+ * config: object,
420
+ * onEvent: (message: unknown) => void,
421
+ * queryFn?: typeof query,
422
+ * }} params
423
+ */
424
+ async function runAgentQuery({ prompt, worktreePath, config, onEvent, queryFn = query }) {
425
+ const timeoutMs = config.agentTimeoutMs ?? 900_000;
426
+ let lastMessage = null;
427
+ let resultMessage = null;
428
+ const abortController = new AbortController();
429
+
430
+ // Claude Agent SDK defaults to injecting Co-Authored-By trailers into commit
431
+ // instructions unless attribution is cleared via Claude settings.
432
+ ensureNoAiAttributionSettings(worktreePath);
433
+
434
+ const runLoop = async () => {
435
+ try {
436
+ for await (const message of queryFn({
437
+ // String prompt = single-turn SDK input (still uses stream-json transport).
438
+ prompt,
439
+ options: {
440
+ cwd: worktreePath,
441
+ allowedTools: config.allowedTools,
442
+ disallowedTools: DISALLOWED_AGENT_TOOLS,
443
+ permissionMode: 'acceptEdits',
444
+ maxTurns: config.maxAgentTurns,
445
+ model: config.model || 'claude-sonnet-5',
446
+ // Load only local settings so our empty attribution wins without
447
+ // pulling in unrelated user settings.
448
+ settingSources: ['local'],
449
+ abortController,
450
+ },
451
+ })) {
452
+ onEvent(message);
453
+ lastMessage = message;
454
+ if (message?.type === 'result') {
455
+ resultMessage = mergeAgentResult(resultMessage, message);
456
+ // First success is authoritative — stop before SDK async-task teardown
457
+ // can overwrite it with a spurious streaming-mode error.
458
+ if (message.subtype === 'success') {
459
+ abortController.abort();
460
+ break;
461
+ }
462
+ }
463
+ }
464
+ } catch (err) {
465
+ // Aborting after success can surface as an iterator/abort error.
466
+ if (resultMessage?.subtype === 'success') return;
467
+ throw err;
468
+ }
469
+ };
470
+
471
+ let timeoutId;
472
+ try {
473
+ await Promise.race([
474
+ runLoop(),
475
+ new Promise((_, reject) => {
476
+ timeoutId = setTimeout(() => {
477
+ abortController.abort();
478
+ reject(new Error(`Agent timed out after ${timeoutMs}ms`));
479
+ }, timeoutMs);
480
+ }),
481
+ ]);
482
+ } finally {
483
+ if (timeoutId) clearTimeout(timeoutId);
484
+ }
485
+
486
+ if (!resultMessage || resultMessage.subtype !== 'success') {
487
+ const detail = lastMessage ? JSON.stringify(lastMessage) : 'no result received';
488
+ throw new Error(`Agent did not complete successfully: ${detail}`);
489
+ }
490
+
491
+ const resultText = resultMessage.result ?? '';
492
+ const meta = extractPrMetadata(resultText);
493
+ const usage = extractUsageFromResult(resultMessage);
494
+ return { resultText, meta, usage };
495
+ }
496
+
497
+ function stubAgentResult(onEvent, title, body) {
498
+ const fakeResult = {
499
+ type: 'result',
500
+ subtype: 'success',
501
+ result: `Stub agent completed.\n\n\`\`\`json\n${JSON.stringify({ prTitle: title, prBody: body })}\n\`\`\``,
502
+ total_cost_usd: 0.0123,
503
+ duration_ms: 2000,
504
+ num_turns: 1,
505
+ usage: {
506
+ input_tokens: 1200,
507
+ output_tokens: 400,
508
+ cache_read_input_tokens: 0,
509
+ cache_creation_input_tokens: 0,
510
+ },
511
+ modelUsage: {
512
+ 'claude-sonnet-5': {
513
+ inputTokens: 1200,
514
+ outputTokens: 400,
515
+ cacheReadInputTokens: 0,
516
+ cacheCreationInputTokens: 0,
517
+ webSearchRequests: 0,
518
+ costUSD: 0.0123,
519
+ contextWindow: 200000,
520
+ },
521
+ },
522
+ };
523
+ onEvent(fakeResult);
524
+ const meta = extractPrMetadata(fakeResult.result);
525
+ return {
526
+ prTitle: meta?.prTitle ?? title,
527
+ prBody: meta?.prBody ?? stripAiAttribution(fakeResult.result),
528
+ resultText: fakeResult.result,
529
+ usage: extractUsageFromResult(fakeResult),
530
+ };
531
+ }
532
+
533
+ /**
534
+ * @param {{
535
+ * issueUrl: string,
536
+ * worktreePath: string,
537
+ * config: object,
538
+ * onEvent: (message: unknown) => void,
539
+ * stub?: boolean,
540
+ * branchName?: string,
541
+ * issueNumber?: number,
542
+ * ticketSource?: 'github' | 'jira',
543
+ * jiraKey?: string,
544
+ * jiraIssue?: object,
545
+ * queryFn?: typeof query,
546
+ * }} params
547
+ */
548
+ export async function runAgentOnIssue({
549
+ issueUrl,
550
+ worktreePath,
551
+ config,
552
+ onEvent,
553
+ stub = false,
554
+ branchName,
555
+ issueNumber,
556
+ ticketSource,
557
+ jiraKey,
558
+ jiraIssue,
559
+ queryFn = query,
560
+ }) {
561
+ if (stub) {
562
+ await new Promise((resolve) => setTimeout(resolve, 2000));
563
+ const body =
564
+ ticketSource === 'jira' || jiraKey
565
+ ? `Automated fix from stub agent.\n\nRelates to ${jiraKey || 'KEY-1'}`
566
+ : 'Automated fix from stub agent.\n\nCloses #1';
567
+ return stubAgentResult(onEvent, 'Fix issue (stub)', body);
568
+ }
569
+
570
+ const { resultText, meta, usage } = await runAgentQuery({
571
+ prompt: buildPrompt(issueUrl, config, {
572
+ branchName,
573
+ issueNumber,
574
+ ticketSource,
575
+ jiraKey,
576
+ jiraIssue,
577
+ jiraPrLinkPhrase: config.jiraPrLinkPhrase,
578
+ }),
579
+ worktreePath,
580
+ config,
581
+ onEvent,
582
+ queryFn,
583
+ });
584
+
585
+ if (meta) {
586
+ return { prTitle: meta.prTitle, prBody: meta.prBody, resultText, usage };
587
+ }
588
+
589
+ const issueTitle =
590
+ jiraIssue?.summary ||
591
+ (await fetchIssueTitle(issueUrl, worktreePath).catch(() => issueUrl));
592
+ return {
593
+ prTitle: stripAiAttribution(issueTitle),
594
+ prBody: stripAiAttribution(resultText),
595
+ resultText,
596
+ usage,
597
+ };
598
+ }
599
+
600
+ /**
601
+ * Continue on an existing worktree using human review comments (no push/PR).
602
+ * @param {{
603
+ * issueUrl: string,
604
+ * worktreePath: string,
605
+ * config: object,
606
+ * generalComment?: string,
607
+ * lineComments?: Array<{ path: string, line: number, side: 'LEFT' | 'RIGHT', body: string }>,
608
+ * onEvent: (message: unknown) => void,
609
+ * stub?: boolean,
610
+ * branchName?: string,
611
+ * issueNumber?: number,
612
+ * ticketSource?: 'github' | 'jira',
613
+ * jiraKey?: string,
614
+ * queryFn?: typeof query,
615
+ * }} params
616
+ */
617
+ export async function runAgentOnReviewFeedback({
618
+ issueUrl,
619
+ worktreePath,
620
+ config,
621
+ generalComment = '',
622
+ lineComments = [],
623
+ onEvent,
624
+ stub = false,
625
+ branchName,
626
+ issueNumber,
627
+ ticketSource,
628
+ jiraKey,
629
+ queryFn = query,
630
+ }) {
631
+ if (stub) {
632
+ await new Promise((resolve) => setTimeout(resolve, 500));
633
+ const body =
634
+ ticketSource === 'jira' || jiraKey
635
+ ? `Applied review feedback.\n\nRelates to ${jiraKey || 'KEY-1'}`
636
+ : 'Applied review feedback.\n\nCloses #1';
637
+ return stubAgentResult(onEvent, 'Address review feedback (stub)', body);
638
+ }
639
+
640
+ const { resultText, meta, usage } = await runAgentQuery({
641
+ prompt: buildReviewFeedbackPrompt({
642
+ issueUrl,
643
+ generalComment,
644
+ lineComments,
645
+ config,
646
+ branchName,
647
+ issueNumber,
648
+ ticketSource,
649
+ jiraKey,
650
+ jiraPrLinkPhrase: config.jiraPrLinkPhrase,
651
+ }),
652
+ worktreePath,
653
+ config,
654
+ onEvent,
655
+ queryFn,
656
+ });
657
+
658
+ if (meta) {
659
+ return { prTitle: meta.prTitle, prBody: meta.prBody, resultText, usage };
660
+ }
661
+
662
+ const issueTitle = await fetchIssueTitle(issueUrl, worktreePath).catch(() => issueUrl);
663
+ return {
664
+ prTitle: stripAiAttribution(issueTitle),
665
+ prBody: stripAiAttribution(resultText),
666
+ resultText,
667
+ usage,
668
+ };
669
+ }