@in-the-loop-labs/pair-review 1.0.4 → 1.0.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
5
5
  "main": "src/server.js",
6
6
  "bin": {
package/public/css/pr.css CHANGED
@@ -295,18 +295,6 @@
295
295
  /* Styles handled by individual file items */
296
296
  }
297
297
 
298
- .file-item {
299
- display: flex;
300
- justify-content: space-between;
301
- align-items: center;
302
- padding: 8px 0;
303
- border-bottom: 1px solid #f6f8fa;
304
- }
305
-
306
- .file-item:last-child {
307
- border-bottom: none;
308
- }
309
-
310
298
  .file-name {
311
299
  font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', monospace;
312
300
  font-size: 13px;
@@ -963,6 +951,8 @@
963
951
  overflow: hidden;
964
952
  text-overflow: ellipsis;
965
953
  white-space: nowrap;
954
+ flex: 1;
955
+ min-width: 0;
966
956
  }
967
957
 
968
958
  .file-item .file-changes {
@@ -973,16 +963,38 @@
973
963
  margin-left: 8px;
974
964
  }
975
965
 
976
- .file-item[data-status="added"] .file-changes {
966
+ .file-additions {
977
967
  color: var(--diff-addition-marker, #3fb950);
978
968
  }
979
969
 
980
- .file-item[data-status="modified"] .file-changes {
981
- color: var(--color-warning, #d29922);
970
+ .file-deletions {
971
+ color: var(--diff-deletion-marker, #f85149);
972
+ margin-left: 6px;
982
973
  }
983
974
 
984
- .file-item[data-status="deleted"] .file-changes {
985
- color: var(--diff-deletion-marker, #f85149);
975
+ .file-item[data-status="renamed"] .file-changes {
976
+ color: var(--color-fg-muted, #8b949e);
977
+ }
978
+
979
+ .file-rename-icon-wrapper {
980
+ display: inline-flex;
981
+ align-items: center;
982
+ flex-shrink: 0;
983
+ }
984
+
985
+ .file-rename-icon {
986
+ color: var(--color-fg-muted, #8b949e);
987
+ margin-right: 4px;
988
+ vertical-align: middle;
989
+ flex-shrink: 0;
990
+ }
991
+
992
+ .file-rename-old-path {
993
+ opacity: 0.7;
994
+ }
995
+
996
+ .file-rename-arrow {
997
+ margin: 0 4px;
986
998
  }
987
999
 
988
1000
  .file-item.generated {
@@ -8558,6 +8570,27 @@ body.resizing * {
8558
8570
  .file-comment-card.user-comment {
8559
8571
  /* Override file-comment-card defaults to use user-comment styling */
8560
8572
  margin: 0; /* Remove margin since container handles spacing */
8573
+ background: linear-gradient(to right, #f5f0ff 0%, var(--file-comment-bg, #ffffff) 100%);
8574
+ border: 1px solid #8b5cf6;
8575
+ border-left: 4px solid #8b5cf6;
8576
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
8577
+ }
8578
+
8579
+ .file-comment-card.user-comment .file-comment-header {
8580
+ background: transparent;
8581
+ border-bottom-color: rgba(139, 92, 246, 0.1);
8582
+ }
8583
+
8584
+ [data-theme="dark"] .file-comment-card.user-comment {
8585
+ background: linear-gradient(to right, rgba(139, 92, 246, 0.15) 0%, var(--file-comment-bg, #0d1117) 100%);
8586
+ border-color: #a78bfa;
8587
+ border-left-color: #a78bfa;
8588
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
8589
+ }
8590
+
8591
+ [data-theme="dark"] .file-comment-card.user-comment .file-comment-header {
8592
+ background: transparent;
8593
+ border-bottom-color: rgba(167, 139, 250, 0.1);
8561
8594
  }
8562
8595
 
8563
8596
  /* ==========================================================================
@@ -333,6 +333,8 @@ class DiffRenderer {
333
333
  * @param {boolean} [options.isViewed=false] - Whether file is marked as viewed
334
334
  * @param {Object} [options.generatedInfo] - Info about generated file (insertions, deletions)
335
335
  * @param {Object} [options.fileStats] - File stats for collapsed view {insertions, deletions}
336
+ * @param {boolean} [options.renamed=false] - Whether the file was renamed
337
+ * @param {string|null} [options.renamedFrom=null] - Original file path before rename
336
338
  * @param {Function} [options.onToggleCollapse] - Callback for toggling collapse state
337
339
  * @param {Function} [options.onToggleViewed] - Callback for toggling viewed state
338
340
  * @returns {HTMLElement} File header element
@@ -344,6 +346,8 @@ class DiffRenderer {
344
346
  isViewed = false,
345
347
  generatedInfo = null,
346
348
  fileStats = null,
349
+ renamed = false,
350
+ renamedFrom = null,
347
351
  onToggleCollapse = null,
348
352
  onToggleViewed = null
349
353
  } = options;
@@ -375,7 +379,20 @@ class DiffRenderer {
375
379
  // File name
376
380
  const fileName = document.createElement('span');
377
381
  fileName.className = 'd2h-file-name';
378
- fileName.textContent = filePath;
382
+ if (renamed && renamedFrom) {
383
+ const oldPath = document.createElement('span');
384
+ oldPath.className = 'file-rename-old-path';
385
+ oldPath.textContent = renamedFrom;
386
+ const arrow = document.createElement('span');
387
+ arrow.className = 'file-rename-arrow';
388
+ arrow.textContent = '\u2192';
389
+ const newPath = document.createTextNode(filePath);
390
+ fileName.appendChild(oldPath);
391
+ fileName.appendChild(arrow);
392
+ fileName.appendChild(newPath);
393
+ } else {
394
+ fileName.textContent = filePath;
395
+ }
379
396
  fileHeader.appendChild(fileName);
380
397
 
381
398
  // File stats summary (visible in collapsed view)
@@ -345,6 +345,61 @@ class HunkParser {
345
345
  static shouldAutoExpand(gapSize) {
346
346
  return gapSize < HunkParser.AUTO_EXPAND_THRESHOLD;
347
347
  }
348
+
349
+ /**
350
+ * Parse a unified diff patch string into structured blocks (hunks).
351
+ * Each block contains the hunk header info and its content lines.
352
+ * Strips trailing empty-string artifacts from split('\n') on newline-terminated input,
353
+ * which would otherwise be misclassified as context lines and corrupt coordinate calculations.
354
+ * @param {string} patch - Unified diff patch string (may include diff --git headers)
355
+ * @returns {Array<{header: string, oldStart: number, newStart: number, lines: string[]}>}
356
+ */
357
+ static parseDiffIntoBlocks(patch) {
358
+ const lines = patch.split('\n');
359
+ const blocks = [];
360
+ let currentBlock = null;
361
+
362
+ lines.forEach(line => {
363
+ if (line.startsWith('@@')) {
364
+ if (currentBlock) {
365
+ HunkParser._stripTrailingEmpty(currentBlock);
366
+ blocks.push(currentBlock);
367
+ }
368
+ currentBlock = null;
369
+
370
+ const match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
371
+ if (match) {
372
+ currentBlock = {
373
+ header: line,
374
+ oldStart: parseInt(match[1]),
375
+ newStart: parseInt(match[2]),
376
+ lines: []
377
+ };
378
+ }
379
+ } else if (currentBlock) {
380
+ currentBlock.lines.push(line);
381
+ }
382
+ });
383
+
384
+ if (currentBlock) {
385
+ HunkParser._stripTrailingEmpty(currentBlock);
386
+ blocks.push(currentBlock);
387
+ }
388
+
389
+ return blocks;
390
+ }
391
+
392
+ /**
393
+ * Remove trailing empty string artifact from a block's lines array.
394
+ * split('\n') on newline-terminated input produces a trailing '' that has no
395
+ * diff prefix (+/-/space) and would be misclassified as a context line.
396
+ * @param {Object} block - Block with lines array to clean up
397
+ */
398
+ static _stripTrailingEmpty(block) {
399
+ if (block.lines.length > 0 && block.lines[block.lines.length - 1] === '') {
400
+ block.lines.pop();
401
+ }
402
+ }
348
403
  }
349
404
 
350
405
  // Make HunkParser available globally in browser
package/public/js/pr.js CHANGED
@@ -675,6 +675,8 @@ class PRManager {
675
675
  isViewed,
676
676
  generatedInfo: isGenerated ? this.generatedFiles.get(file.file) : null,
677
677
  fileStats,
678
+ renamed: file.renamed || false,
679
+ renamedFrom: file.renamedFrom || null,
678
680
  onToggleCollapse: (path) => this.toggleFileCollapse(path),
679
681
  onToggleViewed: (path, checked) => this.toggleFileViewed(path, checked)
680
682
  });
@@ -738,40 +740,11 @@ class PRManager {
738
740
  * @param {string} fileName - File name
739
741
  */
740
742
  renderPatch(tbody, patch, fileName) {
741
- const lines = patch.split('\n');
742
743
  let diffPosition = 0; // GitHub diff_position (1-indexed, consecutive)
743
744
  let prevBlockEnd = { old: 0, new: 0 };
744
745
  let isFirstHunk = true;
745
746
 
746
- // Parse diff into blocks (hunks)
747
- const blocks = [];
748
- let currentBlock = null;
749
-
750
- lines.forEach(line => {
751
- if (line.startsWith('@@')) {
752
- // Start new block
753
- if (currentBlock) {
754
- blocks.push(currentBlock);
755
- }
756
-
757
- // Parse hunk header
758
- const match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
759
- if (match) {
760
- currentBlock = {
761
- header: line,
762
- oldStart: parseInt(match[1]),
763
- newStart: parseInt(match[2]),
764
- lines: []
765
- };
766
- }
767
- } else if (currentBlock) {
768
- currentBlock.lines.push(line);
769
- }
770
- });
771
-
772
- if (currentBlock) {
773
- blocks.push(currentBlock);
774
- }
747
+ const blocks = window.HunkParser.parseDiffIntoBlocks(patch);
775
748
 
776
749
  // Render blocks with gap sections
777
750
  blocks.forEach((block, blockIndex) => {
@@ -784,7 +757,7 @@ class PRManager {
784
757
  );
785
758
 
786
759
  const gapStartOld = prevBlockEnd.old + 1;
787
- const gapEndOld = (blockBounds.old || block.oldStart) - 1;
760
+ const gapEndOld = (blockBounds.old ?? block.oldStart) - 1;
788
761
  const gapSize = gapEndOld - gapStartOld + 1;
789
762
  // Calculate the corresponding NEW line number for correct right-side display
790
763
  const gapStartNew = prevBlockEnd.new + 1;
@@ -889,8 +862,8 @@ class PRManager {
889
862
  'last'
890
863
  );
891
864
  prevBlockEnd = {
892
- old: endBounds.old || (block.oldStart + block.lines.filter(l => !l.startsWith('+')).length - 1),
893
- new: endBounds.new || (block.newStart + block.lines.filter(l => !l.startsWith('-')).length - 1)
865
+ old: endBounds.old ?? (block.oldStart + block.lines.filter(l => !l.startsWith('+')).length - 1),
866
+ new: endBounds.new ?? (block.newStart + block.lines.filter(l => !l.startsWith('-')).length - 1)
894
867
  };
895
868
  });
896
869
 
@@ -898,7 +871,9 @@ class PRManager {
898
871
  // This handles the case where there are unchanged lines after the last change
899
872
  // Use EOF_SENTINEL (-1) for endLine to indicate "rest of file" (unknown size)
900
873
  // The gap is marked as pending validation and will be removed async if no lines exist
901
- if (blocks.length > 0) {
874
+ // Skip for new files: when gapStartOld <= 0, the old file has no content (e.g. @@ -0,0 +1,N @@)
875
+ // so there are no trailing unchanged lines to expand
876
+ if (blocks.length > 0 && prevBlockEnd.old > 0) {
902
877
  const gapStartOld = prevBlockEnd.old + 1;
903
878
  const gapStartNew = prevBlockEnd.new + 1;
904
879
  const gapRow = window.HunkParser.createGapSection(
@@ -1164,6 +1139,14 @@ class PRManager {
1164
1139
  const fileName = controls.dataset.fileName;
1165
1140
  const startLine = parseInt(controls.dataset.startLine);
1166
1141
 
1142
+ // Safety net: remove gaps with invalid start lines (should not occur after
1143
+ // the prevBlockEnd.old > 0 guard in renderPatch, but handles edge cases defensively)
1144
+ if (startLine <= 0) {
1145
+ console.debug('Removing EOF gap with invalid startLine:', startLine, 'for file:', fileName);
1146
+ gapRow.remove();
1147
+ return;
1148
+ }
1149
+
1167
1150
  try {
1168
1151
  const data = await this.fetchFileContent(fileName);
1169
1152
  if (!data) {
@@ -2651,7 +2634,9 @@ class PRManager {
2651
2634
  additions: file.insertions,
2652
2635
  deletions: file.deletions,
2653
2636
  binary: file.binary,
2654
- generated: file.generated || false
2637
+ generated: file.generated || false,
2638
+ renamed: file.renamed || false,
2639
+ renamedFrom: file.renamedFrom || null,
2655
2640
  });
2656
2641
  } else {
2657
2642
  if (!current[part]) current[part] = {};
@@ -2663,6 +2648,10 @@ class PRManager {
2663
2648
  }
2664
2649
 
2665
2650
  getFileStatus(file) {
2651
+ if (file.renamed) {
2652
+ if (file.insertions > 0 || file.deletions > 0) return 'modified';
2653
+ return 'renamed';
2654
+ }
2666
2655
  if (file.binary) return 'modified';
2667
2656
  if (!file.deletions || file.deletions === 0) return 'added';
2668
2657
  if (!file.insertions || file.insertions === 0) return 'deleted';
@@ -2685,7 +2674,9 @@ class PRManager {
2685
2674
  additions: file.insertions,
2686
2675
  deletions: file.deletions,
2687
2676
  binary: file.binary,
2688
- generated: file.generated || false
2677
+ generated: file.generated || false,
2678
+ renamed: file.renamed || false,
2679
+ renamedFrom: file.renamedFrom || null,
2689
2680
  });
2690
2681
  });
2691
2682
 
@@ -2770,6 +2761,13 @@ class PRManager {
2770
2761
  item.dataset.status = file.status;
2771
2762
 
2772
2763
  if (file.generated) item.classList.add('generated');
2764
+ if (file.renamed && file.renamedFrom) {
2765
+ item.title = `Renamed from: ${file.renamedFrom}`;
2766
+ const renameIcon = document.createElement('span');
2767
+ renameIcon.className = 'file-rename-icon-wrapper';
2768
+ renameIcon.innerHTML = '<svg class="file-rename-icon" viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M13.25 1c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 15H2.75A1.75 1.75 0 0 1 1 13.25V2.75C1 1.784 1.784 1 2.75 1ZM2.75 2.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25Zm9.03 6.03-3.25 3.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.97-1.97H4.75a.75.75 0 0 1 0-1.5h4.69L7.47 5.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018l3.25 3.25a.75.75 0 0 1 0 1.06Z"/></svg>';
2769
+ item.appendChild(renameIcon);
2770
+ }
2773
2771
 
2774
2772
  const fileName = document.createElement('span');
2775
2773
  fileName.className = 'file-name';
@@ -2781,10 +2779,18 @@ class PRManager {
2781
2779
  if (file.binary) {
2782
2780
  changes.textContent = 'BIN';
2783
2781
  } else {
2784
- const parts = [];
2785
- if (file.additions > 0) parts.push(`+${file.additions}`);
2786
- if (file.deletions > 0) parts.push(`-${file.deletions}`);
2787
- changes.textContent = parts.join(' ') || '';
2782
+ if (file.additions > 0) {
2783
+ const addSpan = document.createElement('span');
2784
+ addSpan.className = 'file-additions';
2785
+ addSpan.textContent = `+${file.additions}`;
2786
+ changes.appendChild(addSpan);
2787
+ }
2788
+ if (file.deletions > 0) {
2789
+ const delSpan = document.createElement('span');
2790
+ delSpan.className = 'file-deletions';
2791
+ delSpan.textContent = `-${file.deletions}`;
2792
+ changes.appendChild(delSpan);
2793
+ }
2788
2794
  }
2789
2795
 
2790
2796
  item.appendChild(fileName);
@@ -9,7 +9,7 @@ const execPromise = util.promisify(exec);
9
9
  const logger = require('../utils/logger');
10
10
  const { extractJSON } = require('../utils/json-extractor');
11
11
  const { getGeneratedFilePatterns } = require('../git/gitattributes');
12
- const { normalizePath, pathExistsInList } = require('../utils/paths');
12
+ const { normalizePath, pathExistsInList, resolveRenamedFile } = require('../utils/paths');
13
13
  const { buildFileLineCountMap, validateSuggestionLineNumbers } = require('../utils/line-validation');
14
14
  const { getPromptBuilder } = require('./prompts');
15
15
  const { formatValidFiles } = require('./prompts/shared/valid-files');
@@ -185,7 +185,7 @@ class Analyzer {
185
185
  level3: levelResults.level3.suggestions
186
186
  };
187
187
 
188
- const orchestrationResult = await this.orchestrateWithAI(allSuggestions, prMetadata, mergedInstructions, fileLineCountMap, worktreePath, { analysisId, tier });
188
+ const orchestrationResult = await this.orchestrateWithAI(allSuggestions, prMetadata, mergedInstructions, worktreePath, { analysisId, tier, progressCallback });
189
189
 
190
190
  // Validate and finalize suggestions
191
191
  const finalSuggestions = this.validateAndFinalizeSuggestions(
@@ -418,6 +418,54 @@ Your suggestions MUST reference the EXACT line where the issue exists:
418
418
  `;
419
419
  }
420
420
 
421
+ /**
422
+ * Build lightweight line number guidance for the orchestration step.
423
+ *
424
+ * Unlike buildLineNumberGuidance() (used for levels 1-3), this does NOT
425
+ * instruct the AI to routinely run git-diff-lines or verify every line number.
426
+ * The orchestration step receives pre-computed suggestions whose line numbers
427
+ * were already determined by the analysis levels; its primary job is to
428
+ * intelligently combine them. However, it retains access to git-diff-lines
429
+ * for cases where it needs to investigate conflicting suggestions or verify
430
+ * a specific concern.
431
+ *
432
+ * @param {string|null} worktreePath - Path to the git worktree (for --cwd option)
433
+ * @returns {string} Orchestration-specific line number guidance
434
+ */
435
+ buildOrchestrationLineNumberGuidance(worktreePath = null) {
436
+ const scriptCommand = 'git-diff-lines';
437
+ const cwdOption = worktreePath ? ` --cwd "${worktreePath}"` : '';
438
+ const fullCommand = `${scriptCommand}${cwdOption}`;
439
+ return `
440
+ ## Line Number Handling
441
+
442
+ You are receiving pre-computed suggestions from the analysis levels. Each suggestion
443
+ already carries a \`line\` number and \`old_or_new\` value determined during analysis.
444
+ Your primary focus is curation and synthesis, not line number verification.
445
+
446
+ **Your responsibilities:**
447
+ - **Preserve line numbers as-is** when passing suggestions through to the output.
448
+ - **Preserve \`old_or_new\` values** from input suggestions.
449
+ - **When merging duplicates or near-duplicates** that reference the same line,
450
+ keep the line number and \`old_or_new\` from the suggestion with the richest
451
+ context (prefer higher-level analysis when in doubt).
452
+ - **When levels conflict** on the line number or \`old_or_new\` for what appears to
453
+ be the same issue, use your judgment based on the nature of the concern:
454
+ - For **architectural or cross-cutting issues**, prefer the suggestion from the
455
+ level with broader context (Level 3 > Level 2 > Level 1).
456
+ - For **precise line-level bugs or typos**, prefer the suggestion from the level
457
+ that targets the specific line most directly (often Level 1, which works
458
+ closest to the raw diff).
459
+
460
+ **If you need to inspect a file diff** (e.g., to resolve conflicting suggestions or
461
+ verify a specific concern), use the annotated diff tool instead of \`git diff\`:
462
+ \`\`\`
463
+ ${fullCommand}
464
+ \`\`\`
465
+ All git diff arguments work: \`${fullCommand} HEAD~1\`, \`${fullCommand} -- src/\`
466
+ `;
467
+ }
468
+
421
469
  /**
422
470
  * Build the section of the prompt that includes custom review instructions
423
471
  * @param {string} customInstructions - Custom instructions text
@@ -454,31 +502,6 @@ Do NOT create suggestions for any files not in this list. If you cannot find iss
454
502
  `;
455
503
  }
456
504
 
457
- /**
458
- * Build the file line counts section for orchestration prompt
459
- * @param {Map<string, number>} fileLineCountMap - Map of file paths to line counts
460
- * @returns {string} Prompt section or empty string
461
- */
462
- buildFileLineCountsSection(fileLineCountMap) {
463
- if (!fileLineCountMap || fileLineCountMap.size === 0) return '';
464
-
465
- const lines = ['', '## File Line Counts for Validation'];
466
- for (const [filePath, lineCount] of fileLineCountMap) {
467
- if (lineCount === 0) {
468
- // Empty files are valid text files - any line-specific suggestion would be invalid
469
- lines.push(`- ${filePath}: 0 lines (empty file)`);
470
- } else if (lineCount > 0) {
471
- lines.push(`- ${filePath}: ${lineCount} lines`);
472
- }
473
- // Skip binary/missing files (lineCount === -1)
474
- }
475
- lines.push('');
476
- lines.push('Verify that all suggestion line numbers are within these bounds.');
477
- lines.push('If a suggestion has an invalid line number but valuable insight, convert it to a file-level suggestion.');
478
- lines.push('');
479
- return lines.join('\n');
480
- }
481
-
482
505
  /**
483
506
  * Get list of changed files from git diff
484
507
  * @param {string} worktreePath - Path to the git worktree
@@ -556,13 +579,15 @@ Do NOT create suggestions for any files not in this list. If you cannot find iss
556
579
  }
557
580
 
558
581
  // Create a Set of normalized valid paths for efficient lookup
559
- const normalizedValidPaths = new Set(validPaths.map(p => normalizePath(p)));
582
+ // Resolve git rename syntax (e.g., "tests/{old.js => new.js}" → "tests/new.js")
583
+ // so both the rename syntax path and the plain new filename will match
584
+ const normalizedValidPaths = new Set(validPaths.map(p => normalizePath(resolveRenamedFile(p))));
560
585
 
561
586
  const validSuggestions = [];
562
587
  const discardedSuggestions = [];
563
588
 
564
589
  for (const suggestion of suggestions) {
565
- const normalizedSuggestionPath = normalizePath(suggestion.file);
590
+ const normalizedSuggestionPath = normalizePath(resolveRenamedFile(suggestion.file));
566
591
 
567
592
  if (normalizedValidPaths.has(normalizedSuggestionPath)) {
568
593
  validSuggestions.push(suggestion);
@@ -1417,7 +1442,7 @@ If you are unsure, use "NEW" - it is correct for the vast majority of suggestion
1417
1442
  // Fallback to getValidFilePaths() which properly looks up pr_metadata via review.id
1418
1443
  let validFilePaths;
1419
1444
  if (changedFiles && changedFiles.length > 0) {
1420
- validFilePaths = changedFiles.map(f => normalizePath(f));
1445
+ validFilePaths = changedFiles.map(f => normalizePath(resolveRenamedFile(f)));
1421
1446
  } else {
1422
1447
  // Fallback to pr_metadata lookup via review -> pr_metadata join
1423
1448
  validFilePaths = await this.getValidFilePaths(reviewId);
@@ -1570,12 +1595,13 @@ If you are unsure, use "NEW" - it is correct for the vast majority of suggestion
1570
1595
  }
1571
1596
 
1572
1597
  // Use O(1) Set lookup if validPaths is a Set, otherwise normalize and check
1573
- const normalizedSuggestionPath = normalizePath(suggestionPath);
1598
+ // Resolve git rename syntax so suggestions for renamed files match
1599
+ const normalizedSuggestionPath = normalizePath(resolveRenamedFile(suggestionPath));
1574
1600
  if (validPaths instanceof Set) {
1575
1601
  return validPaths.has(normalizedSuggestionPath);
1576
1602
  }
1577
1603
  // Fallback for array (convert to Set for lookup)
1578
- const validPathsSet = new Set(validPaths.map(p => normalizePath(p)));
1604
+ const validPathsSet = new Set(validPaths.map(p => normalizePath(resolveRenamedFile(p))));
1579
1605
  return validPathsSet.has(normalizedSuggestionPath);
1580
1606
  }
1581
1607
 
@@ -2273,14 +2299,13 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2273
2299
  * @param {Object} allSuggestions - Object containing suggestions from all levels: {level1: [...], level2: [...], level3: [...]}
2274
2300
  * @param {Object} prMetadata - PR metadata for context
2275
2301
  * @param {string} customInstructions - Optional custom instructions to guide prioritization/filtering
2276
- * @param {Map<string, number>} fileLineCountMap - Optional map of file paths to line counts for validation
2277
2302
  * @param {string} worktreePath - Path to the git worktree
2278
2303
  * @param {Object} options - Additional options
2279
2304
  * @param {string} options.analysisId - Analysis ID for process tracking (enables cancellation)
2280
2305
  * @returns {Promise<Array>} Curated suggestions array
2281
2306
  */
2282
- async orchestrateWithAI(allSuggestions, prMetadata, customInstructions = null, fileLineCountMap = null, worktreePath = null, options = {}) {
2283
- const { analysisId, tier = 'balanced' } = options;
2307
+ async orchestrateWithAI(allSuggestions, prMetadata, customInstructions = null, worktreePath = null, options = {}) {
2308
+ const { analysisId, tier = 'balanced', progressCallback } = options;
2284
2309
  logger.section('[Orchestration] AI Orchestration Starting');
2285
2310
 
2286
2311
  const totalSuggestions = (allSuggestions.level1?.length || 0) +
@@ -2299,7 +2324,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2299
2324
  const aiProvider = createProvider(this.provider, this.model);
2300
2325
 
2301
2326
  // Build the orchestration prompt
2302
- const prompt = this.buildOrchestrationPrompt(allSuggestions, prMetadata, customInstructions, fileLineCountMap, worktreePath, tier);
2327
+ const prompt = this.buildOrchestrationPrompt(allSuggestions, prMetadata, customInstructions, worktreePath, tier);
2303
2328
 
2304
2329
  // Execute Claude CLI for orchestration
2305
2330
  logger.info('[Orchestration] Running AI orchestration to curate and merge suggestions...');
@@ -2402,12 +2427,11 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2402
2427
  * @param {Object} allSuggestions - Suggestions from all levels
2403
2428
  * @param {Object} prMetadata - PR metadata for context
2404
2429
  * @param {string} customInstructions - Optional custom instructions to guide prioritization/filtering
2405
- * @param {Map<string, number>} fileLineCountMap - Optional map of file paths to line counts for validation
2406
2430
  * @param {string} worktreePath - Path to the git worktree
2407
2431
  * @param {string} tier - Capability tier: 'fast', 'balanced', or 'thorough' (default: 'balanced')
2408
2432
  * @returns {string} Orchestration prompt
2409
2433
  */
2410
- buildOrchestrationPrompt(allSuggestions, prMetadata, customInstructions = null, fileLineCountMap = null, worktreePath = null, tier = 'balanced') {
2434
+ buildOrchestrationPrompt(allSuggestions, prMetadata, customInstructions = null, worktreePath = null, tier = 'balanced') {
2411
2435
  logger.debug(`[Orchestration] Building prompt with tier: ${tier}`);
2412
2436
  const promptBuilder = getPromptBuilder('orchestration', tier, this.provider);
2413
2437
 
@@ -2420,14 +2444,13 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2420
2444
  const context = {
2421
2445
  reviewIntro: `You are orchestrating AI-powered code review suggestions for ${reviewDescription}.`,
2422
2446
  customInstructions: customInstructions ? this.buildCustomInstructionsSection(customInstructions) : '',
2423
- lineNumberGuidance: this.buildLineNumberGuidance(worktreePath),
2447
+ lineNumberGuidance: this.buildOrchestrationLineNumberGuidance(worktreePath),
2424
2448
  level1Count: allSuggestions.level1?.length || 0,
2425
2449
  level2Count: allSuggestions.level2?.length || 0,
2426
2450
  level3Count: allSuggestions.level3?.length || 0,
2427
2451
  level1Suggestions: this._formatSuggestionsForOrchestration(allSuggestions.level1),
2428
2452
  level2Suggestions: this._formatSuggestionsForOrchestration(allSuggestions.level2),
2429
- level3Suggestions: this._formatSuggestionsForOrchestration(allSuggestions.level3),
2430
- fileLineCounts: this.buildFileLineCountsSection(fileLineCountMap)
2453
+ level3Suggestions: this._formatSuggestionsForOrchestration(allSuggestions.level3)
2431
2454
  };
2432
2455
 
2433
2456
  return promptBuilder.build(context);
@@ -32,7 +32,6 @@ const { ORCHESTRATION_INPUT_SCHEMA_DOCS } = require('../../shared/output-schema'
32
32
  * - {{level1Suggestions}} - Level 1 suggestions as JSON array
33
33
  * - {{level2Suggestions}} - Level 2 suggestions as JSON array
34
34
  * - {{level3Suggestions}} - Level 3 suggestions as JSON array
35
- * - {{fileLineCounts}} - File line count validation data (optional)
36
35
  */
37
36
  const taggedPrompt = `<section name="role" required="true">
38
37
  {{reviewIntro}}
@@ -74,10 +73,6 @@ ${ORCHESTRATION_INPUT_SCHEMA_DOCS}
74
73
  {{level3Suggestions}}
75
74
  </section>
76
75
 
77
- <section name="file-line-counts" optional="true">
78
- {{fileLineCounts}}
79
- </section>
80
-
81
76
  <section name="intelligent-merging" required="true">
82
77
  ## Orchestration Guidelines
83
78
 
@@ -194,7 +189,6 @@ const sections = [
194
189
  { name: 'role-description', required: true },
195
190
  { name: 'custom-instructions', optional: true },
196
191
  { name: 'input-suggestions', locked: true },
197
- { name: 'file-line-counts', optional: true },
198
192
  { name: 'intelligent-merging', required: true },
199
193
  { name: 'priority-curation', required: true },
200
194
  { name: 'balanced-output', required: true },
@@ -216,7 +210,6 @@ const defaultOrder = [
216
210
  'role-description',
217
211
  'custom-instructions',
218
212
  'input-suggestions',
219
- 'file-line-counts',
220
213
  'intelligent-merging',
221
214
  'priority-curation',
222
215
  'balanced-output',
@@ -35,7 +35,6 @@ const { ORCHESTRATION_INPUT_SCHEMA_DOCS } = require('../../shared/output-schema'
35
35
  * - {{level1Suggestions}} - Level 1 suggestions as JSON array
36
36
  * - {{level2Suggestions}} - Level 2 suggestions as JSON array
37
37
  * - {{level3Suggestions}} - Level 3 suggestions as JSON array
38
- * - {{fileLineCounts}} - File line count validation data (optional)
39
38
  */
40
39
  const taggedPrompt = `<section name="role" required="true" tier="fast">
41
40
  {{reviewIntro}}
@@ -77,10 +76,6 @@ ${ORCHESTRATION_INPUT_SCHEMA_DOCS}
77
76
  {{level3Suggestions}}
78
77
  </section>
79
78
 
80
- <section name="file-line-counts" optional="true" tier="fast,balanced,thorough">
81
- {{fileLineCounts}}
82
- </section>
83
-
84
79
  <section name="intelligent-merging" required="true" tier="fast">
85
80
  ## Rules
86
81
  Combine related suggestions. Merge overlaps. Preserve unique insights. Never mention levels.
@@ -149,7 +144,6 @@ const sections = [
149
144
  { name: 'role-description', required: true, tier: ['fast'] },
150
145
  { name: 'custom-instructions', optional: true, tier: ['fast', 'balanced', 'thorough'] },
151
146
  { name: 'input-suggestions', locked: true },
152
- { name: 'file-line-counts', optional: true, tier: ['fast', 'balanced', 'thorough'] },
153
147
  { name: 'intelligent-merging', required: true, tier: ['fast'] },
154
148
  { name: 'priority-curation', required: true, tier: ['fast'] },
155
149
  { name: 'balanced-output', required: true, tier: ['fast'] },
@@ -171,7 +165,6 @@ const defaultOrder = [
171
165
  'role-description',
172
166
  'custom-instructions',
173
167
  'input-suggestions',
174
- 'file-line-counts',
175
168
  'intelligent-merging',
176
169
  'priority-curation',
177
170
  'balanced-output',
@@ -39,7 +39,6 @@ const { ORCHESTRATION_INPUT_SCHEMA_DOCS } = require('../../shared/output-schema'
39
39
  * - {{level1Suggestions}} - Level 1 suggestions as JSON array
40
40
  * - {{level2Suggestions}} - Level 2 suggestions as JSON array
41
41
  * - {{level3Suggestions}} - Level 3 suggestions as JSON array
42
- * - {{fileLineCounts}} - File line count validation data (optional)
43
42
  */
44
43
  const taggedPrompt = `<section name="role" required="true" tier="thorough">
45
44
  {{reviewIntro}}
@@ -101,10 +100,6 @@ ${ORCHESTRATION_INPUT_SCHEMA_DOCS}
101
100
  {{level3Suggestions}}
102
101
  </section>
103
102
 
104
- <section name="file-line-counts" optional="true" tier="balanced,thorough">
105
- {{fileLineCounts}}
106
- </section>
107
-
108
103
  <section name="intelligent-merging" required="true" tier="thorough">
109
104
  ## Orchestration Guidelines
110
105
 
@@ -375,7 +370,6 @@ const sections = [
375
370
  { name: 'reasoning-encouragement', required: true, tier: ['thorough'] },
376
371
  { name: 'custom-instructions', optional: true, tier: ['balanced', 'thorough'] },
377
372
  { name: 'input-suggestions', locked: true },
378
- { name: 'file-line-counts', optional: true, tier: ['balanced', 'thorough'] },
379
373
  { name: 'intelligent-merging', required: true, tier: ['thorough'] },
380
374
  { name: 'priority-curation', required: true, tier: ['thorough'] },
381
375
  { name: 'balanced-output', required: true, tier: ['thorough'] },
@@ -401,7 +395,6 @@ const defaultOrder = [
401
395
  'reasoning-encouragement',
402
396
  'custom-instructions',
403
397
  'input-suggestions',
404
- 'file-line-counts',
405
398
  'intelligent-merging',
406
399
  'priority-curation',
407
400
  'balanced-output',
@@ -6,7 +6,7 @@ const os = require('os');
6
6
  const { getConfigDir } = require('../config');
7
7
  const { WorktreeRepository, generateWorktreeId } = require('../database');
8
8
  const { getGeneratedFilePatterns } = require('./gitattributes');
9
- const { normalizeRepository } = require('../utils/paths');
9
+ const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('../utils/paths');
10
10
 
11
11
  /**
12
12
  * Git worktree manager for handling PR branch checkouts and diffs
@@ -294,14 +294,23 @@ class GitWorktreeManager {
294
294
  // Parse .gitattributes to identify generated files
295
295
  const gitattributes = await getGeneratedFilePatterns(worktreePath);
296
296
 
297
- return diffSummary.files.map(file => ({
298
- file: file.file,
299
- insertions: file.insertions,
300
- deletions: file.deletions,
301
- changes: file.changes,
302
- binary: file.binary || false,
303
- generated: gitattributes.isGenerated(file.file)
304
- }));
297
+ return diffSummary.files.map(file => {
298
+ const resolvedFile = resolveRenamedFile(file.file);
299
+ const isRenamed = resolvedFile !== file.file;
300
+ const result = {
301
+ file: resolvedFile,
302
+ insertions: file.insertions,
303
+ deletions: file.deletions,
304
+ changes: file.changes,
305
+ binary: file.binary || false,
306
+ generated: gitattributes.isGenerated(resolvedFile)
307
+ };
308
+ if (isRenamed) {
309
+ result.renamed = true;
310
+ result.renamedFrom = resolveRenamedFileOld(file.file);
311
+ }
312
+ return result;
313
+ });
305
314
 
306
315
  } catch (error) {
307
316
  console.error('Error getting changed files:', error);
@@ -152,10 +152,58 @@ function normalizeRepository(owner, repo) {
152
152
  return `${trimmedOwner.toLowerCase()}/${trimmedRepo.toLowerCase()}`;
153
153
  }
154
154
 
155
+ /**
156
+ * Resolve git rename syntax to extract the new filename.
157
+ *
158
+ * Git represents renames with curly-brace syntax:
159
+ * "tests/{old.js => new.js}" → "tests/new.js"
160
+ * "{old-dir => new-dir}/file.js" → "new-dir/file.js"
161
+ * "a/{b => c}/d.js" → "a/c/d.js"
162
+ *
163
+ * @param {string} fileName - File name possibly containing rename syntax
164
+ * @returns {string} Resolved file name with the new path, or original if no rename syntax
165
+ *
166
+ * @example
167
+ * resolveRenamedFile('tests/{old.js => new.js}') // => 'tests/new.js'
168
+ * resolveRenamedFile('{old-dir => new-dir}/file.js') // => 'new-dir/file.js'
169
+ * resolveRenamedFile('a/{b => c}/d.js') // => 'a/c/d.js'
170
+ * resolveRenamedFile('src/foo.js') // => 'src/foo.js'
171
+ * resolveRenamedFile(null) // => null
172
+ */
173
+ function resolveRenamedFile(fileName) {
174
+ if (!fileName) return fileName;
175
+ return fileName.replace(/\{[^}]*\s*=>\s*([^}]*)\}/, '$1').replace(/\/+/g, '/').replace(/^\//, '');
176
+ }
177
+
178
+ /**
179
+ * Resolve git rename syntax to extract the OLD filename.
180
+ *
181
+ * Git represents renames with curly-brace syntax:
182
+ * "tests/{old.js => new.js}" → "tests/old.js"
183
+ * "{old-dir => new-dir}/file.js" → "old-dir/file.js"
184
+ * "a/{b => c}/d.js" → "a/b/d.js"
185
+ *
186
+ * @param {string} fileName - File name possibly containing rename syntax
187
+ * @returns {string} Resolved file name with the old path, or original if no rename syntax
188
+ *
189
+ * @example
190
+ * resolveRenamedFileOld('tests/{old.js => new.js}') // => 'tests/old.js'
191
+ * resolveRenamedFileOld('{old-dir => new-dir}/file.js') // => 'old-dir/file.js'
192
+ * resolveRenamedFileOld('a/{b => c}/d.js') // => 'a/b/d.js'
193
+ * resolveRenamedFileOld('src/foo.js') // => 'src/foo.js'
194
+ * resolveRenamedFileOld(null) // => null
195
+ */
196
+ function resolveRenamedFileOld(fileName) {
197
+ if (!fileName) return fileName;
198
+ return fileName.replace(/\{([^}]*?)\s*=>\s*[^}]*\}/, '$1').replace(/\/+/g, '/').replace(/^\//, '');
199
+ }
200
+
155
201
  module.exports = {
156
202
  normalizePath,
157
203
  pathsEqual,
158
204
  pathExistsInList,
159
205
  pathExistsInSet,
160
- normalizeRepository
206
+ normalizeRepository,
207
+ resolveRenamedFile,
208
+ resolveRenamedFileOld
161
209
  };