@in-the-loop-labs/pair-review 3.2.2 → 3.3.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 (46) hide show
  1. package/README.md +7 -6
  2. package/package.json +5 -4
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/plugin-code-critic/skills/analyze/references/orchestration-balanced.md +9 -1
  6. package/plugin-code-critic/skills/analyze/references/orchestration-fast.md +8 -1
  7. package/plugin-code-critic/skills/analyze/references/orchestration-thorough.md +8 -7
  8. package/public/css/repo-settings.css +347 -0
  9. package/public/index.html +46 -9
  10. package/public/js/components/AIPanel.js +79 -37
  11. package/public/js/components/DiffOptionsDropdown.js +84 -1
  12. package/public/js/index.js +31 -6
  13. package/public/js/modules/analysis-history.js +11 -7
  14. package/public/js/pr.js +22 -0
  15. package/public/js/repo-settings.js +334 -6
  16. package/public/repo-settings.html +29 -0
  17. package/src/ai/analyzer.js +28 -19
  18. package/src/ai/claude-cli.js +2 -0
  19. package/src/ai/claude-provider.js +4 -1
  20. package/src/ai/prompts/baseline/consolidation/balanced.js +6 -4
  21. package/src/ai/prompts/baseline/consolidation/fast.js +6 -2
  22. package/src/ai/prompts/baseline/consolidation/thorough.js +7 -6
  23. package/src/ai/prompts/baseline/orchestration/balanced.js +13 -1
  24. package/src/ai/prompts/baseline/orchestration/fast.js +12 -1
  25. package/src/ai/prompts/baseline/orchestration/thorough.js +8 -7
  26. package/src/ai/provider.js +7 -6
  27. package/src/chat/session-manager.js +6 -3
  28. package/src/config.js +230 -38
  29. package/src/database.js +766 -38
  30. package/src/git/worktree-pool-lifecycle.js +674 -0
  31. package/src/git/worktree-pool-usage.js +216 -0
  32. package/src/git/worktree.js +46 -13
  33. package/src/main.js +185 -26
  34. package/src/routes/analyses.js +48 -26
  35. package/src/routes/chat.js +27 -3
  36. package/src/routes/config.js +17 -5
  37. package/src/routes/executable-analysis.js +38 -19
  38. package/src/routes/local.js +19 -6
  39. package/src/routes/mcp.js +13 -2
  40. package/src/routes/pr.js +72 -29
  41. package/src/routes/setup.js +41 -4
  42. package/src/routes/stack-analysis.js +29 -10
  43. package/src/routes/worktrees.js +294 -9
  44. package/src/server.js +20 -3
  45. package/src/setup/pr-setup.js +161 -27
  46. package/src/ws/server.js +51 -1
