@in-the-loop-labs/pair-review 2.6.3 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/.pi/extensions/task/index.ts +1 -1
  2. package/.pi/skills/review-roulette/SKILL.md +1 -1
  3. package/LICENSE +201 -674
  4. package/README.md +2 -2
  5. package/bin/pair-review.js +1 -1
  6. package/package.json +2 -2
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin-code-critic/.claude-plugin/plugin.json +2 -2
  9. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +1 -1
  10. package/public/css/ai-summary-modal.css +1 -1
  11. package/public/css/pr.css +194 -0
  12. package/public/index.html +168 -3
  13. package/public/js/components/AIPanel.js +17 -3
  14. package/public/js/components/AISummaryModal.js +1 -1
  15. package/public/js/components/AdvancedConfigTab.js +1 -1
  16. package/public/js/components/AnalysisConfigModal.js +1 -1
  17. package/public/js/components/ChatPanel.js +42 -7
  18. package/public/js/components/ConfirmDialog.js +22 -3
  19. package/public/js/components/CouncilProgressModal.js +14 -1
  20. package/public/js/components/DiffOptionsDropdown.js +411 -24
  21. package/public/js/components/EmojiPicker.js +1 -1
  22. package/public/js/components/KeyboardShortcuts.js +1 -1
  23. package/public/js/components/PanelGroup.js +1 -1
  24. package/public/js/components/PreviewModal.js +1 -1
  25. package/public/js/components/ReviewModal.js +1 -1
  26. package/public/js/components/SplitButton.js +1 -1
  27. package/public/js/components/StatusIndicator.js +1 -1
  28. package/public/js/components/SuggestionNavigator.js +13 -6
  29. package/public/js/components/TabTitle.js +96 -0
  30. package/public/js/components/TextInputDialog.js +1 -1
  31. package/public/js/components/TimeoutSelect.js +1 -1
  32. package/public/js/components/Toast.js +7 -1
  33. package/public/js/components/VoiceCentricConfigTab.js +1 -1
  34. package/public/js/index.js +649 -44
  35. package/public/js/local.js +570 -77
  36. package/public/js/modules/analysis-history.js +4 -3
  37. package/public/js/modules/comment-manager.js +6 -1
  38. package/public/js/modules/comment-minimizer.js +304 -0
  39. package/public/js/modules/diff-context.js +1 -1
  40. package/public/js/modules/diff-renderer.js +1 -1
  41. package/public/js/modules/file-comment-manager.js +1 -1
  42. package/public/js/modules/file-list-merger.js +1 -1
  43. package/public/js/modules/gap-coordinates.js +1 -1
  44. package/public/js/modules/hunk-parser.js +1 -1
  45. package/public/js/modules/line-tracker.js +1 -1
  46. package/public/js/modules/panel-resizer.js +1 -1
  47. package/public/js/modules/storage-cleanup.js +1 -1
  48. package/public/js/modules/suggestion-manager.js +1 -1
  49. package/public/js/pr.js +83 -7
  50. package/public/js/repo-settings.js +1 -1
  51. package/public/js/utils/category-emoji.js +1 -1
  52. package/public/js/utils/file-order.js +1 -1
  53. package/public/js/utils/markdown.js +1 -1
  54. package/public/js/utils/suggestion-ui.js +1 -1
  55. package/public/js/utils/tier-icons.js +1 -1
  56. package/public/js/utils/time.js +1 -1
  57. package/public/js/ws-client.js +1 -1
  58. package/public/local.html +14 -0
  59. package/public/pr.html +3 -0
  60. package/public/setup.html +1 -1
  61. package/src/ai/analyzer.js +18 -12
  62. package/src/ai/claude-cli.js +1 -1
  63. package/src/ai/claude-provider.js +1 -1
  64. package/src/ai/codex-provider.js +1 -1
  65. package/src/ai/copilot-provider.js +1 -1
  66. package/src/ai/cursor-agent-provider.js +1 -1
  67. package/src/ai/gemini-provider.js +1 -1
  68. package/src/ai/index.js +1 -1
  69. package/src/ai/opencode-provider.js +1 -1
  70. package/src/ai/pi-provider.js +1 -1
  71. package/src/ai/prompts/baseline/consolidation/balanced.js +1 -1
  72. package/src/ai/prompts/baseline/consolidation/fast.js +1 -1
  73. package/src/ai/prompts/baseline/consolidation/thorough.js +1 -1
  74. package/src/ai/prompts/baseline/level1/balanced.js +1 -1
  75. package/src/ai/prompts/baseline/level1/fast.js +1 -1
  76. package/src/ai/prompts/baseline/level1/thorough.js +1 -1
  77. package/src/ai/prompts/baseline/level2/balanced.js +1 -1
  78. package/src/ai/prompts/baseline/level2/fast.js +1 -1
  79. package/src/ai/prompts/baseline/level2/thorough.js +1 -1
  80. package/src/ai/prompts/baseline/level3/balanced.js +1 -1
  81. package/src/ai/prompts/baseline/level3/fast.js +1 -1
  82. package/src/ai/prompts/baseline/level3/thorough.js +1 -1
  83. package/src/ai/prompts/baseline/orchestration/balanced.js +1 -1
  84. package/src/ai/prompts/baseline/orchestration/fast.js +1 -1
  85. package/src/ai/prompts/baseline/orchestration/thorough.js +1 -1
  86. package/src/ai/prompts/config.js +1 -1
  87. package/src/ai/prompts/index.js +1 -1
  88. package/src/ai/prompts/line-number-guidance.js +1 -1
  89. package/src/ai/prompts/render-for-skill.js +1 -1
  90. package/src/ai/prompts/shared/diff-instructions.js +1 -1
  91. package/src/ai/prompts/shared/output-schema.js +1 -1
  92. package/src/ai/prompts/shared/valid-files.js +1 -1
  93. package/src/ai/prompts/sparse-checkout-guidance.js +1 -1
  94. package/src/ai/provider-availability.js +1 -1
  95. package/src/ai/provider.js +1 -1
  96. package/src/ai/stream-parser.js +1 -1
  97. package/src/chat/acp-bridge.js +1 -1
  98. package/src/chat/api-reference.js +1 -1
  99. package/src/chat/chat-providers.js +1 -1
  100. package/src/chat/claude-code-bridge.js +1 -1
  101. package/src/chat/codex-bridge.js +1 -1
  102. package/src/chat/pi-bridge.js +1 -1
  103. package/src/chat/prompt-builder.js +1 -1
  104. package/src/chat/session-manager.js +1 -1
  105. package/src/config.js +3 -1
  106. package/src/database.js +591 -40
  107. package/src/events/review-events.js +1 -1
  108. package/src/git/base-branch.js +173 -0
  109. package/src/git/gitattributes.js +1 -1
  110. package/src/git/sha-abbrev.js +35 -0
  111. package/src/git/worktree.js +1 -1
  112. package/src/github/client.js +33 -2
  113. package/src/github/parser.js +1 -1
  114. package/src/hooks/hook-runner.js +100 -0
  115. package/src/hooks/payloads.js +212 -0
  116. package/src/local-review.js +469 -130
  117. package/src/local-scope.js +58 -0
  118. package/src/main.js +56 -5
  119. package/src/mcp-stdio.js +1 -1
  120. package/src/protocol-handler.js +1 -1
  121. package/src/routes/analyses.js +74 -11
  122. package/src/routes/chat.js +34 -1
  123. package/src/routes/config.js +2 -1
  124. package/src/routes/context-files.js +1 -1
  125. package/src/routes/councils.js +1 -1
  126. package/src/routes/github-collections.js +1 -1
  127. package/src/routes/local.js +735 -69
  128. package/src/routes/mcp.js +21 -11
  129. package/src/routes/pr.js +91 -13
  130. package/src/routes/reviews.js +1 -1
  131. package/src/routes/setup.js +2 -1
  132. package/src/routes/shared.js +1 -1
  133. package/src/routes/worktrees.js +213 -149
  134. package/src/server.js +31 -1
  135. package/src/setup/local-setup.js +47 -6
  136. package/src/setup/pr-setup.js +29 -6
  137. package/src/utils/auto-context.js +1 -1
  138. package/src/utils/category-emoji.js +1 -1
  139. package/src/utils/comment-formatter.js +1 -1
  140. package/src/utils/diff-annotator.js +1 -1
  141. package/src/utils/diff-file-list.js +1 -1
  142. package/src/utils/instructions.js +1 -1
  143. package/src/utils/json-extractor.js +1 -1
  144. package/src/utils/line-validation.js +1 -1
  145. package/src/utils/logger.js +1 -1
  146. package/src/utils/paths.js +1 -1
  147. package/src/utils/safe-parse-json.js +1 -1
  148. package/src/utils/stats-calculator.js +1 -1
  149. package/src/ws/index.js +1 -1
  150. package/src/ws/server.js +1 -1
