@in-the-loop-labs/pair-review 3.0.6 → 3.1.1

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 (81) hide show
  1. package/package.json +2 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/skills/analyze/references/level1-balanced.md +8 -0
  5. package/plugin-code-critic/skills/analyze/references/level1-fast.md +7 -0
  6. package/plugin-code-critic/skills/analyze/references/level1-thorough.md +8 -0
  7. package/plugin-code-critic/skills/analyze/references/level2-balanced.md +9 -0
  8. package/plugin-code-critic/skills/analyze/references/level2-fast.md +8 -0
  9. package/plugin-code-critic/skills/analyze/references/level2-thorough.md +9 -0
  10. package/plugin-code-critic/skills/analyze/references/level3-balanced.md +9 -0
  11. package/plugin-code-critic/skills/analyze/references/level3-fast.md +8 -0
  12. package/plugin-code-critic/skills/analyze/references/level3-thorough.md +9 -0
  13. package/plugin-code-critic/skills/analyze/references/orchestration-balanced.md +9 -0
  14. package/plugin-code-critic/skills/analyze/references/orchestration-fast.md +5 -0
  15. package/plugin-code-critic/skills/analyze/references/orchestration-thorough.md +9 -0
  16. package/public/css/analysis-config.css +103 -0
  17. package/public/css/pr.css +191 -4
  18. package/public/index.html +20 -0
  19. package/public/js/components/AIPanel.js +1 -1
  20. package/public/js/components/AdvancedConfigTab.js +87 -9
  21. package/public/js/components/AnalysisConfigModal.js +206 -5
  22. package/public/js/components/ChatPanel.js +22 -5
  23. package/public/js/components/CouncilProgressModal.js +241 -23
  24. package/public/js/components/TimeoutSelect.js +2 -0
  25. package/public/js/components/VoiceCentricConfigTab.js +183 -13
  26. package/public/js/index.js +119 -1
  27. package/public/js/local.js +166 -51
  28. package/public/js/modules/suggestion-manager.js +2 -1
  29. package/public/js/pr.js +71 -12
  30. package/public/js/repo-settings.js +2 -2
  31. package/public/local.html +32 -11
  32. package/public/pr.html +2 -0
  33. package/src/ai/analyzer.js +371 -111
  34. package/src/ai/claude-provider.js +2 -0
  35. package/src/ai/codex-provider.js +1 -1
  36. package/src/ai/copilot-provider.js +2 -0
  37. package/src/ai/executable-provider.js +538 -0
  38. package/src/ai/gemini-provider.js +2 -0
  39. package/src/ai/index.js +9 -1
  40. package/src/ai/pi-provider.js +10 -8
  41. package/src/ai/prompts/baseline/consolidation/balanced.js +54 -2
  42. package/src/ai/prompts/baseline/consolidation/fast.js +31 -1
  43. package/src/ai/prompts/baseline/consolidation/thorough.js +46 -3
  44. package/src/ai/prompts/baseline/level1/balanced.js +12 -0
  45. package/src/ai/prompts/baseline/level1/fast.js +11 -0
  46. package/src/ai/prompts/baseline/level1/thorough.js +12 -0
  47. package/src/ai/prompts/baseline/level2/balanced.js +13 -0
  48. package/src/ai/prompts/baseline/level2/fast.js +12 -0
  49. package/src/ai/prompts/baseline/level2/thorough.js +13 -0
  50. package/src/ai/prompts/baseline/level3/balanced.js +13 -0
  51. package/src/ai/prompts/baseline/level3/fast.js +12 -0
  52. package/src/ai/prompts/baseline/level3/thorough.js +13 -0
  53. package/src/ai/prompts/baseline/orchestration/balanced.js +15 -0
  54. package/src/ai/prompts/baseline/orchestration/fast.js +11 -0
  55. package/src/ai/prompts/baseline/orchestration/thorough.js +15 -0
  56. package/src/ai/prompts/render-for-skill.js +3 -0
  57. package/src/ai/prompts/shared/output-schema.js +8 -0
  58. package/src/ai/provider.js +91 -4
  59. package/src/chat/prompt-builder.js +39 -4
  60. package/src/chat/session-manager.js +32 -28
  61. package/src/config.js +15 -2
  62. package/src/database.js +59 -15
  63. package/src/git/base-branch.js +113 -29
  64. package/src/github/parser.js +1 -1
  65. package/src/local-review.js +15 -9
  66. package/src/local-scope.js +83 -0
  67. package/src/main.js +3 -2
  68. package/src/routes/analyses.js +34 -8
  69. package/src/routes/chat.js +15 -8
  70. package/src/routes/config.js +3 -120
  71. package/src/routes/councils.js +15 -6
  72. package/src/routes/executable-analysis.js +494 -0
  73. package/src/routes/local.js +152 -15
  74. package/src/routes/mcp.js +9 -4
  75. package/src/routes/pr.js +166 -29
  76. package/src/routes/reviews.js +31 -5
  77. package/src/routes/shared.js +72 -5
  78. package/src/routes/worktrees.js +4 -2
  79. package/src/utils/comment-formatter.js +28 -11
  80. package/src/utils/instructions.js +22 -8
  81. package/src/utils/logger.js +20 -10