@@ -0,0 +1,216 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ 'use strict';
3
+
4
+ const logger = require('../utils/logger');
5
+
6
+ const GRACE_PERIOD_MS = 30_000; // 30 seconds after last WS disconnect
7
+
8
+ /**
9
+ * In-memory tracker that determines whether a pool worktree is "in use".
10
+ *
11
+ * A pool worktree is considered in-use when any of:
12
+ * - At least one WebSocket session is subscribed to its review topic
13
+ * - An AI analysis is running against it
14
+ * - The grace period after the last session disconnect hasn't expired
15
+ *
16
+ * When a worktree becomes idle (all of the above are false), the `onIdle`
17
+ * callback fires so the pool manager can mark it available.
18
+ */
19
+ class WorktreePoolUsageTracker {
20
+ constructor() {
21
+ /** @type {Map<string, Set<string>>} worktreeId -> Set of active session keys */
22
+ this._sessions = new Map();
23
+ /** @type {Map<string, Set<string>>} worktreeId -> Set of active analysis IDs */
24
+ this._analyses = new Map();
25
+ /** @type {Map<string, NodeJS.Timeout>} worktreeId -> grace period timer */
26
+ this._graceTimers = new Map();
27
+ /** @type {Function|null} Callback when a worktree becomes idle: (worktreeId) => void */
28
+ this.onIdle = null;
29
+ }
30
+
31
+ /**
32
+ * Register an active WebSocket session for a worktree.
33
+ * Clears any pending grace-period timer.
34
+ * @param {string} worktreeId - Pool worktree ID
35
+ * @param {string} sessionKey - Unique key for this WS connection
36
+ */
37
+ addSession(worktreeId, sessionKey) {
38
+ if (!this._sessions.has(worktreeId)) {
39
+ this._sessions.set(worktreeId, new Set());
40
+ }
41
+ this._sessions.get(worktreeId).add(sessionKey);
42
+
43
+ // Cancel any pending grace timer
44
+ const timer = this._graceTimers.get(worktreeId);
45
+ if (timer) {
46
+ clearTimeout(timer);
47
+ this._graceTimers.delete(worktreeId);
48
+ logger.debug(`Grace period cancelled for pool worktree ${worktreeId} — new session connected`);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Remove a WebSocket session. Starts grace period if no sessions remain
54
+ * and no analyses are running.
55
+ * @param {string} worktreeId
56
+ * @param {string} sessionKey
57
+ */
58
+ removeSession(worktreeId, sessionKey) {
59
+ const sessions = this._sessions.get(worktreeId);
60
+ if (!sessions) return;
61
+
62
+ sessions.delete(sessionKey);
63
+ if (sessions.size === 0) {
64
+ this._sessions.delete(worktreeId);
65
+ }
66
+
67
+ this._checkIdle(worktreeId);
68
+ }
69
+
70
+ /**
71
+ * Register an active analysis for a worktree.
72
+ * Clears any pending grace-period timer.
73
+ * @param {string} worktreeId - Pool worktree ID
74
+ * @param {string} analysisId - Unique analysis identifier
75
+ */
76
+ addAnalysis(worktreeId, analysisId) {
77
+ if (!this._analyses.has(worktreeId)) {
78
+ this._analyses.set(worktreeId, new Set());
79
+ }
80
+ this._analyses.get(worktreeId).add(analysisId);
81
+
82
+ // Cancel any pending grace timer
83
+ const timer = this._graceTimers.get(worktreeId);
84
+ if (timer) {
85
+ clearTimeout(timer);
86
+ this._graceTimers.delete(worktreeId);
87
+ logger.debug(`Grace period cancelled for pool worktree ${worktreeId} — new analysis started`);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Remove an active analysis hold.
93
+ * @param {string} worktreeId
94
+ * @param {string} analysisId
95
+ */
96
+ removeAnalysis(worktreeId, analysisId) {
97
+ const analyses = this._analyses.get(worktreeId);
98
+ if (!analyses) return;
99
+
100
+ analyses.delete(analysisId);
101
+ if (analyses.size === 0) {
102
+ this._analyses.delete(worktreeId);
103
+ }
104
+
105
+ this._checkIdle(worktreeId);
106
+ }
107
+
108
+ /**
109
+ * Remove an analysis hold by analysisId only (without knowing worktreeId).
110
+ * Searches all worktrees for the analysis.
111
+ * @param {string} analysisId
112
+ */
113
+ removeAnalysisById(analysisId) {
114
+ for (const [worktreeId, analyses] of this._analyses) {
115
+ if (analyses.has(analysisId)) {
116
+ this.removeAnalysis(worktreeId, analysisId);
117
+ return;
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Check if a worktree is currently in use.
124
+ * @param {string} worktreeId
125
+ * @returns {boolean}
126
+ */
127
+ isInUse(worktreeId) {
128
+ const hasSessions = (this._sessions.get(worktreeId)?.size || 0) > 0;
129
+ const hasAnalyses = (this._analyses.get(worktreeId)?.size || 0) > 0;
130
+ const hasGraceTimer = this._graceTimers.has(worktreeId);
131
+ return hasSessions || hasAnalyses || hasGraceTimer;
132
+ }
133
+
134
+ /**
135
+ * Return the set of active analysis IDs for a worktree (may be empty).
136
+ * @param {string} worktreeId
137
+ * @returns {Set<string>}
138
+ */
139
+ getActiveAnalyses(worktreeId) {
140
+ return new Set(this._analyses.get(worktreeId) || []);
141
+ }
142
+
143
+ /**
144
+ * Forcefully clear all tracking state for a single worktree.
145
+ *
146
+ * Removes sessions, analyses, and grace timers.
147
+ * Does NOT fire the onIdle callback — the caller is assumed to be
148
+ * handling the worktree lifecycle directly (e.g., deleting or
149
+ * marking it available in the pool).
150
+ *
151
+ * @param {string} worktreeId - Pool worktree ID to purge
152
+ */
153
+ clearWorktree(worktreeId) {
154
+ this._sessions.delete(worktreeId);
155
+ this._analyses.delete(worktreeId);
156
+ const timer = this._graceTimers.get(worktreeId);
157
+ if (timer) {
158
+ clearTimeout(timer);
159
+ this._graceTimers.delete(worktreeId);
160
+ }
161
+ logger.debug(`Cleared all tracking state for pool worktree ${worktreeId}`);
162
+ }
163
+
164
+ /**
165
+ * Internal: check if a worktree has become idle and start grace period or fire callback.
166
+ * @param {string} worktreeId
167
+ * @private
168
+ */
169
+ _checkIdle(worktreeId) {
170
+ const hasSessions = (this._sessions.get(worktreeId)?.size || 0) > 0;
171
+ const hasAnalyses = (this._analyses.get(worktreeId)?.size || 0) > 0;
172
+
173
+ if (hasSessions || hasAnalyses) return; // still in use
174
+
175
+ // Already have a grace timer? Let it run
176
+ if (this._graceTimers.has(worktreeId)) return;
177
+
178
+ // Start grace period
179
+ logger.debug(`Starting ${GRACE_PERIOD_MS / 1000}s grace period for pool worktree ${worktreeId}`);
180
+ const timer = setTimeout(async () => {
181
+ this._graceTimers.delete(worktreeId);
182
+ // Double-check still idle (a new session could have connected during grace)
183
+ const stillHasSessions = (this._sessions.get(worktreeId)?.size || 0) > 0;
184
+ const stillHasAnalyses = (this._analyses.get(worktreeId)?.size || 0) > 0;
185
+ if (!stillHasSessions && !stillHasAnalyses) {
186
+ logger.info(`Pool worktree ${worktreeId} idle after grace period`);
187
+ if (this.onIdle) {
188
+ try {
189
+ await this.onIdle(worktreeId);
190
+ } catch (err) {
191
+ logger.error(`onIdle callback failed for ${worktreeId}: ${err.message}`);
192
+ }
193
+ }
194
+ }
195
+ }, GRACE_PERIOD_MS);
196
+
197
+ // Don't hold the process open for grace timers
198
+ if (timer.unref) timer.unref();
199
+ this._graceTimers.set(worktreeId, timer);
200
+ }
201
+
202
+ /**
203
+ * Clear all tracking state. Useful for testing.
204
+ */
205
+ reset() {
206
+ this._sessions.clear();
207
+ this._analyses.clear();
208
+ for (const timer of this._graceTimers.values()) {
209
+ clearTimeout(timer);
210
+ }
211
+ this._graceTimers.clear();
212
+ this.onIdle = null;
213
+ }
214
+ }
215
+
216
+ module.exports = { WorktreePoolUsageTracker, GRACE_PERIOD_MS };
@@ -241,10 +241,12 @@ class GitWorktreeManager {
241
241
  * When set, worktree is created with --no-checkout from the main git root (no sparse-checkout inheritance),
242
242
  * and the script is executed before checkout with PR context as environment variables.
243
243
  * @param {number} [options.checkoutTimeout] - Timeout in ms for checkout script (default: 300000 = 5 minutes)
244
- * @returns {Promise<string>} Path to created worktree
244
+ * @param {string} [options.explicitId] - When provided, use this ID for the worktrees-table record
245
+ * instead of generating one. Used by the worktree pool to align the worktrees-table ID with the pool ID.
246
+ * @returns {Promise<{ path: string, id: string }>} Path and database ID of created worktree
245
247
  */
246
248
  async createWorktreeForPR(prInfo, prData, repositoryPath, options = {}) {
247
- const { worktreeSourcePath, checkoutScript, checkoutTimeout } = options;
249
+ const { worktreeSourcePath, checkoutScript, checkoutTimeout, explicitId } = options;
248
250
  // Check if worktree already exists in DB
249
251
  const repository = normalizeRepository(prInfo.owner, prInfo.repo);
250
252
  let worktreePath;
@@ -265,7 +267,25 @@ class GitWorktreeManager {
265
267
  // Try to reuse existing worktree by refreshing it
266
268
  console.log(`Found existing worktree for PR #${prInfo.number} at ${worktreePath}`);
267
269
  try {
268
- return await this.refreshWorktree(worktreeRecord, prInfo.number, prData, prInfo);
270
+ const refreshedPath = await this.refreshWorktree(worktreeRecord, prInfo.number, prData, prInfo);
271
+ let returnId = worktreeRecord.id;
272
+
273
+ // If explicitId is provided and differs from the existing record's ID,
274
+ // migrate the worktrees-table row to use the pool ID. This happens when
275
+ // pool mode is enabled for a repo that already has legacy worktree records.
276
+ if (explicitId && worktreeRecord.id !== explicitId && this.worktreeRepo) {
277
+ const migrated = await this.worktreeRepo.getOrCreate({
278
+ prNumber: prInfo.number,
279
+ repository,
280
+ branch: prData.head_branch || prData.base_branch,
281
+ path: refreshedPath,
282
+ explicitId,
283
+ });
284
+ returnId = migrated.id;
285
+ console.log(`Migrated worktree record from ${worktreeRecord.id} to ${explicitId}`);
286
+ }
287
+
288
+ return { path: refreshedPath, id: returnId };
269
289
  } catch (refreshError) {
270
290
  // If refresh fails due to uncommitted changes, propagate that error
271
291
  if (refreshError.message.includes('uncommitted changes')) {
@@ -289,20 +309,23 @@ class GitWorktreeManager {
289
309
  if (legacyExists && await this.isValidGitWorktree(legacyPath)) {
290
310
  console.log(`Found legacy worktree for PR #${prInfo.number} at ${legacyPath}, adopting it`);
291
311
 
292
- // Create DB record for the legacy worktree
312
+ // Create DB record for the legacy worktree — pass explicitId so the record
313
+ // is created with the pool ID when pool mode is active
293
314
  if (this.worktreeRepo) {
294
315
  worktreeRecord = await this.worktreeRepo.getOrCreate({
295
316
  prNumber: prInfo.number,
296
317
  repository,
297
318
  branch: prData.head_branch || prData.base_branch,
298
- path: legacyPath
319
+ path: legacyPath,
320
+ explicitId,
299
321
  });
300
322
  console.log(`Created database record for legacy worktree`);
301
323
  }
302
324
 
303
325
  // Try to refresh and reuse the legacy worktree
304
326
  try {
305
- return await this.refreshWorktree({ path: legacyPath, id: worktreeRecord?.id }, prInfo.number, prData, prInfo);
327
+ const refreshedPath = await this.refreshWorktree({ path: legacyPath, id: worktreeRecord?.id }, prInfo.number, prData, prInfo);
328
+ return { path: refreshedPath, id: worktreeRecord?.id };
306
329
  } catch (refreshError) {
307
330
  // If refresh fails due to uncommitted changes, propagate that error
308
331
  if (refreshError.message.includes('uncommitted changes')) {
@@ -438,8 +461,14 @@ class GitWorktreeManager {
438
461
  }
439
462
 
440
463
  // Checkout to PR head commit
441
- console.log(`Checking out to PR head commit ${prData.head_sha}...`);
442
- await worktreeGit.checkout([`${remote}/pr-${prInfo.number}`]);
464
+ const targetSha = prData.head_sha;
465
+ if (targetSha) {
466
+ console.log(`Checking out to PR head commit ${targetSha}...`);
467
+ await worktreeGit.checkout([targetSha]);
468
+ } else {
469
+ console.log(`Checking out to PR head ref ${remote}/pr-${prInfo.number}...`);
470
+ await worktreeGit.checkout([`${remote}/pr-${prInfo.number}`]);
471
+ }
443
472
 
444
473
  // Verify we're at the correct commit
445
474
  const currentCommit = await worktreeGit.revparse(['HEAD']);
@@ -448,18 +477,21 @@ class GitWorktreeManager {
448
477
  }
449
478
 
450
479
  // Store/update worktree record in database
480
+ let worktreeDbId;
451
481
  if (this.worktreeRepo) {
452
- await this.worktreeRepo.getOrCreate({
482
+ const record = await this.worktreeRepo.getOrCreate({
453
483
  prNumber: prInfo.number,
454
484
  repository,
455
485
  branch: prData.head_branch || prData.base_branch,
456
- path: worktreePath
486
+ path: worktreePath,
487
+ explicitId,
457
488
  });
489
+ worktreeDbId = record.id;
458
490
  console.log(`Worktree record stored in database`);
459
491
  }
460
492
 
461
493
  console.log(`Worktree created successfully at ${worktreePath}`);
462
- return worktreePath;
494
+ return { path: worktreePath, id: worktreeDbId };
463
495
 
464
496
  } catch (error) {
465
497
  console.error('Error creating worktree:', error);
@@ -503,9 +535,10 @@ class GitWorktreeManager {
503
535
  // Resolve which remote points to the PR's base repository (handles forks)
504
536
  const remote = await this.resolveRemoteForPR(worktreeGit, prData, prInfo);
505
537
 
506
- // Fetch the latest from the resolved remote
538
+ // Fetch the latest from the resolved remote (--prune removes stale
539
+ // tracking refs that would otherwise block fetch on ref hierarchy conflicts)
507
540
  console.log(`Fetching latest changes from ${remote}...`);
508
- await worktreeGit.fetch([remote]);
541
+ await worktreeGit.fetch([remote, '--prune']);
509
542
 
510
543
  // Fetch the PR head using GitHub's pull request refs
511
544
  console.log(`Fetching PR #${number} head...`);