@in-the-loop-labs/pair-review 1.0.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 (91) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +371 -0
  3. package/bin/git-diff-lines +146 -0
  4. package/bin/pair-review.js +49 -0
  5. package/package.json +71 -0
  6. package/public/css/ai-summary-modal.css +183 -0
  7. package/public/css/pr.css +8698 -0
  8. package/public/css/repo-settings.css +891 -0
  9. package/public/css/styles.css +479 -0
  10. package/public/favicon.png +0 -0
  11. package/public/index.html +1104 -0
  12. package/public/js/components/AIPanel.js +1639 -0
  13. package/public/js/components/AISummaryModal.js +278 -0
  14. package/public/js/components/AnalysisConfigModal.js +684 -0
  15. package/public/js/components/ConfirmDialog.js +227 -0
  16. package/public/js/components/PreviewModal.js +344 -0
  17. package/public/js/components/ProgressModal.js +678 -0
  18. package/public/js/components/ReviewModal.js +531 -0
  19. package/public/js/components/SplitButton.js +382 -0
  20. package/public/js/components/StatusIndicator.js +265 -0
  21. package/public/js/components/SuggestionNavigator.js +489 -0
  22. package/public/js/components/Toast.js +166 -0
  23. package/public/js/local.js +1580 -0
  24. package/public/js/modules/analysis-history.js +940 -0
  25. package/public/js/modules/comment-manager.js +643 -0
  26. package/public/js/modules/diff-renderer.js +585 -0
  27. package/public/js/modules/file-comment-manager.js +1242 -0
  28. package/public/js/modules/gap-coordinates.js +190 -0
  29. package/public/js/modules/hunk-parser.js +358 -0
  30. package/public/js/modules/line-tracker.js +386 -0
  31. package/public/js/modules/panel-resizer.js +228 -0
  32. package/public/js/modules/storage-cleanup.js +36 -0
  33. package/public/js/modules/suggestion-manager.js +692 -0
  34. package/public/js/pr.js +3503 -0
  35. package/public/js/repo-settings.js +691 -0
  36. package/public/js/utils/file-order.js +87 -0
  37. package/public/js/utils/markdown.js +97 -0
  38. package/public/js/utils/suggestion-ui.js +55 -0
  39. package/public/js/utils/tier-icons.js +25 -0
  40. package/public/local.html +460 -0
  41. package/public/pr.html +329 -0
  42. package/public/repo-settings.html +243 -0
  43. package/src/ai/analyzer.js +2592 -0
  44. package/src/ai/claude-cli.js +153 -0
  45. package/src/ai/claude-provider.js +261 -0
  46. package/src/ai/codex-provider.js +361 -0
  47. package/src/ai/copilot-provider.js +345 -0
  48. package/src/ai/gemini-provider.js +375 -0
  49. package/src/ai/index.js +47 -0
  50. package/src/ai/prompts/baseline/_meta.json +14 -0
  51. package/src/ai/prompts/baseline/level1/balanced.js +239 -0
  52. package/src/ai/prompts/baseline/level1/fast.js +194 -0
  53. package/src/ai/prompts/baseline/level1/thorough.js +319 -0
  54. package/src/ai/prompts/baseline/level2/balanced.js +248 -0
  55. package/src/ai/prompts/baseline/level2/fast.js +201 -0
  56. package/src/ai/prompts/baseline/level2/thorough.js +367 -0
  57. package/src/ai/prompts/baseline/level3/balanced.js +280 -0
  58. package/src/ai/prompts/baseline/level3/fast.js +220 -0
  59. package/src/ai/prompts/baseline/level3/thorough.js +459 -0
  60. package/src/ai/prompts/baseline/orchestration/balanced.js +259 -0
  61. package/src/ai/prompts/baseline/orchestration/fast.js +213 -0
  62. package/src/ai/prompts/baseline/orchestration/thorough.js +446 -0
  63. package/src/ai/prompts/config.js +52 -0
  64. package/src/ai/prompts/index.js +267 -0
  65. package/src/ai/prompts/shared/diff-instructions.js +50 -0
  66. package/src/ai/prompts/shared/output-schema.js +179 -0
  67. package/src/ai/prompts/shared/valid-files.js +37 -0
  68. package/src/ai/provider.js +260 -0
  69. package/src/config.js +139 -0
  70. package/src/database.js +2284 -0
  71. package/src/git/gitattributes.js +207 -0
  72. package/src/git/worktree.js +688 -0
  73. package/src/github/client.js +893 -0
  74. package/src/github/parser.js +247 -0
  75. package/src/local-review.js +691 -0
  76. package/src/main.js +987 -0
  77. package/src/routes/analysis.js +897 -0
  78. package/src/routes/comments.js +534 -0
  79. package/src/routes/config.js +250 -0
  80. package/src/routes/local.js +1728 -0
  81. package/src/routes/pr.js +1164 -0
  82. package/src/routes/shared.js +218 -0
  83. package/src/routes/worktrees.js +500 -0
  84. package/src/server.js +295 -0
  85. package/src/utils/diff-annotator.js +414 -0
  86. package/src/utils/instructions.js +33 -0
  87. package/src/utils/json-extractor.js +107 -0
  88. package/src/utils/line-validation.js +183 -0
  89. package/src/utils/logger.js +142 -0
  90. package/src/utils/paths.js +161 -0
  91. package/src/utils/stats-calculator.js +86 -0
