@in-the-loop-labs/pair-review 3.5.2 → 3.7.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.
Files changed (93) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/public/css/analysis-config.css +1807 -0
  6. package/public/css/pr.css +1029 -2169
  7. package/public/index.html +11 -0
  8. package/public/js/components/AIPanel.js +39 -23
  9. package/public/js/components/AdvancedConfigTab.js +56 -4
  10. package/public/js/components/AnalysisConfigModal.js +41 -25
  11. package/public/js/components/ChatPanel.js +163 -3
  12. package/public/js/components/KeyboardShortcuts.js +10 -26
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/TourBar.js +248 -0
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +64 -8
  18. package/public/js/modules/cancel-background-job.js +183 -0
  19. package/public/js/modules/hunk-summary-renderer.js +116 -0
  20. package/public/js/modules/storage-cleanup.js +16 -0
  21. package/public/js/modules/suggestion-manager.js +25 -1
  22. package/public/js/modules/tour-renderer.js +755 -0
  23. package/public/js/pr.js +1826 -56
  24. package/public/js/repo-links.js +328 -0
  25. package/public/js/utils/modal-detection.js +77 -0
  26. package/public/js/utils/provider-model.js +88 -0
  27. package/public/js/utils/storage-keys.js +50 -0
  28. package/public/local.html +24 -0
  29. package/public/pr.html +24 -0
  30. package/public/repo-settings.html +1 -0
  31. package/public/setup.html +2 -0
  32. package/src/ai/abort-signal-wiring.js +130 -0
  33. package/src/ai/analyzer.js +125 -18
  34. package/src/ai/background-queue.js +290 -0
  35. package/src/ai/claude-cli.js +1 -1
  36. package/src/ai/claude-provider.js +50 -7
  37. package/src/ai/codex-provider.js +28 -5
  38. package/src/ai/copilot-provider.js +22 -3
  39. package/src/ai/cursor-agent-provider.js +22 -6
  40. package/src/ai/executable-provider.js +4 -19
  41. package/src/ai/gemini-provider.js +22 -5
  42. package/src/ai/hunk-hashing.js +161 -0
  43. package/src/ai/index.js +2 -0
  44. package/src/ai/opencode-provider.js +21 -5
  45. package/src/ai/pi-provider.js +21 -5
  46. package/src/ai/prompts/hunk-summary.js +199 -0
  47. package/src/ai/prompts/tour.js +232 -0
  48. package/src/ai/provider.js +21 -1
  49. package/src/ai/summary-generator.js +469 -0
  50. package/src/ai/tour-generator.js +568 -0
  51. package/src/config.js +778 -10
  52. package/src/database.js +282 -1
  53. package/src/external/github-adapter.js +114 -25
  54. package/src/git/base-branch.js +11 -4
  55. package/src/github/client.js +482 -588
  56. package/src/github/errors.js +55 -0
  57. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  58. package/src/github/impl/graphql/pending-review.js +153 -0
  59. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  60. package/src/github/impl/graphql/stack-walker.js +210 -0
  61. package/src/github/impl/host/pending-review-comments.js +338 -0
  62. package/src/github/impl/rest/pending-review.js +251 -0
  63. package/src/github/impl/rest/review-lifecycle.js +226 -0
  64. package/src/github/impl/rest/stack-walker.js +309 -0
  65. package/src/github/operations/pending-review-comments.js +79 -0
  66. package/src/github/operations/pending-review.js +89 -0
  67. package/src/github/operations/review-lifecycle.js +126 -0
  68. package/src/github/operations/stack-walker.js +87 -0
  69. package/src/github/parser.js +230 -4
  70. package/src/github/stack-walker.js +14 -189
  71. package/src/links/repo-links.js +230 -0
  72. package/src/local-review.js +201 -172
  73. package/src/main.js +133 -30
  74. package/src/routes/analyses.js +30 -7
  75. package/src/routes/bulk-analysis-configs.js +295 -0
  76. package/src/routes/config.js +118 -3
  77. package/src/routes/context-files.js +2 -29
  78. package/src/routes/external-comments.js +20 -10
  79. package/src/routes/github-collections.js +3 -1
  80. package/src/routes/local.js +410 -13
  81. package/src/routes/mcp.js +47 -4
  82. package/src/routes/middleware/validate-review-id.js +53 -0
  83. package/src/routes/pr.js +556 -71
  84. package/src/routes/reviews.js +145 -29
  85. package/src/routes/setup.js +8 -3
  86. package/src/routes/stack-analysis.js +33 -9
  87. package/src/routes/worktrees.js +3 -2
  88. package/src/server.js +2 -0
  89. package/src/setup/pr-setup.js +37 -11
  90. package/src/setup/stack-setup.js +13 -3
  91. package/src/single-port.js +6 -3
  92. package/src/utils/diff-hunks.js +65 -0
  93. package/src/utils/json-extractor.js +5 -2