@@ -0,0 +1,58 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+
3
+ const STOPS = ['branch', 'staged', 'unstaged', 'untracked'];
4
+
5
+ const DEFAULT_SCOPE = { start: 'unstaged', end: 'untracked' };
6
+
7
+ function isValidScope(start, end) {
8
+ const si = STOPS.indexOf(start);
9
+ const ei = STOPS.indexOf(end);
10
+ return si !== -1 && ei !== -1 && si <= ei;
11
+ }
12
+
13
+ function scopeIncludes(start, end, stop) {
14
+ if (!isValidScope(start, end)) return false;
15
+ const si = STOPS.indexOf(start);
16
+ const ei = STOPS.indexOf(end);
17
+ const ti = STOPS.indexOf(stop);
18
+ return ti !== -1 && ti >= si && ti <= ei;
19
+ }
20
+
21
+ function includesBranch(start) {
22
+ return start === 'branch';
23
+ }
24
+
25
+ function fromLegacyMode(localMode) {
26
+ if (localMode === 'uncommitted') {
27
+ return { start: 'unstaged', end: 'untracked' };
28
+ }
29
+ if (localMode === 'branch') {
30
+ return { start: 'branch', end: 'branch' };
31
+ }
32
+ return { start: DEFAULT_SCOPE.start, end: DEFAULT_SCOPE.end };
33
+ }
34
+
35
+ function scopeLabel(start, end) {
36
+ if (!isValidScope(start, end)) return '';
37
+ const label = s => s.charAt(0).toUpperCase() + s.slice(1);
38
+ if (start === end) return label(start);
39
+ return `${label(start)}\u2013${label(end)}`;
40
+ }
41
+
42
+ const LocalScope = {
43
+ STOPS,
44
+ DEFAULT_SCOPE,
45
+ isValidScope,
46
+ scopeIncludes,
47
+ includesBranch,
48
+ fromLegacyMode,
49
+ scopeLabel,
50
+ };
51
+
52
+ if (typeof window !== 'undefined') {
53
+ window.LocalScope = LocalScope;
54
+ }
55
+
56
+ if (typeof module !== 'undefined' && module.exports) {
57
+ module.exports = LocalScope;
58
+ }
package/src/main.js CHANGED
@@ -1,4 +1,4 @@
1
- // SPDX-License-Identifier: GPL-3.0-or-later
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  const fs = require('fs');
3
3
  const { loadConfig, getConfigDir, getGitHubToken, showWelcomeMessage, resolveDbName, resolveMonorepoOptions } = require('./config');
