@claudemini/ses-cli 1.4.3
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 +465 -0
- package/bin/ses.js +85 -0
- package/lib/agent-review.js +722 -0
- package/lib/checkpoint.js +320 -0
- package/lib/checkpoints.js +54 -0
- package/lib/clean.js +45 -0
- package/lib/commit.js +60 -0
- package/lib/config.js +28 -0
- package/lib/disable.js +152 -0
- package/lib/doctor.js +307 -0
- package/lib/enable.js +294 -0
- package/lib/explain.js +212 -0
- package/lib/extract.js +265 -0
- package/lib/git-shadow.js +136 -0
- package/lib/init.js +83 -0
- package/lib/list.js +62 -0
- package/lib/log.js +77 -0
- package/lib/prompts.js +125 -0
- package/lib/query.js +110 -0
- package/lib/redact.js +170 -0
- package/lib/report.js +296 -0
- package/lib/reset.js +122 -0
- package/lib/resume.js +224 -0
- package/lib/review-common.js +100 -0
- package/lib/review.js +652 -0
- package/lib/rewind.js +198 -0
- package/lib/session.js +225 -0
- package/lib/shadow.js +51 -0
- package/lib/status.js +198 -0
- package/lib/summarize.js +315 -0
- package/lib/view.js +50 -0
- package/lib/webhook.js +224 -0
- package/package.json +41 -0
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'fs';
|
|
3
|
+
import { basename, join, resolve, sep } from 'path';
|
|
4
|
+
import {
|
|
5
|
+
Agent,
|
|
6
|
+
BaseTool,
|
|
7
|
+
ToolRegistry,
|
|
8
|
+
createModel,
|
|
9
|
+
createOpenAIAdapter,
|
|
10
|
+
createAnthropicAdapter,
|
|
11
|
+
textContent,
|
|
12
|
+
} from 'goatchain';
|
|
13
|
+
import { getApiConfig } from './summarize.js';
|
|
14
|
+
import {
|
|
15
|
+
loadJsonIfExists,
|
|
16
|
+
normalizeEvidence as normalizeEvidenceList,
|
|
17
|
+
normalizeLocation,
|
|
18
|
+
} from './review-common.js';
|
|
19
|
+
import { REVIEW_AGENT_SYSTEM_PROMPT, buildReviewUserMessage } from './prompts.js';
|
|
20
|
+
|
|
21
|
+
const MAX_OUTPUT_BYTES = 50 * 1024;
|
|
22
|
+
const MAX_ITERATIONS = 30;
|
|
23
|
+
const BASE_TIMEOUT_MS = 60000;
|
|
24
|
+
const PER_SESSION_TIMEOUT_MS = 30000;
|
|
25
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 30000;
|
|
26
|
+
const MIN_AGENT_TIMEOUT_MS = 1000;
|
|
27
|
+
const BLOCKED_BASENAMES = new Set([
|
|
28
|
+
'.env',
|
|
29
|
+
'.env.local',
|
|
30
|
+
'.env.development',
|
|
31
|
+
'.env.production',
|
|
32
|
+
'.env.test',
|
|
33
|
+
'.npmrc',
|
|
34
|
+
'.pypirc',
|
|
35
|
+
'.git-credentials',
|
|
36
|
+
'id_rsa',
|
|
37
|
+
'id_ed25519',
|
|
38
|
+
]);
|
|
39
|
+
const BLOCKED_SUFFIXES = ['.pem', '.key', '.p12', '.pfx'];
|
|
40
|
+
|
|
41
|
+
const VALID_CATEGORY = new Set([
|
|
42
|
+
'testing',
|
|
43
|
+
'correctness',
|
|
44
|
+
'reliability',
|
|
45
|
+
'maintainability',
|
|
46
|
+
'security',
|
|
47
|
+
'performance',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const VALID_SEVERITY = new Set(['info', 'low', 'medium', 'high', 'critical']);
|
|
51
|
+
const VALID_CONFIDENCE = new Set(['low', 'medium', 'high']);
|
|
52
|
+
|
|
53
|
+
function truncateText(text, maxBytes = MAX_OUTPUT_BYTES) {
|
|
54
|
+
const input = String(text || '');
|
|
55
|
+
if (Buffer.byteLength(input, 'utf8') <= maxBytes) {
|
|
56
|
+
return input;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const suffix = '\n...[truncated]';
|
|
60
|
+
const bodyBytes = Math.max(0, maxBytes - Buffer.byteLength(suffix, 'utf8'));
|
|
61
|
+
const body = Buffer.from(input, 'utf8').subarray(0, bodyBytes).toString('utf8');
|
|
62
|
+
return `${body}${suffix}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function runGit(projectRoot, args) {
|
|
66
|
+
try {
|
|
67
|
+
return execFileSync('git', args, {
|
|
68
|
+
cwd: projectRoot,
|
|
69
|
+
encoding: 'utf8',
|
|
70
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
71
|
+
timeout: 12000,
|
|
72
|
+
}).trim();
|
|
73
|
+
} catch {
|
|
74
|
+
return '';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getLinkedCommits(state) {
|
|
79
|
+
const checkpoints = Array.isArray(state?.checkpoints) ? state.checkpoints : [];
|
|
80
|
+
const commits = [];
|
|
81
|
+
for (const checkpoint of checkpoints) {
|
|
82
|
+
const commit = String(checkpoint?.linked_commit || '').trim().toLowerCase();
|
|
83
|
+
if (/^[a-f0-9]{7,40}$/.test(commit)) {
|
|
84
|
+
commits.push(commit);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return [...new Set(commits)];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function preloadSessionData(selectedSessions) {
|
|
91
|
+
const sessionMap = new Map();
|
|
92
|
+
|
|
93
|
+
for (const session of selectedSessions) {
|
|
94
|
+
const summaryPath = join(session.dir, 'summary.json');
|
|
95
|
+
const statePath = join(session.dir, 'state.json');
|
|
96
|
+
const metadataPath = join(session.dir, 'metadata.json');
|
|
97
|
+
|
|
98
|
+
const summary = loadJsonIfExists(summaryPath);
|
|
99
|
+
const state = loadJsonIfExists(statePath, {});
|
|
100
|
+
const metadata = loadJsonIfExists(metadataPath, {});
|
|
101
|
+
const linkedCommits = getLinkedCommits(state);
|
|
102
|
+
|
|
103
|
+
sessionMap.set(session.id, {
|
|
104
|
+
id: session.id,
|
|
105
|
+
dir: session.dir,
|
|
106
|
+
summaryPath,
|
|
107
|
+
statePath,
|
|
108
|
+
metadataPath,
|
|
109
|
+
summary,
|
|
110
|
+
state,
|
|
111
|
+
metadata,
|
|
112
|
+
linkedCommits,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return sessionMap;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function pickSession(sessionMap, requestedSessionId) {
|
|
120
|
+
if (requestedSessionId) {
|
|
121
|
+
const match = sessionMap.get(String(requestedSessionId));
|
|
122
|
+
if (!match) {
|
|
123
|
+
throw new Error(`Unknown session_id: ${requestedSessionId}`);
|
|
124
|
+
}
|
|
125
|
+
return match;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const first = sessionMap.values().next();
|
|
129
|
+
if (first.done) {
|
|
130
|
+
throw new Error('No session loaded for review.');
|
|
131
|
+
}
|
|
132
|
+
return first.value;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function buildSessionDiff(projectRoot, sessionData, options = {}) {
|
|
136
|
+
const commits = sessionData.linkedCommits;
|
|
137
|
+
|
|
138
|
+
if (commits.length >= 2) {
|
|
139
|
+
const from = commits[commits.length - 2];
|
|
140
|
+
const to = commits[commits.length - 1];
|
|
141
|
+
const diff = runGit(projectRoot, ['diff', '--unified=3', `${from}..${to}`]);
|
|
142
|
+
if (diff) {
|
|
143
|
+
return {
|
|
144
|
+
strategy: 'checkpoint_range',
|
|
145
|
+
reference: `${from}..${to}`,
|
|
146
|
+
diff,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (commits.length >= 1) {
|
|
152
|
+
const latest = commits[commits.length - 1];
|
|
153
|
+
const diff = runGit(projectRoot, ['show', '--format=', '--unified=3', latest]);
|
|
154
|
+
if (diff) {
|
|
155
|
+
return {
|
|
156
|
+
strategy: 'checkpoint_show',
|
|
157
|
+
reference: latest,
|
|
158
|
+
diff,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const changedFiles = Array.isArray(sessionData.summary?.changes?.files)
|
|
164
|
+
? sessionData.summary.changes.files
|
|
165
|
+
.map(file => String(file.path || '').trim())
|
|
166
|
+
.filter(Boolean)
|
|
167
|
+
.slice(0, 40)
|
|
168
|
+
: [];
|
|
169
|
+
|
|
170
|
+
if (changedFiles.length > 0 && options.allowWorktreeDiff) {
|
|
171
|
+
const diff = runGit(projectRoot, ['diff', '--unified=3', '--', ...changedFiles]);
|
|
172
|
+
if (diff) {
|
|
173
|
+
return {
|
|
174
|
+
strategy: 'worktree_files',
|
|
175
|
+
reference: changedFiles,
|
|
176
|
+
diff,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (changedFiles.length > 0) {
|
|
182
|
+
return {
|
|
183
|
+
strategy: 'summary_files_only',
|
|
184
|
+
reference: changedFiles,
|
|
185
|
+
diff: '',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
strategy: 'none',
|
|
191
|
+
reference: null,
|
|
192
|
+
diff: '',
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isSensitivePath(inputPath) {
|
|
197
|
+
const normalized = String(inputPath || '').replace(/\\/g, '/').toLowerCase();
|
|
198
|
+
if (!normalized) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
if (normalized.includes('/.git/') || normalized.startsWith('.git/')) {
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const file = basename(normalized);
|
|
206
|
+
if (BLOCKED_BASENAMES.has(file)) {
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return BLOCKED_SUFFIXES.some(suffix => file.endsWith(suffix));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function resolveProjectFile(projectRoot, inputPath) {
|
|
214
|
+
const root = resolve(projectRoot);
|
|
215
|
+
const target = resolve(projectRoot, String(inputPath || ''));
|
|
216
|
+
if (target !== root && !target.startsWith(`${root}${sep}`)) {
|
|
217
|
+
throw new Error('Path escapes project root.');
|
|
218
|
+
}
|
|
219
|
+
return target;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
class ReadSessionSummaryTool extends BaseTool {
|
|
223
|
+
name = 'read_session_summary';
|
|
224
|
+
description = 'Read summary.json, state.json, and metadata.json for a selected session.';
|
|
225
|
+
parameters = {
|
|
226
|
+
type: 'object',
|
|
227
|
+
properties: {
|
|
228
|
+
session_id: {
|
|
229
|
+
type: 'string',
|
|
230
|
+
description: 'Optional session id. Defaults to the first selected session.',
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
additionalProperties: false,
|
|
234
|
+
};
|
|
235
|
+
riskLevel = 'safe';
|
|
236
|
+
|
|
237
|
+
constructor(sessionMap) {
|
|
238
|
+
super();
|
|
239
|
+
this.sessionMap = sessionMap;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async execute(args) {
|
|
243
|
+
const session = pickSession(this.sessionMap, args?.session_id);
|
|
244
|
+
const payload = {
|
|
245
|
+
session_id: session.id,
|
|
246
|
+
source: {
|
|
247
|
+
summary_file: session.summaryPath,
|
|
248
|
+
state_file: session.statePath,
|
|
249
|
+
metadata_file: session.metadataPath,
|
|
250
|
+
},
|
|
251
|
+
summary: session.summary,
|
|
252
|
+
state: session.state,
|
|
253
|
+
metadata: session.metadata,
|
|
254
|
+
};
|
|
255
|
+
return textContent(truncateText(JSON.stringify(payload, null, 2)));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
class ReadGitDiffTool extends BaseTool {
|
|
260
|
+
name = 'read_git_diff';
|
|
261
|
+
description = 'Read git diff for a selected session. Prefer checkpoint commit range when available.';
|
|
262
|
+
parameters = {
|
|
263
|
+
type: 'object',
|
|
264
|
+
properties: {
|
|
265
|
+
session_id: {
|
|
266
|
+
type: 'string',
|
|
267
|
+
description: 'Optional session id. Defaults to the first selected session.',
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
additionalProperties: false,
|
|
271
|
+
};
|
|
272
|
+
riskLevel = 'safe';
|
|
273
|
+
|
|
274
|
+
constructor(projectRoot, sessionMap, options) {
|
|
275
|
+
super();
|
|
276
|
+
this.projectRoot = projectRoot;
|
|
277
|
+
this.sessionMap = sessionMap;
|
|
278
|
+
this.options = options;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async execute(args) {
|
|
282
|
+
const session = pickSession(this.sessionMap, args?.session_id);
|
|
283
|
+
const diffData = buildSessionDiff(this.projectRoot, session, this.options);
|
|
284
|
+
|
|
285
|
+
const payload = [
|
|
286
|
+
`session_id=${session.id}`,
|
|
287
|
+
`strategy=${diffData.strategy}`,
|
|
288
|
+
`reference=${JSON.stringify(diffData.reference)}`,
|
|
289
|
+
'',
|
|
290
|
+
diffData.diff || 'No diff available for this session.',
|
|
291
|
+
].join('\n');
|
|
292
|
+
|
|
293
|
+
return textContent(truncateText(payload));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
class ReadSourceFileTool extends BaseTool {
|
|
298
|
+
name = 'read_source_file';
|
|
299
|
+
description = 'Read a source file under project root with optional line range.';
|
|
300
|
+
parameters = {
|
|
301
|
+
type: 'object',
|
|
302
|
+
properties: {
|
|
303
|
+
path: { type: 'string', description: 'Path relative to project root.' },
|
|
304
|
+
start_line: { type: 'integer', minimum: 1 },
|
|
305
|
+
end_line: { type: 'integer', minimum: 1 },
|
|
306
|
+
},
|
|
307
|
+
required: ['path'],
|
|
308
|
+
additionalProperties: false,
|
|
309
|
+
};
|
|
310
|
+
riskLevel = 'safe';
|
|
311
|
+
|
|
312
|
+
constructor(projectRoot) {
|
|
313
|
+
super();
|
|
314
|
+
this.projectRoot = projectRoot;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async execute(args) {
|
|
318
|
+
if (isSensitivePath(args?.path)) {
|
|
319
|
+
throw new Error(`Access denied for sensitive path: ${args?.path}`);
|
|
320
|
+
}
|
|
321
|
+
const target = resolveProjectFile(this.projectRoot, args?.path);
|
|
322
|
+
if (!existsSync(target) || !statSync(target).isFile()) {
|
|
323
|
+
throw new Error(`File not found: ${args?.path}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const content = readFileSync(target, 'utf8');
|
|
327
|
+
const lines = content.split('\n');
|
|
328
|
+
|
|
329
|
+
const parsedStart = Number(args?.start_line);
|
|
330
|
+
const parsedEnd = Number(args?.end_line);
|
|
331
|
+
const startLine = Number.isFinite(parsedStart) ? Math.max(1, Math.floor(parsedStart)) : 1;
|
|
332
|
+
const endLine = Number.isFinite(parsedEnd)
|
|
333
|
+
? Math.max(startLine, Math.floor(parsedEnd))
|
|
334
|
+
: lines.length;
|
|
335
|
+
|
|
336
|
+
const slice = lines.slice(startLine - 1, endLine);
|
|
337
|
+
const numbered = slice.map((line, index) => `${startLine + index}\t${line}`).join('\n');
|
|
338
|
+
|
|
339
|
+
const payload = [
|
|
340
|
+
`file=${target}`,
|
|
341
|
+
`line_range=${startLine}-${endLine}`,
|
|
342
|
+
'',
|
|
343
|
+
numbered,
|
|
344
|
+
].join('\n');
|
|
345
|
+
|
|
346
|
+
return textContent(truncateText(payload));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
class SubmitFindingsTool extends BaseTool {
|
|
351
|
+
name = 'submit_findings';
|
|
352
|
+
description = 'Submit structured findings. This is the final output channel for review results.';
|
|
353
|
+
parameters = {
|
|
354
|
+
type: 'object',
|
|
355
|
+
properties: {
|
|
356
|
+
findings: {
|
|
357
|
+
type: 'array',
|
|
358
|
+
items: {
|
|
359
|
+
type: 'object',
|
|
360
|
+
properties: {
|
|
361
|
+
rule_id: { type: 'string' },
|
|
362
|
+
category: { type: 'string' },
|
|
363
|
+
severity: { type: 'string' },
|
|
364
|
+
confidence: { type: 'string' },
|
|
365
|
+
summary: { type: 'string' },
|
|
366
|
+
details: { type: 'string' },
|
|
367
|
+
suggestion: { type: 'string' },
|
|
368
|
+
evidence: {
|
|
369
|
+
type: 'array',
|
|
370
|
+
items: { type: 'string' },
|
|
371
|
+
},
|
|
372
|
+
location: {
|
|
373
|
+
type: 'object',
|
|
374
|
+
properties: {
|
|
375
|
+
file: { type: 'string' },
|
|
376
|
+
line: { type: 'integer' },
|
|
377
|
+
column: { type: 'integer' },
|
|
378
|
+
},
|
|
379
|
+
additionalProperties: true,
|
|
380
|
+
},
|
|
381
|
+
sessions: {
|
|
382
|
+
type: 'array',
|
|
383
|
+
items: { type: 'string' },
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
additionalProperties: true,
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
required: ['findings'],
|
|
391
|
+
additionalProperties: false,
|
|
392
|
+
};
|
|
393
|
+
riskLevel = 'safe';
|
|
394
|
+
|
|
395
|
+
constructor(resultHolder) {
|
|
396
|
+
super();
|
|
397
|
+
this.resultHolder = resultHolder;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async execute(args) {
|
|
401
|
+
let findings = args?.findings;
|
|
402
|
+
|
|
403
|
+
if (typeof findings === 'string') {
|
|
404
|
+
try {
|
|
405
|
+
findings = JSON.parse(findings);
|
|
406
|
+
} catch {
|
|
407
|
+
findings = [];
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
this.resultHolder.findings = Array.isArray(findings) ? findings : [];
|
|
412
|
+
this.resultHolder.submitted = true;
|
|
413
|
+
return textContent(`received_findings=${this.resultHolder.findings.length}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function normalizeEvidenceWithFallback(evidence, fallbackSessionId) {
|
|
418
|
+
return normalizeEvidenceList(evidence, { fallbackItems: [`session_id=${fallbackSessionId}`] });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function normalizeSubmittedFindings(rawFindings, sessionIds) {
|
|
422
|
+
const fallbackSessionId = sessionIds[0] || 'unknown';
|
|
423
|
+
const allowedSessions = new Set(sessionIds);
|
|
424
|
+
|
|
425
|
+
const normalized = (Array.isArray(rawFindings) ? rawFindings : []).map((finding, index) => {
|
|
426
|
+
const input = finding && typeof finding === 'object' ? finding : {};
|
|
427
|
+
|
|
428
|
+
const category = VALID_CATEGORY.has(input.category) ? input.category : 'maintainability';
|
|
429
|
+
const severity = VALID_SEVERITY.has(input.severity) ? input.severity : 'low';
|
|
430
|
+
const confidence = VALID_CONFIDENCE.has(input.confidence) ? input.confidence : 'medium';
|
|
431
|
+
const summary = String(input.summary || '').trim() || `Agent finding #${index + 1}`;
|
|
432
|
+
const details = String(input.details || '').trim();
|
|
433
|
+
const suggestion = String(input.suggestion || '').trim();
|
|
434
|
+
|
|
435
|
+
const requestedSessions = Array.isArray(input.sessions)
|
|
436
|
+
? input.sessions.map(id => String(id || '').trim()).filter(Boolean)
|
|
437
|
+
: [];
|
|
438
|
+
|
|
439
|
+
const sessions = requestedSessions.filter(id => allowedSessions.has(id));
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
rule_id: String(input.rule_id || 'agent.review.finding').trim(),
|
|
443
|
+
category,
|
|
444
|
+
severity,
|
|
445
|
+
confidence,
|
|
446
|
+
summary,
|
|
447
|
+
details,
|
|
448
|
+
suggestion,
|
|
449
|
+
evidence: normalizeEvidenceWithFallback(input.evidence, fallbackSessionId),
|
|
450
|
+
location: normalizeLocation(input.location),
|
|
451
|
+
sessions: sessions.length > 0 ? [...new Set(sessions)] : [fallbackSessionId],
|
|
452
|
+
};
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
return normalized;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function extractFindingsFromText(outputText) {
|
|
459
|
+
const raw = String(outputText || '').trim();
|
|
460
|
+
if (!raw) {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const blockMatch = raw.match(/```json\s*([\s\S]*?)```/i);
|
|
465
|
+
const candidates = [
|
|
466
|
+
blockMatch ? blockMatch[1] : '',
|
|
467
|
+
raw,
|
|
468
|
+
].filter(Boolean);
|
|
469
|
+
|
|
470
|
+
for (const candidate of candidates) {
|
|
471
|
+
try {
|
|
472
|
+
const parsed = JSON.parse(candidate);
|
|
473
|
+
if (Array.isArray(parsed)) {
|
|
474
|
+
return parsed;
|
|
475
|
+
}
|
|
476
|
+
if (parsed && Array.isArray(parsed.findings)) {
|
|
477
|
+
return parsed.findings;
|
|
478
|
+
}
|
|
479
|
+
} catch {
|
|
480
|
+
// Keep trying.
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function resolveAgentTimeoutMs(options, sessionCount = 1) {
|
|
488
|
+
const optionValue = Number(options?.agentTimeoutMs);
|
|
489
|
+
if (Number.isFinite(optionValue) && optionValue >= MIN_AGENT_TIMEOUT_MS) {
|
|
490
|
+
return Math.floor(optionValue);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const envValue = Number(process.env.SES_REVIEW_AGENT_TIMEOUT_MS);
|
|
494
|
+
if (Number.isFinite(envValue) && envValue >= MIN_AGENT_TIMEOUT_MS) {
|
|
495
|
+
return Math.floor(envValue);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return BASE_TIMEOUT_MS + PER_SESSION_TIMEOUT_MS * Math.max(1, sessionCount);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function resolveAutoApprove(options) {
|
|
502
|
+
if (typeof options?.agentAutoApprove === 'boolean') {
|
|
503
|
+
return options.agentAutoApprove;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const env = String(process.env.SES_REVIEW_AUTO_APPROVE || '').trim().toLowerCase();
|
|
507
|
+
if (env === '0' || env === 'false' || env === 'no') {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
if (env === '1' || env === 'true' || env === 'yes') {
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function createModelFromConfig(projectRoot) {
|
|
517
|
+
const config = getApiConfig(projectRoot);
|
|
518
|
+
if (!config.api_key) {
|
|
519
|
+
throw new Error('No API key configured. Set OPENAI_API_KEY or ANTHROPIC_API_KEY environment variable.');
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (config.provider === 'anthropic') {
|
|
523
|
+
const adapter = createAnthropicAdapter({
|
|
524
|
+
apiKey: config.api_key,
|
|
525
|
+
defaultModelId: config.model,
|
|
526
|
+
});
|
|
527
|
+
return createModel({ adapter });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const adapter = createOpenAIAdapter({
|
|
531
|
+
apiKey: config.api_key,
|
|
532
|
+
defaultModelId: config.model || 'gpt-4o-mini',
|
|
533
|
+
baseUrl: config.openai_endpoint || config.openai_base_url,
|
|
534
|
+
});
|
|
535
|
+
return createModel({ adapter });
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function logAgentEvent(event) {
|
|
539
|
+
switch (event.type) {
|
|
540
|
+
case 'tool_call_start':
|
|
541
|
+
console.error(`[agent] tool start: ${event.toolName || 'unknown'}`);
|
|
542
|
+
break;
|
|
543
|
+
case 'tool_call_end':
|
|
544
|
+
console.error(`[agent] tool end: ${event.toolCall?.function?.name || 'unknown'}`);
|
|
545
|
+
break;
|
|
546
|
+
case 'iteration_end':
|
|
547
|
+
console.error(`[agent] iteration ${event.iteration} complete`);
|
|
548
|
+
break;
|
|
549
|
+
case 'done':
|
|
550
|
+
console.error('[agent] done');
|
|
551
|
+
break;
|
|
552
|
+
default:
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export async function runAgentReview(projectRoot, selectedSessions, options) {
|
|
558
|
+
const sessionMap = preloadSessionData(selectedSessions);
|
|
559
|
+
const model = createModelFromConfig(projectRoot);
|
|
560
|
+
const autoApprove = resolveAutoApprove(options);
|
|
561
|
+
const timeoutMs = resolveAgentTimeoutMs(options, selectedSessions.length);
|
|
562
|
+
const idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS;
|
|
563
|
+
const resultHolder = {
|
|
564
|
+
submitted: false,
|
|
565
|
+
findings: [],
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const tools = new ToolRegistry();
|
|
569
|
+
tools.register(new ReadSessionSummaryTool(sessionMap));
|
|
570
|
+
tools.register(new ReadGitDiffTool(projectRoot, sessionMap, options));
|
|
571
|
+
tools.register(new ReadSourceFileTool(projectRoot));
|
|
572
|
+
tools.register(new SubmitFindingsTool(resultHolder));
|
|
573
|
+
|
|
574
|
+
const agent = new Agent({
|
|
575
|
+
name: 'ses-review-agent',
|
|
576
|
+
systemPrompt: REVIEW_AGENT_SYSTEM_PROMPT,
|
|
577
|
+
model,
|
|
578
|
+
tools,
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const agentSession = await agent.createSession({
|
|
582
|
+
maxIterations: MAX_ITERATIONS,
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
const userMessage = buildReviewUserMessage(
|
|
586
|
+
selectedSessions.map(session => session.id),
|
|
587
|
+
{
|
|
588
|
+
...options,
|
|
589
|
+
timeoutMs,
|
|
590
|
+
autoApprove,
|
|
591
|
+
},
|
|
592
|
+
);
|
|
593
|
+
agentSession.send(userMessage, {
|
|
594
|
+
toolContext: { approval: { autoApprove } },
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
const outputChunks = [];
|
|
598
|
+
let receiveError = null;
|
|
599
|
+
const startTime = Date.now();
|
|
600
|
+
|
|
601
|
+
let globalHandle;
|
|
602
|
+
let settleGlobal = () => {};
|
|
603
|
+
const globalTimeoutPromise = new Promise((resolve, reject) => {
|
|
604
|
+
settleGlobal = resolve;
|
|
605
|
+
globalHandle = setTimeout(() => {
|
|
606
|
+
reject(new Error(`Agent review timed out after ${timeoutMs}ms.`));
|
|
607
|
+
}, timeoutMs);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
let idleHandle;
|
|
611
|
+
const resetIdle = () => {
|
|
612
|
+
if (idleHandle) clearTimeout(idleHandle);
|
|
613
|
+
};
|
|
614
|
+
let settleIdle = () => {};
|
|
615
|
+
let idleReject;
|
|
616
|
+
const idlePromise = new Promise((resolve, reject) => {
|
|
617
|
+
settleIdle = resolve;
|
|
618
|
+
idleReject = reject;
|
|
619
|
+
});
|
|
620
|
+
const startIdle = () => {
|
|
621
|
+
resetIdle();
|
|
622
|
+
idleHandle = setTimeout(() => {
|
|
623
|
+
idleReject(new Error(`Agent idle timeout after ${idleTimeoutMs}ms with no activity.`));
|
|
624
|
+
}, idleTimeoutMs);
|
|
625
|
+
};
|
|
626
|
+
startIdle();
|
|
627
|
+
|
|
628
|
+
try {
|
|
629
|
+
await Promise.race([
|
|
630
|
+
(async () => {
|
|
631
|
+
for await (const event of agentSession.receive({
|
|
632
|
+
toolContext: { approval: { autoApprove } },
|
|
633
|
+
})) {
|
|
634
|
+
startIdle();
|
|
635
|
+
logAgentEvent(event);
|
|
636
|
+
|
|
637
|
+
if (event.type === 'text_delta' && event.delta) {
|
|
638
|
+
outputChunks.push(String(event.delta));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (event.type === 'done') {
|
|
642
|
+
if (event.finalResponse) {
|
|
643
|
+
outputChunks.push(String(event.finalResponse));
|
|
644
|
+
}
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (resultHolder.submitted) {
|
|
649
|
+
const elapsed = Date.now() - startTime;
|
|
650
|
+
console.error(`[agent] findings submitted at ${elapsed}ms, wrapping up.`);
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
})(),
|
|
655
|
+
globalTimeoutPromise,
|
|
656
|
+
idlePromise,
|
|
657
|
+
]);
|
|
658
|
+
} catch (error) {
|
|
659
|
+
receiveError = error;
|
|
660
|
+
console.error(`[agent] warning: ${error.message}`);
|
|
661
|
+
if (typeof agentSession.abort === 'function') {
|
|
662
|
+
try {
|
|
663
|
+
await agentSession.abort();
|
|
664
|
+
} catch {
|
|
665
|
+
// Best-effort cancellation only.
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
} finally {
|
|
669
|
+
settleGlobal();
|
|
670
|
+
settleIdle();
|
|
671
|
+
resetIdle();
|
|
672
|
+
if (globalHandle) {
|
|
673
|
+
clearTimeout(globalHandle);
|
|
674
|
+
}
|
|
675
|
+
const elapsed = Date.now() - startTime;
|
|
676
|
+
console.error(`[agent] total elapsed: ${elapsed}ms (limit: ${timeoutMs}ms)`);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (!resultHolder.submitted) {
|
|
680
|
+
const parsed = extractFindingsFromText(outputChunks.join(''));
|
|
681
|
+
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
682
|
+
resultHolder.findings = parsed;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const sessionIds = selectedSessions.map(sessionEntry => sessionEntry.id);
|
|
687
|
+
const normalized = normalizeSubmittedFindings(resultHolder.findings, sessionIds);
|
|
688
|
+
|
|
689
|
+
if (normalized.length === 0) {
|
|
690
|
+
normalized.push({
|
|
691
|
+
rule_id: 'agent.no_findings',
|
|
692
|
+
category: 'maintainability',
|
|
693
|
+
severity: 'info',
|
|
694
|
+
confidence: 'low',
|
|
695
|
+
summary: 'Agent review produced no structured findings',
|
|
696
|
+
details: receiveError
|
|
697
|
+
? `Agent run finished without valid findings after error: ${receiveError.message}`
|
|
698
|
+
: 'Agent run finished but did not submit parseable findings.',
|
|
699
|
+
suggestion: 'Re-run review and inspect agent tool logs if this repeats.',
|
|
700
|
+
evidence: sessionIds.map(id => `session_id=${id}`),
|
|
701
|
+
location: null,
|
|
702
|
+
sessions: sessionIds.length > 0 ? sessionIds : ['unknown'],
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (!normalized.some(item => item.severity === 'info')) {
|
|
707
|
+
normalized.push({
|
|
708
|
+
rule_id: 'agent.review.summary',
|
|
709
|
+
category: 'maintainability',
|
|
710
|
+
severity: 'info',
|
|
711
|
+
confidence: 'high',
|
|
712
|
+
summary: `Agent review completed for ${sessionIds.length} session(s)`,
|
|
713
|
+
details: `Analyzed sessions: ${sessionIds.join(', ')}`,
|
|
714
|
+
suggestion: 'Review high/medium findings first, then verify remaining low-level observations.',
|
|
715
|
+
evidence: sessionIds.map(id => `session_id=${id}`),
|
|
716
|
+
location: null,
|
|
717
|
+
sessions: sessionIds.length > 0 ? sessionIds : ['unknown'],
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return normalized;
|
|
722
|
+
}
|