package/public/setup.html CHANGED
@@ -769,6 +769,7 @@
769
769
  var targetUrl = new URL(data.reviewUrl, window.location.origin);
770
770
  var qs = new URLSearchParams(window.location.search);
771
771
  if (qs.get('analyze') === 'true') targetUrl.searchParams.set('analyze', qs.get('analyze'));
772
+ if (qs.get('analysisConfigId')) targetUrl.searchParams.set('analysisConfigId', qs.get('analysisConfigId'));
772
773
  window.location.href = targetUrl.toString();
773
774
  return;
774
775
  }
@@ -829,6 +830,7 @@
829
830
  var targetUrl = new URL(msg.reviewUrl, window.location.origin);
830
831
  var qs = new URLSearchParams(window.location.search);
831
832
  if (qs.get('analyze') === 'true') targetUrl.searchParams.set('analyze', qs.get('analyze'));
833
+ if (qs.get('analysisConfigId')) targetUrl.searchParams.set('analysisConfigId', qs.get('analysisConfigId'));
832
834
  window.location.href = targetUrl.toString();
833
835
  }
834
836
  }, 400);
@@ -0,0 +1,130 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+
3
+ const { spawn } = require('child_process');
4
+ const logger = require('../utils/logger');
5
+
6
+ /**
7
+ * Attach an `AbortSignal` to a spawned child process so that aborting the
8
+ * signal kills the child with `SIGTERM`. Returns a cleanup function that
9
+ * detaches the abort listener — call it from the `close` / `error` /
10
+ * `settle` handler so the listener never outlives the process.
11
+ *
12
+ * Pattern: every provider that spawns an upstream CLI for tour/summary
13
+ * generation calls this once right after `spawn(...)`. The returned
14
+ * `cancelled` getter is included so the post-exit path can distinguish a
15
+ * user-initiated cancel (exit due to SIGTERM we sent) from a real failure.
16
+ *
17
+ * If `signal` is already aborted at the time of wiring, the child is
18
+ * killed immediately and `cancelled` is set to true. Callers should still
19
+ * check `cancelled` before treating the eventual exit as a "real" error.
20
+ *
21
+ * Shell-mode caveat: when the caller spawned with `shell: true`, the
22
+ * `child` we hold is the shell, not the underlying CLI. `child.kill()`
23
+ * only terminates the shell; the grandchild CLI keeps burning tokens.
24
+ * Pass `{ shell: true }` here so we signal the whole process group via
25
+ * `process.kill(-pid, 'SIGTERM')` instead. On Windows we fall back to
26
+ * `taskkill /T /F /PID`. Prefer `shell: false` invocation when an
27
+ * abortSignal is in play — fewer moving parts.
28
+ *
29
+ * @param {import('child_process').ChildProcess} child - Spawned process.
30
+ * @param {AbortSignal | null | undefined} signal - Signal to listen on.
31
+ * @param {Object} [opts]
32
+ * @param {string} [opts.logPrefix] - Log prefix for diagnostics.
33
+ * @param {boolean} [opts.shell=false] - True when the child was spawned
34
+ * with `shell: true`. Causes group-kill semantics so the grandchild CLI
35
+ * dies along with the shell wrapper.
36
+ * @returns {{cancelled: () => boolean, detach: () => void}}
37
+ */
38
+ function wireAbortToChild(child, signal, opts = {}) {
39
+ let cancelled = false;
40
+ if (!signal) {
41
+ return { cancelled: () => cancelled, detach: () => {} };
42
+ }
43
+ const prefix = opts.logPrefix || '';
44
+ const isShell = opts.shell === true;
45
+
46
+ const killChild = () => {
47
+ // `kill` / process group signaling returns false (or throws ESRCH) if
48
+ // the process is already gone, which is fine — we just need the side
49
+ // effect when it IS still alive.
50
+ if (isShell && child.pid && process.platform !== 'win32') {
51
+ // Group-kill the shell AND its CLI descendant. Requires the caller
52
+ // to have spawned with `detached: true` so the child became a
53
+ // process-group leader (`-pid` targets the group).
54
+ try {
55
+ process.kill(-child.pid, 'SIGTERM');
56
+ return;
57
+ } catch (err) {
58
+ if (err && err.code === 'ESRCH') {
59
+ // Group already gone — nothing to kill.
60
+ return;
61
+ }
62
+ // Fall through to single-process kill as a best effort.
63
+ logger.warn(
64
+ `${prefix} process.kill(-pid) failed (${err.message}); falling back to child.kill`
65
+ );
66
+ }
67
+ }
68
+ if (isShell && child.pid && process.platform === 'win32') {
69
+ // Windows has no process groups: spawn taskkill /T /F to wipe the
70
+ // tree rooted at our shell pid.
71
+ try {
72
+ spawn('taskkill', ['/T', '/F', '/PID', String(child.pid)], { stdio: 'ignore' })
73
+ .on('error', (err) => {
74
+ logger.warn(`${prefix} taskkill failed: ${err.message}`);
75
+ });
76
+ return;
77
+ } catch (err) {
78
+ logger.warn(
79
+ `${prefix} spawn(taskkill) failed (${err.message}); falling back to child.kill`
80
+ );
81
+ }
82
+ }
83
+ child.kill('SIGTERM');
84
+ };
85
+
86
+ const onAbort = () => {
87
+ cancelled = true;
88
+ try {
89
+ killChild();
90
+ } catch (err) {
91
+ logger.warn(`${prefix} child.kill on abort failed: ${err.message}`);
92
+ }
93
+ };
94
+
95
+ if (signal.aborted) {
96
+ // Pre-aborted: trigger the kill immediately. The eventual `close`
97
+ // handler will see `cancelled === true` and short-circuit.
98
+ onAbort();
99
+ } else {
100
+ signal.addEventListener('abort', onAbort, { once: true });
101
+ }
102
+
103
+ return {
104
+ cancelled: () => cancelled,
105
+ detach: () => {
106
+ try {
107
+ signal.removeEventListener('abort', onAbort);
108
+ } catch {
109
+ // Older AbortSignal polyfills may lack removeEventListener; safe to ignore.
110
+ }
111
+ },
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Build a standardized cancellation error. Providers should throw this
117
+ * (or reject with it) when they detect the abort wiring fired, so the
118
+ * BackgroundQueue's broadcast can mark the job as `cancelled: true`.
119
+ *
120
+ * @param {string} [message] - Human-readable context (defaults to 'cancelled').
121
+ * @returns {Error}
122
+ */
123
+ function makeAbortError(message) {
124
+ const err = new Error(message || 'cancelled');
125
+ err.name = 'AbortError';
126
+ err.isCancellation = true;
127
+ return err;
128
+ }
129
+
130
+ module.exports = { wireAbortToChild, makeAbortError };
@@ -215,11 +215,79 @@ function buildDedupContext(prMetadata, { reviewId, serverPort, runId, excludeRun
215
215
  return { owner, repo, pullNumber: prMetadata.pr_number, reviewId, serverPort, runId, excludeRunIds };
216
216
  }
217
217
 
218
+ /**
219
+ * Fetch existing PR review comments via the injected GitHubClient/Octokit.
220
+ *
221
+ * Replaces the previous `gh api repos/.../comments --paginate` shell-out so the
222
+ * analyzer no longer depends on the `gh` CLI. The result is embedded directly
223
+ * in the dedup prompt section, removing the need for the AI to spawn a process
224
+ * to fetch the data.
225
+ *
226
+ * Pagination is delegated to Octokit's `paginate` helper so PRs with more than
227
+ * 100 comments are handled transparently. The Octokit instance is taken from
228
+ * the caller-supplied `GitHubClient` so per-repo host bindings (api host,
229
+ * token) are honoured.
230
+ *
231
+ * @param {Object} githubClient - GitHubClient instance (must expose `.octokit`)
232
+ * @param {Object} target - { owner, repo, pullNumber }
233
+ * @param {string} [logPrefix=''] - Log prefix for traceability
234
+ * @returns {Promise<Array<{path: string, line: number|null, start_line: number|null, original_line: number|null, original_start_line: number|null, body: string}>|null>}
235
+ * Array of simplified comment objects, or `null` if the fetch failed or the
236
+ * client/target was incomplete.
237
+ */
238
+ async function fetchExistingReviewComments(githubClient, target, logPrefix = '') {
239
+ if (!githubClient || !githubClient.octokit) {
240
+ return null;
241
+ }
242
+ const { owner, repo, pullNumber } = target || {};
243
+ if (!owner || !repo || !pullNumber) {
244
+ return null;
245
+ }
246
+
247
+ try {
248
+ logger.info(`${logPrefix}[Dedup] Fetching existing PR review comments for ${owner}/${repo}#${pullNumber} via Octokit`);
249
+ const comments = await githubClient.octokit.paginate(
250
+ githubClient.octokit.rest.pulls.listReviewComments,
251
+ {
252
+ owner,
253
+ repo,
254
+ pull_number: pullNumber,
255
+ per_page: 100
256
+ }
257
+ );
258
+
259
+ const simplified = (comments || []).map(c => ({
260
+ path: c.path,
261
+ line: c.line ?? null,
262
+ start_line: c.start_line ?? null,
263
+ original_line: c.original_line ?? null,
264
+ original_start_line: c.original_start_line ?? null,
265
+ body: c.body || ''
266
+ }));
267
+
268
+ logger.info(`${logPrefix}[Dedup] Fetched ${simplified.length} existing review comment(s) for dedup`);
269
+ return simplified;
270
+ } catch (err) {
271
+ logger.warn(`${logPrefix}[Dedup] Failed to fetch existing review comments: ${err.message}`);
272
+ return null;
273
+ }
274
+ }
275
+
218
276
  /**
219
277
  * Build dedup instructions text for excluding previously identified issues.
220
278
  *
279
+ * The GitHub section is rendered when `context.githubComments` is a non-empty
280
+ * array of pre-fetched comments (see `fetchExistingReviewComments`). The
281
+ * comments are embedded as JSON directly in the prompt so the AI does not
282
+ * need to spawn `gh` (which is unavailable on alt-hosts) to fetch them.
283
+ *
284
+ * If `excludePrevious.github` is set but no comments were supplied (no client,
285
+ * empty list, or fetch failed), the GitHub section is silently omitted — a
286
+ * warning will already have been logged at fetch time.
287
+ *
221
288
  * @param {Object|null} excludePrevious - { github: bool, feedback: bool } (or falsy if disabled)
222
- * @param {Object} context - { owner, repo, pullNumber, reviewId, serverPort, runId, excludeRunIds }
289
+ * @param {Object} context - { reviewId, serverPort, runId, excludeRunIds, githubComments }
290
+ * @param {Array} [context.githubComments] - Pre-fetched PR review comments (path, line, original_line, body)
223
291
  * @param {string} [context.runId] - Single run ID to exclude (backward compat)
224
292
  * @param {string[]} [context.excludeRunIds] - Array of run IDs to exclude (takes precedence over runId)
225
293
  * @returns {string} Instruction text for the dedup-instructions prompt section, or empty string
@@ -236,13 +304,13 @@ function buildDedupInstructions(excludePrevious, context) {
236
304
 
237
305
  After consolidating suggestions, check your results against previously identified issues and remove any that are duplicates or substantially similar. If you have zero suggestions after consolidation, skip this step entirely.`);
238
306
 
239
- if (excludePrevious.github && context.owner && context.repo && context.pullNumber) {
307
+ const githubComments = Array.isArray(context.githubComments) ? context.githubComments : null;
308
+ if (excludePrevious.github && githubComments && githubComments.length > 0) {
240
309
  sections.push(`### GitHub PR Review Comments
241
- Fetch inline review comments:
242
- \`\`\`
243
- gh api repos/${context.owner}/${context.repo}/pulls/${context.pullNumber}/comments --paginate
310
+ The following inline review comments already exist on this pull request. Each entry has \`path\` (file), \`line\`/\`original_line\` (end line), \`start_line\`/\`original_start_line\` (start line for multi-line comments; null for single-line comments), and \`body\` (content):
311
+ \`\`\`json
312
+ ${JSON.stringify(githubComments, null, 2)}
244
313
  \`\`\`
245
- Each comment has \`path\` (file), \`line\`/\`original_line\` (line number), and \`body\` (content).
246
314
  A suggestion is a duplicate if it matches on **all three** of: (1) same file, (2) overlapping or adjacent line range (within 5 lines), and (3) substantially similar issue — i.e., the same category of issue (error handling, validation, naming, etc.) applied to the same code. If a previous comment partially overlaps your suggestion — e.g., it flags missing error handling while your suggestion flags missing error handling *and* input validation — keep only the novel portion that the previous comment does not address. If there is no novel portion, exclude it entirely.`);
247
315
  }
248
316
 
@@ -321,11 +389,12 @@ class Analyzer {
321
389
  * @param {string} [options.tier='balanced'] - Analysis tier (fast, balanced, thorough)
322
390
  * @param {Object} [options.excludePrevious] - { github: bool, feedback: bool } for dedup
323
391
  * @param {number} [options.serverPort] - Server port for dedup API calls
392
+ * @param {Object} [options.githubClient] - GitHubClient used to pre-fetch existing PR review comments for dedup (PR mode only)
324
393
  * @returns {Promise<Object>} Analysis results
325
394
  */
326
395
  async analyzeAllLevels(prId, worktreePath, prMetadata, progressCallback = null, instructions = null, changedFiles = null, options = {}) {
327
396
  const runId = options.runId || uuidv4();
328
- const { analysisId, skipRunCreation, skipLevel3, reviewerNum, excludePrevious, serverPort } = options;
397
+ const { analysisId, skipRunCreation, skipLevel3, reviewerNum, excludePrevious, serverPort, githubClient } = options;
329
398
  const logPrefix = options.logPrefix || '';
330
399
  // Respect provider-configured timeout (e.g. Pi's 15 min, executable providers)
331
400
  const ProviderClass = getProviderClass(this.provider);
@@ -526,7 +595,7 @@ class Analyzer {
526
595
  // Build dedup context from prMetadata and options
527
596
  const dedupContext = buildDedupContext(prMetadata, { reviewId: prId, serverPort, runId });
528
597
 
529
- const orchestrationResult = await this.orchestrateWithAI(allSuggestions, prMetadata, mergedInstructions, worktreePath, { analysisId, tier, progressCallback, timeout: executionTimeout, logPrefix, reviewerNum, excludePrevious, dedupContext });
598
+ const orchestrationResult = await this.orchestrateWithAI(allSuggestions, prMetadata, mergedInstructions, worktreePath, { analysisId, tier, progressCallback, timeout: executionTimeout, logPrefix, reviewerNum, excludePrevious, dedupContext, githubClient });
530
599
 
531
600
  // Report orchestration step as completed
532
601
  if (progressCallback) {
@@ -2661,10 +2730,11 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2661
2730
  * @param {string} options.analysisId - Analysis ID for process tracking (enables cancellation)
2662
2731
  * @param {Object} [options.excludePrevious] - { github: bool, feedback: bool } for dedup
2663
2732
  * @param {Object} [options.dedupContext] - { owner, repo, pullNumber, reviewId, serverPort }
2733
+ * @param {Object} [options.githubClient] - GitHubClient used to pre-fetch existing PR review comments for dedup
2664
2734
  * @returns {Promise<Array>} Curated suggestions array
2665
2735
  */
2666
2736
  async orchestrateWithAI(allSuggestions, prMetadata, customInstructions = null, worktreePath = null, options = {}) {
2667
- const { analysisId, tier = 'balanced', progressCallback, providerOverride, modelOverride, timeout = 600000, logPrefix: lp = '', reviewerNum, excludePrevious, dedupContext } = options;
2737
+ const { analysisId, tier = 'balanced', progressCallback, providerOverride, modelOverride, timeout = 600000, logPrefix: lp = '', reviewerNum, excludePrevious, dedupContext, githubClient } = options;
2668
2738
  // Build adapter-level log prefix: when reviewerNum is set (council mode),
2669
2739
  // use compact format like [R1 Orch] so concurrent reviewers are disambiguated
2670
2740
  const adapterLogPrefix = reviewerNum ? `[R${reviewerNum} Orch]` : '';
@@ -2685,8 +2755,26 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2685
2755
  // Create provider instance for consolidation (use overrides if provided)
2686
2756
  const aiProvider = createProvider(providerOverride || this.provider, modelOverride || this.model, this.providerOverrides);
2687
2757
 
2758
+ // Pre-fetch existing PR review comments for dedup (replaces the prior
2759
+ // `gh api` shell-out — the analyzer no longer depends on the `gh` CLI).
2760
+ // The fetch runs only when the caller opted in via excludePrevious.github
2761
+ // *and* supplied a GitHubClient. When the client is unavailable (e.g.
2762
+ // local mode or a caller that has not yet been updated) the GitHub dedup
2763
+ // section is silently omitted; buildDedupInstructions handles that case.
2764
+ let resolvedDedupContext = dedupContext;
2765
+ if (excludePrevious?.github && githubClient && dedupContext?.owner && dedupContext?.repo && dedupContext?.pullNumber) {
2766
+ const githubComments = await fetchExistingReviewComments(
2767
+ githubClient,
2768
+ { owner: dedupContext.owner, repo: dedupContext.repo, pullNumber: dedupContext.pullNumber },
2769
+ lp
2770
+ );
2771
+ resolvedDedupContext = { ...dedupContext, githubComments: githubComments || [] };
2772
+ } else if (excludePrevious?.github && !githubClient) {
2773
+ logger.warn(`${lp}[Dedup] excludePrevious.github is enabled but no githubClient was supplied — GitHub dedup section will be omitted`);
2774
+ }
2775
+
2688
2776
  // Build the consolidation prompt
2689
- const prompt = this.buildOrchestrationPrompt(allSuggestions, prMetadata, customInstructions, worktreePath, tier, lp, { excludePrevious, dedupContext });
2777
+ const prompt = this.buildOrchestrationPrompt(allSuggestions, prMetadata, customInstructions, worktreePath, tier, lp, { excludePrevious, dedupContext: resolvedDedupContext });
2690
2778
 
2691
2779
  // Execute AI for cross-level consolidation
2692
2780
  logger.info(`${lp}[Consolidation] Running AI consolidation to curate and merge suggestions...`);
@@ -2849,11 +2937,12 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2849
2937
  * @param {Function} [options.progressCallback] - Progress callback
2850
2938
  * @param {Object} [options.excludePrevious] - { github: bool, feedback: bool } for dedup
2851
2939
  * @param {number} [options.serverPort] - Server port for dedup API calls
2940
+ * @param {Object} [options.githubClient] - GitHubClient used to pre-fetch existing PR review comments for dedup
2852
2941
  * @returns {Promise<Object>} Analysis results { runId, suggestions, summary }
2853
2942
  */
2854
2943
  async runReviewerCentricCouncil(reviewContext, councilConfig, options = {}) {
2855
2944
  const { reviewId, worktreePath, prMetadata, changedFiles, instructions } = reviewContext;
2856
- const { analysisId, progressCallback, excludePrevious, serverPort } = options;
2945
+ const { analysisId, progressCallback, excludePrevious, serverPort, githubClient } = options;
2857
2946
  const parentRunId = options.runId || uuidv4();
2858
2947
 
2859
2948
  logger.section('Review Council Analysis Starting (Reviewer-Centric)');
@@ -3027,7 +3116,8 @@ File-level suggestions should NOT have a line number. They apply to the entire f
3027
3116
  logPrefix: `[${reviewerLabel}] `,
3028
3117
  reviewerNum: 1,
3029
3118
  excludePrevious,
3030
- serverPort
3119
+ serverPort,
3120
+ githubClient
3031
3121
  }
3032
3122
  );
3033
3123
 
@@ -3305,7 +3395,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
3305
3395
 
3306
3396
  const consolidated = await this._crossVoiceConsolidate(
3307
3397
  voiceReviews, prMetadata, consolInstructions, worktreePath,
3308
- { provider: consolProvider, model: consolModel, tier: consolTier, timeout: consolConfig.timeout, analysisId, progressCallback, excludePrevious, dedupContext, providerOverrides: this.providerOverrides }
3398
+ { provider: consolProvider, model: consolModel, tier: consolTier, timeout: consolConfig.timeout, analysisId, progressCallback, excludePrevious, dedupContext, githubClient, providerOverrides: this.providerOverrides }
3309
3399
  );
3310
3400
 
3311
3401
  const finalSuggestions = this.validateAndFinalizeSuggestions(
@@ -3391,7 +3481,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
3391
3481
  */
3392
3482
  async runCouncilAnalysis(reviewContext, councilConfig, options = {}) {
3393
3483
  const { reviewId, worktreePath, prMetadata, changedFiles, instructions } = reviewContext;
3394
- const { analysisId, progressCallback, excludePrevious, serverPort } = options;
3484
+ const { analysisId, progressCallback, excludePrevious, serverPort, githubClient } = options;
3395
3485
  const runId = options.runId || uuidv4();
3396
3486
 
3397
3487
  logger.section('Review Council Analysis Starting');
@@ -3647,7 +3737,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
3647
3737
 
3648
3738
  const orchestrationResult = await this.orchestrateWithAI(
3649
3739
  allSuggestions, prMetadata, orchInstructions, worktreePath,
3650
- { analysisId, tier: orchTier, progressCallback, providerOverride: orchProvider, modelOverride: orchModel, timeout: orchConfig.timeout || 600000, excludePrevious, dedupContext }
3740
+ { analysisId, tier: orchTier, progressCallback, providerOverride: orchProvider, modelOverride: orchModel, timeout: orchConfig.timeout || 600000, excludePrevious, dedupContext, githubClient }
3651
3741
  );
3652
3742
 
3653
3743
  // Report cross-level orchestration step as completed
@@ -3958,10 +4048,26 @@ File-level suggestions should NOT have a line number. They apply to the entire f
3958
4048
  * @private
3959
4049
  */
3960
4050
  async _crossVoiceConsolidate(voiceReviews, prMetadata, customInstructions, worktreePath, config) {
3961
- const { provider, model, tier, timeout, analysisId, progressCallback, excludePrevious, dedupContext, providerOverrides } = config;
4051
+ const { provider, model, tier, timeout, analysisId, progressCallback, excludePrevious, dedupContext, githubClient, providerOverrides } = config;
3962
4052
 
3963
4053
  const aiProvider = createProvider(provider, model, providerOverrides || {});
3964
4054
 
4055
+ // Pre-fetch existing PR review comments for dedup (replaces the prior
4056
+ // `gh api` shell-out — the analyzer no longer depends on the `gh` CLI).
4057
+ // See orchestrateWithAI for the matching code path used by the other two
4058
+ // top-level analysis flows (analyzeAllLevels, runCouncilAnalysis).
4059
+ let resolvedDedupContext = dedupContext;
4060
+ if (excludePrevious?.github && githubClient && dedupContext?.owner && dedupContext?.repo && dedupContext?.pullNumber) {
4061
+ const githubComments = await fetchExistingReviewComments(
4062
+ githubClient,
4063
+ { owner: dedupContext.owner, repo: dedupContext.repo, pullNumber: dedupContext.pullNumber },
4064
+ '[ReviewerCouncil]'
4065
+ );
4066
+ resolvedDedupContext = { ...dedupContext, githubComments: githubComments || [] };
4067
+ } else if (excludePrevious?.github && !githubClient) {
4068
+ logger.warn('[ReviewerCouncil][Dedup] excludePrevious.github is enabled but no githubClient was supplied — GitHub dedup section will be omitted');
4069
+ }
4070
+
3965
4071
  const voiceDescriptions = voiceReviews.map(v => {
3966
4072
  let desc = `### Reviewer: ${v.voiceKey}`;
3967
4073
  if (v.isExecutable) desc += ' [external tool]';
@@ -3987,7 +4093,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
3987
4093
  reviewIntro: `You are consolidating code review results from ${voiceReviews.length} independent AI reviewers for ${reviewDescription}. Each reviewer independently analyzed the same code changes and produced a complete review.`,
3988
4094
  customInstructions: customInstructions ? this.buildCustomInstructionsSection(customInstructions) : '',
3989
4095
  lineNumberGuidance: this.buildOrchestrationLineNumberGuidance(worktreePath),
3990
- dedupInstructions: buildDedupInstructions(excludePrevious, dedupContext || {}),
4096
+ dedupInstructions: buildDedupInstructions(excludePrevious, resolvedDedupContext || {}),
3991
4097
  reviewerSuggestions: voiceDescriptions,
3992
4098
  suggestionCount: voiceReviews.reduce((sum, v) => sum + v.suggestionCount, 0),
3993
4099
  reviewerCount: voiceReviews.length
@@ -4052,4 +4158,5 @@ File-level suggestions should NOT have a line number. They apply to the entire f
4052
4158
 
4053
4159
  module.exports = Analyzer;
4054
4160
  module.exports.buildDedupContext = buildDedupContext;
4055
- module.exports.buildDedupInstructions = buildDedupInstructions;
4161
+ module.exports.buildDedupInstructions = buildDedupInstructions;
4162
+ module.exports.fetchExistingReviewComments = fetchExistingReviewComments;