4
4
  const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, WorktreeRepository, ReviewRepository, RepoSettingsRepository, GitHubReviewRepository } = require('./database');
@@ -10,6 +10,7 @@ const Analyzer = require('./ai/analyzer');
10
10
  const { applyConfigOverrides } = require('./ai');
11
11
  const { handleLocalReview, findMainGitRoot } = require('./local-review');
12
12
  const { storePRData, registerRepositoryLocation, findRepositoryPath } = require('./setup/pr-setup');
13
+ const { fireReviewStartedHook } = require('./hooks/payloads');
13
14
  const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('./utils/paths');
14
15
  const logger = require('./utils/logger');
15
16
  const simpleGit = require('simple-git');
@@ -182,6 +183,41 @@ function cleanupStaleWorktreesAsync(config) {
182
183
  });
183
184
  }
184
185
 
186
+ /**
187
+ * Asynchronously cleanup stale reviews (runs in background, doesn't block)
188
+ * @param {Object} config - Application configuration
189
+ */
190
+ function cleanupStaleReviewsAsync(config) {
191
+ setImmediate(async () => {
192
+ try {
193
+ const retentionDays = config.review_retention_days || 21;
194
+ const cutoffDate = new Date();
195
+ cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
196
+ const cutoffISO = cutoffDate.toISOString();
197
+
198
+ const reviewRepo = new ReviewRepository(db);
199
+ const staleReviews = await reviewRepo.findStale(cutoffISO);
200
+
201
+ if (staleReviews.length === 0) return;
202
+
203
+ logger.info(`[pair-review] Cleaning up ${staleReviews.length} reviews older than ${retentionDays} days`);
204
+
205
+ for (const review of staleReviews) {
206
+ try {
207
+ await reviewRepo.deleteWithRelatedData(review.id, {
208
+ prNumber: review.pr_number,
209
+ repository: review.repository
210
+ });
211
+ } catch (err) {
212
+ logger.error(`[pair-review] Failed to cleanup review ${review.id}: ${err.message}`);
213
+ }
214
+ }
215
+ } catch (error) {
216
+ logger.error('[pair-review] Background review cleanup error:', error.message);
217
+ }
218
+ });
219
+ }
220
+
185
221
  // Known flags that are valid (for validation)
