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/.acdev/.env.example +13 -0
- package/README.md +231 -0
- package/bin/acdev.js +138 -0
- package/package.json +56 -0
- package/public/acdev_wordmark_logo.svg +10 -0
- package/public/app.js +3291 -0
- package/public/index.html +449 -0
- package/public/styles.css +1870 -0
- package/src/afterPrRules.js +116 -0
- package/src/agent.js +669 -0
- package/src/claude-auth.js +81 -0
- package/src/config.js +426 -0
- package/src/env.js +98 -0
- package/src/gh-auth.js +41 -0
- package/src/git.js +867 -0
- package/src/github.js +179 -0
- package/src/jira.js +418 -0
- package/src/paths.js +128 -0
- package/src/server.js +988 -0
- package/src/store.js +135 -0
- package/src/urls.js +16 -0
- package/src/usage.js +122 -0
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
|
+
}
|