@@ -0,0 +1,361 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Codex AI Provider
4
+ *
5
+ * Implements the AI provider interface for OpenAI's Codex CLI.
6
+ * Uses the `codex exec` command for non-interactive execution.
7
+ */
8
+
9
+ const path = require('path');
10
+ const { spawn } = require('child_process');
11
+ const { AIProvider, registerProvider } = require('./provider');
12
+ const logger = require('../utils/logger');
13
+ const { extractJSON } = require('../utils/json-extractor');
14
+ const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
15
+
16
+ // Directory containing bin scripts (git-diff-lines, etc.)
17
+ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
18
+
19
+ /**
20
+ * Codex model definitions with tier mappings
21
+ *
22
+ * Based on OpenAI Codex Models guide (developers.openai.com/codex/models)
23
+ * - gpt-5.1-codex-mini: Smaller, cost-effective variant for quick scans
24
+ * - gpt-5.1-codex-max: Optimized for long-horizon agentic coding tasks
25
+ * - gpt-5.2-codex: Most advanced agentic coding model for real-world engineering
26
+ */
27
+ const CODEX_MODELS = [
28
+ {
29
+ id: 'gpt-5.1-codex-mini',
30
+ name: 'GPT-5.1 Mini',
31
+ tier: 'fast',
32
+ tagline: 'Blazing Fast',
33
+ description: 'Quick, low-cost reviews for style issues, obvious bugs, and lint-level feedback.',
34
+ badge: 'Fastest',
35
+ badgeClass: 'badge-speed'
36
+ },
37
+ {
38
+ id: 'gpt-5.1-codex-max',
39
+ name: 'GPT-5.1 Max',
40
+ tier: 'balanced',
41
+ tagline: 'Best Balance',
42
+ description: 'Strong everyday reviewer—quality + speed for PR-sized changes and practical suggestions.',
43
+ badge: 'Recommended',
44
+ badgeClass: 'badge-recommended',
45
+ default: true
46
+ },
47
+ {
48
+ id: 'gpt-5.2-codex',
49
+ name: 'GPT-5.2',
50
+ tier: 'thorough',
51
+ tagline: 'Deep Review',
52
+ description: 'Most capable for complex diffs—finds subtle issues, reasons across files, and proposes step-by-step fixes.',
53
+ badge: 'Most Thorough',
54
+ badgeClass: 'badge-power'
55
+ }
56
+ ];
57
+
58
+ class CodexProvider extends AIProvider {
59
+ constructor(model = 'gpt-5.1-codex-max') {
60
+ super(model);
61
+
62
+ // Check for environment variable to override default command
63
+ // Supports multi-word commands like "npx codex" or "/path/to/codex --verbose"
64
+ const codexCmd = process.env.PAIR_REVIEW_CODEX_CMD || 'codex';
65
+
66
+ // For multi-word commands, use shell mode (same pattern as Claude provider)
67
+ this.useShell = codexCmd.includes(' ');
68
+
69
+ // SECURITY: Codex sandbox modes and shell execution
70
+ //
71
+ // Codex sandbox modes:
72
+ // - read-only: Can browse files but CANNOT run shell commands (too restrictive)
73
+ // - workspace-write: Can read, edit, run commands in working directory only
74
+ // - danger-full-access: Full system access (too permissive)
75
+ //
76
+ // For code review, we need shell commands (git, git-diff-lines) but don't need
77
+ // network access or writes outside the worktree. We use "workspace-write" because:
78
+ // 1. We run in a dedicated worktree, not the main repo
79
+ // 2. "read-only" prevents ALL shell commands including git-diff-lines
80
+ // 3. The AI is instructed to only analyze code, not modify it
81
+ //
82
+ // --full-auto: Non-interactive mode that auto-approves within sandbox bounds.
83
+ // Combined with workspace-write sandbox, this limits damage to the worktree only.
84
+ // Note: The -a flag is for interactive mode only; exec subcommand uses --full-auto.
85
+ if (this.useShell) {
86
+ // In shell mode, build full command string with args
87
+ this.command = `${codexCmd} exec -m ${model} --json --sandbox workspace-write --full-auto -`;
88
+ this.args = [];
89
+ } else {
90
+ this.command = codexCmd;
91
+ this.args = ['exec', '-m', model, '--json', '--sandbox', 'workspace-write', '--full-auto', '-'];
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Execute Codex CLI with a prompt
97
+ * @param {string} prompt - The prompt to send to Codex
98
+ * @param {Object} options - Optional configuration
99
+ * @returns {Promise<Object>} Parsed response or error
100
+ */
101
+ async execute(prompt, options = {}) {
102
+ return new Promise((resolve, reject) => {
103
+ const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess } = options;
104
+
105
+ const levelPrefix = `[Level ${level}]`;
106
+ logger.info(`${levelPrefix} Executing Codex CLI...`);
107
+ logger.info(`${levelPrefix} Writing prompt: ${prompt.length} bytes`);
108
+
109
+ const codex = spawn(this.command, this.args, {
110
+ cwd,
111
+ env: {
112
+ ...process.env,
113
+ PATH: `${BIN_DIR}:${process.env.PATH}`
114
+ },
115
+ shell: this.useShell
116
+ });
117
+
118
+ const pid = codex.pid;
119
+ logger.info(`${levelPrefix} Spawned Codex CLI process: PID ${pid}`);
120
+
121
+ // Register process for cancellation tracking if analysisId provided
122
+ if (analysisId && registerProcess) {
123
+ registerProcess(analysisId, codex);
124
+ logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
125
+ }
126
+
127
+ let stdout = '';
128
+ let stderr = '';
129
+ let timeoutId = null;
130
+ let settled = false; // Guard against multiple resolve/reject calls
131
+
132
+ const settle = (fn, value) => {
133
+ if (settled) return;
134
+ settled = true;
135
+ if (timeoutId) clearTimeout(timeoutId);
136
+ fn(value);
137
+ };
138
+
139
+ // Set timeout
140
+ if (timeout) {
141
+ timeoutId = setTimeout(() => {
142
+ logger.error(`${levelPrefix} Process ${pid} timed out after ${timeout}ms`);
143
+ codex.kill('SIGTERM');
144
+ settle(reject, new Error(`${levelPrefix} Codex CLI timed out after ${timeout}ms`));
145
+ }, timeout);
146
+ }
147
+
148
+ // Collect stdout
149
+ codex.stdout.on('data', (data) => {
150
+ stdout += data.toString();
151
+ });
152
+
153
+ // Collect stderr
154
+ codex.stderr.on('data', (data) => {
155
+ stderr += data.toString();
156
+ });
157
+
158
+ // Handle completion
159
+ codex.on('close', (code) => {
160
+ if (settled) return; // Already settled by timeout or error
161
+
162
+ // Check for cancellation signals (SIGTERM=143, SIGKILL=137)
163
+ const isCancellationCode = code === 143 || code === 137;
164
+ if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
165
+ logger.info(`${levelPrefix} Codex CLI terminated due to analysis cancellation (exit code ${code})`);
166
+ settle(reject, new CancellationError(`${levelPrefix} Analysis cancelled by user`));
167
+ return;
168
+ }
169
+
170
+ // Always log stderr if present
171
+ if (stderr.trim()) {
172
+ if (code !== 0) {
173
+ logger.error(`${levelPrefix} Codex CLI stderr (exit code ${code}): ${stderr}`);
174
+ } else {
175
+ logger.warn(`${levelPrefix} Codex CLI stderr (success): ${stderr}`);
176
+ }
177
+ }
178
+
179
+ if (code !== 0) {
180
+ logger.error(`${levelPrefix} Codex CLI exited with code ${code}`);
181
+ settle(reject, new Error(`${levelPrefix} Codex CLI exited with code ${code}: ${stderr}`));
182
+ return;
183
+ }
184
+
185
+ // Parse the Codex JSONL response
186
+ const parsed = this.parseCodexResponse(stdout, level);
187
+ if (parsed.success) {
188
+ logger.success(`${levelPrefix} Successfully parsed JSON response`);
189
+ settle(resolve, parsed.data);
190
+ } else {
191
+ logger.warn(`${levelPrefix} Failed to extract JSON: ${parsed.error}`);
192
+ logger.info(`${levelPrefix} Raw response length: ${stdout.length} characters`);
193
+ logger.info(`${levelPrefix} Raw response preview: ${stdout.substring(0, 500)}...`);
194
+ settle(resolve, { raw: stdout, parsed: false });
195
+ }
196
+ });
197
+
198
+ // Handle errors
199
+ codex.on('error', (error) => {
200
+ if (error.code === 'ENOENT') {
201
+ logger.error(`${levelPrefix} Codex CLI not found. Please ensure Codex CLI is installed.`);
202
+ settle(reject, new Error(`${levelPrefix} Codex CLI not found. ${CodexProvider.getInstallInstructions()}`));
203
+ } else {
204
+ logger.error(`${levelPrefix} Codex process error: ${error}`);
205
+ settle(reject, error);
206
+ }
207
+ });
208
+
209
+ // Send the prompt to stdin
210
+ codex.stdin.write(prompt, (err) => {
211
+ if (err) {
212
+ logger.error(`${levelPrefix} Failed to write prompt to stdin: ${err}`);
213
+ codex.kill('SIGTERM');
214
+ settle(reject, new Error(`${levelPrefix} Failed to write prompt to stdin: ${err}`));
215
+ }
216
+ });
217
+ codex.stdin.end();
218
+ });
219
+ }
220
+
221
+ /**
222
+ * Parse Codex CLI JSONL response
223
+ * Codex outputs JSONL with multiple event types:
224
+ * - thread.started: Session info
225
+ * - turn.started: Turn begins
226
+ * - item.completed: Contains reasoning or agent_message items
227
+ * - turn.completed: Turn ends with usage stats
228
+ *
229
+ * We need to extract the agent_message content which contains the AI response.
230
+ *
231
+ * @param {string} stdout - Raw stdout from Codex CLI (JSONL format)
232
+ * @param {string|number} level - Analysis level for logging
233
+ * @returns {{success: boolean, data?: Object, error?: string}}
234
+ */
235
+ parseCodexResponse(stdout, level) {
236
+ const levelPrefix = `[Level ${level}]`;
237
+
238
+ try {
239
+ // Split by newlines and parse each JSON line
240
+ const lines = stdout.trim().split('\n').filter(line => line.trim());
241
+ let agentMessage = null;
242
+
243
+ for (const line of lines) {
244
+ try {
245
+ const event = JSON.parse(line);
246
+
247
+ // Look for agent_message items which contain the actual response
248
+ if (event.type === 'item.completed' &&
249
+ event.item?.type === 'agent_message' &&
250
+ event.item?.text) {
251
+ agentMessage = event.item.text;
252
+ }
253
+ } catch (lineError) {
254
+ // Skip malformed lines
255
+ logger.debug(`${levelPrefix} Skipping malformed JSONL line: ${line.substring(0, 100)}`);
256
+ }
257
+ }
258
+
259
+ if (agentMessage) {
260
+ // The agent_message contains the AI's text response
261
+ // Try to extract JSON from it (the AI was asked to output JSON)
262
+ const extracted = extractJSON(agentMessage, level);
263
+ if (extracted.success) {
264
+ return extracted;
265
+ }
266
+
267
+ // If no JSON found, return the raw message
268
+ logger.warn(`${levelPrefix} Agent message is not JSON, treating as raw text`);
269
+ return { success: false, error: 'Agent message is not valid JSON' };
270
+ }
271
+
272
+ // No agent message found, try extracting JSON directly from stdout
273
+ const extracted = extractJSON(stdout, level);
274
+ return extracted;
275
+
276
+ } catch (parseError) {
277
+ // stdout might not be valid JSONL at all, try extracting JSON from it
278
+ const extracted = extractJSON(stdout, level);
279
+ if (extracted.success) {
280
+ return extracted;
281
+ }
282
+
283
+ return { success: false, error: `JSONL parse error: ${parseError.message}` };
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Test if Codex CLI is available
289
+ * @returns {Promise<boolean>}
290
+ */
291
+ async testAvailability() {
292
+ return new Promise((resolve) => {
293
+ // For availability test, we just need to check --version
294
+ // Use shell mode if the command contains spaces
295
+ const codexCmd = process.env.PAIR_REVIEW_CODEX_CMD || 'codex';
296
+ const useShell = codexCmd.includes(' ');
297
+ const command = useShell ? `${codexCmd} --version` : codexCmd;
298
+ const args = useShell ? [] : ['--version'];
299
+
300
+ const codex = spawn(command, args, {
301
+ env: {
302
+ ...process.env,
303
+ PATH: `${BIN_DIR}:${process.env.PATH}`
304
+ },
305
+ shell: useShell
306
+ });
307
+
308
+ let stdout = '';
309
+ let settled = false;
310
+
311
+ codex.stdout.on('data', (data) => {
312
+ stdout += data.toString();
313
+ });
314
+
315
+ codex.on('close', (code) => {
316
+ if (settled) return;
317
+ settled = true;
318
+ if (code === 0 && stdout.includes('codex')) {
319
+ logger.info(`Codex CLI available: ${stdout.trim()}`);
320
+ resolve(true);
321
+ } else {
322
+ logger.warn('Codex CLI not available or returned unexpected output');
323
+ resolve(false);
324
+ }
325
+ });
326
+
327
+ codex.on('error', (error) => {
328
+ if (settled) return;
329
+ settled = true;
330
+ logger.warn(`Codex CLI not available: ${error.message}`);
331
+ resolve(false);
332
+ });
333
+ });
334
+ }
335
+
336
+ static getProviderName() {
337
+ return 'Codex';
338
+ }
339
+
340
+ static getProviderId() {
341
+ return 'codex';
342
+ }
343
+
344
+ static getModels() {
345
+ return CODEX_MODELS;
346
+ }
347
+
348
+ static getDefaultModel() {
349
+ return 'gpt-5.1-codex-max';
350
+ }
351
+
352
+ static getInstallInstructions() {
353
+ return 'Install Codex CLI: npm install -g @openai/codex\n' +
354
+ 'Or visit: https://github.com/openai/codex';
355
+ }
356
+ }
357
+
358
+ // Register this provider
359
+ registerProvider('codex', CodexProvider);
360
+
361
+ module.exports = CodexProvider;
@@ -0,0 +1,345 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * GitHub Copilot AI Provider
4
+ *
5
+ * Implements the AI provider interface for GitHub's Copilot CLI.
6
+ * Uses the `copilot -p` command for non-interactive execution.
7
+ */
8
+
9
+ const path = require('path');
10
+ const { spawn } = require('child_process');
11
+ const { AIProvider, registerProvider } = require('./provider');
12
+ const logger = require('../utils/logger');
13
+ const { extractJSON } = require('../utils/json-extractor');
14
+ const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
15
+
16
+ // Directory containing bin scripts (git-diff-lines, etc.)
17
+ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
18
+
19
+ /**
20
+ * Copilot model definitions with tier mappings
21
+ *
22
+ * GitHub Copilot CLI supports multiple AI models including OpenAI,
23
+ * Anthropic, and Google models via the --model flag.
24
+ */
25
+ const COPILOT_MODELS = [
26
+ {
27
+ id: 'gpt-5.1-codex-mini',
28
+ name: 'GPT-5.1 Mini',
29
+ tier: 'fast',
30
+ tagline: 'Quick Scan',
31
+ description: 'Rapid feedback for obvious issues and style checks',
32
+ badge: 'Speedy',
33
+ badgeClass: 'badge-speed'
34
+ },
35
+ {
36
+ id: 'gemini-3-pro-preview',
37
+ name: 'Gemini 3 Pro',
38
+ tier: 'balanced',
39
+ tagline: 'Reliable Review',
40
+ description: 'Solid everyday reviews with good coverage',
41
+ badge: 'Recommended',
42
+ badgeClass: 'badge-recommended',
43
+ default: true
44
+ },
45
+ {
46
+ id: 'gpt-5.1-codex-max',
47
+ name: 'GPT-5.1 Max',
48
+ tier: 'thorough',
49
+ tagline: 'Deep Analysis',
50
+ description: 'Comprehensive reviews for complex changes',
51
+ badge: 'Thorough',
52
+ badgeClass: 'badge-power'
53
+ },
54
+ {
55
+ id: 'claude-opus-4.5',
56
+ name: 'Claude Opus 4.5',
57
+ tier: 'premium',
58
+ tagline: 'Ultimate Review',
59
+ description: 'The most capable model for critical code reviews',
60
+ badge: 'Premium',
61
+ badgeClass: 'badge-premium'
62
+ }
63
+ ];
64
+
65
+ class CopilotProvider extends AIProvider {
66
+ constructor(model = 'gemini-3-pro-preview') {
67
+ super(model);
68
+
69
+ // Check for environment variable to override default command
70
+ // Supports multi-word commands like "gh copilot" or custom paths
71
+ const copilotCmd = process.env.PAIR_REVIEW_COPILOT_CMD || 'copilot';
72
+
73
+ // For multi-word commands, use shell mode (same pattern as other providers)
74
+ this.useShell = copilotCmd.includes(' ');
75
+
76
+ // Store base args for later - prompt value will be inserted after -p flag
77
+ // -p: non-interactive prompt mode (exits after completion)
78
+ // --model: specify the AI model
79
+ // -s: silent mode (output only agent response, no stats)
80
+ //
81
+ // SECURITY: Use --allow-tool and --deny-tool to control tool permissions.
82
+ //
83
+ // Copilot CLI permission flags:
84
+ // - --allow-tool <pattern>: Whitelist tools (supports glob patterns)
85
+ // - --deny-tool <pattern>: Blacklist tools (takes precedence over allow)
86
+ // - --allow-all-tools: Auto-approve all tools without prompts
87
+ //
88
+ // For shell commands, use shell(<prefix>) syntax to match command prefixes.
89
+ // E.g., shell(git) allows "git status", "git diff", etc.
90
+ // ============================================================================
91
+ const readOnlyArgs = [
92
+ // Allow specific read-only git commands (not blanket 'git' to block git commit, push, etc.)
93
+ '--allow-tool', 'shell(git diff)',
94
+ '--allow-tool', 'shell(git log)',
95
+ '--allow-tool', 'shell(git show)',
96
+ '--allow-tool', 'shell(git status)',
97
+ '--allow-tool', 'shell(git branch)',
98
+ '--allow-tool', 'shell(git rev-parse)',
99
+ // Custom tool for annotated diff line mapping (matches both direct and path invocations)
100
+ '--allow-tool', 'shell(git-diff-lines)',
101
+ '--allow-tool', 'shell(*/git-diff-lines)', // Absolute path invocation
102
+ // Allow read-only shell commands
103
+ '--allow-tool', 'shell(ls)', // Directory listing
104
+ '--allow-tool', 'shell(cat)', // File content viewing
105
+ '--allow-tool', 'shell(pwd)', // Current directory
106
+ '--allow-tool', 'shell(head)', // File head viewing
107
+ '--allow-tool', 'shell(tail)', // File tail viewing
108
+ '--allow-tool', 'shell(wc)', // Word/line count
109
+ '--allow-tool', 'shell(find)', // File finding
110
+ '--allow-tool', 'shell(grep)', // Pattern searching
111
+ '--allow-tool', 'shell(rg)', // Ripgrep (fast pattern searching)
112
+ // Deny dangerous shell commands (takes precedence over allow)
113
+ '--deny-tool', 'shell(rm)',
114
+ '--deny-tool', 'shell(mv)',
115
+ '--deny-tool', 'shell(chmod)',
116
+ '--deny-tool', 'shell(chown)',
117
+ '--deny-tool', 'shell(sudo)',
118
+ '--deny-tool', 'shell(git commit)',
119
+ '--deny-tool', 'shell(git push)',
120
+ '--deny-tool', 'shell(git checkout)',
121
+ '--deny-tool', 'shell(git reset)',
122
+ '--deny-tool', 'shell(git rebase)',
123
+ '--deny-tool', 'shell(git merge)',
124
+ // Block file write tools
125
+ '--deny-tool', 'write',
126
+ // Auto-approve remaining tools to avoid interactive prompts
127
+ '--allow-all-tools',
128
+ // Allow access to all paths (needed for analyzing files outside cwd)
129
+ '--allow-all-paths',
130
+ ];
131
+
132
+ // Command and base args are the same regardless of shell mode
133
+ // (shell mode only affects how command is built in execute())
134
+ this.command = copilotCmd;
135
+ // Args without the prompt - prompt will be added as value to -p flag in execute()
136
+ this.baseArgs = ['--model', model, ...readOnlyArgs, '-s'];
137
+ }
138
+
139
+ /**
140
+ * Execute Copilot CLI with a prompt
141
+ * @param {string} prompt - The prompt to send to Copilot
142
+ * @param {Object} options - Optional configuration
143
+ * @returns {Promise<Object>} Parsed response or error
144
+ */
145
+ async execute(prompt, options = {}) {
146
+ return new Promise((resolve, reject) => {
147
+ const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess } = options;
148
+
149
+ const levelPrefix = `[Level ${level}]`;
150
+ logger.info(`${levelPrefix} Executing Copilot CLI...`);
151
+ logger.info(`${levelPrefix} Writing prompt: ${prompt.length} bytes`);
152
+
153
+ // Build the command with other args first, then -p <prompt> at the end
154
+ // The -p flag expects the prompt value immediately after it
155
+ let fullCommand = this.command;
156
+ let fullArgs;
157
+
158
+ if (this.useShell) {
159
+ // Escape the prompt for shell
160
+ const escapedPrompt = prompt.replace(/'/g, "'\\''");
161
+ // Build: copilot --model X --deny-tool ... -s -p 'prompt'
162
+ fullCommand = `${this.command} ${this.baseArgs.join(' ')} -p '${escapedPrompt}'`;
163
+ fullArgs = [];
164
+ } else {
165
+ // Build args array: --model X --deny-tool ... -s -p <prompt>
166
+ fullArgs = [...this.baseArgs, '-p', prompt];
167
+ }
168
+
169
+ const copilot = spawn(fullCommand, fullArgs, {
170
+ cwd,
171
+ env: {
172
+ ...process.env,
173
+ PATH: `${BIN_DIR}:${process.env.PATH}`
174
+ },
175
+ shell: this.useShell
176
+ });
177
+
178
+ const pid = copilot.pid;
179
+ logger.info(`${levelPrefix} Spawned Copilot CLI process: PID ${pid}`);
180
+
181
+ // Register process for cancellation tracking if analysisId provided
182
+ if (analysisId && registerProcess) {
183
+ registerProcess(analysisId, copilot);
184
+ logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
185
+ }
186
+
187
+ let stdout = '';
188
+ let stderr = '';
189
+ let timeoutId = null;
190
+ let settled = false; // Guard against multiple resolve/reject calls
191
+
192
+ const settle = (fn, value) => {
193
+ if (settled) return;
194
+ settled = true;
195
+ if (timeoutId) clearTimeout(timeoutId);
196
+ fn(value);
197
+ };
198
+
199
+ // Set timeout
200
+ if (timeout) {
201
+ timeoutId = setTimeout(() => {
202
+ logger.error(`${levelPrefix} Process ${pid} timed out after ${timeout}ms`);
203
+ copilot.kill('SIGTERM');
204
+ settle(reject, new Error(`${levelPrefix} Copilot CLI timed out after ${timeout}ms`));
205
+ }, timeout);
206
+ }
207
+
208
+ // Collect stdout
209
+ copilot.stdout.on('data', (data) => {
210
+ stdout += data.toString();
211
+ });
212
+
213
+ // Collect stderr
214
+ copilot.stderr.on('data', (data) => {
215
+ stderr += data.toString();
216
+ });
217
+
218
+ // Handle completion
219
+ copilot.on('close', (code) => {
220
+ if (settled) return; // Already settled by timeout or error
221
+
222
+ // Check for cancellation signals (SIGTERM=143, SIGKILL=137)
223
+ const isCancellationCode = code === 143 || code === 137;
224
+ if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
225
+ logger.info(`${levelPrefix} Copilot CLI terminated due to analysis cancellation (exit code ${code})`);
226
+ settle(reject, new CancellationError(`${levelPrefix} Analysis cancelled by user`));
227
+ return;
228
+ }
229
+
230
+ // Always log stderr if present
231
+ if (stderr.trim()) {
232
+ if (code !== 0) {
233
+ logger.error(`${levelPrefix} Copilot CLI stderr (exit code ${code}): ${stderr}`);
234
+ } else {
235
+ logger.warn(`${levelPrefix} Copilot CLI stderr (success): ${stderr}`);
236
+ }
237
+ }
238
+
239
+ if (code !== 0) {
240
+ logger.error(`${levelPrefix} Copilot CLI exited with code ${code}`);
241
+ settle(reject, new Error(`${levelPrefix} Copilot CLI exited with code ${code}: ${stderr}`));
242
+ return;
243
+ }
244
+
245
+ // Extract JSON from the response
246
+ const extracted = extractJSON(stdout, level);
247
+ if (extracted.success) {
248
+ logger.success(`${levelPrefix} Successfully parsed JSON response`);
249
+ settle(resolve, extracted.data);
250
+ } else {
251
+ logger.warn(`${levelPrefix} Failed to extract JSON: ${extracted.error}`);
252
+ logger.info(`${levelPrefix} Raw response length: ${stdout.length} characters`);
253
+ logger.info(`${levelPrefix} Raw response preview: ${stdout.substring(0, 500)}...`);
254
+ settle(resolve, { raw: stdout, parsed: false });
255
+ }
256
+ });
257
+
258
+ // Handle errors
259
+ copilot.on('error', (error) => {
260
+ if (error.code === 'ENOENT') {
261
+ logger.error(`${levelPrefix} Copilot CLI not found. Please ensure Copilot CLI is installed.`);
262
+ settle(reject, new Error(`${levelPrefix} Copilot CLI not found. ${CopilotProvider.getInstallInstructions()}`));
263
+ } else {
264
+ logger.error(`${levelPrefix} Copilot process error: ${error}`);
265
+ settle(reject, error);
266
+ }
267
+ });
268
+ });
269
+ }
270
+
271
+ /**
272
+ * Test if Copilot CLI is available
273
+ * @returns {Promise<boolean>}
274
+ */
275
+ async testAvailability() {
276
+ return new Promise((resolve) => {
277
+ // For availability test, check --version
278
+ const copilotCmd = process.env.PAIR_REVIEW_COPILOT_CMD || 'copilot';
279
+ const useShell = copilotCmd.includes(' ');
280
+ const command = useShell ? `${copilotCmd} --version` : copilotCmd;
281
+ const args = useShell ? [] : ['--version'];
282
+
283
+ const copilot = spawn(command, args, {
284
+ env: {
285
+ ...process.env,
286
+ PATH: `${BIN_DIR}:${process.env.PATH}`
287
+ },
288
+ shell: useShell
289
+ });
290
+
291
+ let stdout = '';
292
+ let settled = false;
293
+
294
+ copilot.stdout.on('data', (data) => {
295
+ stdout += data.toString();
296
+ });
297
+
298
+ copilot.on('close', (code) => {
299
+ if (settled) return;
300
+ settled = true;
301
+ // Copilot CLI typically outputs version info on success
302
+ if (code === 0) {
303
+ logger.info(`Copilot CLI available: ${stdout.trim()}`);
304
+ resolve(true);
305
+ } else {
306
+ logger.warn('Copilot CLI not available or returned unexpected output');
307
+ resolve(false);
308
+ }
309
+ });
310
+
311
+ copilot.on('error', (error) => {
312
+ if (settled) return;
313
+ settled = true;
314
+ logger.warn(`Copilot CLI not available: ${error.message}`);
315
+ resolve(false);
316
+ });
317
+ });
318
+ }
319
+
320
+ static getProviderName() {
321
+ return 'Copilot';
322
+ }
323
+
324
+ static getProviderId() {
325
+ return 'copilot';
326
+ }
327
+
328
+ static getModels() {
329
+ return COPILOT_MODELS;
330
+ }
331
+
332
+ static getDefaultModel() {
333
+ return 'gemini-3-pro-preview';
334
+ }
335
+
336
+ static getInstallInstructions() {
337
+ return 'Install GitHub Copilot CLI: npm install -g @github/copilot\n' +
338
+ 'Or visit: https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli';
339
+ }
340
+ }
341
+
342
+ // Register this provider
343
+ registerProvider('copilot', CopilotProvider);
344
+
345
+ module.exports = CopilotProvider;