@in-the-loop-labs/pair-review 3.1.0 → 3.1.2

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": "3.1.0",
3
+ "version": "3.1.2",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "3.1.0",
3
+ "version": "3.1.2",
4
4
  "description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-critic",
3
- "version": "3.1.0",
3
+ "version": "3.1.2",
4
4
  "description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -81,3 +81,23 @@
81
81
  .option-hint:last-child {
82
82
  margin-bottom: 0;
83
83
  }
84
+
85
+ /* Section disabled by provider capability —
86
+ pointer-events on the section blocks interaction; opacity is applied
87
+ only to the options area so the note remains clearly readable and
88
+ the already-disabled GitHub toggle doesn't double-dim. */
89
+ .exclude-previous-section.exclude-previous-disabled {
90
+ pointer-events: none;
91
+ }
92
+
93
+ .exclude-previous-section.exclude-previous-disabled > summary {
94
+ opacity: 0.5;
95
+ }
96
+
97
+ .exclude-previous-section.exclude-previous-disabled .exclude-previous-options {
98
+ opacity: 0.5;
99
+ }
100
+
101
+ .exclude-previous-section .executable-provider-note {
102
+ margin: 8px 14px;
103
+ }
@@ -705,10 +705,13 @@ class AdvancedConfigTab {
705
705
 
706
706
  _populateProviderDropdown(select) {
707
707
  const currentValue = select.value;
708
+ const isConsolidation = select.dataset.target === 'orchestration';
708
709
  select.innerHTML = '';
709
710
  const providerIds = Object.keys(this.providers).filter(id => {
710
711
  const p = this.providers[id];
711
- return !p.availability || p.availability.available;
712
+ if (p.availability && !p.availability.available) return false;
713
+ if (isConsolidation && p.capabilities?.consolidation === false) return false;
714
+ return true;
712
715
  }).sort((a, b) => (this.providers[a].name || a).localeCompare(this.providers[b].name || b));
713
716
 
714
717
  for (const id of providerIds) {
@@ -616,6 +616,9 @@ class AnalysisConfigModal {
616
616
  if (this.selectedModel) {
617
617
  this.selectModel(this.selectedModel);
618
618
  }
619
+
620
+ // Update exclude-previous section based on provider's exclude_previous capability
621
+ this._updateExcludePreviousState();
619
622
  }
620
623
 
621
624
  /**
@@ -827,7 +830,9 @@ class AnalysisConfigModal {
827
830
  enabledLevels: [...this.enabledLevels],
828
831
  skipLevel3: !this.enabledLevels.includes(3),
829
832
  noLevels,
830
- excludePrevious: this._getAndSaveExcludePrevious()
833
+ excludePrevious: selectedProvider?.capabilities?.exclude_previous === false
834
+ ? undefined
835
+ : this._getAndSaveExcludePrevious()
831
836
  };
832
837
 
833
838
  if (this.onSubmit) this.onSubmit(config);
@@ -1151,6 +1156,9 @@ class AnalysisConfigModal {
1151
1156
  dirtyHintContainer.style.display = 'none';
1152
1157
  }
1153
1158
  }
1159
+
1160
+ // Re-evaluate exclude-previous section based on new tab context
1161
+ this._updateExcludePreviousState();
1154
1162
  }
1155
1163
 
1156
1164
  /**
@@ -1362,6 +1370,49 @@ class AnalysisConfigModal {
1362
1370
  return state;
1363
1371
  }
1364
1372
 
1373
+ /**
1374
+ * Update the exclude-previous section's enabled/disabled state based on
1375
+ * the active tab and the selected provider's capabilities.
1376
+ *
1377
+ * Council/Advanced tabs: always enabled (orchestration handles dedup).
1378
+ * Single Model tab: disabled when provider has exclude_previous === false.
1379
+ *
1380
+ * This only toggles the visual state of the entire section — individual
1381
+ * checkbox checked/disabled state is never modified, so persisted
1382
+ * localStorage values remain untouched.
1383
+ * @private
1384
+ */
1385
+ _updateExcludePreviousState() {
1386
+ const section = this.modal.querySelector('.exclude-previous-section');
1387
+ if (!section) return;
1388
+
1389
+ const provider = this.providers[this.selectedProvider];
1390
+ const disabledByCapability = this.activeTab === 'single'
1391
+ && provider?.capabilities?.exclude_previous === false;
1392
+
1393
+ const existingNote = section.querySelector('.executable-provider-exclude-note');
1394
+
1395
+ if (disabledByCapability) {
1396
+ section.classList.add('exclude-previous-disabled');
1397
+ section.setAttribute('open', ''); // Ensure note is visible even if section was collapsed
1398
+
1399
+ if (!existingNote) {
1400
+ const note = document.createElement('div');
1401
+ note.className = 'executable-provider-note executable-provider-exclude-note';
1402
+ note.textContent = 'This provider does not support excluding previous findings.';
1403
+ const options = section.querySelector('.exclude-previous-options');
1404
+ if (options) {
1405
+ section.insertBefore(note, options);
1406
+ } else {
1407
+ section.appendChild(note);
1408
+ }
1409
+ }
1410
+ } else {
1411
+ section.classList.remove('exclude-previous-disabled');
1412
+ if (existingNote) existingNote.remove();
1413
+ }
1414
+ }
1415
+
1365
1416
  /**
1366
1417
  * Cleanup event listeners and pending timeouts
1367
1418
  */
@@ -1621,7 +1621,8 @@ class CouncilProgressModal {
1621
1621
  * -> "Claude sonnet-4-5 (Balanced)"
1622
1622
  */
1623
1623
  _formatVoiceLabel(voice, { isExecutable = false } = {}) {
1624
- const provider = this._capitalize(voice.provider || 'unknown');
1624
+ const providersMap = window.analysisConfigModal?.providers || {};
1625
+ const provider = providersMap[voice.provider]?.name || this._capitalize(voice.provider || 'unknown');
1625
1626
  const model = voice.model || 'default';
1626
1627
  if (isExecutable) return `${provider} ${model}`;
1627
1628
  const tier = this._capitalize(voice.tier || 'balanced');
@@ -770,10 +770,13 @@ class VoiceCentricConfigTab {
770
770
 
771
771
  _populateProviderDropdown(select) {
772
772
  const currentValue = select.value;
773
+ const isConsolidation = select.dataset.target === 'orchestration';
773
774
  select.innerHTML = '';
774
775
  const providerIds = Object.keys(this.providers).filter(id => {
775
776
  const p = this.providers[id];
776
- return !p.availability || p.availability.available;
777
+ if (p.availability && !p.availability.available) return false;
778
+ if (isConsolidation && p.capabilities?.consolidation === false) return false;
779
+ return true;
777
780
  }).sort((a, b) => (this.providers[a].name || a).localeCompare(this.providers[b].name || b));
778
781
 
779
782
  for (const id of providerIds) {
@@ -1526,6 +1526,23 @@ class LocalManager {
1526
1526
  }
1527
1527
  }
1528
1528
 
1529
+ /**
1530
+ * Build a notification string describing a scope change for the chat agent.
1531
+ * @param {string} prefix - Leading message (e.g. "Diff scope changed to X.")
1532
+ * @param {{ description: string, diffCommand: string, excludes: string, includesUntracked: boolean }|null} hints - Scope git hints
1533
+ * @returns {string} Formatted notification text
1534
+ */
1535
+ _buildScopeNotification(prefix, hints) {
1536
+ const parts = [prefix];
1537
+ if (hints) {
1538
+ parts.push(`Scope: ${hints.description}`);
1539
+ parts.push(`Diff command: \`${hints.diffCommand}\``);
1540
+ if (hints.excludes) parts.push(hints.excludes);
1541
+ if (hints.includesUntracked) parts.push('Untracked files are included. List them with: `git ls-files --others --exclude-standard`');
1542
+ }
1543
+ return parts.join('\n');
1544
+ }
1545
+
1529
1546
  /**
1530
1547
  * Apply the result of a scope-change POST to local state, UI, and diff.
1531
1548
  * Shared by _handleScopeChange and showBranchReviewDialog.handleConfirm.
@@ -1617,9 +1634,11 @@ class LocalManager {
1617
1634
  // Notify chat agent about scope change
1618
1635
  if (window.chatPanel) {
1619
1636
  const label = LS ? LS.scopeLabel(scopeStart, scopeEnd) : `${scopeStart}\u2013${scopeEnd}`;
1620
- window.chatPanel.queueDiffStateNotification(
1621
- `Diff scope changed to ${label}. The set of reviewed files has changed.`
1637
+ const hints = LS ? LS.scopeGitHints(scopeStart, scopeEnd, this.localData?.baseBranch) : null;
1638
+ const notification = this._buildScopeNotification(
1639
+ `Diff scope changed to ${label}. The set of reviewed files has changed.`, hints
1622
1640
  );
1641
+ window.chatPanel.queueDiffStateNotification(notification);
1623
1642
  }
1624
1643
 
1625
1644
  if (window.toast) {
@@ -1752,9 +1771,11 @@ class LocalManager {
1752
1771
 
1753
1772
  if (window.chatPanel) {
1754
1773
  const label = LS ? LS.scopeLabel('branch', newEnd) : 'branch';
1755
- window.chatPanel.queueDiffStateNotification(
1756
- `Diff scope changed to ${label} via branch review. The set of reviewed files has changed.`
1774
+ const hints = LS ? LS.scopeGitHints('branch', newEnd, branchInfo.baseBranch) : null;
1775
+ const notification = self._buildScopeNotification(
1776
+ `Diff scope changed to ${label} via branch review. The set of reviewed files has changed.`, hints
1757
1777
  );
1778
+ window.chatPanel.queueDiffStateNotification(notification);
1758
1779
  }
1759
1780
 
1760
1781
  if (window.toast) {
@@ -86,6 +86,8 @@ ${rawOutput}`;
86
86
  * @param {Object} config.capabilities - Provider capabilities overrides
87
87
  * @param {boolean} config.capabilities.review_levels - Whether the tool supports L1/L2/L3 analysis
88
88
  * @param {boolean} config.capabilities.custom_instructions - Whether the tool supports custom instructions
89
+ * @param {boolean} config.capabilities.exclude_previous - Whether the tool supports excluding previous findings
90
+ * @param {boolean} config.capabilities.consolidation - Whether the tool can be used for consolidation
89
91
  * @param {Object} config.context_args - Maps context keys to CLI flags
90
92
  * @param {string} config.output_glob - Glob pattern to find result file
91
93
  * @param {string} config.mapping_instructions - Tool-specific mapping instructions for LLM
@@ -525,7 +527,9 @@ function createExecutableProviderClass(id, config) {
525
527
  const caps = config.capabilities || {};
526
528
  ExecProvider.capabilities = {
527
529
  review_levels: caps.review_levels !== undefined ? caps.review_levels : false,
528
- custom_instructions: caps.custom_instructions !== undefined ? caps.custom_instructions : false
530
+ custom_instructions: caps.custom_instructions !== undefined ? caps.custom_instructions : false,
531
+ exclude_previous: caps.exclude_previous !== undefined ? caps.exclude_previous : false,
532
+ consolidation: caps.consolidation !== undefined ? caps.consolidation : false
529
533
  };
530
534
 
531
535
  return ExecProvider;
@@ -621,7 +621,9 @@ function getAllProvidersInfo() {
621
621
  // Build capabilities: executable providers define their own, others get defaults
622
622
  const capabilities = ProviderClass.capabilities || {
623
623
  review_levels: true,
624
- custom_instructions: true
624
+ custom_instructions: true,
625
+ exclude_previous: true,
626
+ consolidation: true
625
627
  };
626
628
 
627
629
  providers.push({
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  const logger = require('../utils/logger');
11
+ const { scopeGitHints, DEFAULT_SCOPE } = require('../local-scope');
11
12
 
12
13
  /**
13
14
  * Build a lean system prompt for chat sessions.
@@ -140,9 +141,27 @@ function buildReviewContext(review, prData) {
140
141
  lines.push(`This is a local code review for: ${name}`);
141
142
  lines.push('');
142
143
  lines.push('## Viewing Code Changes');
143
- lines.push('The changes under review are **unstaged and untracked local changes**. Staged changes (`git diff --no-ext-diff --cached`) are treated as already reviewed.');
144
- lines.push('To see the diff under review: `git diff --no-ext-diff`');
145
- lines.push('Do NOT use `git diff HEAD~1` or `git log` — those show committed history, not the changes under review.');
144
+
145
+ const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
146
+ const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
147
+ const baseBranch = review.local_base_branch || null;
148
+ const hints = scopeGitHints(scopeStart, scopeEnd, baseBranch);
149
+
150
+ if (hints) {
151
+ lines.push(`The current diff scope is **${hints.label}**: ${hints.description}`);
152
+ lines.push(`To see the diff under review: \`${hints.diffCommand}\``);
153
+ if (hints.includesUntracked) {
154
+ lines.push('Untracked files are included. List them with: `git ls-files --others --exclude-standard`');
155
+ }
156
+ if (hints.excludes) {
157
+ lines.push(hints.excludes);
158
+ }
159
+ } else {
160
+ lines.push('The changes under review are **unstaged and untracked local changes**. Staged changes (`git diff --no-ext-diff --cached`) are treated as already reviewed.');
161
+ lines.push('To see the diff under review: `git diff --no-ext-diff`');
162
+ lines.push('Do NOT use `git diff HEAD~1` or `git log` — those show committed history, not the changes under review.');
163
+ }
164
+ lines.push('The diff scope can change during this session. If you receive a "[Diff State Update]" notification, follow the updated diff command it provides.');
146
165
  } else {
147
166
  const parts = [];
148
167
  if (review.repository) {
@@ -30,7 +30,7 @@ class PRArgumentParser {
30
30
  // Check if input is a PR number
31
31
  const prNumber = parseInt(input);
32
32
  if (isNaN(prNumber) || prNumber <= 0) {
33
- throw new Error('Invalid input format. Expected: PR number, GitHub URL (https://github.com/owner/repo/pull/number), Graphite URL (https://app.graphite.com/github/pr/owner/repo/number), or pair-review:// URL');
33
+ throw new Error('Invalid input format. Expected: PR number, GitHub URL (https://github.com/owner/repo/pull/number), Graphite URL (https://app.graphite.com/github/pr/owner/repo/number or https://app.graphite.com/github/owner/repo/pull/number), or pair-review:// URL');
34
34
  }
35
35
 
36
36
  // Parse repository from current directory's git remote
@@ -112,14 +112,18 @@ class PRArgumentParser {
112
112
  * @returns {Object} Parsed information { owner, repo, number }
113
113
  */
114
114
  parseGraphiteURL(url) {
115
- // Match Graphite PR URL pattern: https://app.graphite.{dev|com}/github/pr/owner/repo/number[/optional-title]
116
- const match = url.match(/^https:\/\/app\.graphite\.(?:dev|com)\/github\/pr\/([^\/]+)\/([^\/]+)\/(\d+)(?:\/.*)?$/);
115
+ // Match Graphite PR URL patterns:
116
+ // https://app.graphite.{dev|com}/github/pr/owner/repo/number[/optional-title]
117
+ // https://app.graphite.{dev|com}/github/owner/repo/pull/number[/optional-title]
118
+ const match = url.match(/^https:\/\/app\.graphite\.(?:dev|com)\/github\/(?:pr\/([^\/]+)\/([^\/]+)\/(\d+)|([^\/]+)\/([^\/]+)\/pull\/(\d+))(?:\/[^?]*)?(?:\?.*)?$/);
117
119
 
118
120
  if (!match) {
119
- throw new Error('Invalid Graphite URL format. Expected: https://app.graphite.com/github/pr/owner/repo/number');
121
+ throw new Error('Invalid Graphite URL format. Expected: https://app.graphite.com/github/pr/owner/repo/number or https://app.graphite.com/github/owner/repo/pull/number');
120
122
  }
121
123
 
122
- const [, owner, repo, numberStr] = match;
124
+ const owner = match[1] || match[4];
125
+ const repo = match[2] || match[5];
126
+ const numberStr = match[3] || match[6];
123
127
  return this._createPRInfo(owner, repo, numberStr, 'Graphite');
124
128
  }
125
129
 
@@ -156,7 +160,7 @@ class PRArgumentParser {
156
160
  ? 'https://github.com/owner/repo/pull/number'
157
161
  : source === 'pair-review://'
158
162
  ? 'pair-review://pr/owner/repo/number'
159
- : 'https://app.graphite.com/github/pr/owner/repo/number';
163
+ : 'https://app.graphite.com/github/pr/owner/repo/number or https://app.graphite.com/github/owner/repo/pull/number';
160
164
  throw new Error(`Invalid ${source} URL format. Expected: ${exampleUrl}`);
161
165
  }
162
166
 
@@ -39,6 +39,88 @@ function scopeLabel(start, end) {
39
39
  return `${label(start)}\u2013${label(end)}`;
40
40
  }
41
41
 
42
+ /**
43
+ * Return git command hints for a scope range.
44
+ * @param {string} start - Scope start
45
+ * @param {string} end - Scope end
46
+ * @param {string} [baseBranch] - Base branch name (e.g. 'main'); used in merge-base commands
47
+ * @returns {{ label: string, description: string, diffCommand: string, excludes: string, includesUntracked: boolean }|null}
48
+ */
49
+ function scopeGitHints(start, end, baseBranch) {
50
+ if (!isValidScope(start, end)) return null;
51
+
52
+ const mb = baseBranch
53
+ ? '$(git merge-base ' + baseBranch + ' HEAD)'
54
+ : '<merge-base>';
55
+ const incUntracked = scopeIncludes(start, end, 'untracked');
56
+ const label = scopeLabel(start, end);
57
+
58
+ const key = start + '-' + end;
59
+ const hints = {
60
+ 'branch-branch': {
61
+ description: 'Committed changes on this branch since the merge-base.',
62
+ diffCommand: 'git diff --no-ext-diff ' + mb + '..HEAD',
63
+ excludes: 'Staged, unstaged, and untracked changes are NOT included in the review.'
64
+ },
65
+ 'branch-staged': {
66
+ description: 'Committed changes plus staged changes, relative to the merge-base.',
67
+ diffCommand: 'git diff --no-ext-diff --cached ' + mb,
68
+ excludes: 'Unstaged and untracked changes are NOT included in the review.'
69
+ },
70
+ 'branch-unstaged': {
71
+ description: 'All tracked changes (committed, staged, and unstaged) relative to the merge-base.',
72
+ diffCommand: 'git diff --no-ext-diff ' + mb,
73
+ excludes: 'Untracked files are NOT included in the review.'
74
+ },
75
+ 'branch-untracked': {
76
+ description: 'All changes (committed, staged, unstaged, and untracked) relative to the merge-base.',
77
+ diffCommand: 'git diff --no-ext-diff ' + mb,
78
+ excludes: ''
79
+ },
80
+ 'staged-staged': {
81
+ description: 'Only staged changes (added to the index but not yet committed).',
82
+ diffCommand: 'git diff --no-ext-diff --cached',
83
+ excludes: 'Unstaged, untracked, and committed branch changes are NOT included in the review.'
84
+ },
85
+ 'staged-unstaged': {
86
+ description: 'Staged and unstaged changes relative to HEAD.',
87
+ diffCommand: 'git diff --no-ext-diff HEAD',
88
+ excludes: 'Untracked files are NOT included in the review.'
89
+ },
90
+ 'staged-untracked': {
91
+ description: 'Staged, unstaged, and untracked changes relative to HEAD.',
92
+ diffCommand: 'git diff --no-ext-diff HEAD',
93
+ excludes: ''
94
+ },
95
+ 'unstaged-unstaged': {
96
+ description: 'Only unstaged working tree changes (not staged, not committed).',
97
+ diffCommand: 'git diff --no-ext-diff',
98
+ excludes: 'Staged changes (`git diff --no-ext-diff --cached`) are treated as already reviewed. Untracked files are NOT included in the review.'
99
+ },
100
+ 'unstaged-untracked': {
101
+ description: 'Unstaged and untracked local changes.',
102
+ diffCommand: 'git diff --no-ext-diff',
103
+ excludes: 'Staged changes (`git diff --no-ext-diff --cached`) are treated as already reviewed.'
104
+ },
105
+ 'untracked-untracked': {
106
+ description: 'Only untracked files (new files not yet added to git).',
107
+ diffCommand: 'git ls-files --others --exclude-standard',
108
+ excludes: 'Tracked file changes (staged, unstaged, committed) are NOT included in the review.'
109
+ }
110
+ };
111
+
112
+ const entry = hints[key];
113
+ if (!entry) return null;
114
+
115
+ return {
116
+ label: label,
117
+ description: entry.description,
118
+ diffCommand: entry.diffCommand,
119
+ excludes: entry.excludes,
120
+ includesUntracked: incUntracked && key !== 'untracked-untracked'
121
+ };
122
+ }
123
+
42
124
  const LocalScope = {
43
125
  STOPS,
44
126
  DEFAULT_SCOPE,
@@ -47,6 +129,7 @@ const LocalScope = {
47
129
  includesBranch,
48
130
  fromLegacyMode,
49
131
  scopeLabel,
132
+ scopeGitHints,
50
133
  };
51
134
 
52
135
  if (typeof window !== 'undefined') {