@@ -171,6 +171,8 @@ class ClaudeProvider extends AIProvider {
171
171
  'Bash(grep *)',
172
172
  'Bash(find *)',
173
173
  'Bash(rg *)',
174
+ 'Bash(gh *)', // GitHub CLI (fetch previous findings for dedup)
175
+ 'Bash(curl *)', // HTTP requests (fetch previous findings for dedup)
174
176
  ].join(',');
175
177
  permissionArgs = ['--allowedTools', allowedTools];
176
178
  }
@@ -123,7 +123,7 @@ class CodexProvider extends AIProvider {
123
123
  // Shell env args prevent login shell from reconstructing PATH (orthogonal to
124
124
  // sandbox permissions). Overridable via configOverrides.args following the
125
125
  // same two-tier pattern as chat-providers.js: args replaces, extra_args appends.
126
- const defaultShellEnvArgs = ['-c', 'allow_login_shell=false', '-c', 'shell_environment_policy.include_only=["PATH","HOME","USER"]'];
126
+ const defaultShellEnvArgs = ['-c', 'allow_login_shell=false', '-c', 'shell_environment_policy.include_only=["PATH","HOME","USER","GH_TOKEN","GITHUB_TOKEN"]'];
127
127
  const configArgs = configOverrides.args || defaultShellEnvArgs;
128
128
  const baseArgs = ['exec', '-m', model, '--json', ...sandboxArgs, ...configArgs, '-'];
129
129
  const providerArgs = configOverrides.extra_args || [];
@@ -169,6 +169,8 @@ class CopilotProvider extends AIProvider {
169
169
  '--allow-tool', 'shell(find)', // File finding
170
170
  '--allow-tool', 'shell(grep)', // Pattern searching
171
171
  '--allow-tool', 'shell(rg)', // Ripgrep (fast pattern searching)
172
+ '--allow-tool', 'shell(gh)', // GitHub CLI (fetch previous findings for dedup)
173
+ '--allow-tool', 'shell(curl)', // HTTP requests (fetch previous findings for dedup)
172
174
  // Deny dangerous shell commands (takes precedence over allow)
173
175
  '--deny-tool', 'shell(rm)',
174
176
  '--deny-tool', 'shell(mv)',
@@ -0,0 +1,538 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Executable AI Provider
4
+ *
5
+ * A dynamic provider type that runs external CLI tools
6
+ * as code review providers. The tool's output is mapped to pair-review's suggestion
7
+ * schema via an LLM.
8
+ *
9
+ * Unlike other providers, this is a factory that returns a provider class per config
10
+ * entry. Registration happens in provider.js during applyConfigOverrides(), not at
11
+ * module load time.
12
+ */
13
+
14
+ const providerModule = require('./provider');
15
+ const { AIProvider, resolveDefaultModel, inferModelDefaults } = providerModule;
16
+ const { spawn } = require('child_process');
17
+ const { glob } = require('glob');
18
+ const fs = require('fs').promises;
19
+ const path = require('path');
20
+ const logger = require('../utils/logger');
21
+ const jsonExtractor = require('../utils/json-extractor');
22
+ const configModule = require('../config');
23
+
24
+ /**
25
+ * Convert a snake_case string to camelCase
26
+ * @param {string} str - snake_case string
27
+ * @returns {string} camelCase string
28
+ */
29
+ function snakeToCamel(str) {
30
+ return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
31
+ }
32
+
33
+ /**
34
+ * Build the mapping prompt for translating tool output to pair-review schema.
35
+ * Kept here rather than a separate file since the template is short and
36
+ * tightly coupled to the executable provider logic.
37
+ *
38
+ * @param {string} mappingInstructions - Tool-specific mapping instructions
39
+ * @param {string} rawOutput - Raw JSON output from the external tool
40
+ * @returns {string} Complete mapping prompt
41
+ */
42
+ function buildMappingPrompt(mappingInstructions, rawOutput) {
43
+ return `You are mapping the output of an external code review tool to a standardized JSON format.
44
+
45
+ Map the tool's output to this exact JSON schema:
46
+ {
47
+ "reasoning": ["step 1...", "step 2..."],
48
+ "suggestions": [{
49
+ "file": "path/to/file",
50
+ "line_start": 42,
51
+ "line_end": 42,
52
+ "old_or_new": "NEW",
53
+ "type": "bug|improvement|security|performance|design|suggestion|code-style|praise",
54
+ "severity": "critical|medium|minor",
55
+ "title": "Brief title",
56
+ "description": "Detailed explanation",
57
+ "suggestion": "Fix guidance (omit for praise)",
58
+ "confidence": 0.85,
59
+ "is_file_level": false
60
+ }],
61
+ "summary": "Overall assessment"
62
+ }
63
+
64
+ Rules:
65
+ - Include a "reasoning" array with step-by-step reasoning about how you mapped the tool output
66
+ - Map each review finding to one suggestion object
67
+ - Use "NEW" for old_or_new (external tools review the new version)
68
+ - Preserve severity if the tool provides it
69
+ - Set is_file_level: true and line_start/line_end to null for findings without line numbers
70
+ - Output ONLY valid JSON, no markdown or explanation
71
+
72
+ ${mappingInstructions}
73
+
74
+ --- RAW TOOL OUTPUT ---
75
+ ${rawOutput}`;
76
+ }
77
+
78
+ /**
79
+ * Create a dynamic AIProvider subclass for an executable tool.
80
+ *
81
+ * @param {string} id - Provider ID (used in config and registration)
82
+ * @param {Object} config - Provider configuration from config.json
83
+ * @param {string} config.command - CLI command to run
84
+ * @param {string[]} config.args - Base CLI arguments
85
+ * @param {string} config.name - Display name
86
+ * @param {Object} config.capabilities - Provider capabilities overrides
87
+ * @param {boolean} config.capabilities.review_levels - Whether the tool supports L1/L2/L3 analysis
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
91
+ * @param {Object} config.context_args - Maps context keys to CLI flags
92
+ * @param {string} config.output_glob - Glob pattern to find result file
93
+ * @param {string} config.mapping_instructions - Tool-specific mapping instructions for LLM
94
+ * @param {Object} config.env - Extra environment variables
95
+ * @param {string} config.installInstructions - Installation instructions
96
+ * @param {Object[]} config.models - Model definitions
97
+ * @returns {typeof AIProvider} A provider class for this executable tool
98
+ */
99
+ function createExecutableProviderClass(id, config) {
100
+ // Process models from config, inferring defaults for each
101
+ const models = (config.models || [{ id: 'default', name: 'Default', tier: 'thorough', default: true }])
102
+ .map(m => inferModelDefaults(m));
103
+
104
+ class ExecProvider extends AIProvider {
105
+ /**
106
+ * @param {string} model - Model identifier (may be unused for single-model tools)
107
+ * @param {Object} configOverrides - Config overrides from providers config
108
+ */
109
+ constructor(model = 'default', configOverrides = {}) {
110
+ super(model);
111
+
112
+ // Command precedence: ENV > config > id
113
+ const envVar = `PAIR_REVIEW_${id.toUpperCase().replace(/-/g, '_')}_CMD`;
114
+ const envCmd = process.env[envVar];
115
+ const configCmd = config.command;
116
+ this.execCommand = envCmd || configCmd || id;
117
+
118
+ // For multi-word commands, use shell mode
119
+ this.useShell = this.execCommand.includes(' ');
120
+
121
+ // Resolve model-level config from the models array
122
+ const modelConfig = models.find(m => m.id === model) || {};
123
+ // cli_model: explicit string overrides model id; "" or null suppresses model; undefined falls back to id
124
+ this.resolvedModel = modelConfig.cli_model !== undefined ? (modelConfig.cli_model || null) : model;
125
+ this.modelExtraArgs = modelConfig.extra_args || [];
126
+
127
+ // Store config fields
128
+ this.baseArgs = config.args || [];
129
+ this.providerExtraArgs = [
130
+ ...(config.extra_args || []),
131
+ ...(configOverrides.extra_args || [])
132
+ ];
133
+ this.contextArgs = config.context_args || {};
134
+ this.diffArgs = config.diff_args || [];
135
+ this.outputGlob = config.output_glob || '**/results.json';
136
+ this.mappingInstructions = config.mapping_instructions || '';
137
+ this.timeout = config.timeout || 600000; // Default 10 minutes
138
+ this.availabilityCommand = config.availability_command || 'true';
139
+ this.extraEnv = {
140
+ ...(config.env || {}),
141
+ ...(configOverrides.env || {}),
142
+ ...(modelConfig.env || {})
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Build CLI arguments from executable context.
148
+ * Maps context keys (camelCase in code) to CLI flags via context_args config (snake_case keys).
149
+ *
150
+ * @param {Object} executableContext - Context from the analysis runner
151
+ * @returns {string[]} Complete args array
152
+ * @private
153
+ */
154
+ _buildArgs(executableContext) {
155
+ const args = [...this.baseArgs, ...this.providerExtraArgs, ...this.modelExtraArgs];
156
+
157
+ for (const [configKey, flag] of Object.entries(this.contextArgs)) {
158
+ // Config keys are snake_case, context keys are camelCase
159
+ const contextKey = snakeToCamel(configKey);
160
+ const value = executableContext[contextKey];
161
+ if (value != null) {
162
+ args.push(flag, String(value));
163
+ }
164
+ }
165
+
166
+ return args;
167
+ }
168
+
169
+ /**
170
+ * Execute the external CLI tool.
171
+ *
172
+ * @param {string} prompt - Unused (tool has its own prompts)
173
+ * @param {Object} options - Execution options
174
+ * @param {Object} options.executableContext - Context: { title, description, outputDir, cwd }
175
+ * @param {string} options.analysisId - Analysis ID for cancellation tracking
176
+ * @param {Function} options.registerProcess - Register child process for cancellation
177
+ * @param {Function} options.onStreamEvent - Callback for progress updates
178
+ * @param {number} options.timeout - Timeout in ms (default 300000)
179
+ * @returns {Promise<Object>} { success: true, data: { suggestions, summary } }
180
+ */
181
+ async execute(prompt, options = {}) {
182
+ const {
183
+ executableContext = {},
184
+ analysisId,
185
+ registerProcess,
186
+ onStreamEvent,
187
+ timeout = 300000
188
+ } = options;
189
+
190
+ const outputDir = executableContext.outputDir;
191
+ const cwd = executableContext.cwd || process.cwd();
192
+
193
+ // Build CLI args from context
194
+ const cliArgs = this._buildArgs(executableContext);
195
+
196
+ logger.info(`[${id}] Executing external tool: ${this.execCommand} ${cliArgs.join(' ')}`);
197
+
198
+ // Spawn the process
199
+ const command = this.useShell
200
+ ? `${this.execCommand} ${cliArgs.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ')}`
201
+ : this.execCommand;
202
+ const spawnArgs = this.useShell ? [] : cliArgs;
203
+
204
+ const child = spawn(command, spawnArgs, {
205
+ cwd,
206
+ env: {
207
+ ...process.env,
208
+ ...this.extraEnv
209
+ },
210
+ shell: this.useShell
211
+ });
212
+
213
+ const pid = child.pid;
214
+ logger.info(`[${id}] Spawned process: PID ${pid}`);
215
+
216
+ // Register for cancellation tracking.
217
+ // Wrap the child's kill method so we can detect when the process is
218
+ // killed externally (e.g., via shared.killProcesses on user cancel).
219
+ let cancelled = false;
220
+ if (analysisId && registerProcess) {
221
+ const originalKill = child.kill.bind(child);
222
+ child.kill = (...args) => {
223
+ cancelled = true;
224
+ return originalKill(...args);
225
+ };
226
+ registerProcess(analysisId, child);
227
+ logger.info(`[${id}] Registered process ${pid} for analysis ${analysisId}`);
228
+ }
229
+
230
+ // Emit progress event
231
+ if (onStreamEvent) {
232
+ onStreamEvent({
233
+ type: 'assistant_text',
234
+ text: `Running external tool: ${config.name || id}...`,
235
+ timestamp: Date.now()
236
+ });
237
+ }
238
+
239
+ // Collect stdout/stderr and wait for completion
240
+ return new Promise((resolve, reject) => {
241
+ let stdout = '';
242
+ let stderr = '';
243
+ let timeoutId = null;
244
+ let timedOut = false;
245
+ let settled = false;
246
+
247
+ const settle = (fn, value) => {
248
+ if (settled) return;
249
+ settled = true;
250
+ if (timeoutId) clearTimeout(timeoutId);
251
+ fn(value);
252
+ };
253
+
254
+ // Set timeout — kills process but lets the close handler check for output
255
+ if (timeout) {
256
+ timeoutId = setTimeout(() => {
257
+ timedOut = true;
258
+ logger.error(`[${id}] Process ${pid} timed out after ${timeout}ms`);
259
+ if (stdout.trim()) {
260
+ logger.warn(`[${id}] stdout before timeout: ${stdout.trim().slice(0, 2000)}`);
261
+ }
262
+ if (stderr.trim()) {
263
+ logger.warn(`[${id}] stderr before timeout: ${stderr.trim().slice(0, 2000)}`);
264
+ }
265
+ child.kill('SIGTERM');
266
+ }, timeout);
267
+ }
268
+
269
+ child.stdout.on('data', (data) => {
270
+ const chunk = data.toString();
271
+ stdout += chunk;
272
+ // Stream each line to debug output for live visibility
273
+ for (const line of chunk.split('\n')) {
274
+ const trimmed = line.trim();
275
+ if (trimmed) {
276
+ logger.streamDebug(`[${id}] ${trimmed}`);
277
+ if (onStreamEvent) {
278
+ onStreamEvent({
279
+ type: 'assistant_text',
280
+ text: trimmed.slice(0, 200),
281
+ timestamp: Date.now()
282
+ });
283
+ }
284
+ }
285
+ }
286
+ });
287
+
288
+ child.stderr.on('data', (data) => {
289
+ const chunk = data.toString();
290
+ stderr += chunk;
291
+ for (const line of chunk.split('\n')) {
292
+ if (line.trim()) logger.streamDebug(`[${id}] ${line}`);
293
+ }
294
+ });
295
+
296
+ child.on('close', async (code) => {
297
+ if (settled) return;
298
+
299
+ if (stderr.trim()) {
300
+ if (code !== 0) {
301
+ logger.error(`[${id}] stderr (exit code ${code}): ${stderr}`);
302
+ } else {
303
+ logger.warn(`[${id}] stderr (success): ${stderr}`);
304
+ }
305
+ }
306
+
307
+ if (code !== 0) {
308
+ logger.warn(`[${id}] External tool exited with code ${code}`);
309
+ if (stdout.trim()) {
310
+ logger.warn(`[${id}] stdout: ${stdout.trim().slice(0, 2000)}`);
311
+ }
312
+ } else {
313
+ logger.info(`[${id}] External tool completed successfully`);
314
+ }
315
+
316
+ try {
317
+ // Find the result file — check even on non-zero exit code,
318
+ // since some tools exit non-zero but still produce valid output
319
+ if (!outputDir) {
320
+ settle(reject, new Error(`[${id}] No output directory specified in executableContext`));
321
+ return;
322
+ }
323
+
324
+ const matches = await glob(this.outputGlob, { cwd: outputDir });
325
+ if (!matches || matches.length === 0) {
326
+ if (cancelled && !timedOut) {
327
+ const cancelError = new Error(`[${id}] Analysis cancelled by user`);
328
+ cancelError.isCancellation = true;
329
+ settle(reject, cancelError);
330
+ } else if (timedOut) {
331
+ settle(reject, new Error(`[${id}] External tool timed out after ${timeout}ms and produced no output`));
332
+ } else if (code !== 0) {
333
+ const output = (stderr || stdout || '(no output)').trim().slice(0, 2000);
334
+ settle(reject, new Error(`[${id}] External tool exited with code ${code} and produced no output: ${output}`));
335
+ } else {
336
+ settle(reject, new Error(`[${id}] No result file matching ${this.outputGlob} found in ${outputDir}`));
337
+ }
338
+ return;
339
+ }
340
+
341
+ if (timedOut) {
342
+ logger.warn(`[${id}] Tool timed out but produced output — treating as success`);
343
+ } else if (code !== 0) {
344
+ logger.warn(`[${id}] Tool exited with code ${code} but produced output — treating as success`);
345
+ }
346
+
347
+ const resultPath = path.join(outputDir, matches[0]);
348
+ logger.info(`[${id}] Reading result file: ${resultPath}`);
349
+ const rawJson = await fs.readFile(resultPath, 'utf-8');
350
+ logger.info(`[${id}] Result file: ${rawJson.length} bytes`);
351
+
352
+ // Map the output to pair-review's schema
353
+ if (onStreamEvent) {
354
+ onStreamEvent({
355
+ type: 'assistant_text',
356
+ text: 'Mapping tool output to suggestion format...',
357
+ timestamp: Date.now()
358
+ });
359
+ }
360
+ const mapped = await this.mapOutputToSchema(rawJson);
361
+ logger.info(`[${id}] Mapped ${mapped.suggestions?.length || 0} suggestions`);
362
+
363
+ settle(resolve, {
364
+ success: true,
365
+ data: {
366
+ suggestions: mapped.suggestions,
367
+ summary: mapped.summary
368
+ }
369
+ });
370
+ } catch (err) {
371
+ logger.error(`[${id}] Post-processing failed: ${err.message}`);
372
+ settle(reject, err);
373
+ }
374
+ });
375
+
376
+ child.on('error', (error) => {
377
+ if (error.code === 'ENOENT') {
378
+ logger.error(`[${id}] Command not found: ${this.execCommand}`);
379
+ settle(reject, new Error(`[${id}] Command not found: ${this.execCommand}. ${config.installInstructions || `Install ${id}`}`));
380
+ } else {
381
+ logger.error(`[${id}] Process error: ${error}`);
382
+ settle(reject, error);
383
+ }
384
+ });
385
+ });
386
+ }
387
+
388
+ /**
389
+ * Map raw tool output to pair-review's suggestion schema using an LLM.
390
+ *
391
+ * @param {string} rawOutput - Raw JSON string from the external tool
392
+ * @returns {Promise<{suggestions: Array, summary: string}>}
393
+ */
394
+ async mapOutputToSchema(rawOutput) {
395
+ const mappingPrompt = buildMappingPrompt(this.mappingInstructions, rawOutput);
396
+
397
+ // Find a mapping provider: prefer the user's configured default, fall back to
398
+ // any registered non-executable provider. Never hardcode a specific provider.
399
+ let mappingProviderId = null;
400
+
401
+ // Try the user's configured default provider first
402
+ try {
403
+ const config = await configModule.loadConfig();
404
+ const defaultId = configModule.getDefaultProvider(config);
405
+ const defaultClass = providerModule.getProviderClass(defaultId);
406
+ if (defaultClass && !defaultClass.isExecutable) {
407
+ mappingProviderId = defaultId;
408
+ }
409
+ } catch {
410
+ // Config or provider not available — fall through to fallback
411
+ }
412
+
413
+ if (!mappingProviderId) {
414
+ // Fall back to any registered non-executable provider
415
+ for (const pid of providerModule.getRegisteredProviderIds()) {
416
+ const pClass = providerModule.getProviderClass(pid);
417
+ if (pClass && !pClass.isExecutable) {
418
+ mappingProviderId = pid;
419
+ break;
420
+ }
421
+ }
422
+ }
423
+
424
+ if (!mappingProviderId) {
425
+ throw new Error(`[${id}] No mapping provider available. Need at least one non-executable provider (e.g., claude) registered.`);
426
+ }
427
+
428
+ logger.info(`[${id}] Mapping output using provider: ${mappingProviderId}`);
429
+ const provider = providerModule.createProvider(mappingProviderId);
430
+ const result = await provider.execute(mappingPrompt, {
431
+ cwd: process.cwd(),
432
+ timeout: 60000,
433
+ level: 'mapping'
434
+ });
435
+
436
+ // Try to extract suggestions from the result
437
+ if (result && result.suggestions) {
438
+ return { suggestions: result.suggestions, summary: result.summary || '' };
439
+ }
440
+
441
+ // If result has a data property (from structured output)
442
+ if (result && result.data) {
443
+ const data = result.data;
444
+ if (data.suggestions) {
445
+ return { suggestions: data.suggestions, summary: data.summary || '' };
446
+ }
447
+ }
448
+
449
+ // If result has raw text, try to extract JSON from it
450
+ if (result && result.raw) {
451
+ const extracted = jsonExtractor.extractJSON(result.raw, 'mapping');
452
+ if (extracted.success && extracted.data) {
453
+ return {
454
+ suggestions: extracted.data.suggestions || [],
455
+ summary: extracted.data.summary || ''
456
+ };
457
+ }
458
+ }
459
+
460
+ // Last resort: accept only if the result actually has a suggestions array
461
+ if (result && typeof result === 'object' && Array.isArray(result.suggestions)) {
462
+ return { suggestions: result.suggestions, summary: result.summary || '' };
463
+ }
464
+
465
+ throw new Error(`[${id}] Failed to map tool output to suggestion schema`);
466
+ }
467
+
468
+ /**
469
+ * Test if the external tool is available.
470
+ * Runs the configured availability_command (defaults to 'true', i.e. always available).
471
+ *
472
+ * @returns {Promise<boolean>}
473
+ */
474
+ async testAvailability() {
475
+ return new Promise((resolve) => {
476
+ const command = this.availabilityCommand;
477
+ logger.debug(`${id} availability check: ${command}`);
478
+
479
+ const child = spawn(command, [], {
480
+ env: { ...process.env, ...this.extraEnv },
481
+ shell: true
482
+ });
483
+
484
+ let settled = false;
485
+
486
+ const availabilityTimeout = setTimeout(() => {
487
+ if (settled) return;
488
+ settled = true;
489
+ logger.warn(`${id} availability check timed out after 10s`);
490
+ try { child.kill(); } catch { /* ignore */ }
491
+ resolve(false);
492
+ }, 10000);
493
+
494
+ child.on('close', (code) => {
495
+ if (settled) return;
496
+ settled = true;
497
+ clearTimeout(availabilityTimeout);
498
+ if (code === 0) {
499
+ logger.info(`${id} tool available`);
500
+ resolve(true);
501
+ } else {
502
+ logger.warn(`${id} tool not available (exit code ${code})`);
503
+ resolve(false);
504
+ }
505
+ });
506
+
507
+ child.on('error', (error) => {
508
+ if (settled) return;
509
+ settled = true;
510
+ clearTimeout(availabilityTimeout);
511
+ logger.warn(`${id} tool not available: ${error.message}`);
512
+ resolve(false);
513
+ });
514
+ });
515
+ }
516
+ }
517
+
518
+ // Static methods and flags on the class
519
+ ExecProvider.getProviderName = () => config.name || id;
520
+ ExecProvider.getProviderId = () => id;
521
+ ExecProvider.getModels = () => models;
522
+ ExecProvider.getDefaultModel = () => resolveDefaultModel(models) || models[0]?.id;
523
+ ExecProvider.getInstallInstructions = () => config.installInstructions || `Install ${id}`;
524
+
525
+ // Flags for the system
526
+ ExecProvider.isExecutable = true;
527
+ const caps = config.capabilities || {};
528
+ ExecProvider.capabilities = {
529
+ review_levels: caps.review_levels !== undefined ? caps.review_levels : 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
533
+ };
534
+
535
+ return ExecProvider;
536
+ }
537
+
538
+ module.exports = { createExecutableProviderClass };
@@ -143,6 +143,8 @@ class GeminiProvider extends AIProvider {
143
143
  'run_shell_command(find)', // File finding
144
144
  'run_shell_command(grep)', // Pattern searching
145
145
  'run_shell_command(rg)', // Ripgrep (fast pattern searching)
146
+ 'run_shell_command(gh)', // GitHub CLI (fetch previous findings for dedup)
147
+ 'run_shell_command(curl)', // HTTP requests (fetch previous findings for dedup)
146
148
  // git-diff-lines is added to PATH via BIN_DIR so bare command works
147
149
  'run_shell_command(git-diff-lines)', // Custom annotated diff tool
148
150
  ].join(',');
package/src/ai/index.js CHANGED
@@ -20,7 +20,8 @@ const {
20
20
  getProviderConfigOverrides,
21
21
  inferModelDefaults,
22
22
  resolveDefaultModel,
23
- prettifyModelId
23
+ prettifyModelId,
24
+ createAliasedProviderClass
24
25
  } = require('./provider');
25
26
 
26
27
  // Load the availability checking module
@@ -33,6 +34,9 @@ const {
33
34
  clearCache: clearAvailabilityCache
34
35
  } = require('./provider-availability');
35
36
 
37
+ // Load executable provider factory (used by applyConfigOverrides for dynamic registration)
38
+ const { createExecutableProviderClass } = require('./executable-provider');
39
+
36
40
  // Load and register all providers
37
41
  // Each provider self-registers when loaded
38
42
  require('./claude-provider');
@@ -70,6 +74,10 @@ module.exports = {
70
74
  resolveDefaultModel,
71
75
  prettifyModelId,
72
76
 
77
+ // Provider factories
78
+ createExecutableProviderClass,
79
+ createAliasedProviderClass,
80
+
73
81
  // Provider availability checking
74
82
  getCachedAvailability,
75
83
  getAllCachedAvailability,
@@ -240,7 +240,7 @@ class PiProvider extends AIProvider {
240
240
  */
241
241
  async execute(prompt, options = {}) {
242
242
  return new Promise((resolve, reject) => {
243
- const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix } = options;
243
+ const { cwd = process.cwd(), timeout = 900000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix } = options;
244
244
 
245
245
  const levelPrefix = logPrefix || `[Level ${level}]`;
246
246
  logger.info(`${levelPrefix} Executing Pi CLI...`);
@@ -776,7 +776,8 @@ class PiProvider extends AIProvider {
776
776
 
777
777
  // Log the actual command for debugging config/override issues
778
778
  const fullCmd = useShell ? command : `${command} ${args.join(' ')}`;
779
- logger.debug(`Pi availability check: ${fullCmd}`);
779
+ const name = this.constructor.getProviderName();
780
+ logger.debug(`${name} availability check: ${fullCmd}`);
780
781
 
781
782
  const pi = spawn(command, args, {
782
783
  env: {
@@ -787,8 +788,6 @@ class PiProvider extends AIProvider {
787
788
  shell: useShell
788
789
  });
789
790
 
790
- logger.debug(`Pi CLI spawn: ${command} ${args.join(' ')}`);
791
-
792
791
  let stdout = '';
793
792
  let settled = false;
794
793
 
@@ -796,7 +795,7 @@ class PiProvider extends AIProvider {
796
795
  const availabilityTimeout = setTimeout(() => {
797
796
  if (settled) return;
798
797
  settled = true;
799
- logger.warn('Pi CLI availability check timed out after 10s');
798
+ logger.warn(`${name} CLI availability check timed out after 10s`);
800
799
  try { pi.kill(); } catch { /* ignore */ }
801
800
  resolve(false);
802
801
  }, 10000);
@@ -810,10 +809,10 @@ class PiProvider extends AIProvider {
810
809
  settled = true;
811
810
  clearTimeout(availabilityTimeout);
812
811
  if (code === 0) {
813
- logger.info(`Pi CLI available: ${stdout.trim()}`);
812
+ logger.info(`${name} CLI available: ${stdout.trim()}`);
814
813
  resolve(true);
815
814
  } else {
816
- logger.warn('Pi CLI not available or returned unexpected output');
815
+ logger.warn(`${name} CLI not available or returned unexpected output`);
817
816
  resolve(false);
818
817
  }
819
818
  });
@@ -822,7 +821,7 @@ class PiProvider extends AIProvider {
822
821
  if (settled) return;
823
822
  settled = true;
824
823
  clearTimeout(availabilityTimeout);
825
- logger.warn(`Pi CLI not available: ${error.message}`);
824
+ logger.warn(`${name} CLI not available: ${error.message}`);
826
825
  resolve(false);
827
826
  });
828
827
  });
@@ -849,6 +848,9 @@ class PiProvider extends AIProvider {
849
848
  return 'Install Pi: npm install -g @mariozechner/pi-coding-agent\n' +
850
849
  'Or visit: https://github.com/badlogic/pi-mono';
851
850
  }
851
+
852
+ /** Default timeout in ms (15 minutes) — Pi is slower than most providers */
853
+ static defaultTimeout = 900000;
852
854
  }
853
855
 
854
856
  // Register this provider