@in-the-loop-labs/pair-review 3.1.3 → 3.2.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 (39) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  4. package/public/css/pr.css +980 -3
  5. package/public/js/components/AIPanel.js +7 -4
  6. package/public/js/components/ChatPanel.js +34 -4
  7. package/public/js/components/CouncilProgressModal.js +11 -0
  8. package/public/js/components/NotificationDropdown.js +257 -0
  9. package/public/js/components/StackAnalysisDialog.js +313 -0
  10. package/public/js/components/StackProgressModal.js +475 -0
  11. package/public/js/components/StatusIndicator.js +1 -0
  12. package/public/js/components/SuggestionNavigator.js +2 -0
  13. package/public/js/modules/comment-manager.js +7 -0
  14. package/public/js/modules/comment-minimizer.js +151 -4
  15. package/public/js/modules/file-comment-manager.js +66 -2
  16. package/public/js/modules/suggestion-manager.js +2 -1
  17. package/public/js/pr.js +433 -2
  18. package/public/js/utils/notification-sounds.js +62 -0
  19. package/public/local.html +10 -0
  20. package/public/pr.html +12 -0
  21. package/public/setup.html +4 -0
  22. package/src/ai/claude-provider.js +1 -11
  23. package/src/ai/codex-provider.js +18 -16
  24. package/src/ai/copilot-provider.js +21 -21
  25. package/src/ai/gemini-provider.js +10 -0
  26. package/src/ai/pi-provider.js +22 -25
  27. package/src/ai/provider.js +26 -3
  28. package/src/chat/pi-bridge.js +8 -0
  29. package/src/chat/session-manager.js +1 -0
  30. package/src/git/base-branch.js +1 -51
  31. package/src/git/worktree-lock.js +88 -0
  32. package/src/git/worktree.js +64 -0
  33. package/src/github/stack-walker.js +196 -0
  34. package/src/routes/local.js +12 -8
  35. package/src/routes/pr.js +139 -26
  36. package/src/routes/sound.js +49 -0
  37. package/src/routes/stack-analysis.js +886 -0
  38. package/src/server.js +4 -0
  39. package/src/setup/stack-setup.js +77 -0
package/public/setup.html CHANGED
@@ -528,6 +528,7 @@
528
528
 
529
529
  <!-- WebSocket client -->
530
530
  <script src="/js/ws-client.js"></script>
531
+ <script src="/js/utils/notification-sounds.js"></script>
531
532
 
532
533
  <script>
533
534
  // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
@@ -818,6 +819,9 @@
818
819
  updateProgressBar();
819
820
  showRedirect();
820
821
 
822
+ // Play notification sound for PR setup completion
823
+ if (window.notificationSounds && mode !== 'local') window.notificationSounds.playIfEnabled('setup');
824
+
821
825
  // Small delay so the user sees the completed state
