jstar-reviewer 2.1.4 → 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,
@@ -66,6 +69,38 @@ function sleep(ms: number): Promise<void> {
66
69
  return new Promise(resolve => setTimeout(resolve, ms));
67
70
  }
68
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
+
69
104
  function parseReviewResponse(text: string): LLMReviewResponse {
70
105
  try {
71
106
  // Try to extract JSON from the response
@@ -108,20 +143,23 @@ function parseReviewResponse(text: string): LLMReviewResponse {
108
143
 
109
144
  // --- Main ---
110
145
  async function main() {
111
- 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"));
112
150
 
113
151
  // 0. Environment Validation
114
152
  if (!geminiKey || !process.env.GROQ_API_KEY) {
115
- console.error(chalk.red("āŒ Missing API Keys!"));
116
- console.log(chalk.yellow("\nPlease ensure you have a .env.local file with:"));
117
- console.log(chalk.white("- GEMINI_API_KEY (or GOOGLE_API_KEY)"));
118
- console.log(chalk.white("- GROQ_API_KEY"));
119
- console.log(chalk.white("\nCheck .env.example for a template.\n"));
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"));
120
158
  return;
121
159
  }
122
160
 
123
161
  // 1. Detective
124
- console.log(chalk.blue("šŸ”Ž Running Detective Engine..."));
162
+ Logger.info(chalk.blue("šŸ”Ž Running Detective Engine..."));
125
163
 
126
164
  const detective = new Detective(SOURCE_DIR);
127
165
  await detective.scan();
@@ -130,13 +168,13 @@ async function main() {
130
168
  // 1. Get the Diff
131
169
  const diff = await git.diff(["--staged"]);
132
170
  if (!diff) {
133
- 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'?)"));
134
172
  return;
135
173
  }
136
174
 
137
175
  // 2. Load the Brain
138
176
  if (!fs.existsSync(STORAGE_DIR)) {
139
- 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."));
140
178
  return;
141
179
  }
142
180
  const storageContext = await storageContextFromDefaults({ persistDir: STORAGE_DIR });
@@ -150,15 +188,15 @@ async function main() {
150
188
  const contextNodes = await retriever.retrieve(keywords);
151
189
  const relatedContext = contextNodes.map(n => n.node.getContent(MetadataMode.NONE).slice(0, 1500)).join("\n");
152
190
 
153
- console.log(chalk.yellow(`\n🧠 Found ${contextNodes.length} context chunk.`));
191
+ Logger.info(chalk.yellow(`\n🧠 Found ${contextNodes.length} context chunk.`));
154
192
 
155
193
  // 4. Chunk the Diff
156
194
  const fileChunks = chunkDiffByFile(diff);
157
195
  const totalTokens = estimateTokens(diff);
158
- 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.`));
159
197
 
160
- // 5. Structured JSON Prompt
161
- 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.
162
200
 
163
201
  Analyze the Git Diff and return a JSON response with this EXACT structure:
164
202
  {
@@ -168,7 +206,8 @@ Analyze the Git Diff and return a JSON response with this EXACT structure:
168
206
  "title": "Short issue title",
169
207
  "description": "Detailed description of the problem",
170
208
  "line": 42,
171
- "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
172
211
  }
173
212
  ]
174
213
  }
@@ -179,10 +218,20 @@ SEVERITY GUIDE:
179
218
  - P2_MEDIUM: Code quality, missing types, cleanup needed
180
219
  - LGTM: No issues found (return empty issues array)
181
220
 
182
- IMPORTANT:
183
- - Return ONLY valid JSON, no markdown or explanation
184
- - Each issue MUST have a fixPrompt that explains exactly how to fix it
185
- - 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": []}
186
235
 
187
236
  Context: ${relatedContext.slice(0, 800)}`;
188
237
 
@@ -190,7 +239,7 @@ Context: ${relatedContext.slice(0, 800)}`;
190
239
  let chunkIndex = 0;
191
240
  let skippedCount = 0;
192
241
 
193
- console.log(chalk.blue("\nāš–ļø Sending to Judge...\n"));
242
+ Logger.info(chalk.blue("\nāš–ļø Sending to Judge...\n"));
194
243
 
195
244
  for (const chunk of fileChunks) {
196
245
  chunkIndex++;
@@ -198,7 +247,7 @@ Context: ${relatedContext.slice(0, 800)}`;
198
247
 
199
248
  // Skip excluded files
200
249
  if (shouldSkipFile(fileName)) {
201
- console.log(chalk.dim(` ā­ļø Skipping ${fileName} (excluded)`));
250
+ Logger.info(chalk.dim(` ā­ļø Skipping ${fileName} (excluded)`));
202
251
  skippedCount++;
203
252
  continue;
204
253
  }
@@ -207,7 +256,7 @@ Context: ${relatedContext.slice(0, 800)}`;
207
256
 
208
257
  // Skip huge files
209
258
  if (chunkTokens > MAX_TOKENS_PER_REQUEST) {
210
- console.log(chalk.yellow(` āš ļø Skipping ${fileName} (too large: ~${chunkTokens} tokens)`));
259
+ Logger.info(chalk.yellow(` āš ļø Skipping ${fileName} (too large: ~${chunkTokens} tokens)`));
211
260
  findings.push({
212
261
  file: fileName,
213
262
  severity: Config.DEFAULT_SEVERITY,
@@ -220,7 +269,7 @@ Context: ${relatedContext.slice(0, 800)}`;
220
269
  continue;
221
270
  }
222
271
 
223
- process.stdout.write(chalk.dim(` šŸ“„ ${fileName}...`));
272
+ Logger.progress(chalk.dim(` šŸ“„ ${fileName}...`));
224
273
 
225
274
  try {
226
275
  const { text } = await generateText({
@@ -240,10 +289,10 @@ Context: ${relatedContext.slice(0, 800)}`;
240
289
  const emoji = response.severity === 'LGTM' ? 'āœ…' :
241
290
  response.severity === 'P0_CRITICAL' ? 'šŸ›‘' :
242
291
  response.severity === 'P1_HIGH' ? 'āš ļø' : 'šŸ“';
243
- console.log(` ${emoji}`);
292
+ Logger.info(` ${emoji}`);
244
293
 
245
294
  } catch (error: any) {
246
- console.log(chalk.red(` āŒ (${error.message.slice(0, 50)})`));
295
+ Logger.info(chalk.red(` āŒ (${error.message.slice(0, 50)})`));
247
296
  findings.push({
248
297
  file: fileName,
249
298
  severity: Config.DEFAULT_SEVERITY,
@@ -261,15 +310,24 @@ Context: ${relatedContext.slice(0, 800)}`;
261
310
  }
262
311
  }
263
312
 
264
- // 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
265
323
  const metrics = {
266
324
  filesScanned: fileChunks.length - skippedCount,
267
325
  totalTokens,
268
- violations: findings.reduce((sum, f) => sum + f.issues.length, 0),
269
- critical: findings.filter(f => f.severity === 'P0_CRITICAL').length,
270
- high: findings.filter(f => f.severity === 'P1_HIGH').length,
271
- medium: findings.filter(f => f.severity === 'P2_MEDIUM').length,
272
- 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,
273
331
  };
274
332
 
275
333
  const report: DashboardReport = {
@@ -277,7 +335,7 @@ Context: ${relatedContext.slice(0, 800)}`;
277
335
  reviewer: 'Detective Engine & Judge',
278
336
  status: determineStatus(metrics),
279
337
  metrics,
280
- findings,
338
+ findings: filteredFindings,
281
339
  recommendedAction: generateRecommendation(metrics)
282
340
  };
283
341
 
@@ -288,20 +346,72 @@ Context: ${relatedContext.slice(0, 800)}`;
288
346
  fs.mkdirSync(path.dirname(OUTPUT_FILE), { recursive: true });
289
347
  fs.writeFileSync(OUTPUT_FILE, dashboard);
290
348
 
291
- console.log("\n" + chalk.bold.green("šŸ“Š DASHBOARD GENERATED"));
292
- console.log(chalk.dim(` Saved to: ${OUTPUT_FILE}`));
293
- 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)));
294
360
 
295
361
  // Print summary to console
296
362
  const statusEmoji = report.status === 'APPROVED' ? '🟢' :
297
363
  report.status === 'NEEDS_REVIEW' ? '🟔' : 'šŸ”“';
298
- console.log(`\n${statusEmoji} Status: ${report.status.replace('_', ' ')}`);
299
- console.log(` šŸ›‘ Critical: ${metrics.critical}`);
300
- console.log(` āš ļø High: ${metrics.high}`);
301
- console.log(` šŸ“ Medium: ${metrics.medium}`);
302
- console.log(` āœ… LGTM: ${metrics.lgtm}`);
303
- console.log(`\nšŸ’” ${report.recommendedAction}`);
304
- 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
+ }
305
415
  }
306
416
 
307
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