@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.
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/references/level1-balanced.md +8 -0
- package/plugin-code-critic/skills/analyze/references/level1-fast.md +7 -0
- package/plugin-code-critic/skills/analyze/references/level1-thorough.md +8 -0
- package/plugin-code-critic/skills/analyze/references/level2-balanced.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level2-fast.md +8 -0
- package/plugin-code-critic/skills/analyze/references/level2-thorough.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level3-balanced.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level3-fast.md +8 -0
- package/plugin-code-critic/skills/analyze/references/level3-thorough.md +9 -0
- package/plugin-code-critic/skills/analyze/references/orchestration-balanced.md +9 -0
- package/plugin-code-critic/skills/analyze/references/orchestration-fast.md +5 -0
- package/plugin-code-critic/skills/analyze/references/orchestration-thorough.md +9 -0
- package/public/css/analysis-config.css +103 -0
- package/public/css/pr.css +191 -4
- package/public/index.html +20 -0
- package/public/js/components/AIPanel.js +1 -1
- package/public/js/components/AdvancedConfigTab.js +87 -9
- package/public/js/components/AnalysisConfigModal.js +206 -5
- package/public/js/components/ChatPanel.js +22 -5
- package/public/js/components/CouncilProgressModal.js +241 -23
- package/public/js/components/TimeoutSelect.js +2 -0
- package/public/js/components/VoiceCentricConfigTab.js +183 -13
- package/public/js/index.js +119 -1
- package/public/js/local.js +166 -51
- package/public/js/modules/suggestion-manager.js +2 -1
- package/public/js/pr.js +71 -12
- package/public/js/repo-settings.js +2 -2
- package/public/local.html +32 -11
- package/public/pr.html +2 -0
- package/src/ai/analyzer.js +371 -111
- package/src/ai/claude-provider.js +2 -0
- package/src/ai/codex-provider.js +1 -1
- package/src/ai/copilot-provider.js +2 -0
- package/src/ai/executable-provider.js +538 -0
- package/src/ai/gemini-provider.js +2 -0
- package/src/ai/index.js +9 -1
- package/src/ai/pi-provider.js +10 -8
- package/src/ai/prompts/baseline/consolidation/balanced.js +54 -2
- package/src/ai/prompts/baseline/consolidation/fast.js +31 -1
- package/src/ai/prompts/baseline/consolidation/thorough.js +46 -3
- package/src/ai/prompts/baseline/level1/balanced.js +12 -0
- package/src/ai/prompts/baseline/level1/fast.js +11 -0
- package/src/ai/prompts/baseline/level1/thorough.js +12 -0
- package/src/ai/prompts/baseline/level2/balanced.js +13 -0
- package/src/ai/prompts/baseline/level2/fast.js +12 -0
- package/src/ai/prompts/baseline/level2/thorough.js +13 -0
- package/src/ai/prompts/baseline/level3/balanced.js +13 -0
- package/src/ai/prompts/baseline/level3/fast.js +12 -0
- package/src/ai/prompts/baseline/level3/thorough.js +13 -0
- package/src/ai/prompts/baseline/orchestration/balanced.js +15 -0
- package/src/ai/prompts/baseline/orchestration/fast.js +11 -0
- package/src/ai/prompts/baseline/orchestration/thorough.js +15 -0
- package/src/ai/prompts/render-for-skill.js +3 -0
- package/src/ai/prompts/shared/output-schema.js +8 -0
- package/src/ai/provider.js +91 -4
- package/src/chat/prompt-builder.js +39 -4
- package/src/chat/session-manager.js +32 -28
- package/src/config.js +15 -2
- package/src/database.js +59 -15
- package/src/git/base-branch.js +113 -29
- package/src/github/parser.js +1 -1
- package/src/local-review.js +15 -9
- package/src/local-scope.js +83 -0
- package/src/main.js +3 -2
- package/src/routes/analyses.js +34 -8
- package/src/routes/chat.js +15 -8
- package/src/routes/config.js +3 -120
- package/src/routes/councils.js +15 -6
- package/src/routes/executable-analysis.js +494 -0
- package/src/routes/local.js +152 -15
- package/src/routes/mcp.js +9 -4
- package/src/routes/pr.js +166 -29
- package/src/routes/reviews.js +31 -5
- package/src/routes/shared.js +72 -5
- package/src/routes/worktrees.js +4 -2
- package/src/utils/comment-formatter.js +28 -11
- package/src/utils/instructions.js +22 -8
- 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
|
}
|
package/src/ai/codex-provider.js
CHANGED
|
@@ -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,
|
package/src/ai/pi-provider.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
812
|
+
logger.info(`${name} CLI available: ${stdout.trim()}`);
|
|
814
813
|
resolve(true);
|
|
815
814
|
} else {
|
|
816
|
-
logger.warn(
|
|
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(
|
|
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
|