186
222
  const KNOWN_FLAGS = new Set([
187
223
  '--ai',
@@ -436,6 +472,11 @@ AI PROVIDERS:
436
472
  // Resolve localPath, defaulting to cwd if not provided
437
473
  const targetPath = flags.localPath || process.cwd();
438
474
  await handleLocalReview(targetPath, flags);
475
+
476
+ // Async cleanup of stale worktrees and reviews (don't block startup)
477
+ cleanupStaleWorktreesAsync(config);
478
+ cleanupStaleReviewsAsync(config);
479
+
439
480
  return; // Exit after local review
440
481
  }
441
482
 
@@ -531,8 +572,9 @@ async function handlePullRequest(args, config, db, flags = {}) {
531
572
  // Start server and open browser to setup page
532
573
  const port = await startServer(db);
533
574
 
534
- // Async cleanup of stale worktrees (don't block startup)
575
+ // Async cleanup of stale worktrees and reviews (don't block startup)
535
576
  cleanupStaleWorktreesAsync(config);
577
+ cleanupStaleReviewsAsync(config);
536
578
 
537
579
  let url = `http://localhost:${port}/pr/${prInfo.owner}/${prInfo.repo}/${prInfo.number}`;
538
580
  if (flags.ai) {
@@ -555,8 +597,9 @@ async function handlePullRequest(args, config, db, flags = {}) {
555
597
  async function startServerOnly(config) {
556
598
  const port = await startServer(db);
557
599
 
558
- // Async cleanup of stale worktrees (don't block startup)
600
+ // Async cleanup of stale worktrees and reviews (don't block startup)
559
601
  cleanupStaleWorktreesAsync(config);
602
+ cleanupStaleReviewsAsync(config);
560
603
 
561
604
  // Open browser to landing page
562
605
  const url = `http://localhost:${port}/`;
@@ -744,10 +787,18 @@ async function performHeadlessReview(args, config, db, flags, options) {
744
787
 
745
788
  // Store PR data in database
746
789
  console.log('Storing pull request data...');
747
- await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath, {
790
+ const { isNewReview, reviewId: storedReviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath, {
748
791
  skipWorktreeRecord: !!flags.useCheckout
749
792
  });
750
793
 
794
+ // Fire review.started hook for new reviews (non-blocking)
795
+ if (isNewReview) {
796
+ fireReviewStartedHook({
797
+ reviewId: storedReviewId, prNumber: prInfo.number,
798
+ owner: prInfo.owner, repo: prInfo.repo, prData, config,
799
+ }).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
800
+ }
801
+
751
802
  // Get PR metadata ID for AI analysis
752
803
  const prMetadata = await queryOne(db, `
753
804
  SELECT id, pr_data FROM pr_metadata
@@ -764,7 +815,7 @@ async function performHeadlessReview(args, config, db, flags, options) {
764
815
  // The review.id is passed to the analyzer so comments use review.id, not prMetadata.id
765
816
  // This avoids ID collision with local mode where comments also use reviews.id
766
817
  const reviewRepo = new ReviewRepository(db);
767
- const review = await reviewRepo.getOrCreate({ prNumber: prInfo.number, repository });
818
+ const { review } = await reviewRepo.getOrCreate({ prNumber: prInfo.number, repository });
768
819
 
769
820
  // Fetch repo settings to get default instructions
770
821
  const repoSettingsRepo = new RepoSettingsRepository(db);
package/src/mcp-stdio.js CHANGED
@@ -1,4 +1,4 @@
1
- // SPDX-License-Identifier: GPL-3.0-or-later
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  /**
3
3
  * MCP stdio transport orchestrator.
4
4
  *
@@ -1,4 +1,4 @@
1
- // SPDX-License-Identifier: GPL-3.0-or-later
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const { execSync } = require('child_process');
@@ -1,4 +1,4 @@
1
- // SPDX-License-Identifier: GPL-3.0-or-later
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  /**
3
3
  * AI Analysis Routes (shared, ID-based endpoints)
4
4
  *
@@ -21,6 +21,8 @@ const { getTierForModel } = require('../ai/provider');
21
21
  const { v4: uuidv4 } = require('uuid');
22
22
  const logger = require('../utils/logger');
23
23
  const { broadcastReviewEvent } = require('../events/review-events');
24
+ const { fireHooks, hasHooks } = require('../hooks/hook-runner');
25
+ const { buildAnalysisStartedPayload, buildAnalysisCompletedPayload, getCachedUser } = require('../hooks/payloads');
24
26
  const path = require('path');
25
27
  const { normalizeRepository } = require('../utils/paths');
26
28
  const {
@@ -31,7 +33,7 @@ const {
31
33
  killProcesses,
32
34
  createProgressCallback
33
35
  } = require('./shared');
34
- const { generateLocalDiff, computeLocalDiffDigest } = require('../local-review');
36
+ const { generateLocalDiff, computeLocalDiffDigest, getCurrentBranch } = require('../local-review');
35
37
  const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
36
38
  const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
37
39
 
@@ -209,13 +211,21 @@ router.post('/api/analyses/results', async (req, res) => {
209
211
  // --- Resolve review ---
210
212
  let reviewId;
211
213
  if (hasLocal) {
212
- // Local mode: derive repository name from the directory basename
213
- const repository = path.basename(localPath) || 'local';
214
- reviewId = await reviewRepo.upsertLocalReview({
215
- localPath,
216
- localHeadSha: headSha,
217
- repository
218
- });
214
+ // Local mode: find existing session or create a new one
215
+ let localHeadBranch;
216
+ try { localHeadBranch = await getCurrentBranch(localPath); } catch (_) { /* non-fatal */ }
217
+ const existingReview = await reviewRepo.findLocalReview(localPath, headSha, localHeadBranch);
218
+ if (existingReview) {
219
+ reviewId = existingReview.id;
220
+ } else {
221
+ const repository = path.basename(localPath) || 'local';
222
+ reviewId = await reviewRepo.upsertLocalReview({
223
+ localPath,
224
+ localHeadSha: headSha,
225
+ repository,
226
+ localHeadBranch
227
+ });
228
+ }
219
229
 
220
230
  // Generate and store diff so the web UI can display it
221
231
  try {
@@ -235,7 +245,7 @@ router.post('/api/analyses/results', async (req, res) => {
235
245
  return res.status(400).json({ error: 'Invalid pull request number' });
236
246
  }
237
247
  const repository = normalizeRepository(repoParts[0], repoParts[1]);
238
- const review = await reviewRepo.getOrCreate({
248
+ const { review } = await reviewRepo.getOrCreate({
239
249
  prNumber: parsedPR,
240
250
  repository
241
251
  });
@@ -395,6 +405,10 @@ router.post('/api/analyses/:id/cancel', async (req, res) => {
395
405
  // Broadcast cancelled status to WebSocket clients
396
406
  broadcastProgress(id, cancelledStatus);
397
407
 
408
+ // Hook firing removed — the .catch(isCancellation) handlers in
409
+ // pr.js, local.js, and launchCouncilAnalysis already fire
410
+ // analysis.completed with full context when the process exits.
411
+
398
412
  // Clean up review to analysis ID mapping
399
413
  if (analysis.reviewId) {
400
414
  reviewToAnalysisId.delete(analysis.reviewId);
@@ -453,7 +467,9 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
453
467
  logLabel,
454
468
  initialStatusExtra,
455
469
  onSuccess,
456
- runUpdateExtra
470
+ runUpdateExtra,
471
+ config: modeConfig,
472
+ hookContext = {},
457
473
  } = modeContext;
458
474
 
459
475
  const { repoInstructions, requestInstructions } = instructions;
@@ -521,6 +537,17 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
521
537
 
522
538
  broadcastProgress(analysisId, initialStatus);
523
539
  broadcastReviewEvent(reviewId, { type: 'review:analysis_started', analysisId });
540
+ const effectiveConfig = modeConfig || {};
541
+ if (hasHooks('analysis.started', effectiveConfig)) {
542
+ getCachedUser(effectiveConfig).then(user => {
543
+ fireHooks('analysis.started', buildAnalysisStartedPayload({
544
+ reviewId, analysisId, provider: 'council', model: councilId || 'inline-config',
545
+ mode: initialStatusExtra?.reviewType || 'pr',
546
+ prContext: hookContext.prContext, localContext: hookContext.localContext,
547
+ user,
548
+ }), effectiveConfig);
549
+ }).catch(err => { logger.warn(`Analysis hook failed: ${err.message}`); });
550
+ }
524
551
 
525
552
  const analyzer = new Analyzer(db, 'council', 'council');
526
553
 
@@ -588,10 +615,34 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
588
615
  activeAnalyses.set(analysisId, completedStatus);
589
616
  broadcastProgress(analysisId, completedStatus);
590
617
  broadcastReviewEvent(initialStatus.reviewId, { type: 'review:analysis_completed' });
618
+
619
+ // Fire analysis.completed hook
620
+ if (hasHooks('analysis.completed', effectiveConfig)) {
621
+ getCachedUser(effectiveConfig).then(user => {
622
+ fireHooks('analysis.completed', buildAnalysisCompletedPayload({
623
+ reviewId: initialStatus.reviewId, analysisId, provider: 'council',
624
+ model: councilId || 'inline-config',
625
+ status: 'success', totalSuggestions: result.suggestions.length,
626
+ mode: initialStatusExtra?.reviewType || 'pr',
627
+ prContext: hookContext.prContext, localContext: hookContext.localContext, user,
628
+ }), effectiveConfig);
629
+ }).catch(() => {});
630
+ }
591
631
  })
592
632
  .catch(error => {
593
633
  if (error.isCancellation) {
594
634
  logger.info(`Council analysis cancelled for ${logLabel}`);
635
+ if (hasHooks('analysis.completed', effectiveConfig)) {
636
+ getCachedUser(effectiveConfig).then(user => {
637
+ fireHooks('analysis.completed', buildAnalysisCompletedPayload({
638
+ reviewId, analysisId, provider: 'council',
639
+ model: councilId || 'inline-config',
640
+ status: 'cancelled', totalSuggestions: 0,
641
+ mode: initialStatusExtra?.reviewType || 'pr',
642
+ prContext: hookContext.prContext, localContext: hookContext.localContext, user,
643
+ }), effectiveConfig);
644
+ }).catch(() => {});
645
+ }
595
646
  return;
596
647
  }
597
648
  logger.error(`Council analysis failed for ${logLabel}: ${error.message}`);
@@ -607,6 +658,18 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
607
658
  broadcastProgress(analysisId, failedStatus);
608
659
 
609
660
  analysisRunRepo.update(runId, { status: 'failed' }).catch(() => {});
661
+
662
+ if (hasHooks('analysis.completed', effectiveConfig)) {
663
+ getCachedUser(effectiveConfig).then(user => {
664
+ fireHooks('analysis.completed', buildAnalysisCompletedPayload({
665
+ reviewId, analysisId, provider: 'council',
666
+ model: councilId || 'inline-config',
667
+ status: 'failed', totalSuggestions: 0,
668
+ mode: initialStatusExtra?.reviewType || 'pr',
669
+ prContext: hookContext.prContext, localContext: hookContext.localContext, user,
670
+ }), effectiveConfig);
671
+ }).catch(() => {});
672
+ }
610
673
  })
611
674
  .finally(() => {
612
675
  // Clean up unified tracking map entry
@@ -1,4 +1,4 @@
1
- // SPDX-License-Identifier: GPL-3.0-or-later
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  /**
3
3
  * Chat Routes
4
4
  *
@@ -18,6 +18,32 @@ const { renderApiDocs, buildApiCheatSheet } = require('../chat/api-reference');
18
18
  const { GitWorktreeManager } = require('../git/worktree');
19
19
  const logger = require('../utils/logger');
20
20
  const ws = require('../ws');
21
+ const { fireHooks, hasHooks } = require('../hooks/hook-runner');
22
+ const { buildChatStartedPayload, buildChatResumedPayload, buildChatHookContext, getCachedUser } = require('../hooks/payloads');
23
+
24
+ /**
25
+ * Fire a chat hook event (non-blocking). Skips async work when no hooks are configured.
26
+ * @param {string} event - 'chat.started' or 'chat.resumed'
27
+ * @param {Object} opts
28
+ * @param {Object} opts.req - Express request (used to read config)
29
+ * @param {Object} opts.review - Review record
30
+ * @param {number} opts.sessionId - Chat session ID
31
+ * @param {string} opts.provider - AI provider
32
+ * @param {string} opts.model - AI model
33
+ */
34
+ function fireChatHook(event, { req, review, sessionId, provider, model }) {
35
+ const config = req.app.get('config') || {};
36
+ if (!hasHooks(event, config)) return;
37
+
38
+ const buildPayload = event === 'chat.started' ? buildChatStartedPayload : buildChatResumedPayload;
39
+ getCachedUser(config).then(user => {
40
+ const payload = buildPayload({
41
+ reviewId: review.id, sessionId, provider, model,
42
+ ...buildChatHookContext(review), user,
43
+ });
44
+ fireHooks(event, payload, config);
45
+ }).catch(err => { logger.warn(`Chat hook failed: ${err.message}`); });
46
+ }
21
47
 
22
48
  const router = express.Router();
23
49
 
@@ -302,6 +328,8 @@ router.post('/api/chat/session', async (req, res) => {
302
328
  // Register broadcast listeners so events reach all connected clients
303
329
  registerChatBroadcast(chatSessionManager, session.id, serverPort);
304
330
 
331
+ fireChatHook('chat.started', { req, review, sessionId: session.id, provider, model });
332
+
305
333
  const responseData = { id: session.id, status: session.status };
306
334
 
307
335
  // Include analysis context metadata so the frontend can show a context indicator
@@ -372,6 +400,8 @@ router.post('/api/chat/session/:id/message', async (req, res) => {
372
400
  registerChatBroadcast(chatSessionManager, sessionId, req.socket.localPort);
373
401
  logger.info(`[ChatRoute] Auto-resumed session ${sessionId} for message delivery`);
374
402
 
403
+ fireChatHook('chat.resumed', { req, review, sessionId, provider: session.provider, model: session.model });
404
+
375
405
  // Inject port correction so the agent knows the current server address,
376
406
  // even if the conversational history has a stale port from session creation.
377
407
  const serverPort = req.socket.localPort;
@@ -518,6 +548,9 @@ router.post('/api/chat/session/:id/resume', async (req, res) => {
518
548
  );
519
549
 
520
550
  logger.info(`[ChatRoute] Explicitly resumed session ${sessionId}`);
551
+
552
+ fireChatHook('chat.resumed', { req, review, sessionId, provider: session.provider, model: session.model });
553
+
521
554
  res.json({ data: { id: sessionId, status: 'active' } });
522
555
  } catch (error) {
523
556
  logger.error(`Error resuming chat session: ${error.message}`);
@@ -1,4 +1,4 @@
1
- // SPDX-License-Identifier: GPL-3.0-or-later
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  /**
3
3
  * Configuration Routes
4
4
  *
@@ -56,6 +56,7 @@ router.get('/api/config', (req, res) => {
56
56
  pi_available: getCachedAvailability('pi')?.available || false,
57
57
  assisted_by_url: config.assisted_by_url || 'https://github.com/in-the-loop-labs/pair-review',
58
58
  enable_graphite: config.enable_graphite === true,
59
+ chat_spinner: config.chat_spinner || 'dots',
59
60
  // Share configuration for external review viewers.
60
61
  // - url: The base URL of the external share site
61
62
  // - method: Plumbed through for future use (e.g., POST-based share flows).
@@ -1,4 +1,4 @@
1
- // SPDX-License-Identifier: GPL-3.0-or-later
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  /**
3
3
  * Context Files Routes
4
4
  *
@@ -1,4 +1,4 @@
1
- // SPDX-License-Identifier: GPL-3.0-or-later
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  /**
3
3
  * Council Routes
4
4
  *
@@ -1,4 +1,4 @@
1
- // SPDX-License-Identifier: GPL-3.0-or-later
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  /**
3
3
  * GitHub Collections Routes
4
4
  *