822
826
  setTimeout(function() {
823
827
  if (msg.reviewUrl) {
@@ -22,23 +22,13 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
22
22
  const CLAUDE_MODELS = [
23
23
  {
24
24
  id: 'haiku',
25
- name: 'Haiku 4.5',
25
+ name: 'Haiku 4.6',
26
26
  tier: 'fast',
27
27
  tagline: 'Lightning Fast',
28
28
  description: 'Quick analysis for simple changes',
29
29
  badge: 'Fastest',
30
30
  badgeClass: 'badge-speed'
31
31
  },
32
- {
33
- id: 'sonnet-4.5',
34
- cli_model: 'claude-sonnet-4.5',
35
- name: 'Sonnet 4.5',
36
- tier: 'balanced',
37
- tagline: 'Previous Gen',
38
- description: 'Sonnet 4.5 — previous generation balanced model',
39
- badge: 'Previous Gen',
40
- badgeClass: 'badge-balanced'
41
- },
42
32
  {
43
33
  id: 'sonnet-4.6',
44
34
  cli_model: 'claude-sonnet-4-6',
@@ -21,27 +21,29 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
21
21
  * Codex model definitions with tier mappings
22
22
  *
23
23
  * Based on OpenAI Codex Models guide (developers.openai.com/codex/models)
24
- * - gpt-5.1-codex-mini: Smaller, cost-effective variant for quick scans
25
- * - gpt-5.2-codex: Advanced coding model for everyday reviews, good reasoning/cost balance
26
- * - gpt-5.3-codex: Capable agentic coding model with frontier performance and reasoning
27
- * - gpt-5.4: Latest generation with enhanced reasoning depth
24
+ * - gpt-5.4-nano: Cheapest model ($0.20/$1.25 per MTok), good for surface scans
25
+ * - gpt-5.4-mini: Fast with 400k context ($0.75/$4.50 per MTok)
26
+ * - gpt-5.4: Flagship model combining coding, reasoning, and agentic workflows
27
+ * - gpt-5.3-codex: Industry-leading coding model for complex engineering tasks
28
+ *
29
+ * Deprecated (April 2026): gpt-5.1-codex-mini, gpt-5.1-codex-max, gpt-5.1-codex
28
30
  */
29
31
  const CODEX_MODELS = [
30
32
  {
31
- id: 'gpt-5.1-codex-mini',
32
- name: 'GPT-5.1 Mini',
33
+ id: 'gpt-5.4-nano',
34
+ name: 'GPT-5.4 Nano',
33
35
  tier: 'fast',
34
- tagline: 'Blazing Fast',
35
- description: 'Quick, low-cost reviews for style issues, obvious bugs, and lint-level feedback.',
36
- badge: 'Fastest',
36
+ tagline: 'Cheapest',
37
+ description: 'Ultra-low-cost surface scans for style issues, obvious bugs, and lint-level feedback.',
38
+ badge: 'Cheapest',
37
39
  badgeClass: 'badge-speed'
38
40
  },
39
41
  {
40
- id: 'gpt-5.2-codex',
41
- name: 'GPT-5.2 Codex',
42
+ id: 'gpt-5.4-mini',
43
+ name: 'GPT-5.4 Mini',
42
44
  tier: 'balanced',
43
45
  tagline: 'Best Balance',
44
- description: 'Strong everyday reviewer—good reasoning and code understanding for PR-sized changes without top-tier cost.',
46
+ description: 'Fast reviews with 400k context—good balance of speed and capability for everyday PR review.',
45
47
  badge: 'Recommended',
46
48
  badgeClass: 'badge-recommended',
47
49
  default: true
@@ -51,7 +53,7 @@ const CODEX_MODELS = [
51
53
  name: 'GPT-5.3 Codex',
52
54
  tier: 'thorough',
53
55
  tagline: 'Deep Review',
54
- description: 'Capable agentic coding model—combines frontier coding performance with strong reasoning for cross-file analysis.',
56
+ description: 'Industry-leading coding model—frontier performance with strong reasoning for cross-file analysis.',
55
57
  badge: 'Thorough',
56
58
  badgeClass: 'badge-power'
57
59
  },
@@ -60,7 +62,7 @@ const CODEX_MODELS = [
60
62
  name: 'GPT-5.4',
61
63
  tier: 'thorough',
62
64
  tagline: 'Latest Gen',
63
- description: 'Latest generation model with enhanced reasoning depth for complex architectural reviews.',
65
+ description: 'Flagship model combining coding, reasoning, and agentic workflows for complex architectural reviews.',
64
66
  badge: 'Most Thorough',
65
67
  badgeClass: 'badge-power'
66
68
  }
@@ -76,7 +78,7 @@ class CodexProvider extends AIProvider {
76
78
  * @param {Object} configOverrides.env - Additional environment variables
77
79
  * @param {Object[]} configOverrides.models - Custom model definitions
78
80
  */
79
- constructor(model = 'gpt-5.2-codex', configOverrides = {}) {
81
+ constructor(model = 'gpt-5.4-mini', configOverrides = {}) {
80
82
  super(model);
81
83
 
82
84
  // Command precedence: ENV > config > default
@@ -698,7 +700,7 @@ class CodexProvider extends AIProvider {
698
700
  }
699
701
 
700
702
  static getDefaultModel() {
701
- return 'gpt-5.2-codex';
703
+ return 'gpt-5.4-mini';
702
704
  }
703
705
 
704
706
  static getInstallInstructions() {
@@ -21,21 +21,30 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
21
21
  *
22
22
  * GitHub Copilot CLI supports multiple AI models including OpenAI,
23
23
  * Anthropic, and Google models via the --model flag.
24
- * Available models (as of Feb 2026): claude-haiku-4.5, claude-sonnet-4.6,
25
- * claude-sonnet-4.5, gpt-5.2-codex, gpt-5.3-codex,
24
+ * Available models (as of April 2026): claude-haiku-4.6, claude-sonnet-4.6,
25
+ * claude-sonnet-4.5, gpt-5.4, gpt-5.4-mini, gpt-5.3-codex,
26
26
  * claude-opus-4.5, claude-opus-4.6, claude-opus-4.6-fast.
27
27
  * Default is claude-sonnet-4.6.
28
28
  */
29
29
  const COPILOT_MODELS = [
30
30
  {
31
- id: 'claude-haiku-4.5',
32
- name: 'Claude Haiku 4.5',
31
+ id: 'claude-haiku-4.6',
32
+ name: 'Claude Haiku 4.6',
33
33
  tier: 'fast',
34
34
  tagline: 'Quick Scan',
35
35
  description: 'Rapid feedback for obvious issues, style checks, and simple logic errors',
36
36
  badge: 'Speedy',
37
37
  badgeClass: 'badge-speed'
38
38
  },
39
+ {
40
+ id: 'gpt-5.4-mini',
41
+ name: 'GPT-5.4 Mini',
42
+ tier: 'fast',
43
+ tagline: 'Fast & Cheap',
44
+ description: 'Low-cost fast reviews with solid reasoning—included at no premium cost',
45
+ badge: 'Fast',
46
+ badgeClass: 'badge-speed'
47
+ },
39
48
  {
40
49
  id: 'claude-sonnet-4.6',
41
50
  name: 'Claude Sonnet 4.6',
@@ -47,29 +56,20 @@ const COPILOT_MODELS = [
47
56
  default: true
48
57
  },
49
58
  {
50
- id: 'claude-sonnet-4.5',
51
- name: 'Claude Sonnet 4.5',
52
- tier: 'balanced',
53
- tagline: 'Previous Gen',
54
- description: 'Previous generation Sonnet—strong code understanding with excellent quality-to-cost ratio',
55
- badge: 'Previous Gen',
56
- badgeClass: 'badge-balanced'
57
- },
58
- {
59
- id: 'gpt-5.2-codex',
60
- name: 'GPT-5.2 Codex',
61
- tier: 'balanced',
62
- tagline: 'Alternative View',
63
- description: 'OpenAI code-specialized model—different perspective for cross-file analysis',
64
- badge: 'Balanced',
65
- badgeClass: 'badge-balanced'
59
+ id: 'gpt-5.4',
60
+ name: 'GPT-5.4',
61
+ tier: 'thorough',
62
+ tagline: 'Latest OpenAI',
63
+ description: 'Flagship OpenAI model combining coding, reasoning, and agentic workflows',
64
+ badge: 'Latest',
65
+ badgeClass: 'badge-power'
66
66
  },
67
67
  {
68
68
  id: 'gpt-5.3-codex',
69
69
  name: 'GPT-5.3 Codex',
70
70
  tier: 'thorough',
71
71
  tagline: 'Deep Code Analysis',
72
- description: 'Most capable OpenAI coding model—frontier performance for complex multi-file reviews',
72
+ description: 'Industry-leading coding model—frontier performance for complex multi-file reviews',
73
73
  badge: 'Thorough',
74
74
  badgeClass: 'badge-power'
75
75
  },
@@ -20,6 +20,16 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
20
20
  * Gemini model definitions with tier mappings
21
21
  */
22
22
  const GEMINI_MODELS = [
23
+ {
24
+ id: 'gemini-3.1-flash-lite-preview',
25
+ aliases: ['gemini-3.1-flash-lite'],
26
+ name: '3.1 Flash Lite',
27
+ tier: 'fast',
28
+ tagline: 'Cheapest',
29
+ description: 'Ultra-efficient model for high-volume cost-conscious scans',
30
+ badge: 'Cheapest',
31
+ badgeClass: 'badge-speed'
32
+ },
23
33
  {
24
34
  id: 'gemini-3-flash-preview',
25
35
  aliases: ['gemini-3-flash'],
@@ -16,7 +16,10 @@
16
16
  * for cross-provider switching, which translates to `--provider <provider> --model <model>`.
17
17
  */
18
18
 
19
+ const crypto = require('crypto');
19
20
  const path = require('path');
21
+ const os = require('os');
22
+ const fs = require('fs');
20
23
  const { spawn } = require('child_process');
21
24
  const { AIProvider, registerProvider, quoteShellArgs } = require('./provider');
22
25
  const logger = require('../utils/logger');
@@ -244,19 +247,23 @@ class PiProvider extends AIProvider {
244
247
 
245
248
  const levelPrefix = logPrefix || `[Level ${level}]`;
246
249
  logger.info(`${levelPrefix} Executing Pi CLI...`);
247
- logger.info(`${levelPrefix} Writing prompt via stdin: ${prompt.length} bytes`);
250
+ logger.info(`${levelPrefix} Prompt: ${prompt.length} bytes`);
251
+
252
+ // Write prompt to a temp file and use Pi's @file syntax as a positional arg.
253
+ // This bypasses devx stdin interference that breaks --mode json output.
254
+ const tmpFile = path.join(os.tmpdir(), `pair-review-prompt-${Date.now()}-${process.pid}-${crypto.randomUUID()}.txt`);
255
+ fs.writeFileSync(tmpFile, prompt);
256
+ const cleanupTmpFile = () => { try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } };
248
257
 
249
- // Use stdin for prompt instead of CLI argument (avoids shell escaping issues)
250
- // Pi reads from stdin when using -p with no positional message arguments
251
258
  let fullCommand;
252
259
  let fullArgs;
253
260
 
254
261
  if (this.useShell) {
255
- fullCommand = `${this.piCmd} ${quoteShellArgs(this.baseArgs).join(' ')}`;
262
+ fullCommand = `${this.piCmd} ${quoteShellArgs([...this.baseArgs, `@${tmpFile}`]).join(' ')}`;
256
263
  fullArgs = [];
257
264
  } else {
258
265
  fullCommand = this.piCmd;
259
- fullArgs = [...this.baseArgs];
266
+ fullArgs = [...this.baseArgs, `@${tmpFile}`];
260
267
  }
261
268
 
262
269
  const pi = spawn(fullCommand, fullArgs, {
@@ -269,6 +276,10 @@ class PiProvider extends AIProvider {
269
276
  shell: this.useShell
270
277
  });
271
278
 
279
+ // Close stdin immediately — prompt is delivered via @file, but some
280
+ // wrappers (e.g., devx) keep the process alive until stdin is closed.
281
+ pi.stdin.end();
282
+
272
283
  const pid = pi.pid;
273
284
  logger.debug(`${levelPrefix} Pi CLI command: ${fullCommand} ${fullArgs.join(' ')}`);
274
285
  logger.info(`${levelPrefix} Spawned Pi CLI process: PID ${pid}`);
@@ -340,6 +351,7 @@ class PiProvider extends AIProvider {
340
351
 
341
352
  // Handle completion
342
353
  pi.on('close', (code) => {
354
+ cleanupTmpFile();
343
355
  if (settled) return; // Already settled by timeout or error
344
356
 
345
357
  // Flush any remaining stream parser buffer
@@ -413,7 +425,7 @@ class PiProvider extends AIProvider {
413
425
 
414
426
  // Use async IIFE to handle the async LLM extraction
415
427
  (async () => {
416
- // Guard: if already settled (by timeout, stdin error, or cancellation),
428
+ // Guard: if already settled (by timeout, process error, or cancellation),
417
429
  // skip the LLM extraction entirely to avoid misleading log output
418
430
  if (settled) return;
419
431
 
@@ -437,6 +449,7 @@ class PiProvider extends AIProvider {
437
449
 
438
450
  // Handle errors
439
451
  pi.on('error', (error) => {
452
+ cleanupTmpFile();
440
453
  if (error.code === 'ENOENT') {
441
454
  logger.error(`${levelPrefix} Pi CLI not found. Please ensure Pi CLI is installed.`);
442
455
  settle(reject, new Error(`${levelPrefix} Pi CLI not found. ${PiProvider.getInstallInstructions()}`));
@@ -445,21 +458,6 @@ class PiProvider extends AIProvider {
445
458
  settle(reject, error);
446
459
  }
447
460
  });
448
-
449
- // Handle stdin errors (e.g., EPIPE if process exits before write completes)
450
- pi.stdin.on('error', (err) => {
451
- logger.error(`${levelPrefix} stdin error: ${err.message}`);
452
- });
453
-
454
- // Send the prompt to stdin (Pi reads from stdin when using -p with no args)
455
- pi.stdin.write(prompt, (err) => {
456
- if (err) {
457
- logger.error(`${levelPrefix} Failed to write prompt to stdin: ${err}`);
458
- pi.kill('SIGTERM');
459
- settle(reject, new Error(`${levelPrefix} Failed to write prompt to stdin: ${err}`));
460
- }
461
- });
462
- pi.stdin.end();
463
461
  });
464
462
  }
465
463
 
@@ -740,14 +738,13 @@ class PiProvider extends AIProvider {
740
738
  // Build args consistently using the shared method, applying provider and model extra_args
741
739
  const args = this.buildArgsForModel(model);
742
740
 
743
- // For extraction, we pass the prompt via stdin
744
- // Pi reads from stdin when using -p with no positional message arguments
741
+ // Use @file syntax for prompt delivery (bypasses devx stdin interference)
745
742
  if (useShell) {
746
743
  return {
747
744
  command: `${piCmd} ${quoteShellArgs(args).join(' ')}`,
748
745
  args: [],
749
746
  useShell: true,
750
- promptViaStdin: true,
747
+ promptViaFile: true,
751
748
  env: this.extraEnv
752
749
  };
753
750
  }
@@ -755,7 +752,7 @@ class PiProvider extends AIProvider {
755
752
  command: piCmd,
756
753
  args,
757
754
  useShell: false,
758
- promptViaStdin: true,
755
+ promptViaFile: true,
759
756
  env: this.extraEnv
760
757
  };
761
758
  }
@@ -6,7 +6,10 @@
6
6
  * and provides a factory function to create provider instances.
7
7
  */
8
8
 
9
+ const crypto = require('crypto');
9
10
  const path = require('path');
11
+ const os = require('os');
12
+ const fs = require('fs');
10
13
  const { spawn } = require('child_process');
11
14
  const logger = require('../utils/logger');
12
15
  const { extractJSON } = require('../utils/json-extractor');
@@ -181,6 +184,7 @@ class AIProvider {
181
184
  * @property {string[]} args - Arguments (prompt will be appended if promptViaStdin is false)
182
185
  * @property {boolean} useShell - Whether to use shell mode
183
186
  * @property {boolean} promptViaStdin - If true, send prompt to stdin; if false, append to args
187
+ * @property {boolean} promptViaFile - If true, write prompt to a temp file and pass @filepath as a positional arg (Pi-specific @file syntax; currently only used by PiProvider)
184
188
  */
185
189
  getExtractionConfig(model) {
186
190
  // Default: extraction not supported
@@ -213,7 +217,7 @@ class AIProvider {
213
217
  };
214
218
  }
215
219
 
216
- const { command, args, useShell, promptViaStdin, env: configEnv } = config;
220
+ const { command, args, useShell, promptViaStdin, promptViaFile, env: configEnv } = config;
217
221
  const prompt = `Extract the JSON object from the following text. Return ONLY the valid JSON, nothing else. Do not include any explanation, markdown formatting, or code blocks - just the raw JSON.
218
222
 
219
223
  === BEGIN INPUT TEXT ===
@@ -222,7 +226,21 @@ ${rawResponse}
222
226
 
223
227
  return new Promise((resolve) => {
224
228
  // Build final command and args based on prompt delivery method
225
- const finalArgs = promptViaStdin ? args : [...args, prompt];
229
+ // promptViaFile: write to temp file, pass @filepath as positional arg (Pi @file syntax)
230
+ // promptViaStdin: write to process stdin after spawn
231
+ // default: pass prompt as positional CLI arg
232
+ let tmpFile = null;
233
+ let cleanupTmpFile = () => {};
234
+ let finalArgs;
235
+
236
+ if (promptViaFile) {
237
+ tmpFile = path.join(os.tmpdir(), `pair-review-extract-${Date.now()}-${process.pid}-${crypto.randomUUID()}.txt`);
238
+ fs.writeFileSync(tmpFile, prompt);
239
+ cleanupTmpFile = () => { try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } };
240
+ finalArgs = [...args, `@${tmpFile}`];
241
+ } else {
242
+ finalArgs = promptViaStdin ? args : [...args, prompt];
243
+ }
226
244
 
227
245
  logger.info(`${levelPrefix} Attempting LLM-based JSON extraction with ${extractionModel}...`);
228
246
 
@@ -269,6 +287,7 @@ ${rawResponse}
269
287
  });
270
288
 
271
289
  proc.on('close', (code) => {
290
+ cleanupTmpFile();
272
291
  if (settled) return;
273
292
 
274
293
  if (code !== 0) {
@@ -295,11 +314,12 @@ ${rawResponse}
295
314
  });
296
315
 
297
316
  proc.on('error', (error) => {
317
+ cleanupTmpFile();
298
318
  logger.warn(`${levelPrefix} LLM extraction process error: ${error.message}`);
299
319
  settle({ success: false, error: error.message });
300
320
  });
301
321
 
302
- // Send prompt via stdin if configured
322
+ // Deliver prompt based on config method
303
323
  if (promptViaStdin) {
304
324
  // Handle stdin errors (e.g., EPIPE if process exits before write completes)
305
325
  proc.stdin.on('error', (err) => {
@@ -314,6 +334,9 @@ ${rawResponse}
314
334
  }
315
335
  });
316
336
  proc.stdin.end();
337
+ } else if (promptViaFile) {
338
+ // Prompt delivered via @file arg — close stdin so wrappers (e.g., devx) don't hang
339
+ proc.stdin.end();
317
340
  }
318
341
  });
319
342
  }
@@ -35,6 +35,7 @@ class PiBridge extends EventEmitter {
35
35
  * @param {boolean} [options.useShell] - Use shell mode for multi-word commands
36
36
  * @param {string[]} [options.skills] - Array of skill file paths to load via --skill
37
37
  * @param {string[]} [options.extensions] - Array of extension directory paths to load via -e
38
+ * @param {string[]} [options.extraArgs] - Extra CLI args to append (e.g., from config extra_args)
38
39
  * @param {string} [options.sessionPath] - Path to a session file for resumption
39
40
  */
40
41
  constructor(options = {}) {
@@ -49,6 +50,7 @@ class PiBridge extends EventEmitter {
49
50
  this.useShell = options.useShell || false;
50
51
  this.skills = options.skills || [];
51
52
  this.extensions = options.extensions || [];
53
+ this.extraArgs = options.extraArgs || [];
52
54
  this.sessionPath = options.sessionPath || null;
53
55
 
54
56
  this._process = null;
@@ -288,6 +290,12 @@ class PiBridge extends EventEmitter {
288
290
  args.push('-e', ext);
289
291
  }
290
292
 
293
+ // Append extra args from provider config (e.g., extra_args in chat_providers).
294
+ // These go last so they can override earlier flags if needed.
295
+ if (this.extraArgs.length > 0) {
296
+ args.push(...this.extraArgs);
297
+ }
298
+
291
299
  return args;
292
300
  }
293
301
 
@@ -580,6 +580,7 @@ class ChatSessionManager {
580
580
  provider: def?.provider || null,
581
581
  model: options.model || def?.model,
582
582
  piCommand: def?.command,
583
+ extraArgs: def?.args,
583
584
  env: def?.env,
584
585
  useShell: def?.useShell,
585
586
  tools: CHAT_TOOLS,
@@ -1,12 +1,9 @@
1
1
  // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  const { execSync } = require('child_process');
3
- const { readFileSync } = require('fs');
4
- const path = require('path');
5
3
  const logger = require('../utils/logger');
6
4
 
7
5
  const defaults = {
8
6
  execSync,
9
- readFileSync,
10
7
  // Callers should pass a resolved token via _deps.getGitHubToken.
11
8
  // This default returns empty so GitHub lookup is silently skipped
12
9
  // when no token is provided — never re-resolve config internally.
@@ -135,53 +132,6 @@ function buildStack(state, currentBranch, trunk) {
135
132
  return entries;
136
133
  }
137
134
 
138
- /**
139
- * Read Graphite PR info from the `.graphite_pr_info` file in the git dir.
140
- *
141
- * @param {string} repoPath - Absolute path to the repository
142
- * @param {Object} deps - Dependencies (execSync, readFileSync)
143
- * @returns {Object|null} Parsed PR info object with `prInfos` array, or null
144
- */
145
- function readGraphitePRInfo(repoPath, deps) {
146
- try {
147
- const gitCommonDir = deps.execSync('git rev-parse --git-common-dir', {
148
- cwd: repoPath,
149
- encoding: 'utf8',
150
- stdio: ['pipe', 'pipe', 'pipe']
151
- }).trim();
152
-
153
- const prInfoPath = path.resolve(repoPath, gitCommonDir, '.graphite_pr_info');
154
- const raw = deps.readFileSync(prInfoPath, 'utf8');
155
- return JSON.parse(raw);
156
- } catch (error) {
157
- logger.debug(`Graphite PR info read failed: ${error.message}`);
158
- return null;
159
- }
160
- }
161
-
162
- /**
163
- * Enrich stack entries with PR numbers from Graphite PR info.
164
- *
165
- * @param {Array} stack - Stack entries from buildStack
166
- * @param {Array} prInfos - Array of PR info objects with headRefName and prNumber
167
- * @returns {Array} New array of stack entries, each with optional prNumber
168
- */
169
- function enrichStackWithPRInfo(stack, prInfos) {
170
- if (!prInfos || !Array.isArray(prInfos)) return stack;
171
-
172
- const prMap = new Map();
173
- for (const info of prInfos) {
174
- if (info.headRefName) {
175
- prMap.set(info.headRefName, info.prNumber);
176
- }
177
- }
178
-
179
- return stack.map(entry => {
180
- const prNumber = prMap.get(entry.branch);
181
- return prNumber != null ? { ...entry, prNumber } : { ...entry };
182
- });
183
- }
184
-
185
135
  /**
186
136
  * Try GitHub API to find an open PR for this branch.
187
137
  */
@@ -302,4 +252,4 @@ function getDefaultBranch(localPath, _deps) {
302
252
  return null;
303
253
  }
304
254
 
305
- module.exports = { detectBaseBranch, getDefaultBranch, tryGraphiteState, buildStack, readGraphitePRInfo, enrichStackWithPRInfo };
255
+ module.exports = { detectBaseBranch, getDefaultBranch, tryGraphiteState, buildStack };
@@ -0,0 +1,88 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * In-memory worktree lock manager.
4
+ *
5
+ * Prevents concurrent git operations on the same worktree during
6
+ * stack analysis. Non-blocking — callers check and fail fast.
7
+ */
8
+
9
+ const logger = require('../utils/logger');
10
+
11
+ class WorktreeLockManager {
12
+ constructor() {
13
+ /** @type {Map<string, { holderId: string, lockedAt: Date }>} */
14
+ this._locks = new Map();
15
+ }
16
+
17
+ /**
18
+ * Acquire a lock on a worktree path.
19
+ *
20
+ * @param {string} worktreePath - Absolute path to the worktree
21
+ * @param {string} holderId - Unique identifier for the lock holder (e.g. stackAnalysisId)
22
+ * @returns {boolean} true if acquired (or re-acquired by same holder), false if held by another
23
+ */
24
+ acquire(worktreePath, holderId) {
25
+ const existing = this._locks.get(worktreePath);
26
+
27
+ if (existing) {
28
+ if (existing.holderId === holderId) {
29
+ // Re-acquire by same holder — update timestamp
30
+ existing.lockedAt = new Date();
31
+ logger.debug(`Worktree lock re-acquired: ${worktreePath} by ${holderId}`);
32
+ return true;
33
+ }
34
+ logger.debug(`Worktree lock denied: ${worktreePath} held by ${existing.holderId}, requested by ${holderId}`);
35
+ return false;
36
+ }
37
+
38
+ this._locks.set(worktreePath, { holderId, lockedAt: new Date() });
39
+ logger.info(`Worktree lock acquired: ${worktreePath} by ${holderId}`);
40
+ return true;
41
+ }
42
+
43
+ /**
44
+ * Release a lock on a worktree path.
45
+ *
46
+ * @param {string} worktreePath - Absolute path to the worktree
47
+ * @param {string} holderId - Must match the holder that acquired the lock
48
+ * @returns {boolean} true if released, false if not held or held by a different holder
49
+ */
50
+ release(worktreePath, holderId) {
51
+ const existing = this._locks.get(worktreePath);
52
+
53
+ if (!existing) {
54
+ logger.debug(`Worktree lock release: no lock found for ${worktreePath}`);
55
+ return false;
56
+ }
57
+
58
+ if (existing.holderId !== holderId) {
59
+ logger.debug(`Worktree lock release denied: ${worktreePath} held by ${existing.holderId}, release requested by ${holderId}`);
60
+ return false;
61
+ }
62
+
63
+ this._locks.delete(worktreePath);
64
+ logger.info(`Worktree lock released: ${worktreePath} by ${holderId}`);
65
+ return true;
66
+ }
67
+
68
+ /**
69
+ * Check whether a worktree is currently locked.
70
+ *
71
+ * @param {string} worktreePath - Absolute path to the worktree
72
+ * @returns {{ locked: boolean, holderId?: string }}
73
+ */
74
+ isLocked(worktreePath) {
75
+ const existing = this._locks.get(worktreePath);
76
+
77
+ if (!existing) {
78
+ return { locked: false };
79
+ }
80
+
81
+ return { locked: true, holderId: existing.holderId };
82
+ }
83
+ }
84
+
85
+ // Singleton instance for application-wide use
86
+ const worktreeLock = new WorktreeLockManager();
87
+
88
+ module.exports = { worktreeLock, WorktreeLockManager };