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