jstar-reviewer 2.1.3 → 2.2.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.
@@ -5,12 +5,15 @@ import * as path from "path";
5
5
  import * as fs from "fs";
6
6
  import chalk from "chalk";
7
7
  import simpleGit from "simple-git";
8
+ import { Logger } from "./utils/logger";
8
9
  import { Config } from "./config";
9
10
  import { Detective } from "./detective";
10
11
  import { GeminiEmbedding } from "./gemini-embedding";
11
12
  import { MockLLM } from "./mock-llm";
12
13
  import { FileFinding, DashboardReport, LLMReviewResponse, EMPTY_REVIEW } from "./types";
13
14
  import { renderDashboard, determineStatus, generateRecommendation } from "./dashboard";
15
+ import { startInteractiveSession } from "./session";
16
+ import { critiqueFindings } from "./core/critique";
14
17
  import {
15
18
  VectorStoreIndex,
16
19
  storageContextFromDefaults,
@@ -18,7 +21,8 @@ import {
18
21
  serviceContextFromDefaults
19
22
  } from "llamaindex";
20
23
 
21
- const google = createGoogleGenerativeAI({ apiKey: process.env.GOOGLE_API_KEY });
24
+ const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || process.env.GOOGLE_GENERATIVE_AI_API_KEY;
25
+ const google = createGoogleGenerativeAI({ apiKey: geminiKey });
22
26
  const groq = createGroq({ apiKey: process.env.GROQ_API_KEY });
23
27
 
24
28
  const embedModel = new GeminiEmbedding();
@@ -65,6 +69,38 @@ function sleep(ms: number): Promise<void> {
65
69
  return new Promise(resolve => setTimeout(resolve, ms));
66
70
  }
67
71
 
72
+ /**
73
+ * Filter issues by confidence threshold and log what was removed
74
+ */
75
+ function filterByConfidence(findings: FileFinding[]): FileFinding[] {
76
+ const threshold = Config.CONFIDENCE_THRESHOLD;
77
+ let removedCount = 0;
78
+
79
+ const filtered = findings.map(finding => {
80
+ const validIssues = finding.issues.filter(issue => {
81
+ const confidence = issue.confidenceScore ?? 5; // Default to high if not specified
82
+ if (confidence < threshold) {
83
+ removedCount++;
84
+ Logger.info(chalk.dim(` ⚡ Low confidence (${confidence}): "${issue.title}" - filtered out`));
85
+ return false;
86
+ }
87
+ return true;
88
+ });
89
+
90
+ return {
91
+ ...finding,
92
+ issues: validIssues,
93
+ severity: validIssues.length === 0 ? 'LGTM' as const : finding.severity
94
+ };
95
+ });
96
+
97
+ if (removedCount > 0) {
98
+ Logger.info(chalk.blue(`\n 📊 Confidence Filter: ${removedCount} low-confidence issue(s) removed\n`));
99
+ }
100
+
101
+ return filtered;
102
+ }
103
+
68
104
  function parseReviewResponse(text: string): LLMReviewResponse {
69
105
  try {
70
106
  // Try to extract JSON from the response
@@ -107,20 +143,23 @@ function parseReviewResponse(text: string): LLMReviewResponse {
107
143
 
108
144
  // --- Main ---
109
145
  async function main() {
110
- console.log(chalk.blue("🕵️ J-Star Reviewer: Analyzing your changes...\n"));
146
+ // Initialize logger mode based on CLI flags
147
+ Logger.init();
148
+
149
+ Logger.info(chalk.blue("🕵️ J-Star Reviewer: Analyzing your changes...\n"));
111
150
 
112
151
  // 0. Environment Validation
113
- if (!process.env.GOOGLE_API_KEY || !process.env.GROQ_API_KEY) {
114
- console.error(chalk.red("❌ Missing API Keys!"));
115
- console.log(chalk.yellow("\nPlease ensure you have a .env.local file with:"));
116
- console.log(chalk.white("- GOOGLE_API_KEY"));
117
- console.log(chalk.white("- GROQ_API_KEY"));
118
- console.log(chalk.white("\nCheck .env.example for a template.\n"));
152
+ if (!geminiKey || !process.env.GROQ_API_KEY) {
153
+ Logger.error(chalk.red("❌ Missing API Keys!"));
154
+ Logger.info(chalk.yellow("\nPlease ensure you have a .env.local file with:"));
155
+ Logger.info(chalk.white("- GEMINI_API_KEY (or GOOGLE_API_KEY)"));
156
+ Logger.info(chalk.white("- GROQ_API_KEY"));
157
+ Logger.info(chalk.white("\nCheck .env.example for a template.\n"));
119
158
  return;
120
159
  }
121
160
 
122
161
  // 1. Detective
123
- console.log(chalk.blue("🔎 Running Detective Engine..."));
162
+ Logger.info(chalk.blue("🔎 Running Detective Engine..."));
124
163
 
125
164
  const detective = new Detective(SOURCE_DIR);
126
165
  await detective.scan();
@@ -129,13 +168,13 @@ async function main() {
129
168
  // 1. Get the Diff
130
169
  const diff = await git.diff(["--staged"]);
131
170
  if (!diff) {
132
- console.log(chalk.green("\n✅ No staged changes to review. (Did you 'git add'?)"));
171
+ Logger.info(chalk.green("\n✅ No staged changes to review. (Did you 'git add'?)"));
133
172
  return;
134
173
  }
135
174
 
136
175
  // 2. Load the Brain
137
176
  if (!fs.existsSync(STORAGE_DIR)) {
138
- console.error(chalk.red("❌ Local Brain not found. Run 'pnpm run index:init' first."));
177
+ Logger.error(chalk.red("❌ Local Brain not found. Run 'pnpm run index:init' first."));
139
178
  return;
140
179
  }
141
180
  const storageContext = await storageContextFromDefaults({ persistDir: STORAGE_DIR });
@@ -149,15 +188,15 @@ async function main() {
149
188
  const contextNodes = await retriever.retrieve(keywords);
150
189
  const relatedContext = contextNodes.map(n => n.node.getContent(MetadataMode.NONE).slice(0, 1500)).join("\n");
151
190
 
152
- console.log(chalk.yellow(`\n🧠 Found ${contextNodes.length} context chunk.`));
191
+ Logger.info(chalk.yellow(`\n🧠 Found ${contextNodes.length} context chunk.`));
153
192
 
154
193
  // 4. Chunk the Diff
155
194
  const fileChunks = chunkDiffByFile(diff);
156
195
  const totalTokens = estimateTokens(diff);
157
- console.log(chalk.dim(` Total diff: ~${totalTokens} tokens across ${fileChunks.length} files.`));
196
+ Logger.info(chalk.dim(` Total diff: ~${totalTokens} tokens across ${fileChunks.length} files.`));
158
197
 
159
- // 5. Structured JSON Prompt
160
- const systemPrompt = `You are J-Star, a Senior Code Reviewer. Be direct and professional.
198
+ // 5. Structured JSON Prompt (Conservative)
199
+ const systemPrompt = `You are J-Star, a Senior Code Reviewer. Be CONSERVATIVE and PRECISE.
161
200
 
162
201
  Analyze the Git Diff and return a JSON response with this EXACT structure:
163
202
  {
@@ -167,7 +206,8 @@ Analyze the Git Diff and return a JSON response with this EXACT structure:
167
206
  "title": "Short issue title",
168
207
  "description": "Detailed description of the problem",
169
208
  "line": 42,
170
- "fixPrompt": "A specific prompt an AI can use to fix this issue"
209
+ "fixPrompt": "A specific prompt an AI can use to fix this issue",
210
+ "confidenceScore": 5
171
211
  }
172
212
  ]
173
213
  }
@@ -178,10 +218,20 @@ SEVERITY GUIDE:
178
218
  - P2_MEDIUM: Code quality, missing types, cleanup needed
179
219
  - LGTM: No issues found (return empty issues array)
180
220
 
181
- IMPORTANT:
182
- - Return ONLY valid JSON, no markdown or explanation
183
- - Each issue MUST have a fixPrompt that explains exactly how to fix it
184
- - If the file is clean, return {"severity": "LGTM", "issues": []}
221
+ CONFIDENCE SCORE (1-5) - BE HONEST:
222
+ - 5: Absolutely certain. The bug is obvious in the diff.
223
+ - 4: Very likely. Clear code smell or anti-pattern.
224
+ - 3: Probable issue, might be missing context.
225
+ - 2: Unsure, could be intentional.
226
+ - 1: Speculation, likely false positive.
227
+
228
+ CRITICAL RULES:
229
+ 1. Only flag issues you are HIGHLY confident about (4-5).
230
+ 2. Test mocks, stubs, and intentional patterns are NOT bugs.
231
+ 3. If the code looks intentional or well-handled, it's probably fine.
232
+ 4. When in doubt, lean towards LGTM.
233
+ 5. Return ONLY valid JSON, no markdown.
234
+ 6. If the file is clean: {"severity": "LGTM", "issues": []}
185
235
 
186
236
  Context: ${relatedContext.slice(0, 800)}`;
187
237
 
@@ -189,7 +239,7 @@ Context: ${relatedContext.slice(0, 800)}`;
189
239
  let chunkIndex = 0;
190
240
  let skippedCount = 0;
191
241
 
192
- console.log(chalk.blue("\n⚖️ Sending to Judge...\n"));
242
+ Logger.info(chalk.blue("\n⚖️ Sending to Judge...\n"));
193
243
 
194
244
  for (const chunk of fileChunks) {
195
245
  chunkIndex++;
@@ -197,7 +247,7 @@ Context: ${relatedContext.slice(0, 800)}`;
197
247
 
198
248
  // Skip excluded files
199
249
  if (shouldSkipFile(fileName)) {
200
- console.log(chalk.dim(` ⏭️ Skipping ${fileName} (excluded)`));
250
+ Logger.info(chalk.dim(` ⏭️ Skipping ${fileName} (excluded)`));
201
251
  skippedCount++;
202
252
  continue;
203
253
  }
@@ -206,7 +256,7 @@ Context: ${relatedContext.slice(0, 800)}`;
206
256
 
207
257
  // Skip huge files
208
258
  if (chunkTokens > MAX_TOKENS_PER_REQUEST) {
209
- console.log(chalk.yellow(` ⚠️ Skipping ${fileName} (too large: ~${chunkTokens} tokens)`));
259
+ Logger.info(chalk.yellow(` ⚠️ Skipping ${fileName} (too large: ~${chunkTokens} tokens)`));
210
260
  findings.push({
211
261
  file: fileName,
212
262
  severity: Config.DEFAULT_SEVERITY,
@@ -219,7 +269,7 @@ Context: ${relatedContext.slice(0, 800)}`;
219
269
  continue;
220
270
  }
221
271
 
222
- process.stdout.write(chalk.dim(` 📄 ${fileName}...`));
272
+ Logger.progress(chalk.dim(` 📄 ${fileName}...`));
223
273
 
224
274
  try {
225
275
  const { text } = await generateText({
@@ -239,10 +289,10 @@ Context: ${relatedContext.slice(0, 800)}`;
239
289
  const emoji = response.severity === 'LGTM' ? '✅' :
240
290
  response.severity === 'P0_CRITICAL' ? '🛑' :
241
291
  response.severity === 'P1_HIGH' ? '⚠️' : '📝';
242
- console.log(` ${emoji}`);
292
+ Logger.info(` ${emoji}`);
243
293
 
244
294
  } catch (error: any) {
245
- console.log(chalk.red(` ❌ (${error.message.slice(0, 50)})`));
295
+ Logger.info(chalk.red(` ❌ (${error.message.slice(0, 50)})`));
246
296
  findings.push({
247
297
  file: fileName,
248
298
  severity: Config.DEFAULT_SEVERITY,
@@ -260,15 +310,24 @@ Context: ${relatedContext.slice(0, 800)}`;
260
310
  }
261
311
  }
262
312
 
263
- // 6. Build Dashboard Report
313
+ // 6. Confidence Filtering
314
+ Logger.info(chalk.blue("\n🎯 Filtering by Confidence...\n"));
315
+ let filteredFindings = filterByConfidence(findings);
316
+
317
+ // 7. Self-Critique Pass (if enabled)
318
+ if (Config.ENABLE_SELF_CRITIQUE) {
319
+ filteredFindings = await critiqueFindings(filteredFindings, diff);
320
+ }
321
+
322
+ // 8. Build Dashboard Report
264
323
  const metrics = {
265
324
  filesScanned: fileChunks.length - skippedCount,
266
325
  totalTokens,
267
- violations: findings.reduce((sum, f) => sum + f.issues.length, 0),
268
- critical: findings.filter(f => f.severity === 'P0_CRITICAL').length,
269
- high: findings.filter(f => f.severity === 'P1_HIGH').length,
270
- medium: findings.filter(f => f.severity === 'P2_MEDIUM').length,
271
- lgtm: findings.filter(f => f.severity === 'LGTM').length,
326
+ violations: filteredFindings.reduce((sum, f) => sum + f.issues.length, 0),
327
+ critical: filteredFindings.filter(f => f.severity === 'P0_CRITICAL').length,
328
+ high: filteredFindings.filter(f => f.severity === 'P1_HIGH').length,
329
+ medium: filteredFindings.filter(f => f.severity === 'P2_MEDIUM').length,
330
+ lgtm: filteredFindings.filter(f => f.severity === 'LGTM').length,
272
331
  };
273
332
 
274
333
  const report: DashboardReport = {
@@ -276,7 +335,7 @@ Context: ${relatedContext.slice(0, 800)}`;
276
335
  reviewer: 'Detective Engine & Judge',
277
336
  status: determineStatus(metrics),
278
337
  metrics,
279
- findings,
338
+ findings: filteredFindings,
280
339
  recommendedAction: generateRecommendation(metrics)
281
340
  };
282
341
 
@@ -287,20 +346,72 @@ Context: ${relatedContext.slice(0, 800)}`;
287
346
  fs.mkdirSync(path.dirname(OUTPUT_FILE), { recursive: true });
288
347
  fs.writeFileSync(OUTPUT_FILE, dashboard);
289
348
 
290
- console.log("\n" + chalk.bold.green("📊 DASHBOARD GENERATED"));
291
- console.log(chalk.dim(` Saved to: ${OUTPUT_FILE}`));
292
- console.log("\n" + chalk.bold.white("─".repeat(50)));
349
+ // Save Session State for "jstar chat"
350
+ const SESSION_FILE = path.join(process.cwd(), ".jstar", "session.json");
351
+ fs.writeFileSync(SESSION_FILE, JSON.stringify({
352
+ date: report.date,
353
+ findings: report.findings,
354
+ metrics: report.metrics
355
+ }, null, 2));
356
+
357
+ Logger.info("\n" + chalk.bold.green("📊 DASHBOARD GENERATED"));
358
+ Logger.info(chalk.dim(` Saved to: ${OUTPUT_FILE}`));
359
+ Logger.info("\n" + chalk.bold.white("─".repeat(50)));
293
360
 
294
361
  // Print summary to console
295
362
  const statusEmoji = report.status === 'APPROVED' ? '🟢' :
296
363
  report.status === 'NEEDS_REVIEW' ? '🟡' : '🔴';
297
- console.log(`\n${statusEmoji} Status: ${report.status.replace('_', ' ')}`);
298
- console.log(` 🛑 Critical: ${metrics.critical}`);
299
- console.log(` ⚠️ High: ${metrics.high}`);
300
- console.log(` 📝 Medium: ${metrics.medium}`);
301
- console.log(` ✅ LGTM: ${metrics.lgtm}`);
302
- console.log(`\n💡 ${report.recommendedAction}`);
303
- console.log(chalk.dim(`\n📄 Full report: ${OUTPUT_FILE}`));
364
+ Logger.info(`\n${statusEmoji} Status: ${report.status.replace('_', ' ')}`);
365
+ Logger.info(` 🛑 Critical: ${metrics.critical}`);
366
+ Logger.info(` ⚠️ High: ${metrics.high}`);
367
+ Logger.info(` 📝 Medium: ${metrics.medium}`);
368
+ Logger.info(` ✅ LGTM: ${metrics.lgtm}`);
369
+ Logger.info(`\n💡 ${report.recommendedAction}`);
370
+ Logger.info(chalk.dim(`\n📄 Full report: ${OUTPUT_FILE}`));
371
+
372
+ // 8. Interactive Session OR JSON Output
373
+ if (Logger.isHeadless()) {
374
+ // In JSON mode: output report to stdout and skip interactive session
375
+ Logger.json(report);
376
+ } else {
377
+ // Normal TUI mode: start interactive session
378
+ const { updatedFindings, hasUpdates } = await startInteractiveSession(findings, index);
379
+
380
+ if (hasUpdates) {
381
+ Logger.info(chalk.blue("\n🔄 Updating Dashboard with session changes..."));
382
+
383
+ // Recalculate metrics
384
+ const newMetrics = {
385
+ filesScanned: fileChunks.length - skippedCount,
386
+ totalTokens,
387
+ violations: updatedFindings.reduce((sum, f) => sum + f.issues.length, 0),
388
+ critical: updatedFindings.filter(f => f.severity === 'P0_CRITICAL').length,
389
+ high: updatedFindings.filter(f => f.severity === 'P1_HIGH').length,
390
+ medium: updatedFindings.filter(f => f.severity === 'P2_MEDIUM').length,
391
+ lgtm: updatedFindings.filter(f => f.severity === 'LGTM').length,
392
+ };
393
+
394
+ const newReport: DashboardReport = {
395
+ ...report, // Keep date/reviewer
396
+ metrics: newMetrics,
397
+ findings: updatedFindings,
398
+ status: determineStatus(newMetrics),
399
+ recommendedAction: generateRecommendation(newMetrics)
400
+ };
401
+
402
+ const newDashboard = renderDashboard(newReport);
403
+ fs.writeFileSync(OUTPUT_FILE, newDashboard);
404
+
405
+ // Also update session file with new findings
406
+ fs.writeFileSync(SESSION_FILE, JSON.stringify({
407
+ date: newReport.date,
408
+ findings: newReport.findings,
409
+ metrics: newReport.metrics
410
+ }, null, 2));
411
+
412
+ Logger.info(chalk.bold.green("📊 DASHBOARD UPDATED"));
413
+ }
414
+ }
304
415
  }
305
416
 
306
417
  main().catch(console.error);
@@ -0,0 +1,312 @@
1
+ import { FileFinding, ReviewIssue } from "./types";
2
+ import { showActionMenu, askForArgument } from "./ui/interaction";
3
+ import { debateIssue } from "./core/debate";
4
+ import { VectorStoreIndex } from "llamaindex";
5
+ import { Logger } from "./utils/logger";
6
+ import chalk from "chalk";
7
+ import prompts from "prompts";
8
+ import * as readline from "readline";
9
+
10
+ export async function startInteractiveSession(
11
+ findings: FileFinding[],
12
+ index: VectorStoreIndex
13
+ ): Promise<{ updatedFindings: FileFinding[], hasUpdates: boolean }> {
14
+
15
+ // Deep clone to track local state without mutating original immediately (though we return updated findings)
16
+ const interactiveFindings: FileFinding[] = JSON.parse(JSON.stringify(findings));
17
+ let hasUpdates = false;
18
+ let active = true;
19
+
20
+ if (interactiveFindings.length === 0 || interactiveFindings.every(f => f.issues.length === 0)) {
21
+ return { updatedFindings: interactiveFindings, hasUpdates: false };
22
+ }
23
+
24
+ console.log(chalk.bold.magenta("\n🗣️ Interactive Review Session"));
25
+ console.log(chalk.dim(" Use arrow keys to navigate. Select an issue to debate."));
26
+
27
+ while (active) {
28
+ // Re-calculate choices every loop to reflect status changes
29
+ // Flatten
30
+ const flatIssues: {
31
+ issue: ReviewIssue,
32
+ fileIndex: number,
33
+ issueIndex: number,
34
+ file: string
35
+ }[] = [];
36
+
37
+ interactiveFindings.forEach((f, fIdx) => {
38
+ f.issues.forEach((i, iIdx) => {
39
+ flatIssues.push({
40
+ issue: i,
41
+ fileIndex: fIdx,
42
+ issueIndex: iIdx,
43
+ file: f.file
44
+ });
45
+ });
46
+ });
47
+
48
+ const choices = flatIssues.map((item, idx) => {
49
+ const i = item.issue;
50
+ const statusIcon = i.status === 'resolved' ? '✅ ' : i.status === 'ignored' ? '🗑️ ' : '';
51
+ return {
52
+ title: `${statusIcon}${i.confidenceScore ? `[${i.confidenceScore}/5] ` : ''}${i.title} ${chalk.dim(`(${item.file})`)}`,
53
+ value: idx,
54
+ description: i.status ? `Marked as ${i.status}` : i.description.slice(0, 80)
55
+ };
56
+ });
57
+
58
+ choices.push({ title: '🚪 Finish Review', value: -1, description: 'Exit and save report' });
59
+
60
+ const { selectedIdx } = await prompts({
61
+ type: 'select',
62
+ name: 'selectedIdx',
63
+ message: 'Select an issue:',
64
+ choices: choices,
65
+ initial: 0
66
+ });
67
+
68
+ if (selectedIdx === undefined || selectedIdx === -1) {
69
+ active = false;
70
+ break;
71
+ }
72
+
73
+ const selected = flatIssues[selectedIdx];
74
+ const { issue, file } = selected;
75
+
76
+ // Show Details
77
+ console.log(chalk.cyan(`\nTitle: ${issue.title}`));
78
+ console.log(chalk.white(issue.description));
79
+ console.log(chalk.dim(`File: ${file}`));
80
+ if (issue.confidenceScore) console.log(chalk.yellow(`Confidence: ${issue.confidenceScore}/5`));
81
+ if (issue.status) console.log(chalk.green(`Status: ${issue.status}`));
82
+
83
+ // Action Menu
84
+ const action = await showActionMenu(issue.title);
85
+
86
+ if (action === 'discuss') {
87
+ const argument = await askForArgument();
88
+ const result = await debateIssue(
89
+ issue.title,
90
+ issue.description,
91
+ file,
92
+ argument,
93
+ index
94
+ );
95
+
96
+ console.log(chalk.yellow(`\n🤖 Bot: ${result.text}`));
97
+
98
+ if (result.severity === 'LGTM') {
99
+ console.log(chalk.green("✅ Issue withdrawn by AI!"));
100
+ // Direct update to our state
101
+ interactiveFindings[selected.fileIndex].issues[selected.issueIndex].status = 'resolved';
102
+ hasUpdates = true;
103
+ }
104
+
105
+ } else if (action === 'ignore') {
106
+ console.log(chalk.dim('Issue ignored locally.'));
107
+ interactiveFindings[selected.fileIndex].issues[selected.issueIndex].status = 'ignored';
108
+ hasUpdates = true;
109
+ } else if (action === 'accept') {
110
+ console.log(chalk.green('Issue accepted.'));
111
+ } else if (action === 'exit') {
112
+ active = false;
113
+ }
114
+ }
115
+
116
+ // Filter out resolved/ignored issues for the final report
117
+ const finalFindings: FileFinding[] = interactiveFindings.map(f => ({
118
+ ...f,
119
+ issues: f.issues.filter(i => i.status !== 'resolved' && i.status !== 'ignored')
120
+ })).filter(f => f.issues.length > 0);
121
+
122
+ return { updatedFindings: finalFindings, hasUpdates };
123
+ }
124
+
125
+ /**
126
+ * Headless interface for flatIssues structure
127
+ */
128
+ interface FlatIssue {
129
+ id: number;
130
+ title: string;
131
+ description: string;
132
+ file: string;
133
+ confidenceScore?: number;
134
+ status?: 'resolved' | 'ignored';
135
+ fileIndex: number;
136
+ issueIndex: number;
137
+ }
138
+
139
+ /**
140
+ * Headless command protocol
141
+ */
142
+ interface HeadlessCommand {
143
+ action: 'list' | 'debate' | 'ignore' | 'accept' | 'exit';
144
+ issueId?: number;
145
+ argument?: string;
146
+ }
147
+
148
+ /**
149
+ * Flatten findings into a simple array for headless mode
150
+ */
151
+ function flattenIssues(findings: FileFinding[]): FlatIssue[] {
152
+ const flat: FlatIssue[] = [];
153
+ findings.forEach((f, fIdx) => {
154
+ f.issues.forEach((i, iIdx) => {
155
+ flat.push({
156
+ id: flat.length,
157
+ title: i.title,
158
+ description: i.description,
159
+ file: f.file,
160
+ confidenceScore: i.confidenceScore,
161
+ status: i.status,
162
+ fileIndex: fIdx,
163
+ issueIndex: iIdx
164
+ });
165
+ });
166
+ });
167
+ return flat;
168
+ }
169
+
170
+ /**
171
+ * Headless session for AI agents and CI/CD.
172
+ *
173
+ * Protocol:
174
+ * - Input (stdin): JSON commands, one per line
175
+ * { "action": "list" }
176
+ * { "action": "debate", "issueId": 0, "argument": "This is intentional" }
177
+ * { "action": "ignore", "issueId": 0 }
178
+ * { "action": "accept", "issueId": 0 }
179
+ * { "action": "exit" }
180
+ *
181
+ * - Output (stdout): JSON events, one per line
182
+ * { "type": "ready", "issues": [...] }
183
+ * { "type": "list", "issues": [...] }
184
+ * { "type": "response", "issueId": 0, "text": "...", "verdict": "LGTM" | "STANDS" }
185
+ * { "type": "update", "issueId": 0, "status": "ignored" | "resolved" | "accepted" }
186
+ * { "type": "error", "message": "..." }
187
+ * { "type": "done", "hasUpdates": true, "updatedFindings": [...] }
188
+ */
189
+ export async function startHeadlessSession(
190
+ findings: FileFinding[],
191
+ index: VectorStoreIndex
192
+ ): Promise<{ updatedFindings: FileFinding[], hasUpdates: boolean }> {
193
+
194
+ // Deep clone to track state
195
+ const sessionFindings: FileFinding[] = JSON.parse(JSON.stringify(findings));
196
+ let hasUpdates = false;
197
+
198
+ // Emit ready event with all issues
199
+ const issues = flattenIssues(sessionFindings);
200
+ Logger.json({ type: 'ready', issues });
201
+
202
+ // Create readline interface for stdin
203
+ const rl = readline.createInterface({
204
+ input: process.stdin,
205
+ output: process.stdout,
206
+ terminal: false
207
+ });
208
+
209
+ // Process commands
210
+ for await (const line of rl) {
211
+ if (!line.trim()) continue;
212
+
213
+ let cmd: HeadlessCommand;
214
+ try {
215
+ cmd = JSON.parse(line);
216
+ } catch (e) {
217
+ Logger.json({ type: 'error', message: 'Invalid JSON command' });
218
+ continue;
219
+ }
220
+
221
+ const currentIssues = flattenIssues(sessionFindings);
222
+
223
+ switch (cmd.action) {
224
+ case 'list':
225
+ Logger.json({ type: 'list', issues: currentIssues });
226
+ break;
227
+
228
+ case 'debate':
229
+ if (cmd.issueId === undefined || !cmd.argument) {
230
+ Logger.json({ type: 'error', message: 'debate requires issueId and argument' });
231
+ break;
232
+ }
233
+ const debateTarget = currentIssues.find(i => i.id === cmd.issueId);
234
+ if (!debateTarget) {
235
+ Logger.json({ type: 'error', message: `Issue ${cmd.issueId} not found` });
236
+ break;
237
+ }
238
+
239
+ try {
240
+ const result = await debateIssue(
241
+ debateTarget.title,
242
+ debateTarget.description,
243
+ debateTarget.file,
244
+ cmd.argument,
245
+ index
246
+ );
247
+
248
+ const verdict = result.severity === 'LGTM' ? 'LGTM' : 'STANDS';
249
+ Logger.json({
250
+ type: 'response',
251
+ issueId: cmd.issueId,
252
+ text: result.text,
253
+ verdict
254
+ });
255
+
256
+ if (result.severity === 'LGTM') {
257
+ sessionFindings[debateTarget.fileIndex].issues[debateTarget.issueIndex].status = 'resolved';
258
+ hasUpdates = true;
259
+ Logger.json({ type: 'update', issueId: cmd.issueId, status: 'resolved' });
260
+ }
261
+ } catch (e: any) {
262
+ Logger.json({ type: 'error', message: e.message });
263
+ }
264
+ break;
265
+
266
+ case 'ignore':
267
+ if (cmd.issueId === undefined) {
268
+ Logger.json({ type: 'error', message: 'ignore requires issueId' });
269
+ break;
270
+ }
271
+ const ignoreTarget = currentIssues.find(i => i.id === cmd.issueId);
272
+ if (!ignoreTarget) {
273
+ Logger.json({ type: 'error', message: `Issue ${cmd.issueId} not found` });
274
+ break;
275
+ }
276
+ sessionFindings[ignoreTarget.fileIndex].issues[ignoreTarget.issueIndex].status = 'ignored';
277
+ hasUpdates = true;
278
+ Logger.json({ type: 'update', issueId: cmd.issueId, status: 'ignored' });
279
+ break;
280
+
281
+ case 'accept':
282
+ if (cmd.issueId === undefined) {
283
+ Logger.json({ type: 'error', message: 'accept requires issueId' });
284
+ break;
285
+ }
286
+ Logger.json({ type: 'update', issueId: cmd.issueId, status: 'accepted' });
287
+ break;
288
+
289
+ case 'exit':
290
+ // Filter out resolved/ignored for final report
291
+ const finalFindings: FileFinding[] = sessionFindings.map(f => ({
292
+ ...f,
293
+ issues: f.issues.filter(i => i.status !== 'resolved' && i.status !== 'ignored')
294
+ })).filter(f => f.issues.length > 0);
295
+
296
+ Logger.json({ type: 'done', hasUpdates, updatedFindings: finalFindings });
297
+ rl.close();
298
+ return { updatedFindings: finalFindings, hasUpdates };
299
+
300
+ default:
301
+ Logger.json({ type: 'error', message: `Unknown action: ${(cmd as any).action}` });
302
+ }
303
+ }
304
+
305
+ // If stdin closes without exit command, still return
306
+ const finalFindings: FileFinding[] = sessionFindings.map(f => ({
307
+ ...f,
308
+ issues: f.issues.filter(i => i.status !== 'resolved' && i.status !== 'ignored')
309
+ })).filter(f => f.issues.length > 0);
310
+
311
+ return { updatedFindings: finalFindings, hasUpdates };
312
+ }
package/scripts/types.ts CHANGED
@@ -10,6 +10,8 @@ export interface ReviewIssue {
10
10
  description: string;
11
11
  line?: number;
12
12
  fixPrompt: string;
13
+ confidenceScore?: number;
14
+ status?: 'resolved' | 'ignored';
13
15
  }
14
16
 
15
17
  export interface FileFinding {
@@ -35,6 +37,12 @@ export interface DashboardReport {
35
37
  recommendedAction: string;
36
38
  }
37
39
 
40
+ export interface SessionState {
41
+ date: string;
42
+ findings: FileFinding[];
43
+ metrics: DashboardReport['metrics'];
44
+ }
45
+
38
46
  /**
39
47
  * Schema for LLM response (per-file review)
40
48
  */
@@ -45,6 +53,7 @@ export interface LLMReviewResponse {
45
53
  description: string;
46
54
  line?: number;
47
55
  fixPrompt: string;
56
+ confidenceScore?: number; // 1-5 confidence rating
48
57
  }[];
49
58
  }
50
59